Skip to content
Open
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
299 changes: 212 additions & 87 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 +124 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Compatibility] Hardcoding mtp_num_layers=0 and moe_grouped_gemm=False in the shared exporter breaks export of checkpoints that do have MTP layers or grouped experts.

Both comments describe one specific checkpoint ("QAD ckpt has MTP dropped", "QAD ckpt is non-grouped"), but this script is the general Megatron-Bridge → HF quantized exporter, not a QAD-only tool. load_mbridge_model_from_hf defaults moe_grouped_gemm=True (modelopt/torch/utils/plugins/mbridge.py:47), and the model structure built here must match the checkpoint being loaded by load_modelopt_megatron_checkpoint. Forcing both values means:

  • A grouped-expert quantized checkpoint (the common PTQ output — quantize.py only switches to non-grouped for static-block NVFP4) is now loaded into a non-grouped structure. Given this PR's own central finding — that the dist-checkpoint loader silently skips keys the model doesn't advertise — a structural mismatch here is liable to drop expert weights silently rather than error.
  • An MTP-enabled checkpoint loses its MTP layers on export.

Same concern for quantize.py:292,299, which hardcodes the identical pair on the calibration path. There mtp_num_layers=0 is defensible (MTP genuinely isn't supported during calibration), but moe_grouped_gemm=False is only needed for static-block NVFP4 recipes — forcing it for every recipe changes the layer spec for dynamic-NVFP4/FP8 users who previously calibrated with grouped GEMM, which is both a perf regression and a checkpoint-structure change.

Suggested fix: expose both as CLI flags with the current defaults preserved, so the QAD path opts in explicitly:

parser.add_argument("--mtp_num_layers", type=int, default=None,
                    help="Override provider mtp_num_layers (0 for QAD checkpoints with MTP dropped).")
parser.add_argument("--moe_grouped_gemm", action=argparse.BooleanOptionalAction, default=True,
                    help="Use grouped GEMM for MoE. Pass --no-moe-grouped-gemm for "
                         "static-block NVFP4 checkpoints built with non-grouped experts.")

then pass through only when set, leaving the provider default otherwise. Note the mirrored --student_nongrouped_experts flag added in distill.py gets this right — it defaults off precisely to preserve committed grouped behavior. These two sites should follow the same principle.

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}
print_rank_0(f"Model shape from {run_config.name}: {resolved}")
return resolved
Comment on lines +116 to +144

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect how run_config.yaml is written/read for Megatron checkpoints.
rg -n -C5 'run_config\.yaml' --type=py

Repository: NVIDIA/Model-Optimizer

Length of output: 160


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
git ls-files | grep -E '(^|/)(export_quantized_megatron_to_hf\.py|.*run.*config.*\.py|.*megatron.*\.py)$' | head -200
printf '%s\n' '--- references ---'
rg -n -C4 'run_config|_provider_overrides_from_checkpoint' . --glob '*.py' --glob '*.yaml' --glob '*.yml' || true
printf '%s\n' '--- target function ---'
target=$(git ls-files | grep 'examples/megatron_bridge/export_quantized_megatron_to_hf.py$' | head -1)
if [ -n "$target" ]; then
  sed -n '100,155p' "$target"
fi

Repository: NVIDIA/Model-Optimizer

Length of output: 11868


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- expression behavior ---'
python3 - <<'PY'
cases = [None, [], ["x"], 1, "text", {}, {"model": {"mtp_num_layers": 2}}, {"mtp_num_layers": 3}]
for raw in cases:
    cfg = raw or {}
    try:
        model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else cfg
        print(f"{raw!r} -> {model_cfg!r}")
    except Exception as exc:
        print(f"{raw!r} -> {type(exc).__name__}: {exc}")
PY
printf '%s\n' '--- checkpoint-path and exporter references ---'
rg -n -C5 'export_quantized_megatron_to_hf|megatron_path|checkpoints/|iter_[0-9].*run_config|run_config\.yaml' . \
  --glob '*.py' --glob '*.md' --glob '*.yaml' --glob '*.yml' --glob '*.sh' || true
