Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
87bab30
feat(quant): per-expert weight quantizer for TEGroupedMLP
jenchen13 Jul 22, 2026
b6659bc
fix(quant): shard TE grouped per-expert amax with global expert identity
jenchen13 Jul 22, 2026
d2b46a4
feat(export): per-expert HF export for TEGroupedMLP
jenchen13 Jul 22, 2026
e2742fc
test(quant): TEGrouped per-expert quantization tests
jenchen13 Jul 22, 2026
2883da0
refactor(quant): alias the any-quantizer isinstance tuple in model_calib
jenchen13 Jul 22, 2026
5b8f9d7
refactor(quant): centralize quantizer type checks on AnyQuantizer
jenchen13 Jul 24, 2026
4708d85
make tests more robust
jenchen13 Jul 28, 2026
8902e04
docs(changelog): document TEGroupedMLP per-expert quantization + brea…
jenchen13 Jul 29, 2026
8bd4685
feat(quant): make TEGroupedMLP per-expert weight quantizers opt-in + …
jenchen13 Jul 29, 2026
39074f2
feat(quant): make TEGroupedMLP per-expert weight quantizers the default
jenchen13 Jul 29, 2026
761d1c1
test(quant): cap real torch.compile TEGrouped test at 90s
jenchen13 Jul 29, 2026
57fe963
fix(quant/export): restore output_layer static amax + robust TE per-e…
jenchen13 Jul 30, 2026
790a871
fix(quant): emit empty extra_state for unquantized modules
jenchen13 Jul 31, 2026
2b0d5ea
Merge remote-tracking branch 'origin/main' into jennifchen/te_per_expert
jenchen13 Jul 31, 2026
72d3cb5
feat(megatron-bridge): Nemotron-Nano-3 W4A16 NVFP4 four_over_six PTQ/…
yueshen2016 Aug 3, 2026
972a57a
remove deepcopy
jenchen13 Aug 4, 2026
c53ee30
Merge branch 'pr1550' into qad/te-per-expert-1550
yueshen2016 Aug 4, 2026
5f2f3de
fix(quantization): resolve default_quant_desc_weight in TEGroupedLine…
yueshen2016 Aug 5, 2026
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
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ Experimental
0.46 (2026-08-xx)
^^^^^^^^^^^^^^^^^

- Add per-expert weight quantization for Transformer Engine ``TEGroupedMLP`` (fused MoE experts): each expert now has its own ``weight_quantizer`` (a ``GroupedQuantizer`` holding one ``TensorQuantizer`` per expert) with an independent ``amax``, instead of a single shared ``amax`` across all experts. Applies to ``mtq.quantize`` calibration, HF / Megatron export, and QAD.

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.

this is a copy from my PR #1550, you can remove this as you will rebase on my later

- Add opt-in ``torch.compile`` execution for Transformer Engine grouped-linear per-expert weight quantizers while preserving their native checkpoint amax shapes. Set ``MODELOPT_TEGROUPED_COMPILE_WEIGHT_LOOP=1`` before quantized-module conversion; the default path remains eager.

**Backward Breaking Changes**

- Transformer Engine ``TEGroupedMLP`` (fused MoE experts) now uses **per-expert** weight quantization (one ``amax`` per expert) instead of a single shared ``amax`` across all experts. As a result, ModelOpt checkpoints containing quantized ``TEGroupedMLP`` modules saved before 0.46 are **not compatible** with 0.46: the per-expert ``weight_quantizer`` amax layout differs from the previous single-quantizer layout. Re-run PTQ (or re-quantize) with 0.46 to regenerate compatible checkpoints.

- Remove the ``examples/diffusers/eval`` image-quality evaluation example (ImageReward / CLIP-IQA / CLIP metrics) and its references in ``examples/diffusers/README.md``. The example was deprecated in 0.45 and is no longer maintained.
- Remove the deprecated ``examples/llm_autodeploy`` example (deprecated in 0.45). Use TensorRT-LLM's `AutoDeploy <https://github.com/NVIDIA/TensorRT-LLM/tree/main/examples/auto_deploy>`_ directly together with ModelOpt PTQ in ``examples/llm_ptq``.
- Remove the deprecated ``examples/llm_qad`` Megatron-LM QAD example (deprecated in 0.45). Use the `megatron_bridge QAD example <https://github.com/NVIDIA/Model-Optimizer/tree/main/examples/megatron_bridge#quantization-aware-distillation-qad>`_ instead, which provides a simpler Python-based interface and better model coverage.
Expand Down
295 changes: 211 additions & 84 deletions examples/megatron_bridge/distill.py

