Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Changelog

*Megatron Framework (M-LM / M-Bridge)*

- Add mergeable LoRA QAD for fake-quantized dense Megatron/Transformer Engine linear layers. Enable it with ``--lora_rank`` in Megatron-Bridge PTQ, train through the normal QAD flow, and export a standalone quantized checkpoint without deployment adapters.
- Add optional Top-P (nucleus) truncation to the Megatron top-k logits KD loss (now ``TopLogitsKLLoss``) via ``logit_kl_top_p`` and ``logit_kl_top_p_min_k`` in ``DistillationConfig``: after the global Top-K selection, only the smallest prefix whose cumulative teacher probability reaches ``top_p`` (with a floor of ``min_k`` entries) contributes to the KL, mirroring ``--logits-save-top-p`` / ``--logits-save-top-p-min-k`` in Megatron-LM's logits saver.
- Add an end-to-end W4A4 NVFP4 PTQ and QAD tutorial for Qwen3.6-35B-A3B also covering evaluation and vLLM throughput benchmarking. See `examples/megatron_bridge/tutorials/Qwen3.6-35B-A3B/README.md <https://github.com/NVIDIA/Model-Optimizer/tree/main/examples/megatron_bridge/tutorials/Qwen3.6-35B-A3B/>`_ for details.
- Add ``--mlflow <tracking-uri>`` to the ``examples/megatron_bridge`` scripts that write a checkpoint -- ``prune_minitron.py``, ``quantize.py``, ``distill.py``, ``export_quantized_megatron_to_hf.py`` and ``export_distilled_megatron_to_hf.py`` (MLflow's own ``MLFLOW_TRACKING_URI`` is honoured too). Each run records the invocation, its arguments as searchable params and its log -- a distillation records its training metrics instead of the rank-0 log -- and writes ``.experiment.json`` into the checkpoint it produced, so a pruning, a quantization, the distillation that refines its checkpoint and the export that deploys it can be traced to one another; uploading the checkpoints themselves stays off unless ``--mlflow_log_checkpoints`` is passed.
Expand Down
33 changes: 33 additions & 0 deletions examples/megatron_bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,39 @@ torchrun --nproc_per_node 8 distill.py \

The distilled checkpoint retains the ModelOpt quantization state, so it can be converted to a deployable HuggingFace checkpoint with [export_quantized_megatron_to_hf.py](export_quantized_megatron_to_hf.py) (point `--megatron_path` at `/output/qwen3_8b_nvfp4_qad/checkpoints`), exactly like the PTQ checkpoint in [step 2 above](#post-training-quantization).

### LoRA QAD

Add `--lora_rank` to PTQ to train low-rank weight updates with the usual QAD flow:

```bash
torchrun --nproc_per_node 2 quantize.py \
--hf_model_name_or_path Qwen/Qwen3-0.6B \
--recipe general/ptq/nvfp4_default-kv_fp8 \
--tp_size 2 --lora_rank 8 --lora_alpha 16 \
--export_megatron_path /output/qwen3_06b_nvfp4_lora
```

PTQ calibrates the quantizers before adding the adapters. The backbone is frozen; `A` starts
random and `B` starts at zero. Each linear layer quantizes `W + (alpha / rank) B @ A` before
its matrix multiplication. The default targets are `linear_qkv`, `linear_proj`, `linear_fc1`,
and `linear_fc2`; select other module-name globs with `--lora_target_modules`.

Run `distill.py` with this checkpoint as `--student_megatron_path`, just as for regular QAD.
ModelOpt reconstructs the quantizers and factors before DDP and optimizer setup, then loads
the distributed checkpoint tensors. Resuming the output directory also restores the optimizer.

`export_quantized_megatron_to_hf.py` merges the factors into the backbone before exporting a
standalone quantized checkpoint. Deployment does not require a LoRA adapter. The Python APIs
are `modelopt.torch.quantization.enable_quant_lora` and `merge_quant_lora`.

Supported targets are fake-quantized dense PyTorch and Megatron/Transformer Engine linear
layers, including tensor-parallel row/column and fused layer-normalization linear layers.
Grouped MoE experts, shared target weights, and compressed quantized weights are unsupported.
Merge adapters with `merge_quant_lora` before using weight-folding APIs. Full activation
recomputation is supported without unfreezing the backbone. The backbone remains
in floating point and the implementation forms dense weight updates, so memory savings come
primarily from the smaller optimizer state rather than compressed backbone storage.

### Slurm Usage

To run the distillation script on a Slurm cluster for multi-node training, you just need use `python` instead of `torchrun` and set the number of nodes using `#SBATCH --nodes=<num_nodes>` clause in your Slurm script.
Expand Down
2 changes: 2 additions & 0 deletions examples/megatron_bridge/export_quantized_megatron_to_hf.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
resolve_mlflow_args,
)

import modelopt.torch.quantization as mtq
import modelopt.torch.utils.distributed as dist
from modelopt.torch.export import export_mcore_gpt_to_hf
from modelopt.torch.utils import print_args, print_rank_0
Expand Down Expand Up @@ -178,6 +179,7 @@ def main(args: argparse.Namespace):
print_rank_0(f"Loading quantized Megatron checkpoint from {args.megatron_path}...")
load_modelopt_megatron_checkpoint(model, args.megatron_path)
unwrapped_model = unwrap_model(model[0])
mtq.merge_quant_lora(unwrapped_model)

# Extra modules (Medusa / EAGLE / MTP) only exist on the last pipeline stage. Use an all-reduce
# MAX over all ranks (rather than a broadcast from a hard-coded source rank) so the decision is
Expand Down
25 changes: 25 additions & 0 deletions examples/megatron_bridge/quantize.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,16 @@ def get_args() -> argparse.Namespace:
action="store_true",
help="Compress weights to a real low-bit representation (instead of fake quantization).",
)
parser.add_argument(
"--lora_rank", type=int, default=0, help="Enable LoRA-QAT/QAD; 0 disables it."
)
parser.add_argument("--lora_alpha", type=float, default=16.0)
parser.add_argument(
"--lora_target_modules",
nargs="+",
default=["*.linear_qkv", "*.linear_proj", "*.linear_fc1", "*.linear_fc2"],
help="Glob patterns of fake-quantized linear layers to adapt.",
)
# Calibration dataset arguments (matched to hf_ptq.py)
parser.add_argument(
"--calib_dataset_name",
Expand Down Expand Up @@ -251,6 +261,11 @@ def get_args() -> argparse.Namespace:
args = parser.parse_args()
resolve_mlflow_args(args, parser, QUANTIZE)

if args.lora_rank < 0:
parser.error("--lora_rank must be non-negative")
if args.lora_rank and args.compress:
parser.error("--lora_rank requires fake quantization; cannot be combined with --compress")

print_args(masked_args(args))

# Flipped by main() once the Megatron checkpoint is on disk. The MLflow provenance
Expand Down Expand Up @@ -436,6 +451,16 @@ def forward_loop(_model=None):
else:
mtq.quantize(unwrapped_model, mtq_config, forward_loop)

if args.lora_rank:
mtq.enable_quant_lora(
unwrapped_model,
{
"rank": args.lora_rank,
"alpha": args.lora_alpha,
"target_modules": args.lora_target_modules,
},
)

# Free calibration/quantization memory before generate
gc.collect()
torch.cuda.empty_cache()
Expand Down
9 changes: 9 additions & 0 deletions modelopt/torch/quantization/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,26 @@

from importlib import import_module as _import_module

from modelopt.torch.utils import import_plugin as _import_plugin

# Initialize mode and plugins
from . import mode, plugins, utils

# Add methods to mtq namespace
from .compress import *
from .config import *
from .conversion import *
from .lora import *
from .model_quant import *
from .nn.modules.quant_module import QuantModuleRegistry
from .utils import update_quant_cfg_with_kv_cache_quant

# LoRA modes depend on the quantization mode registry, which framework plugin
# initialization can reach before mode.py finishes importing.
with _import_plugin("megatron LoRA"):
_import_module(".plugins.megatron_lora", __name__)
del _import_plugin

# Imported last to register the backend without cycling through quantization.qtensor.
# A dynamic import prevents isort from hoisting it into the import block above.
ggml = _import_module(".ggml", __name__)
Expand Down
217 changes: 217 additions & 0 deletions modelopt/torch/quantization/lora.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Mergeable low-rank adaptation of fake-quantized linear weights."""

import fnmatch
import math
from collections import Counter

import torch
from torch import nn

from modelopt.torch.opt.config import ModeloptBaseConfig, ModeloptField
from modelopt.torch.opt.conversion import apply_mode
from modelopt.torch.opt.dynamic import DynamicModule, _DMRegistryCls
from modelopt.torch.opt.mode import ModeDescriptor

from .config import QuantizeConfig
from .conversion import restore_quantizer_state, update_quantize_metadata
from .mode import QuantizeModeRegistry
from .nn import QuantModule, QuantModuleRegistry
from .qtensor import QTensorWrapper

__all__ = ["QuantLoRAConfig", "enable_quant_lora", "merge_quant_lora"]


class QuantLoRAConfig(ModeloptBaseConfig):
"""Configuration for adapters applied to combined weights before fake quantization."""

rank: int = ModeloptField(8, gt=0)
alpha: float = ModeloptField(16.0, gt=0, allow_inf_nan=False)
target_modules: list[str] = ModeloptField(["*"], min_length=1)


QuantLoRARegistry = _DMRegistryCls("quant_lora")
QuantLoRAInputRegistry = _DMRegistryCls("quant_lora_input")


class _QuantLoRAInput(DynamicModule):
def _setup(self):
"""Preserve activation gradients without adding trainable backbone parameters."""


@QuantLoRAInputRegistry.register(
{nn.Embedding: "nn.Embedding", QuantModuleRegistry[nn.Embedding]: "QuantEmbedding"}
)
class _QuantLoRAEmbedding(_QuantLoRAInput):
def forward(self, *args, **kwargs):
output = super().forward(*args, **kwargs)
if self.training and torch.is_grad_enabled():
output.requires_grad_(True)
return output


@QuantLoRARegistry.register({QuantModuleRegistry[nn.Linear]: "nn.Linear"})
class _QuantLoRALinear(DynamicModule):
def _setup(self, config: QuantLoRAConfig):
"""Initialize factors with a zero effective update."""
weight = self._parameters["weight"]
self.lora_A = nn.Parameter(weight.new_empty(config.rank, weight.shape[1]))
self.lora_B = nn.Parameter(weight.new_zeros(weight.shape[0], config.rank))
nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
self.lora_scale = config.alpha / config.rank

@staticmethod
def _effective_weight(module, weight):
"""Combine the frozen weight and trainable low-rank update."""
return weight + (module.lora_scale * module.lora_B @ module.lora_A).to(weight.dtype)

def forward(self, *args, **kwargs):
"""Run the quantized base layer on the combined weight."""
# Keep the combined weight outside quantizers and TE custom autograd functions.
weight = self._parameters["weight"]
self._parameters["weight"] = self._effective_weight(self, weight)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Make weight folding aware of active LoRA updates

The effective W + delta only exists during forward(), while the inherited QuantModule.fold_weight() sees the base W. Consequently, calling either mtq.fold_weight(model) or mtq.temporarily_fold_weights(model) with active adapters folds Q(W) into the base and disables the weight quantizer. Subsequent forwards compute Q(W) + delta, rather than this feature's intended Q(W + delta).

Using the real public APIs on a small INT8 model with nonzero adapters, both permanent folding and inference inside the temporary-folding context changed outputs by up to 0.07898. Exiting the temporary context restored the original output; merging first and then folding preserved it exactly. The documented Bridge exporter already merges first, so that path is not the affected case.

Could we make folding LoRA-aware, or explicitly reject folding while adapters are active and require an explicit merge for permanent folding? Please add a parity/rejection test for both APIs. Automatically doing an irreversible merge inside the temporary context would also be surprising, since it removes the trainable adapters.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a840b96e. Both public folding APIs now reject active LoRA adapters and require an explicit merge. A model-wide preflight prevents earlier unadapted layers from being folded before rejection. Tests cover permanent and temporary folding, unchanged state after rejection, and exact output parity after explicit merge. The focused CPU suite passed all 61 tests.

try:
return super().forward(*args, **kwargs)
finally:
self._parameters["weight"] = weight

def _validate_fold_weight(self):
raise ValueError("Merge quantization-aware LoRA adapters before folding weights.")

def fold_weight(self, keep_attrs=False):
self._validate_fold_weight()

@torch.no_grad()
def merge_lora(self):
"""Fold the update into the base weight and remove the factors."""
weight = self._parameters["weight"]
weight.copy_(self._effective_weight(self, weight))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Handle shared base parameters before merging adapters in place

This in-place copy changes every consumer of a shared weight parameter. For two supported nn.Linear modules sharing W, active LoRA forwards can use distinct W + delta_1 and W + delta_2, because forward() temporarily replaces each module's weight. Merging instead mutates the shared original twice, leaving both modules with W + delta_1 + delta_2. Even adapting only one module changes the unadapted consumer when merged.

I reproduced this using ordinary tied PyTorch linears with INT8 quantization and nonzero adapter factors: maximum pre/post-merge output differences were approximately 0.513 with both modules adapted and 0.421 with only one adapted. Zero-initialized adapters hide the problem. This does not depend on FSDP.

Could we detect and reject unsupported shared target weights before conversion/freezing, or explicitly handle their aliasing when merging? Please cover both the two-adapted-consumers and one-adapted/one-unadapted cases with pre/post-merge parity tests.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a840b96e. Added validation that rejects shared target weight parameters before freezing or converting any layers. Regression tests cover both adapted consumers and one adapted/one unadapted consumer, and verify parameter values, identities, trainability, and module types remain unchanged on rejection. The focused CPU suite passed all 61 tests.

self.export()
del self.lora_A, self.lora_B, self.lora_scale


def enable_quant_lora(model: nn.Module, config: dict | QuantLoRAConfig | None = None) -> nn.Module:
"""Freeze a fake-quantized backbone and add zero-initialized, mergeable adapters.

The forward quantizes ``W + (alpha / rank) * B @ A``. Save and restore using
ModelOpt checkpoint APIs to reconstruct adapters before loading tensor/optimizer state.
"""
if any(isinstance(module, _QuantLoRALinear) for module in model.modules()):
raise ValueError("Quantization-aware LoRA is already enabled.")
return apply_mode(model, [("quant_lora", config or {})], registry=QuantizeModeRegistry)


def merge_quant_lora(model: nn.Module) -> nn.Module:
"""Merge adapters into floating-point weights, retaining quantizers for deployment export."""
if not any(isinstance(module, _QuantLoRALinear) for module in model.modules()):
return model
return apply_mode(model, "quant_lora_export", registry=QuantizeModeRegistry)


def _convert_quant_lora(model, config):
"""Validate all targets before freezing the backbone and adding factors."""
targets = []
parameter_uses = Counter(
id(parameter)
for module in model.modules()
for parameter in module._parameters.values()
if parameter is not None
)
for name, module in model.named_modules():
if not isinstance(module, QuantModule) or not hasattr(module, "weight_quantizer"):
continue
if not any(fnmatch.fnmatchcase(name, pattern) for pattern in config.target_modules):
continue
weight = module._parameters.get("weight")
if isinstance(weight, QTensorWrapper):
raise ValueError(
"Quantization-aware LoRA requires uncompressed floating-point weights."
)
if weight is None or weight.ndim != 2 or type(module) not in QuantLoRARegistry:
raise ValueError(
f"Quantization-aware LoRA does not support layer {name}: {type(module)}"
)
if parameter_uses[id(weight)] > 1:
raise ValueError(f"Quantization-aware LoRA does not support shared weight at {name}.")
targets.append(module)
if not targets:
raise ValueError("No supported fake-quantized linear layers match target_modules.")
model.requires_grad_(False)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve checkpoint-input gradients when freezing the backbone

Freezing the embedding/backbone here leaves the first checkpoint's hidden-state input with requires_grad=False. With the supported --recompute_granularity full --recompute_method uniform --recompute_num_layers 1 configuration, MCore's reentrant checkpoint runs its forward under no_grad; the trainable LoRA parameters are captured inside the checkpointed closure, not passed as checkpoint inputs. If none of those inputs require gradients, its output is detached and the LoRA loss cannot backpropagate.

A CPU checkpoint reproduction trains before LoRA conversion, then fails with element 0 of tensors does not require grad and does not have a grad_fn after freezing. Making the checkpoint-entry activation require gradients restores adapter gradients. I also checked MCore's actual checkpoint/viewless-tensor implementation with CUDA bookkeeping stubbed: make_viewless_tensor(..., requires_grad=True, keep_graph=True) does not reconnect this case. This is not a full GPU reproduction.

Could we ensure checkpoint-entry activations require gradients without unfreezing the backbone (including restored LoRA models), or use a compatible checkpoint implementation? Please add a Bridge LoRA training test with full/uniform recomputation; the current non-recompute training smoke would not catch this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a840b96e. Preserved checkpoint-entry activation gradients while keeping backbone parameters frozen. The activation wrappers are rebuilt during ModelOpt restore and removed when adapters are merged. Added a reentrant checkpoint regression before/after restore, and changed the LoRA Bridge test to full/uniform recomputation with one layer per checkpoint, including resumed versus uninterrupted adapter equality. OCI-JHB NeMo 26.08 validation passed at TP=1 and TP=2 with full/uniform/1 recomputation, checkpoint resume, exact adapter equality, and merged NVFP4 HF export (job 889266).

for module in targets:
QuantLoRARegistry[type(module)].convert(module, config=config)
for module in list(model.modules()):
if type(module) in QuantLoRAInputRegistry:
QuantLoRAInputRegistry[type(module)].convert(module)
metadata = {}
_update_quant_lora(model, config, metadata)
return model, metadata


def _restore_quant_lora(model, config, metadata):
"""Restore quantizers and recreate factors before loading checkpoint tensors."""
restore_quantizer_state(model, QuantizeConfig(), metadata)
return _convert_quant_lora(model, config)[0]


def _update_quant_lora(model, config, metadata):
"""Record quantizer metadata for adapter and merged checkpoints."""
update_quantize_metadata(model, QuantizeConfig(), metadata)


def _merge_quant_lora(model, config):
"""Merge every adapter and record the remaining quantizers."""
for module in list(model.modules()):
if isinstance(module, _QuantLoRALinear):
module.merge_lora()
elif isinstance(module, _QuantLoRAInput):
module.export()
metadata = {}
_update_quant_lora(model, config, metadata)
return model, metadata


def _restore_merged_quant_lora(model, config, metadata):
"""Remove reconstructed factors before restoring the merged checkpoint state."""
model = _merge_quant_lora(model, config)[0]
return restore_quantizer_state(model, QuantizeConfig(), metadata)


@QuantizeModeRegistry.register_mode
class QuantLoRAModeDescriptor(ModeDescriptor):
"""Record adapters after quantization and reconstruct them before checkpoint loading."""

name = "quant_lora"
config_class = QuantLoRAConfig
export_mode = "quant_lora_export"
next_prohibited_modes = {"quant_lora", "quantize", "auto_quantize", "real_quantize"}
convert = staticmethod(_convert_quant_lora)
restore = staticmethod(_restore_quant_lora)
update_for_save = staticmethod(_update_quant_lora)
update_for_new_mode = staticmethod(_update_quant_lora)


@QuantizeModeRegistry.register_mode
class QuantLoRAExportModeDescriptor(ModeDescriptor):
"""Record merged adapters so exported checkpoints restore without adapter parameters."""

name = "quant_lora_export"
config_class = ModeloptBaseConfig
is_export_mode = True
convert = staticmethod(_merge_quant_lora)
restore = staticmethod(_restore_merged_quant_lora)
update_for_save = staticmethod(_update_quant_lora)
update_for_new_mode = staticmethod(_update_quant_lora)
3 changes: 3 additions & 0 deletions modelopt/torch/quantization/model_quant.py
Original file line number Diff line number Diff line change
Expand Up @@ -1078,6 +1078,9 @@ def fold_weight(model: nn.Module, keep_attrs: bool = False):
Any weight-quantizer rotation is folded into the weights and disabled so subsequent
forwards do not re-rotate the already-folded weights.
"""
for module in model.modules():
if isinstance(module, QuantModule):
module._validate_fold_weight()
for name, module in model.named_modules():
if isinstance(module, QuantModule):
module.fold_weight(keep_attrs)
Expand Down
3 changes: 3 additions & 0 deletions modelopt/torch/quantization/nn/modules/quant_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ def _fold_weight_quantizer(
if hasattr(quantizer, attr_name):
delattr(quantizer, attr_name)

def _validate_fold_weight(self):
"""Validate that weight folding is supported before any module is modified."""

def fold_weight(self, keep_attrs: bool = False):
"""Bake each fake-quant weight quantizer into its weight for faster eval.

Expand Down
Loading
Loading