printf '%s\n' '--- nearby exporter tests ---'
test_file=$(git ls-files | grep 'tests/examples/megatron_bridge/test_quantize_export.py$' | head -1)
if [ -n "$test_file" ]; then
  sed -n '1,260p' "$test_file"
fi

Repository: NVIDIA/Model-Optimizer

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- quantized exporter interface ---'
file=examples/megatron_bridge/export_quantized_megatron_to_hf.py
sed -n '1,110p' "$file"
printf '%s\n' '--- checkpoint resolution helper ---'
file=$(git ls-files | grep 'modelopt.*/mbridge.py$' | head -1)
rg -n -C8 '_get_modelopt_checkpoint_path|latest_checkpointed_iteration|iter_' "$file"
printf '%s\n' '--- quantization output and save arguments ---'
rg -n -C5 'export_megatron_path|save.*checkpoint|checkpoint_dir|iter_|latest_checkpointed_iteration' \
  examples/megatron_bridge/quantize.py tests/examples/megatron_bridge/test_quantize_export.py \
  --glob '*.py' | head -300
printf '%s\n' '--- focused run_config references ---'
rg -l 'run_config\.yaml' . --glob '*.py' --glob '*.md' --glob '*.yaml' --glob '*.yml' |
  while IFS= read -r f; do
    printf '%s\n' "--- $f"
    rg -n -C3 'run_config\.yaml' "$f"
  done

Repository: NVIDIA/Model-Optimizer

Length of output: 4523


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- checkpoint resolution helper ---'
sed -n '115,155p' modelopt/torch/utils/plugins/mbridge.py
rg -n -C10 '_get_modelopt_checkpoint_path|latest_checkpointed_iteration|iter_' \
  modelopt/torch/utils/plugins/mbridge.py
printf '%s\n' '--- quantization output and save arguments ---'
rg -n -C5 'export_megatron_path|save.*checkpoint|checkpoint_dir|iter_|latest_checkpointed_iteration' \
  examples/megatron_bridge/quantize.py tests/examples/megatron_bridge/test_quantize_export.py \
  --glob '*.py' | head -300
printf '%s\n' '--- all run_config references ---'
rg -l 'run_config\.yaml' . --glob '*.py' --glob '*.md' --glob '*.yaml' --glob '*.yml' |
  while IFS= read -r f; do
    printf '%s\n' "--- $f"
    rg -n -C3 'run_config\.yaml' "$f"
  done

Repository: NVIDIA/Model-Optimizer

Length of output: 12424


Guard non-dict YAML roots and select the latest checkpoint. Check isinstance(cfg, dict) before calling cfg.get; the proposed check runs too late for list or scalar roots. When megatron_path is a parent containing multiple iter_* directories, resolve the latest iteration instead of using sorted(...)[0], which can select an older 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 116
- 144, Update _provider_overrides_from_checkpoint to select the latest iter_*
checkpoint directory when megatron_path contains multiple checkpoints, rather
than choosing the first sorted run_config.yaml; retain the root run_config.yaml
fallback. After yaml.safe_load, validate that cfg is a dict before calling
cfg.get, and use defaults for list or scalar YAML roots.



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"))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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()
Comment on lines +193 to +228

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

RuntimeError on one rank can hang the rest of the job under pipeline parallelism.

uncalibrated is built from unwrapped_model.named_modules(), which is per-rank-local when pp_size > 1 (each pipeline stage holds a different subset of layers, and lm_head lives only on the last stage). If only the rank owning the affected layer(s) finds missing scales, only that rank executes raise RuntimeError(message) at Line 219. Every other rank has no way to learn about the failure and continues past this block to the torch.distributed.all_reduce call at Line 236, which then hangs waiting on the crashed rank. This script already implements the correct cross-rank pattern for exactly this situation a few lines below (has_extra_modules all_reduce with ReduceOp.MAX); mirror that pattern here so the decision to raise (or warn/disable) is made consistently on every rank before any rank exits or hangs on a later collective call. This scenario is directly relevant to this PR, which targets fixing lm_head quantization/tiedness handling.