Large diffs are not rendered by default.

102 changes: 102 additions & 0 deletions examples/megatron_bridge/export_quantized_megatron_to_hf.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
"""

import argparse
import yaml
import pathlib
import os

import torch
from megatron.bridge.models.hf_pretrained.utils import is_safe_repo
Expand All @@ -44,6 +47,10 @@
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
from modelopt.torch.quantization.nn.modules.tensor_quantizer import (
StaticBlockScaleQuantizer,
TensorQuantizer,
)
from modelopt.torch.utils.plugins.mbridge import (
load_mbridge_model_from_hf,
load_modelopt_megatron_checkpoint,
Expand All @@ -52,6 +59,13 @@

def get_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument(
"--grouped_experts",
action="store_true",
help="Build MoE experts grouped (GroupedMLP). Default is non-grouped, which per-block "
"NVFP4 checkpoints require. Set this only when the checkpoint was saved with grouped "
"experts; the layout must match or the weights will not load.",
)
parser.add_argument(
"--hf_model_name_or_path",
type=str,
Expand Down Expand Up @@ -99,7 +113,39 @@ def get_args() -> argparse.Namespace:
return args


def _provider_overrides_from_checkpoint(megatron_path: str) -> dict:
"""Read ``mtp_num_layers`` from the checkpoint so the exporter matches how it was saved.