has_uncalibrated = bool(uncalibrated)
if torch.distributed.is_initialized():
    flag = torch.tensor(
        [int(has_uncalibrated)], dtype=torch.int, device=torch.cuda.current_device()
    )
    torch.distributed.all_reduce(flag, op=torch.distributed.ReduceOp.MAX)
    has_uncalibrated = bool(flag.item())

if uncalibrated or has_uncalibrated:
    if not uncalibrated:
        message = (
            "A peer rank found enabled NVFP4 weight quantizer(s) missing calibrated scales "
            f"after loading {args.megatron_path}. Aborting on all ranks to avoid a hang."
        )
    else:
        ...  # existing local `detail`/`message` construction
    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.")
🤖 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 193
- 222, Synchronize the uncalibrated-quantizer condition across ranks before any
raise or later collective, using the existing has_extra_modules all_reduce
pattern and ReduceOp.MAX. Update the validation block around uncalibrated and
the RuntimeError so every rank raises consistently when any rank finds missing
scales; ranks without local findings should use an abort-context message, while
preserving the existing allow-environment warning and local disable behavior.

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.",
)
Comment on lines +177 to +184

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
set -euo pipefail

# Expect: an explicit fail-fast validation for grouped experts and per-block recipes.
rg -n -C 6 \
  'grouped_experts|moe_grouped_gemm|get_quant_config|NVFP4|block_sizes|scale_bits' \
  . --glob '*.py' --glob '*.yaml'

Repository: NVIDIA/Model-Optimizer

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== quantize.py structure and relevant ranges =="
ast-grep outline examples/megatron_bridge/quantize.py
sed -n '280,380p' examples/megatron_bridge/quantize.py
sed -n '380,440p' examples/megatron_bridge/quantize.py

echo "== focused grouped-layout and recipe references =="
rg -n -C 5 \
  'grouped_experts|moe_grouped_gemm|get_quant_config|per.?block|NVFP4|block_sizes|TEGroupedLinear|GroupedMLP|SequentialMLP' \
  examples modelopt tests \
  --glob '*.py' --glob '*.yaml' \
  | head -n 1200

Repository: NVIDIA/Model-Optimizer

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== get_quant_config and parser definitions =="
sed -n '90,270p' examples/megatron_bridge/quantize.py
sed -n '1,95p' examples/megatron_bridge/quantize.py

echo "== all grouped-layout references outside the noisy kernel matches =="
rg -n -C 8 \
  'moe_grouped_gemm|grouped_experts|TEGroupedLinear|GroupedMLP|SequentialMLP' \
  examples modelopt tests \
  --glob '*.py' \
  --glob '!**/kernels/**'

echo "== recipe definitions and exporter references =="
rg -n -C 8 \
  'NVFP4|nvfp4|per.?block|block_sizes|recipe|export' \
  examples/megatron_bridge \
  --glob '*.py' --glob '*.yaml' \
  | head -n 1600

Repository: NVIDIA/Model-Optimizer

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== exact config resolution and save order =="
sed -n '215,285p' examples/megatron_bridge/quantize.py
sed -n '345,430p' examples/megatron_bridge/quantize.py
sed -n '430,495p' examples/megatron_bridge/quantize.py

echo "== grouped-linear implementation constraints =="
rg -n -C 12 \
  'GroupedLinear|TEGroupedLinear|per-tensor|per_tensor|blockwise|block.?size|block_sizes|raise (ValueError|AssertionError)|assert' \
  modelopt/torch/quantization/plugins modelopt/torch/export \
  --glob '*.py' \
  | head -n 1800

echo "== direct tests for grouped plus blockwise recipes =="
rg -n -C 15 \
  'moe_grouped_gemm.*True|grouped.*True|TEGroupedMLP only supports|INT4_BLOCKWISE|blockwise' \
  tests/gpu_megatron tests \
  --glob '*.py' \
  | head -n 1200

Repository: NVIDIA/Model-Optimizer

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

path = Path("examples/megatron_bridge/quantize.py")
tree = ast.parse(path.read_text())

for node in ast.walk(tree):
    if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in {"main", "get_quant_config"}:
        calls = []
        for child in ast.walk(node):
            if isinstance(child, ast.Call):
                fn = child.func
                if isinstance(fn, ast.Name):
                    name = fn.id
                elif isinstance(fn, ast.Attribute):
                    name = f"{ast.unparse(fn.value)}.{fn.attr}"
                else:
                    name = ast.unparse(fn)
                if name in {"get_quant_config", "load_mbridge_model_from_hf", "mtq.quantize"}:
                    calls.append((child.lineno, name))
        print(node.name, sorted(calls))

print("main source ranges:")
lines = path.read_text().splitlines()
for start, end in [(220, 270), (300, 375), (400, 490)]:
    print(f"--- {start}:{end} ---")
    for i in range(start, end + 1):
        print(f"{i}: {lines[i-1]}")
PY

echo "== concise grouped/blockwise evidence =="
rg -n -C 4 \
  'TEGroupedMLP only supports per-tensor quantization|INT4_BLOCKWISE_WEIGHT_ONLY_CFG|NVFP4_DEFAULT_CFG|moe_grouped_gemm' \
  tests/gpu_megatron/torch/quantization/plugins/test_megatron.py \
  tests/gpu_megatron/torch/quantization/plugins/test_transformer_engine.py

Repository: NVIDIA/Model-Optimizer

Length of output: 41021


Validate grouped-layout compatibility before model construction.

The grouped MoE path supports only per-tensor quantization. Resolve mtq_config before load_mbridge_model_from_hf() and reject per-block expert quantization when args.grouped_experts is enabled.

🤖 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/quantize.py` around lines 177 - 184, Update the
quantization setup in the main flow of quantize.py to resolve mtq_config before
calling load_mbridge_model_from_hf(). When args.grouped_experts is enabled,
validate that expert quantization uses per-tensor scaling and reject per-block
configurations before constructing the model; leave compatible configurations
and the non-grouped path unchanged.

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).",
)
Comment on lines +185 to +189

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

Reject or document --calib_random_offset for VLM calibration.

Line [408] is reached only for text calibration. The image-text branch builds get_megatron_vlm_calibration_forward_loop without random_offset. A VLM run can therefore accept the flag and silently ignore it.

Reject this combination or state clearly that the option is text-only.

Proposed fail-fast check
     use_image_calib = args.calib_dataset_name in get_supported_vlm_datasets()
+    if args.calib_random_offset and use_image_calib:
+        raise ValueError(
+            "--calib_random_offset is supported only for text calibration."
+        )

Also applies to: 408-408

🤖 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/quantize.py` around lines 185 - 189, Update the
argument handling in the quantization flow around --calib_random_offset and
get_megatron_vlm_calibration_forward_loop so VLM calibration cannot silently
ignore the option: either reject its combination with VLM calibration via a
clear validation error, or explicitly document and enforce that the flag is
text-only. Preserve the existing random-offset behavior for text calibration.

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
)
Comment on lines +291 to +300

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
set -euo pipefail

# Expect: tests or normalization code cover both mapping-backed and object-backed text_config values.
rg -n -C 4 \
  'text_config|_hf_config_has_mtp|num_nextn_predict_layers|mtp_num_hidden_layers|mtp_num_layers' \
  . --glob '*.py'

Repository: NVIDIA/Model-Optimizer

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd -t f '^quantize\.py$' examples modelopt tests | head -n 1)"
printf '%s\n' "FILE=$file"
wc -l "$file"
cat -n "$file" | sed -n '1,40p;270,345p'