Only ``mtp_num_layers`` is taken from here. ``moe_grouped_gemm`` is deliberately NOT derived:
for a ``MambaModelProvider`` the expert layout is set by ``mamba_stack_spec``, so a checkpoint
saved with non-grouped experts still records ``moe_grouped_gemm: true`` and trusting it would
build a mismatched model.
"""
defaults = {"mtp_num_layers": 0}
run_config = next(iter(sorted(pathlib.Path(megatron_path).glob("*/run_config.yaml"))), None)
if run_config is None:
run_config = pathlib.Path(megatron_path) / "run_config.yaml"
Comment on lines +125 to +127

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the iter_* resolution used by the Megatron checkpoint loader.
set -euo pipefail

fd -t f 'mbridge.py' modelopt | xargs -r rg -n -C 5 '_get_modelopt_checkpoint_path|iter_'
rg -n -C 5 'def _get_modelopt_checkpoint_path' --glob '*.py'

Repository: NVIDIA/Model-Optimizer

Length of output: 2401


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- exporter ---'
cat -n examples/megatron_bridge/export_quantized_megatron_to_hf.py | sed -n '95,145p'

printf '%s\n' '--- loader call sites and definitions ---'
rg -n -C 12 'load_modelopt_megatron_checkpoint|_get_modelopt_checkpoint_path|def _get_modelopt_checkpoint_path' \
  modelopt examples --glob '*.py'

printf '%s\n' '--- iteration directory fixtures/usages ---'
rg -n -C 4 'iter_[0-9]+|run_config\.yaml' modelopt examples --glob '*.py' --glob '*.yaml'

Repository: NVIDIA/Model-Optimizer

Length of output: 29061


🌐 Web query:

Megatron-Bridge _get_modelopt_checkpoint_path source iter_ latest checkpoint

💡 Result:

The function _get_modelopt_checkpoint_path is a utility in the Megatron-Bridge library (specifically within bridge.training.post_training.checkpointing) used to determine the correct path for ModelOpt operations [1][2][3]. This function effectively handles iteration directories by checking the provided checkpoint_path for the presence of ModelOpt states [1][4]. It specifically looks for modelopt_state directories either within specific iteration subdirectories (which follow the iter_* naming convention) or within the root checkpoint directory [1][2]. In the context of Megatron-LM and Megatron-Bridge checkpointing: - Megatron-Bridge typically uses a base directory structure where iteration-specific checkpoints are stored in iter_N/ subdirectories [5][6]. - When determining the "latest" checkpoint, Megatron Bridge relies on a tracker file named latest_train_state.pt located in the root directory [5][7]. - The _get_modelopt_checkpoint_path utility abstracts the path resolution to ensure ModelOpt-specific data is correctly identified and retrieved, regardless of whether it is stored at the root or within an iteration-specific folder [1][3]. For users needing to load a specific checkpoint iteration, the ckpt_step parameter is used to override the default tracker behavior and target a specific iter_N folder directly [5][7]. If you are performing manual conversion or export tasks, logic similar to _get_modelopt_checkpoint_path often involves scanning the checkpoint_path for iter_* directories, sorting them numerically, and selecting the highest index to identify the latest iteration [8].

Citations:


🌐 Web query:

site:github.com/NVIDIA-NeMo/Megatron-Bridge "_get_modelopt_checkpoint_path"

💡 Result:

The term get_modelopt_checkpoint_path does not appear to be a standard, publicly documented public API function within the NVIDIA Model Optimizer (ModelOpt) or the NVIDIA NeMo/Megatron-Bridge libraries. Based on an analysis of the NVIDIA NeMo/Megatron-Bridge repository [1][2][3], interactions with ModelOpt checkpoints typically involve: 1. Manual Directory Specification: Scripts generally expect users to provide a path to the checkpoint directory directly via arguments (e.g., --megatron-save-path or --pretrained-checkpoint) [3][4][5]. 2. Internal Logic: The codebase contains logic within internal utility modules (such as src/megatron/bridge/training/post_training/checkpointing.py) that handles the identification and selection of ModelOpt checkpoints [6]. This logic often involves checking for specific configuration files like run_config.yaml or iterating through subdirectories labeled iter [1][2]. 3. Model Configuration: Quantization configurations are typically handled through functions like get_modelopt_torch_quantization_config [4] or by defining model specifications using tools like get_gpt_modelopt_spec [7]. If you encountered this name in a specific codebase, it is likely an internal helper function or a custom utility specific to that project rather than a public function provided by NVIDIA ModelOpt. You may wish to search for the definition of this function within your local project files to understand its specific implementation and usage.

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

url='https://raw.githubusercontent.com/NVIDIA-NeMo/Megatron-Bridge/main/src/megatron/bridge/training/post_training/checkpointing.py'
curl -fsSL "$url" | rg -n -C 20 '_get_modelopt_checkpoint_path|iter_|latest_train_state|run_config'

Repository: NVIDIA/Model-Optimizer

Length of output: 5010


🏁 Script executed:

#!/bin/bash
set -euo pipefail

url='https://raw.githubusercontent.com/NVIDIA-NeMo/Megatron-Bridge/main/src/megatron/bridge/training/checkpointing.py'
curl -fsSL "$url" | rg -n -C 25 '_resolve_checkpoint_iteration|_DIRECT_ITERATION_DIR_SENTINEL|latest_train_state|ckpt_step'

printf '%s\n' '--- exporter path construction ---'
cat -n examples/megatron_bridge/export_quantized_megatron_to_hf.py | sed -n '145,180p'
rg -n -C 8 'megatron_path|checkpoint_export_paths' examples/megatron_bridge/export_quantized_megatron_to_hf.py

Repository: NVIDIA/Model-Optimizer

Length of output: 28745


Resolve run_config.yaml from the checkpoint iteration selected by the loader. sorted(...)[0] selects the first lexicographic match, while the loader uses the tracker, ckpt_step, direct iteration paths, or checkpoint state metadata. Do not replace this with reverse sorting; reuse the loader’s resolution logic so mtp_num_layers matches the loaded checkpoint.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/megatron_bridge/export_quantized_megatron_to_hf.py` around lines 125
- 127, Update the run_config resolution near run_config so it uses the
checkpoint iteration/path selected by the loader, including tracker, ckpt_step,
direct iteration, or checkpoint state metadata. Remove the lexicographic
sorted(...)[0] selection and derive run_config.yaml from the same resolved
checkpoint location used for loading. Preserve the root-level fallback only when
the loader resolves the root checkpoint itself.

if not run_config.exists():
print_rank_0(f"No run_config.yaml under {megatron_path}; using defaults {defaults}.")
return defaults
try:
cfg = yaml.safe_load(run_config.read_text()) or {}
except Exception as exc:
print_rank_0(f"Could not parse {run_config} ({exc}); using defaults {defaults}.")
return defaults
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else cfg
resolved = {
key: model_cfg.get(key, default)
for key, default in defaults.items()
if isinstance(model_cfg, dict)
}
resolved = {**defaults, **resolved}
Comment on lines +136 to +142

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

An explicit null in the YAML defeats the default.

If run_config.yaml contains mtp_num_layers: null, model_cfg.get(key, default) returns None, and the following merge keeps None because resolved overrides defaults. The provider then receives mtp_num_layers=None instead of 0.

The if isinstance(model_cfg, dict) filter is also loop-invariant. Move it out of the comprehension.

♻️ Proposed fix
     model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else cfg
-    resolved = {
-        key: model_cfg.get(key, default)
-        for key, default in defaults.items()
-        if isinstance(model_cfg, dict)
-    }
-    resolved = {**defaults, **resolved}
+    resolved = dict(defaults)
+    if isinstance(model_cfg, dict):
+        for key, default in defaults.items():
+            value = model_cfg.get(key)
+            resolved[key] = default if value is None else value
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else cfg
resolved = {
key: model_cfg.get(key, default)
for key, default in defaults.items()
if isinstance(model_cfg, dict)
}
resolved = {**defaults, **resolved}
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else cfg
resolved = dict(defaults)
if isinstance(model_cfg, dict):
for key, default in defaults.items():
value = model_cfg.get(key)
resolved[key] = default if value is None else value
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/megatron_bridge/export_quantized_megatron_to_hf.py` around lines 136
- 142, Update the resolved configuration construction to treat explicit null
values in model_cfg as missing, so defaults such as mtp_num_layers=0 are
preserved. Move the isinstance(model_cfg, dict) check outside the comprehension,
and only read keys from model_cfg when it is a dictionary; otherwise retain
defaults unchanged.

print_rank_0(f"Model shape from {run_config.name}: {resolved}")
return resolved


def main(args: argparse.Namespace):
_ckpt_shape = _provider_overrides_from_checkpoint(args.megatron_path)
trust_remote_code = is_safe_repo(
trust_remote_code=args.trust_remote_code, hf_path=args.hf_model_name_or_path
)
Expand All @@ -116,8 +162,11 @@ def main(args: argparse.Namespace):
"num_layers_in_first_pipeline_stage": args.num_layers_in_first_pipeline_stage,
"num_layers_in_last_pipeline_stage": args.num_layers_in_last_pipeline_stage,
"pipeline_dtype": torch.bfloat16,
"mtp_num_layers": _ckpt_shape["mtp_num_layers"],
},
init_model_parallel=True,
# Default non-grouped, matching quantize.py; the layout must match the checkpoint.
moe_grouped_gemm=args.grouped_experts,
load_weights=False, # The weights come from the Megatron checkpoint, so HF weights are not loaded
)

Expand All @@ -127,6 +176,59 @@ def main(args: argparse.Namespace):
load_modelopt_megatron_checkpoint(model, args.megatron_path)
unwrapped_model = unwrap_model(model[0])

# Static-NVFP4 export guard.
#
# An *enabled* NVFP4 weight quantizer that reaches the exporter without its calibrated scales
# means the values stored in the checkpoint were not restored. Exporting such a weight silently
# falls back to BF16: the result is larger than the recipe specifies and no longer matches it,
# with nothing in the logs to say so. Fail loudly instead.
#
# Static-block NVFP4 needs BOTH ``_amax`` (per block) and ``_global_amax`` (per tensor). A
# missing ``_global_amax`` slips past an ``_amax``-only check and then fails much later inside
# ``NVFP4QTensor.quantize``, where ``scale * scale_2`` broadcasts [N, 1] against [N] into an
# N x N allocation. Naming the attribute here turns that into an actionable message.
#
# Set MODELOPT_ALLOW_UNCALIBRATED_NVFP4=1 to keep the previous behavior (disable the quantizer
# and emit BF16), which is then reported rather than silent.
uncalibrated: list[tuple[str, str]] = []
for name, module in unwrapped_model.named_modules():
# StaticBlockScaleQuantizer must be INCLUDED: `_global_amax` is defined on it, so excluding
# it would skip exactly the case this guard exists to catch. Only report enabled quantizers,
# matching the message.
if not isinstance(module, TensorQuantizer) or not getattr(module, "is_enabled", False):
continue
block_sizes = getattr(module, "_block_sizes", None)
is_nvfp4 = getattr(module, "_num_bits", None) == (2, 1) and (
isinstance(block_sizes, dict) and block_sizes.get("scale_bits") == (4, 3)
)
if not is_nvfp4:
continue
if getattr(module, "_amax", None) is None:
uncalibrated.append((name, "_amax"))
elif (
isinstance(module, StaticBlockScaleQuantizer)
or block_sizes.get("type") == "static"
) and getattr(module, "_global_amax", None) is None:
uncalibrated.append((name, "_global_amax"))

if uncalibrated:
detail = ", ".join(f"{name}.{attr}" for name, attr in uncalibrated[:8])
if len(uncalibrated) > 8:
detail += ", ..."
message = (
f"{len(uncalibrated)} enabled NVFP4 weight quantizer(s) are missing calibrated scales "
f"after loading {args.megatron_path}: {detail}. These weights would be exported as "
"BF16 instead of NVFP4. Re-run PTQ with a ModelOpt that saves and restores this "
"quantizer state, or set MODELOPT_ALLOW_UNCALIBRATED_NVFP4=1 to export them as BF16."
)
if os.environ.get("MODELOPT_ALLOW_UNCALIBRATED_NVFP4") != "1":
raise RuntimeError(message)
print_rank_0(f"WARNING (MODELOPT_ALLOW_UNCALIBRATED_NVFP4=1): {message}")
for name, _ in uncalibrated:
unwrapped_model.get_submodule(name).disable()
else:
print_rank_0("All enabled NVFP4 weight quantizers have calibrated scales.")

# 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
# correct regardless of pipeline placement / global rank ordering.
Expand Down
39 changes: 39 additions & 0 deletions examples/megatron_bridge/quantize.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,19 @@ def get_args() -> argparse.Namespace:
parser.add_argument(
"--calib_num_samples", type=int, default=1024, help="Number of samples for calibration"
)
parser.add_argument(
"--grouped_experts",
action="store_true",
help="Build MoE experts grouped (GroupedMLP). Default is non-grouped (SequentialMLP), "
"which per-block NVFP4 requires because TEGroupedLinear can only represent per-tensor "
"scales. Set this for per-tensor recipes on MoE models, where grouped GEMM is faster. "
"The export must use the matching layout.",
)
parser.add_argument(
"--calib_random_offset",
action="store_true",
help="Drop a random leading-token offset before packing calib windows (Megatron-LM --calib-use-random-offset).",
)
parser.add_argument("--calib_batch_size", type=int, default=1, help="Calibration batch size")
parser.add_argument(
"--seq_length",
Expand Down Expand Up @@ -275,6 +288,18 @@ def get_quant_config(args: argparse.Namespace) -> dict:
return mtq_config


_MTP_HF_CONFIG_FIELDS = ("num_nextn_predict_layers", "mtp_num_hidden_layers", "mtp_num_layers")


def _hf_config_has_mtp(hf_cfg) -> bool:
"""Whether an HF config declares MTP heads (checked top-level and under ``text_config``)."""
return any(
cfg is not None and getattr(cfg, field, 0)
for cfg in (getattr(hf_cfg, "text_config", None), hf_cfg)
for field in _MTP_HF_CONFIG_FIELDS
)


def main(args: argparse.Namespace):
bridge, _provider, model, unwrapped_model, tokenizer = load_mbridge_model_from_hf(
hf_model_name_or_path=args.hf_model_name_or_path,
Expand All @@ -284,14 +309,27 @@ def main(args: argparse.Namespace):
"pipeline_model_parallel_size": args.pp_size,
"expert_model_parallel_size": args.ep_size,
"context_parallel_size": args.cp_size,
"mtp_num_layers": 0, # MTP not supported during calibration
"expert_tensor_parallel_size": 1, # Expert tensor parallelism is not supported
"pipeline_dtype": torch.bfloat16,
"seq_length": args.seq_length,
"gradient_accumulation_fusion": False, # not supported
},
init_model_parallel=True,
# Default non-grouped: per-block NVFP4 needs it (TEGroupedLinear is per-tensor only).
# Opt into grouped for per-tensor recipes, where grouped GEMM is faster.
moe_grouped_gemm=args.grouped_experts,
)

# `mtp_num_layers=0` above drops MTP heads: calibration does not support them. Say so rather
# than silently shipping a checkpoint without a head the model declares.
if _hf_config_has_mtp(bridge.hf_pretrained.config):
warn_rank_0(
"Dropping Multi-Token Prediction (MTP): calibration does not support it. The exported "
"checkpoint will not contain MTP weights and standard autoregressive inference is "
"unaffected. To use MTP speculative decoding, run a separate phase with mtp_num_layers>0."
)

# Only the language model is quantized (vision tower + projector stay full precision)
language_model = getattr(unwrapped_model, "language_model", unwrapped_model)
is_vlm = language_model is not unwrapped_model
Expand Down Expand Up @@ -367,6 +405,7 @@ def main(args: argparse.Namespace):
seq_length=args.seq_length,
batch_size=args.calib_batch_size,
pack=True, # Megatron pretraining-style global-stream document packing
random_offset=args.calib_random_offset,
)

# Run text prefill on the language model: we quantize the root (a VLM root forward expects
Expand Down
46 changes: 46 additions & 0 deletions modelopt/torch/distill/plugins/megatron.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,52 @@ def _set_input_tensor(self, input_tensors: list[Tensor]):

# HACK: Concatenate output tensors when PP>1 so they can be passed between ranks.
def _forward(self, *args, **kwargs):
# Static-block NVFP4: promote the student's weight quantizers once, after the
# checkpoint amax/scales have been loaded, so the training forward takes the
# StaticBlockScaleQuantizer path rather than the generic FP8 (E4M3) path. Promotion
# cannot happen at build time because the scales only exist after the load.
#
# NOTE: in practice this converts exactly ONE module -- ``output_layer``. A measured run
# reports ``already promoted 460, converted 1, skipped 0``: every other quantizer is
# already a StaticBlockScaleQuantizer by the time training starts. So this is a workaround
# for output_layer being the one module the restore path does not promote (the same
# asymmetry behind its weight-quantizer scales not being restored). The better fix is to
# promote it on the normal path; until then, without this block the output projection
# would train through the generic FP8 path instead of static-block NVFP4.
if not getattr(self, "_modelopt_nvfp4_promoted", False):
from modelopt.torch.quantization.nn.modules.tensor_quantizer import (
StaticBlockScaleQuantizer,
TensorQuantizer,
)

n_promoted = n_skipped = 0
for name, module in self.named_modules():
if not isinstance(module, TensorQuantizer) or isinstance(
module, StaticBlockScaleQuantizer
):
continue
Comment on lines +630 to +634

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The scan traverses the teacher, not only the student.

self is the mtd.DistillationModel, and the teacher is a registered child module. hide_teacher_model() at line 565 and only_student_forward() at line 663 exist because of that. So self.named_modules() at line 630 also yields the teacher's submodules, and the loop can promote a teacher quantizer. The comment at line 611 states that this promotes "the student's weight quantizers".

A BF16 teacher carries no NVFP4 quantizers, so nothing changes today. A quantized teacher would be mutated silently. Scope the traversal to the student.

🛠️ Proposed fix
             n_promoted = n_skipped = 0
-            for name, module in self.named_modules():
+            teacher_modules = {id(m) for m in self._teacher_model.modules()}
+            for name, module in self.named_modules():
+                if id(module) in teacher_modules:
+                    continue
                 if not isinstance(module, TensorQuantizer) or isinstance(
                     module, StaticBlockScaleQuantizer
                 ):
                     continue
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for name, module in self.named_modules():
if not isinstance(module, TensorQuantizer) or isinstance(
module, StaticBlockScaleQuantizer
):
continue
n_promoted = n_skipped = 0
teacher_modules = {id(m) for m in self._teacher_model.modules()}
for name, module in self.named_modules():
if id(module) in teacher_modules:
continue
if not isinstance(module, TensorQuantizer) or isinstance(
module, StaticBlockScaleQuantizer
):
continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelopt/torch/distill/plugins/megatron.py` around lines 630 - 634, Update
the quantizer scan near the loop over named modules to traverse only the student
model rather than self, preventing teacher submodules from being promoted.
Preserve the existing TensorQuantizer and StaticBlockScaleQuantizer filtering
and promotion behavior for student weight quantizers.

block_sizes = getattr(module, "_block_sizes", None)
is_nvfp4 = getattr(module, "_num_bits", None) == (2, 1) and (
isinstance(block_sizes, dict) and block_sizes.get("scale_bits") == (4, 3)
)
if not is_nvfp4:
continue
amax = getattr(module, "_amax", None)
if amax is None:
# Uncalibrated: leave it alone rather than silently changing precision.
logger.warning(f"NVFP4 weight quantizer {name} has no _amax; not promoted.")
n_skipped += 1
continue
StaticBlockScaleQuantizer.from_tensor_quantizer(
module, global_amax=amax.detach().float().abs().max()
)
n_promoted += 1
if n_promoted or n_skipped:
logger.info(
f"Promoted {n_promoted} NVFP4 weight quantizer(s) to "
f"StaticBlockScaleQuantizer ({n_skipped} skipped)."
)
self._modelopt_nvfp4_promoted = True
with torch.no_grad():
self._teacher_model.eval()
teacher_output = self._teacher_model(*args, **kwargs)
Expand Down
17 changes: 12 additions & 5 deletions modelopt/torch/export/plugins/vllm_fakequant_hf.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@
import modelopt.torch.opt as mto
from modelopt.torch.quantization.conversion import quantizer_state
from modelopt.torch.quantization.model_calib import enable_stats_collection, finish_stats_collection
from modelopt.torch.quantization.nn import QuantModule, SequentialQuantizer, TensorQuantizer
from modelopt.torch.quantization.nn import (
AnyQuantizer,
GroupedQuantizer,
QuantModule,
SequentialQuantizer,
TensorQuantizer,
)
from modelopt.torch.quantization.utils import get_quantizer_state_dict
from modelopt.torch.quantization.utils.core_utils import enable_weight_access_and_writeback
from modelopt.torch.quantization.utils.layerwise_calib import LayerActivationCollector
Expand Down Expand Up @@ -125,9 +131,7 @@ def _check_all_weight_quantizers_disabled(model: nn.Module) -> None:
if not isinstance(module, QuantModule):
continue
for attr_name, quantizer in module.named_children():
if attr_name.endswith("weight_quantizer") and isinstance(
quantizer, (TensorQuantizer, SequentialQuantizer)
):
if attr_name.endswith("weight_quantizer") and isinstance(quantizer, AnyQuantizer):
if quantizer.is_enabled:
raise RuntimeError(
f"vLLM fakequant export: {attr_name!r} must be disabled before saving "
Expand Down Expand Up @@ -625,7 +629,10 @@ def export_hf_vllm_fq_checkpoint(
for attr_name, quantizer in module.named_children():
if not (attr_name.endswith("weight_quantizer") and quantizer.is_enabled):
continue
if isinstance(quantizer, SequentialQuantizer):
if isinstance(quantizer, (SequentialQuantizer, GroupedQuantizer)):
# GroupedQuantizer (per-expert TEGroupedLinear) and SequentialQuantizer
# both hold sub-quantizers; disable each so the widened
# _check_all_weight_quantizers_disabled(AnyQuantizer) check passes.
quantizer.disable()
for sub in quantizer:
wqs_to_restore.append((sub, sub._rotate))
Expand Down
Loading