printf '\n-- relevant definitions and calls --\n'
rg -n -C 8 \
  '_hf_config_has_mtp|_MTP_HF_CONFIG_FIELDS|mtp_num_layers|MTP|text_config' \
  "$file"

printf '\n-- nearby tests --\n'
rg -n -C 6 \
  'hf_config_has_mtp|mtp_num_layers|num_nextn_predict_layers|text_config' \
  tests examples --glob '*.py' \
  | head -n 300

Repository: NVIDIA/Model-Optimizer

Length of output: 5558


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="examples/megatron_bridge/quantize.py"
test -f "$file"
wc -l "$file"
cat -n "$file" | sed -n '1,55p;270,345p'

printf '\n-- relevant definitions and calls --\n'
rg -n -C 10 \
  '_hf_config_has_mtp|_MTP_HF_CONFIG_FIELDS|mtp_num_layers|MTP|text_config' \
  "$file"

printf '\n-- targeted tests and normalization code --\n'
rg -n -C 6 \
  '_hf_config_has_mtp|num_nextn_predict_layers|mtp_num_hidden_layers|mtp_num_layers|text_config' \
  tests examples --glob '*.py' \
  | rg -v '(^|/)(alpamayo|speculative_decoding|gpu_megatron)' \
  | head -n 300

Repository: NVIDIA/Model-Optimizer

Length of output: 36728


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '-- loader definition and config flow --'
rg -n -C 12 \
  'def load_mbridge_model_from_hf|load_mbridge_model_from_hf\(' \
  . --glob '*.py' \
  | head -n 240

printf '%s\n' '-- dependency versions and relevant config construction --'
rg -n -C 8 \
  'transformers|Qwen2VLConfig|Qwen3VLConfig|text_config' \
  pyproject.toml requirements*.txt setup.cfg setup.py tests/_test_utils/torch/transformers_models.py \
  2>/dev/null | head -n 260

printf '%s\n' '-- current import and mapping support --'
sed -n '20,80p' examples/megatron_bridge/quantize.py
rg -n -C 5 \
  'collections\.abc|Mapping|isinstance\(.*dict|isinstance\(.*Mapping' \
  examples/megatron_bridge modelopt tests --glob '*.py' \
  | head -n 220

Repository: NVIDIA/Model-Optimizer

Length of output: 32967


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="modelopt/torch/utils/plugins/mbridge.py"
cat -n "$file" | sed -n '35,180p'

printf '\n-- config access in loader --\n'
rg -n -C 10 \
  'AutoConfig|config|text_config|from_pretrained|from_hf' \
  "$file" | head -n 260

printf '\n-- exact current helper behavior for object and mapping inputs --\n'
python3 - <<'PY'
from collections.abc import Mapping
from types import SimpleNamespace

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

def current(hf_cfg):
    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 fields
    )

def mapping_aware(hf_cfg):
    def value(cfg, field):
        return cfg.get(field, 0) if isinstance(cfg, Mapping) else getattr(cfg, field, 0)
    return any(
        cfg is not None and value(cfg, field)
        for cfg in (getattr(hf_cfg, "text_config", None), hf_cfg)
        for field in fields
    )

cases = {
    "object nested MTP": SimpleNamespace(
        text_config=SimpleNamespace(mtp_num_layers=1)
    ),
    "mapping nested MTP": SimpleNamespace(
        text_config={"mtp_num_layers": 1}
    ),
    "top-level MTP": SimpleNamespace(mtp_num_layers=1),
    "no MTP": SimpleNamespace(text_config={"num_hidden_layers": 2}),
}
for name, cfg in cases.items():
    print(name, "current=", current(cfg), "mapping_aware=", mapping_aware(cfg))
PY

Repository: NVIDIA/Model-Optimizer

Length of output: 8804


🌐 Web query:

Transformers Qwen3VLConfig __init__ text_config dict normalized PretrainedConfig AutoBridge.from_hf_pretrained bridge.hf_pretrained.config

💡 Result:

The query involves two distinct technical domains: the configuration management for the Qwen3-VL model within the Hugging Face Transformers library, and the model conversion workflow using the NVIDIA Megatron-Bridge library [1][2]. Qwen3VLConfig Initialization The Qwen3VLConfig class serves as the configuration container for Qwen3-VL models. During initialization, the text_config and vision_config parameters allow for either passing a configuration dictionary or a PreTrainedConfig object [3]. In the implementation, these sub-configurations are normalized during the post-initialization phase (typically via post_init) [1][4]. If a parameter is passed as a dict, the configuration class automatically instantiates the appropriate sub-config class (Qwen3VLTextConfig or Qwen3VLVisionConfig) using the dictionary as kwargs [1][5]. If a parameter is None, the class defaults to an empty initialization of the corresponding sub-config [1][6]. Megatron-Bridge AutoBridge The AutoBridge.from_hf_pretrained method is a high-level API in the Megatron-Bridge library designed to load a Hugging Face model and prepare it for conversion to Megatron format [2][7]. When you call bridge = AutoBridge.from_hf_pretrained(model_path), the method: 1. Loads the model configuration in a thread-safe manner (often using safe_load_config_with_retry) [8][9]. 2. Validates the configuration against supported architectures [8]. 3. Returns an AutoBridge instance [7]. The bridge object contains an hf_pretrained attribute, which holds the loaded Hugging Face model or configuration. You can access the underlying Hugging Face configuration object via bridge.hf_pretrained.config [8][7]. This enables programmatic access to the model's structural parameters after the bridge has been instantiated for conversion tasks [10].

Citations:


🌐 Web query:

site:github.com/NVIDIA-NeMo/Megatron-Bridge AutoBridge from_hf_pretrained hf_pretrained config PretrainedConfig

💡 Result:

AutoBridge is a component of the NVIDIA-NeMo Megatron-Bridge library designed to facilitate bidirectional conversion between Hugging Face (HF) and Megatron-Core model formats [1][2]. The method AutoBridge.from_hf_pretrained(path, kwargs) is the primary entry point for loading models [1][3]. It automatically detects the model architecture from the provided Hugging Face model path (or ID), selects the appropriate bridge, and validates the configuration to ensure compatibility for conversion operations [1][3][4]. Key technical aspects include: - Configuration Handling: The method loads the Hugging Face configuration, performing architecture validation before proceeding with further operations [3]. It also handles thread-safe configuration loading and supports the trust_remote_code parameter for loading custom models [3]. - PretrainedConfig Interaction: While AutoBridge.from_hf_pretrained loads from a path or model ID, the library also provides AutoBridge.from_hf_config(config: PretrainedConfig), which accepts an already-loaded PretrainedConfig object [1][4]. However, documentation advises using from_hf_pretrained for export workflows to ensure that all necessary artifacts, such as tokenizers, are correctly included [1]. - Workflow: Once an AutoBridge instance is created, it can be used to convert models to a Megatron provider via.to_megatron_provider, which then allows for the instantiation of distributed Megatron models [5][4]. The library is optimized for handling parallelism-aware conversion (TP/PP/EP) and utilizes the MappingRegistry to define weight mappings between HF and Megatron parameters [4][6][2].

Citations:


🌐 Web query:

Transformers configuration text_config isinstance PretrainedConfig dict all multimodal config classes Qwen3VL Qwen2VL Gemma3 Mistral3

💡 Result:

In the Hugging Face Transformers library, multimodal models use a composite configuration pattern to manage separate sub-architectures (like vision and text encoders) [1][2]. The text_config attribute in these configuration classes is typically a PreTrainedConfig object (or a dictionary that is subsequently converted into one) [3][4][5][6]. Multimodal configuration classes—such as Qwen2VLConfig, Qwen3VLConfig, Gemma3Config, and Mistral3Config—commonly use an post_init method to check the type of text_config [4][5][6][7]. When initializing these models, if text_config is passed as a dict, the configuration class uses its internal mapping or a specialized configuration class to instantiate it as a proper PreTrainedConfig object [3][4][8][5]. If it is already a PreTrainedConfig instance, it is assigned directly [9][6]. Summary of specified models: - Qwen2VLConfig: Uses a sub_configs mapping to define text_config as Qwen2VLTextConfig [5]. - Qwen3VLConfig: Defines text_config as Qwen3VLTextConfig [10][6]. - Gemma3Config: Defines text_config as Gemma3TextConfig [4]. - Mistral3Config: Uses AutoConfig to handle the text_config [7]. Additionally, the base PreTrainedConfig class provides a get_text_config method, which is designed to return the relevant text configuration from a composite model, helping to standardize access across different generations of multimodal architectures [1][2].

Citations:


🌐 Web query:

site:github.com/NVIDIA-NeMo/Megatron-Bridge "text_config" "AutoBridge" model bridge

💡 Result:

AutoBridge is a high-level API provided by the NVIDIA NeMo Megatron-Bridge library, designed to facilitate bidirectional model conversion and interoperability between Hugging Face (transformers) and NVIDIA Megatron Core formats [1][2]. Key aspects of AutoBridge include: Unified Entry Point: AutoBridge serves as the primary interface for users, automatically detecting the model architecture from a Hugging Face checkpoint (hub or local path) and selecting the appropriate architecture-specific bridge class to manage the conversion process [1][3][4]. Bidirectional Conversion: Hugging Face to Megatron: It allows users to import Hugging Face models into Megatron for distributed training (handling tensor, pipeline, expert, and sequence parallelism) [1][2][3]. Megatron to Hugging Face: It enables exporting trained Megatron models back into Hugging Face format for deployment or use with other inference engines [1][2][3]. Workflow Integration: It simplifies workflows by providing high-level methods such as from_hf_pretrained() for initialization, to_megatron_provider() for configuring model parallelism, and to_megatron_model() for instantiation [1][3][4]. The bridge is parallelism-aware, managing weight mapping and tensor distribution across different Megatron parallelism configurations (TP/PP/VPP/CP/EP/ETP) [1][2]. It supports memory-efficient operations through per-parameter streaming using safetensors [1][2]. As part of the NeMo Framework ecosystem, Megatron-Bridge ensures checkpoint integrity and accuracy through built-in verification mechanisms during the conversion process [2]. It is widely used for scaling language, vision-language, and multimodal models to large-scale distributed environments [2][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

for spec in \
  "transformers_qwen3_vl.py|https://raw.githubusercontent.com/huggingface/transformers/v5.3.0/src/transformers/models/qwen3_vl/configuration_qwen3_vl.py" \
  "transformers_qwen2_vl.py|https://raw.githubusercontent.com/huggingface/transformers/v4.57.2/src/transformers/models/qwen2_vl/configuration_qwen2_vl.py" \
  "transformers_gemma3.py|https://raw.githubusercontent.com/huggingface/transformers/v4.57.2/src/transformers/models/gemma3/configuration_gemma3.py" \
  "transformers_mistral3.py|https://raw.githubusercontent.com/huggingface/transformers/v5.8.1/src/transformers/models/mistral3/configuration_mistral3.py" \
  "bridge_auto.py|https://raw.githubusercontent.com/NVIDIA-NeMo/Megatron-Bridge/main/src/megatron/bridge/models/conversion/auto_bridge.py"
do
  name="${spec%%|*}"
  url="${spec#*|}"
  curl --fail --silent --show-error --location "$url" > "$tmp/$name"
  printf '\n-- %s --\n' "$name"
  rg -n -C 8 'text_config|hf_pretrained|from_hf_pretrained|PretrainedConfig' "$tmp/$name" | head -n 180 || true
done

Repository: NVIDIA/Model-Optimizer

Length of output: 27932


🏁 Script executed:

#!/bin/bash
set -euo pipefail

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
url="https://raw.githubusercontent.com/NVIDIA-NeMo/Megatron-Bridge/main/src/megatron/bridge/models/conversion/auto_bridge.py"
curl --fail --silent --show-error --location "$url" > "$tmp/auto_bridge.py"

cat -n "$tmp/auto_bridge.py" | sed -n '90,160p;480,620p'

Repository: NVIDIA/Model-Optimizer

Length of output: 10980


Handle mapping-backed text_config values

If a mapping reaches _hf_config_has_mtp, getattr misses all nested MTP fields and suppresses the warning even though line 312 drops the MTP heads. Use a mapping-aware accessor and add tests for object-backed and mapping-backed configurations.

🤖 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/quantize.py` around lines 291 - 300, Update
_hf_config_has_mtp to read MTP fields from both attribute-backed objects and
mapping-backed configs, including nested text_config mappings, so existing MTP
detection remains accurate before line 312 drops the heads. Add tests covering
object-backed and mapping-backed top-level and text_config configurations.

Source: MCP tools



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
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()
)
Comment on lines +631 to +649

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[CRITICAL Algorithm] This ad-hoc promotion predicate is missing the is_static_block_quant condition, so it will promote dynamic NVFP4 quantizers to static block scaling.

The issue. The inline check is _num_bits == (2, 1) and block_sizes["scale_bits"] == (4, 3). The canonical predicate is TensorQuantizer.is_nvfp4_static (modelopt/torch/quantization/nn/modules/tensor_quantizer.py:573), which additionally requires is_static_block_quant — i.e. block_sizes.get("type") != "dynamic" and _fake_quant. A dynamic NVFP4 weight quantizer ({"type": "dynamic", "scale_bits": (4, 3)}, _num_bits == (2, 1)) satisfies the inline predicate but not the real one.

Why it matters. Dynamic NVFP4 weight quantizers do carry _amax: max_calibrate deliberately runs weight calibration on the weight tensor directly "so every weight quantizer gets _amax" (modelopt/torch/quantization/model_calib.py:339-341). So the amax is None skip does not protect them — they pass the guard and get converted via from_tensor_quantizer, permanently switching the layer from per-block dynamic scaling to static block scaling frozen at a stale calibration amax. This is silent numerical corruption of every MoE/linear weight in the student.

This is reachable on the default path: --student_nongrouped_experts defaults off, which the help text describes as the mode for "dynamic NVFP4 / grouped-expert checkpoints (e.g. Nemotron-3-Nano)". The measured run cited in the comment (already promoted 460, converted 1) was a static-NVFP4 recipe, so it would not have surfaced this.

Secondly, global_amax=amax.detach().float().abs().max() bypasses shared-state tying. promote_static_block_weight_quantizers (modelopt/torch/quantization/utils/core_utils.py:1016-1035) checks whether the quantizer belongs to a SharedWeightGlobalAmaxState group and ties it to the group's canonical buffer, raising if the group was never populated — precisely so fusible siblings (q/k/v, gate/up) share one FP8 grid. Promoting here gives each sibling an independent global_amax, reintroducing the inconsistency _check_grouped_weight_global_amax_synced exists to catch. Today only output_layer (no siblings) converts, but the loop is generic and will mis-promote siblings whenever the "everything else is already promoted" assumption breaks (older checkpoint, different restore path).

Suggested fix: delegate to the existing helper rather than reimplementing it — it already handles the format check, shared-state tying, and the reduce_amax global:

if not getattr(self, "_modelopt_nvfp4_promoted", False):
    from modelopt.torch.quantization.utils.core_utils import (
        promote_static_block_weight_quantizers,
    )

    n_promoted = promote_static_block_weight_quantizers(self)
    if n_promoted:
        logger.info(f"Promoted {n_promoted} static-block weight quantizer(s).")
    self._modelopt_nvfp4_promoted = True

If the inline loop must stay, gate it on getattr(module, "is_nvfp4_static", False) instead of the hand-rolled tuple comparison.

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
Loading