Skip to content

Support quantized Qwen3-VL / Qwen3.5-VL (dense + MoE) export from Megatron-Bridge and verify exported checkpoints - #2276

Open
kevalmorabia97 wants to merge 20 commits into
mainfrom
kmorabia/mbridge-qwen3vl-quantized-hf-export
Open

Support quantized Qwen3-VL / Qwen3.5-VL (dense + MoE) export from Megatron-Bridge and verify exported checkpoints#2276
kevalmorabia97 wants to merge 20 commits into
mainfrom
kmorabia/mbridge-qwen3vl-quantized-hf-export

Conversation

@kevalmorabia97

@kevalmorabia97 kevalmorabia97 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Type of change: Bug fix + new feature

Enables quantized Qwen3-VL and Qwen3.5-VL (dense and MoE) → unified HuggingFace export from Megatron-Bridge, and fixes the bugs found along the way (ten from testing, plus a further round from review). Most of them produced a valid-looking checkpoint and a green test run, so the PR also makes the export path verify its own output.

Review is easiest commit-by-commit — each of the eleven commits is self-contained and independently green.

Two blockers

  1. The exporter rejected the Megatron-Bridge VLM wrapper. GPTModelExporter only unwrapped MCore's LLaVAModel, so Qwen3VLModel raised ValueError: Input to GPTModelExport must be a megatron.core.models.GPTModel!. It now unwraps any wrapper exposing .language_model.
  2. A VLM QAD checkpoint couldn't be loaded back. distill.py passes distill_submodule="language_model", so the checkpoint holds only the language model and the load died on KeyError: vision_model.patch_embed.proj.weight. The loader now reads the checkpoint metadata and targets .language_model when there are no vision weights.

Four silent-corruption bugs

  1. VLM QAD discarded all ModelOpt state (shipped in 0.46). ModeloptStateManager requires state on the root of whatever gets checkpointed. quantize.py quantizes the VLM root, so PTQ anchors it there — but QAD checkpoints only language_model, orphaning it. The saved modelopt_state_dict was literally []; the *_quantizer._amax tensors were still present but got dropped on load (dist_ckpt_strictness="assume_ok_unexpected"), and the export came out plain BF16 with no hf_quant_config.json.
  2. Fused grouped-GEMM MoE experts were omitted entirely. The MoE dispatch had no else, so an architecture without an experts.linear_fc1 rule exported zero routed experts. This hit Qwen3MoeForCausalLM — a registered, supported architecture with no export test — not just VLMs. A tiny Qwen3-MoE exported 37 of 45 tensors, exit 0, no warning.
  3. Qwen3.5's GatedDeltaNet output norm was off by exactly 1.0. Megatron stores that gamma zero-centered, HF centers it on 1. Correct names, correct shapes, wrong values — invisible to any structural check. Megatron-Bridge's importer confirms the convention (RMSNorm2ZeroCenteredRMSNormMapping).
  4. The disabled-quantizer patterns silently no-op on Megatron paths. They are written against HuggingFace module names. *mixer.conv1d* matches only because MCore and HF happen to agree on "mixer" for Mamba; *linear_attn.conv1d* never matched (Megatron calls it self_attention.conv1d), so the conv1d was calibrated. *linear_attn.in_proj_a/b* cannot match at all — Megatron fuses all six GDN sections behind one quantizer — so the alpha/beta gates the recipe wants in BF16 were exported in FP8.

Four more bugs, found only by running real checkpoints

The tiny fixtures could not reach these; each came from a real model or a real quant format.

  1. Routed experts were written in a layout no real Qwen3.5 checkpoint uses. Real Qwen3.5 stores experts packed as [num_experts, out, in]; the mapping emitted per-expert names, so every routed expert was dropped. The fixture actively hid this: transformers unpacks experts on save_pretrained, so the saved reference agreed with the wrong output. Fixed with a transpose kwarg on _pack_name_remapping plus a GroupedMLPPacking rule, so fused TEGroupedMLP reaches the same packed tensors — which is also what lets Qwen3.5 keep grouped GEMM (22.1 GB/GPU vs 38.9 GB/GPU on a 20-layer, 256-expert model).
  2. _grouped_mlp_packing was broken for NVFP4. It max-merged weight_scale, but NVFP4 needs each expert's per-block scales stacked with only the global weight_scale_2 merged; it also dequantized packed uint8 against per-block scales, and passed block_size=None. weight_scale_2 is never populated in an FP8 run, so the whole branch was dead code under FP8-only testing. _grouped_mlp_slicing gained quantize=False so packing can quantize once over the stack, matching _pack_name_remapping.
  3. _mtp_prefix corrupted every VLM's MTP tensor names. It did prefix.replace("model", "mtp") uncounted, so model.language_model.layers.{} became mtp.language_mtp.layers.0.* — tensors present and correctly valued, under names nothing loads. LLM-only prefixes contain one occurrence, so this was invisible until a VLM with MTP was exported.
  4. load_multimodal_components rejected HF repo ids. quantize.py --hf_model_name_or_path Qwen/Qwen3.5-0.8B worked, but the documented export step failed with "It should be a directory". Its sibling in the same file already resolved repo ids via snapshot_download; now it does too. This affected every VLM export.

Qwen3_5ForConditionalGeneration (dense Qwen3.5-VL) is now registered for export and vision passthrough, which bugs 9 and 10 were blocking.

New: Qwen3.5-VL

GatedDeltaNetSlicing splits Megatron's fused in_proj ([query, key, value, z, beta, alpha]) into HF's in_proj_qkv / _z / _b / _a, taking sizes from the module's own in_proj_split_sections so TP sharding falls out. Widening coverage to Qwen3.5's gated full-attention layers then exposed a further split bug: gated attention packs a per-head output gate beside each query head, so _qkv_slicing split 192 rows as 96/48/48 instead of 128/32/32. It now derives the group stride from config.attention_output_gate, matching Megatron-Bridge's split_qkv_weights. The non-gated path is unchanged.

New: the export path verifies itself

  • assert_exported_checkpoint_matches compares an exported checkpoint against the model it came from — key set, shapes (accounting for NVFP4 uint8 packing), safetensors index consistency, and values — replacing existence-only assertions in all three export tests.
  • GPTModelExporter.save_pretrained now raises if the export dropped tensors the source checkpoint has, so user runs on architectures CI never sees are protected too, not just tiny models.
  • Loading a checkpoint whose quantizer tensors have no restorable state now raises instead of silently loading unquantized.
  • assert_has_modelopt_state replaces rglob("modelopt_state"), which passes on an empty state; assert_no_quantizers_matching fails on future HF↔Megatron name drift.

The mapping is also table-driven now: vision-tower prefixes live in all_mcore_hf_vision_passthrough_mapping and with_language_model_prefix is shared, so adding a VLM no longer means editing unified_export_megatron.py. Five call sites that answered "is this a VLM" three different ways now share get_language_model / is_vlm_config.

Usage

# Dense VLM (Qwen3-VL) -- no extra flags
torchrun --nproc_per_node 2 quantize.py \
    --hf_model_name_or_path Qwen/Qwen3-VL-8B-Instruct \
    --quant_cfg nvfp4 --tp_size 2 \
    --export_megatron_path /tmp/Qwen3-VL-8B-NVFP4-megatron

torchrun --nproc_per_node 2 export_quantized_megatron_to_hf.py \
    --hf_model_name_or_path Qwen/Qwen3-VL-8B-Instruct \
    --megatron_path /tmp/Qwen3-VL-8B-NVFP4-megatron \
    --pp_size 2 --export_unified_hf_path /tmp/Qwen3-VL-8B-NVFP4-hf

# Gated MoE (Qwen3.5-VL, Qwen3-MoE) -- no extra flags either. The scripts derive the
# expert layout from the model config, so quantize / distill / export all agree.
# --no_moe_grouped_gemm forces SequentialMLP if you want it explicitly.

Testing

All in nvcr.io/nvidia/nemo:26.08 on 2x RTX 6000 Ada.

Suite Result Time
tests/examples/megatron_bridge/ (full) 18 passed 27m58
tests/gpu_megatron/torch/export/ 38 passed 2m13
tests/unit/torch/export/ 186 passed 1.5s
pre-commit (ruff, ruff format, mypy, bandit) clean
tests/examples/megatron_bridge/test_quantize_export.py on 2 GPUs (pp_size=2) 3 passed 5m

The export leg of test_quantize_and_export now scales with num_gpus like its quantize leg
already did. Previously it was hardcoded to one process, so the collective checkpoint load ran at
PP=1 on both the 1-GPU PR runner and the 2-GPU nightly — which is how a guard that raised on only
some pipeline stages (and therefore hung the job) reached review. The dense qwen3 case was dropped
in exchange: qwen3_moe already covers the non-VLM script path, qwen3vl covers a dense decoder,
and that case was the one exceeding the 300s cap in CI.

Model coverage

tests/gpu_megatron runs in-process and is cheap, so it owns per-architecture mapping
correctness. The example tests spawn torchrun per step and are ~50x slower per case, so they
cover script wiring only — CLI flags, recipe resolution, and checkpoint hand-off between steps.

Suite Models
test_unified_export_megatron llama, nemotron, nemotron_h, qwen3vl, qwen3_moe, qwen3_5_moe_vl x {none, FP8, NVFP4, +/-KV} x {grouped GEMM, SequentialMLP} + eagle / medusa / MTP (29 params)
test_megatron_importer nemotron_h, llama export->import round-trip
test_moe_layout_choice per-architecture grouped-GEMM exportability (6 architectures)
test_distill_megatron KD loss mechanics
Model prune quantize+export QAD distill+export
qwen3 Y Y Y Y
qwen3_moe - Y (new) - -
qwen3vl - Y (moved from QAD) - -
nemotron_h Y Y (new) - -
qwen3_5_vl - - - Y
qwen3_5_moe_vl Y Y (new, both expert layouts) Y -
deepseek_v3 Y - - -
gemma3vl Y - manual removed -

QAD's unique property is that ModelOpt state survives distillation, which needs one LLM and one
VLM rather than one case per architecture. Moving the rest to quantize+export drops a torchrun
launch each: QAD went from 3 CI cases to 2 while quantize+export went from 1 to 4, adding two
architectures for about a minute.

Real-model validation

Tiny fixtures cannot catch layout or scale bugs that only appear at real dimensions, so the export
path was run end-to-end on released checkpoints. This is where bugs 7-10 came from.

Model Run Result
Nemotron-3.5-Lightning-30B-A3B NVFP4 4o6 PTQ → export → MMLU 0.7825 ± 0.0105 (gate 0.75)
Nemotron-3.5-Lightning-30B-A3B Minitron pruning 22.28B/3.00B active, 0.5944 (gate 0.58)
Qwen3.5-0.8B (dense VLM) FP8 PTQ → export → MMLU BF16 0.4895 → 0.4832 (±0.0127)
Qwen3.5-35B-A3B, half-depth (20 layers, 256 experts) FP8 + NVFP4 PTQ → export keys + shapes + values match reference
Qwen3.5-35B-A3B, full FP8 PTQ OOM on 2x48GB (see below)

The half-depth model keeps real weights, real dims and all 256 experts. Both expert layouts produce
identical key sets, and all exports pass assert_exported_checkpoint_matches(..., check_values=True)
— every tensor, including all 20 x 256 experts, dequantizes to within tolerance of the BF16
reference, so a transposed or mis-ordered expert stack would fail. NVFP4 lands in the correct packed
layout (gate_up_proj [256, 1024, 1024] U8, weight_scale [256, 1024, 128] E4M3,
weight_scale_2 [] F32). Its accuracy is not meaningful — truncating to 20 of 40 layers leaves a
chance-level model (BF16 0.2322, FP8 0.2538) — so it validates correctness, not quality.

Re-validated on the final code. The numbers above were first taken mid-review; since then the
NVFP4 block-scale merge changed on both packed paths, the vision-tower download became two-stage,
and an expert-layout load guard was added. Both gating runs were therefore repeated end to end:
Nemotron went 0.7748 → 0.7825 ± 0.0105 and Qwen3.5-0.8B went 0.4678 → 0.4832 ± 0.0127, with
the rest of the Nemotron pipeline reproducing exactly (3519 quantizers, 69GB checkpoint, 21GB
export). Both deltas are inside their own stderr, so the claim is that the rework costs no accuracy
— not that it improved it. The Nemotron export also runs at --pp_size 2, exercising the new
collective layout guard on a real 30B MoE across pipeline stages.

Two limitations worth stating plainly:

  • No quantized accuracy number for a full-size MoE. The full 35B OOMs at 47.37 GiB while
    constructing the model on 2x48GB, with grouped GEMM already enabled, so no calibration knob
    helps. Needs more GPUs than this setup has.
  • vLLM cannot yet serve packed FP8 Qwen3.5 experts. vllm 0.24.1.dev0 builds its fused expert
    mapping weight-only, rewriting experts.down_proj_input_scale to w2_weight_input_scale while the
    parameter it registers is w2_input_scale. This is upstream and independent of how the checkpoint
    is produced — both of our export paths fail it identically. The 0.8B numbers above are unaffected
    (dense), and the packed exports are verified against the reference checkpoint instead.

Guard verification

Each new guard was made to fire, not just to compile:

Guard Verification
Export self-check Disabled the MoE guard, re-exported Qwen3-MoE - independently reported all 24 dropped tensors. No false positives across llama, nemotron, qwen3, qwen3-moe, qwen3vl, qwen3.5-vl, deepseek_v3 incl. eagle / medusa / MTP
Dropped-state raise Deleted modelopt_state from a checkpoint with 50 quantizer tensors - raised instead of loading unquantized
NVFP4 value check Flipped a q_proj - failed at max_rel_err=1.74 against a 0.3 threshold
Zero-centered gamma Reproduced the off-by-1.0 on a good export - caught as "not bit-exact"
Exclusion guard Asserts no calibrated quantizer matches conv1d / mlp.router / output_layer

Exported artifacts are validated, not just their existence: 0 missing keys vs reference, vision
tower bitwise-identical, dequantized weights within FP8 E4M3 error (<=4.6%). The
in_proj_a/in_proj_b check is load-bearing - swapped alpha/beta would still match on shape but
show ~100% error.

Also ran a tiny-Qwen3 LLM control through both steps to confirm the exporter changes are a
no-op off the VLM path.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ — the scripts now derive the MoE expert layout from the model config, building SequentialMLP only for architectures with no experts.linear_fc1 rule, and the exporter raises rather than dropping experts it has no rule for. Those runs previously "succeeded" while writing a checkpoint containing no expert weights, so no working behaviour is removed. --no_moe_grouped_gemm forces SequentialMLP explicitly.
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: ✅
  • Did you get Claude approval on this PR?: ✅ — approved (round 10: 0 CRITICAL, 0 IMPORTANT, 0 new suggestions); CodeRabbit approved earlier

Additional Information

MoE expert layout is now chosen automatically. Only Nemotron-H can export fused grouped-GEMM experts, so every other MoE architecture would otherwise need --no_moe_grouped_gemm on all four scripts or hit a wall at export. The scripts derive the layout from the model config — grouped GEMM unless it would not be exportable — so they agree without threading a flag. This changes MoE activation scales from one shared scale to per-expert for the affected architectures.

Known gaps, unchanged by this PR:

  • Gated MoE still cannot use fused grouped GEMM. _grouped_mlp_slicing emits one weight per expert with no gate/up split — its only prior caller, Nemotron-H, is non-gated, so every other MoE architecture is built as SequentialMLP (see below). Adding that split would restore the faster layout, but it needs a deliberate call on activation-scale semantics: grouped GEMM keeps one shared activation scale across experts while SequentialMLP has per-expert scales, so the two are not numerically equivalent. It also needs EP>1 coverage.
  • Qwen3.5's alpha/beta gates share Megatron's fused in_proj quantizer, so they can only be kept in BF16 at export, not excluded by name. Full fidelity needs per-section quantizers on the fused projection.
  • Anchoring ModelOpt state on .language_model (which would let quantize.py quantize the language model directly and drop its name-based non-LM disabling) needs a coordinated Megatron-Bridge change: save_sharded_modelopt_state is ModelOpt code, but the restore the Bridge path uses is Bridge's own and unconditionally restores onto the root.
  • Gemma3-VL remains Megatron-checkpoint only (OMNIML-5366).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added Muse Glimmer AutoQuantize and Alpamayo QAD workflows.
    • Added streaming Kimi-K3 conversion and NVFP4 activation headroom calibration.
    • Added SFT-masked distillation for Megatron-Bridge.
    • Added unified Hugging Face export for quantized Qwen3-VL and Qwen3.5-VL checkpoints.
    • MoE expert layouts are selected automatically, with an option to force sequential experts.
  • Bug Fixes

    • Improved export validation for tensor coverage, MoE mappings, quantizer state, and NVFP4 scales.
    • Fixed Qwen3.5-VL GatedDeltaNet export handling.
    • Preserved visual-model weights exactly during export.

@copy-pr-bot

copy-pr-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Important

Approval pending

CodeRabbit has no unresolved comments, but it has not reviewed the latest commit.

Use the checkbox below to review the latest commit. CodeRabbit will approve the changes if it finds no blocking issues.

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Version 0.47 adds unified HuggingFace export for Qwen3-VL and Qwen3.5-VL, automatic MoE layout selection, GatedDeltaNet mappings, checkpoint-loading fixes, and expanded export validation.

Changes

VLM export and MoE workflow

Layer / File(s) Summary
Workflow flags and checkpoint state
examples/megatron_bridge/*.py, modelopt/torch/utils/plugins/mbridge.py, modelopt_recipes/configs/*
Workflows use shared VLM detection and language-model extraction. Quantization, distillation, and export select grouped or sequential MoE layouts. Checkpoint loading preserves ModelOpt state. GatedDeltaNet convolution layers are excluded from quantization.
Qwen VLM export mappings
modelopt/torch/export/plugins/*, modelopt/torch/export/unified_export_megatron.py, README.md, CHANGELOG.rst
Export supports nested VLM language models, vision passthrough weights, Qwen3.5 GatedDeltaNet parameters, shared experts, gated QKV layouts, zero-centered norms, and explicit unsupported-expert errors.
Exporter verification and test coverage
tests/_test_utils/torch/export/*, tests/_test_utils/torch/megatron/*, tests/examples/megatron_bridge/*, tests/gpu_megatron/torch/export/*, tests/_test_utils/torch/transformers_models.py
Tests validate safetensors indexes, quantized values, ModelOpt state, quantizer exclusions, Qwen3 MoE exports, Nemotron-H compatibility, and exact preservation of VLM vision weights.

Estimated code review effort: 4 (Complex) | ~60 minutes

Merge Risk: 🟡 Moderate · up to 9c8e1

VLM exports can fail for users who provide a Hugging Face Hub model ID, including the model-ID form shown in the usage examples, before the vision-tower weights are copied. This concrete integration issue should be fixed or explicitly accepted before merging.

Sequence Diagram(s)

sequenceDiagram
  participant QuantizationWorkflow
  participant MegatronCheckpoint
  participant unified_export_megatron
  participant Qwen35VLMapping
  participant HFCheckpoint
  QuantizationWorkflow->>MegatronCheckpoint: save quantized model and ModelOpt state
  MegatronCheckpoint->>unified_export_megatron: load VLM language model
  unified_export_megatron->>Qwen35VLMapping: apply Qwen3.5-VL mappings
  Qwen35VLMapping->>HFCheckpoint: write decoder and vision tensors
  unified_export_megatron->>HFCheckpoint: verify exported tensor keys
Loading

Suggested reviewers: chenhanyu, jenchen13, shengliangxu, yueshen2016

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.52% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 58 functions across 18 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns ✅ Passed PASS. The PR adds no covered security anti-patterns. The changed modelopt/examples additions contain no torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), hardcoded `trust_re…
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: support for quantized Qwen3-VL and Qwen3.5-VL export from Megatron-Bridge, including dense and MoE models, with checkpoint verification.
Full details: Docstring Coverage

Explanation

Docstring coverage is 65.52% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 58 functions across 18 files. (1 skipped: 1 unsupported.)

Full details: Security Anti-Patterns

Explanation

PASS. The PR adds no covered security anti-patterns. The changed modelopt/examples additions contain no torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), hardcoded trust_remote_code=True, external-input eval()/exec(), or new # nosec comments. The exporter’s existing weights_only=False call predates this PR and has a comment stating that it loads internally generated sibling-rank data. The complete PR diff changes no dependency manifest.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch kmorabia/mbridge-qwen3vl-quantized-hf-export

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://NVIDIA.github.io/Model-Optimizer/pr-preview/pr-2276/

Built to branch gh-pages at 2026-08-31 08:14 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.08108% with 63 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.18%. Comparing base (022767c) to head (d817d50).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
modelopt/torch/export/unified_export_megatron.py 78.92% 47 Missing ⚠️
...delopt/torch/export/plugins/hf_checkpoint_utils.py 6.66% 14 Missing ⚠️
modelopt/torch/utils/plugins/mbridge.py 96.87% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2276      +/-   ##
==========================================
- Coverage   78.95%   78.18%   -0.77%     
==========================================
  Files         524      526       +2     
  Lines       60866    62645    +1779     
==========================================
+ Hits        48058    48981     +923     
- Misses      12808    13664     +856     
Flag Coverage Δ
examples-diffusers 20.65% <12.91%> (-0.05%) ⬇️
examples-gpt-oss 13.24% <12.91%> (-0.02%) ⬇️
examples-hf_ptq 21.42% <12.91%> (-0.09%) ⬇️
examples-llm_distill 13.31% <12.91%> (-0.03%) ⬇️
examples-llm_eval 17.05% <12.91%> (-0.04%) ⬇️
examples-llm_qat 17.53% <12.91%> (-0.05%) ⬇️
examples-llm_sparsity 15.87% <12.91%> (-0.03%) ⬇️
examples-megatron_bridge 26.39% <75.37%> (+0.64%) ⬆️
examples-specdec_bench 12.99% <12.91%> (-0.02%) ⬇️
examples-speculative_decoding 17.47% <12.91%> (-0.11%) ⬇️
examples-torch_onnx 21.73% <12.91%> (-0.06%) ⬇️
examples-torch_trt 15.04% <12.91%> (-0.03%) ⬇️
gpu 58.42% <62.46%> (-0.61%) ⬇️
regression 14.88% <12.91%> (+0.05%) ⬆️
unit 55.39% <12.91%> (-0.41%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@kevalmorabia97 kevalmorabia97 changed the title Support quantized Qwen3-VL export to unified HF from Megatron-Bridge Support quantized Qwen3-VL / Qwen3.5-VL export to unified HF from Megatron-Bridge Aug 28, 2026
@kevalmorabia97 kevalmorabia97 changed the title Support quantized Qwen3-VL / Qwen3.5-VL export to unified HF from Megatron-Bridge Support quantized Qwen3-VL / Qwen3.5-VL export from Megatron-Bridge and verify exported checkpoints Aug 28, 2026
@kevalmorabia97
kevalmorabia97 marked this pull request as ready for review August 28, 2026 17:25
@kevalmorabia97
kevalmorabia97 requested review from a team as code owners August 28, 2026 17:25

@coderabbitai coderabbitai Bot left a comment

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.

Warning

CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.

Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.

👉 Steps to fix this

Actionable comments posted: 4

🧹 Nitpick comments (1)
modelopt/torch/export/unified_export_megatron.py (1)

1571-1577: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the packing order that torch.split assumes.

The code reads the section sizes into a dict and then rebuilds split_sizes in the hardcoded order query+key+value, z, beta, alpha. The dict discards the order declared by module.in_proj_split_names. torch.split slices by position, so the code silently produces wrong projections if Megatron ever concatenates the six sections in a different order.

Add an assertion so the assumption fails loudly instead of emitting wrong weights.

♻️ Proposed assertion
         sections = dict(zip(module.in_proj_split_names, module.in_proj_split_sections))
+        expected_order = ("query", "key", "value", "z", "beta", "alpha")
+        assert tuple(module.in_proj_split_names) == expected_order, (
+            f"GatedDeltaNet in_proj packing order changed: expected {expected_order}, got "
+            f"{tuple(module.in_proj_split_names)}; the split below is positional."
+        )
         split_sizes = [
🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. 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/export/unified_export_megatron.py` around lines 1571 - 1577,
In the split-size construction near in_proj_split_names, assert that
module.in_proj_split_names matches the hardcoded query, key, value, z, beta,
alpha packing order before rebuilding split_sizes. Keep the existing
section-size calculation, but make any order mismatch fail immediately rather
than allowing torch.split to use incorrect positional boundaries.
🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Inline comments:
In `@examples/megatron_bridge/distill.py`:
- Around line 96-104: Add the no_moe_grouped_gemm CLI option to the distilled
VLM exporter and propagate its value through the model-loading flow to
load_mbridge_model_from_hf(), ensuring SequentialMLP checkpoints use the
matching expert layout instead of the grouped-GEMM default.
- Around line 435-439: Update the ModelOpt state transfer in the is_vlm and
student_has_modelopt_state branch to run only when the restored state belongs to
the full VLM root student; skip it when load_modelopt_megatron_checkpoint()
restored state directly onto student.language_model, or propagate the restored
state owner and use it to decide the transfer target. Preserve state transfer
for full VLM checkpoints.

In `@examples/megatron_bridge/quantize.py`:
- Around line 341-343: In load_mbridge_model_from_hf(), reuse the
is_safe_repo()-filtered trust_remote_code value for
AutoConfig.from_pretrained(), AutoProcessor.from_pretrained(), and
bridge.save_megatron_model() instead of passing the raw CLI flag, while
preserving the existing safety filtering behavior.

In `@modelopt/torch/export/unified_export_megatron.py`:
- Around line 443-480: The _verify_exported_keys method must ignore source
checkpoint keys belonging to skipped non-language-model components when
vision_passthrough_prefixes is None, so validation only compares language-model
tensors. Filter those known multimodal prefixes before adding keys to missing,
or reuse the architecture-specific passthrough mappings, while preserving
validation for all language-model keys.

---

Nitpick comments:
In `@modelopt/torch/export/unified_export_megatron.py`:
- Around line 1571-1577: In the split-size construction near
in_proj_split_names, assert that module.in_proj_split_names matches the
hardcoded query, key, value, z, beta, alpha packing order before rebuilding
split_sizes. Keep the existing section-size calculation, but make any order
mismatch fail immediately rather than allowing torch.split to use incorrect
positional boundaries.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: b7a78056-931a-4870-aff6-18f075a7eec9

📥 Commits

Reviewing files that changed from the base of the PR and between 5500999 and 0afd0b6.

📒 Files selected for processing (20)
  • CHANGELOG.rst
  • examples/megatron_bridge/README.md
  • examples/megatron_bridge/distill.py
  • examples/megatron_bridge/export_distilled_megatron_to_hf.py
  • examples/megatron_bridge/export_quantized_megatron_to_hf.py
  • examples/megatron_bridge/prune_minitron.py
  • examples/megatron_bridge/quantize.py
  • modelopt/torch/export/plugins/mcore_common.py
  • modelopt/torch/export/plugins/mcore_custom.py
  • modelopt/torch/export/plugins/mcore_qwen35vl.py
  • modelopt/torch/export/plugins/mcore_qwen3vl.py
  • modelopt/torch/export/unified_export_megatron.py
  • modelopt/torch/utils/plugins/mbridge.py
  • modelopt_recipes/configs/auto_quantize/units/base_disabled_layers.yaml
  • modelopt_recipes/configs/ptq/units/default_disabled_quantizers.yaml
  • tests/_test_utils/torch/export/unified_checkpoint.py
  • tests/_test_utils/torch/megatron/modelopt_state.py
  • tests/examples/megatron_bridge/test_qad.py
  • tests/examples/megatron_bridge/test_quantize_export.py
  • tests/gpu_megatron/torch/export/test_unified_export_megatron.py

Included review availability: Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Comment thread examples/megatron_bridge/distill.py
Comment thread examples/megatron_bridge/distill.py Outdated
Comment thread examples/megatron_bridge/quantize.py Outdated
Comment thread modelopt/torch/export/unified_export_megatron.py Outdated
@kevalmorabia97

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment thread modelopt/torch/export/unified_export_megatron.py Outdated
Comment thread examples/megatron_bridge/quantize.py Outdated
Comment thread examples/megatron_bridge/README.md Outdated
Comment thread modelopt/torch/export/plugins/mcore_qwen35vl.py

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review — 2 blocking findings

Scope: full review per procedure (trigger comment carried no scoping instructions). 20 changed files; reviewed all of modelopt/ (6 files), all of examples/megatron_bridge/ (5 files), both recipe YAMLs, and the two new test utils. Note that git diff origin/main HEAD here also surfaces unrelated drift (unified_export_hf.py, examples/alpamayo/*) because main has moved on — I reviewed only the 20 files in the PR's file list.

Findings: CRITICAL: 1 · IMPORTANT: 1 · SUGGESTION: 2

Most impactful

1. [CRITICAL Export] _verify_exported_keys blocks export for supported architectures whose HF source is itself quantized (unified_export_megatron.py:462-480). The check raises on any source key with no exported counterpart, but several registered archs have such keys by construction: DeepSeek-V3 ships *.weight_scale_inv per weight, GPT-OSS ships experts.*_blocks / *_scales (the constructor's del self._hf_config.quantization_config at line 191 confirms the source is expected quantized), and older Llama/Qwen conversions ship rotary_emb.inv_freq. The unit tests can't catch it — they build a tiny unquantized reference, so its key set is plain BF16. A user exporting a local gpt-oss-20b or DeepSeek-V3 snapshot gets a hard abort, after the shards are written, with no bypass. The guard itself is valuable; it needs a suffix allowlist (or a warning for archs outside the validated set) rather than an unconditional raise.

2. [IMPORTANT Compatibility] The new quantize.py MoE pre-flight guard rejects runs that previously produced a correct Megatron checkpoint (quantize.py:333-349). Only Nemotron-H declares experts.linear_fc1; DeepSeek V2/V3, GPT-OSS, Llama-4, Qwen3-MoE and Qwen3.5-VL all use local_experts.linear_fc1, so with the default layout every one of them now raises. The PR's backward-compat argument is sound for the exporter's new raise (that checkpoint was genuinely empty of experts) but not here: quantize.py writes only a Megatron checkpoint, where grouped-GEMM experts serialize fine. PTQ → QAD → Megatron/NeMo and PTQ → prune flows never invoke the HF exporter, yet now must pay for SequentialMLP calibration. Separately, .get(arch, {}) makes the error fire for archs absent from the mapping entirely (e.g. Qwen3VLMoeForConditionalGeneration), where --no_moe_grouped_gemm cannot make HF export work either — so the remedy the message names is wrong for that case.

Two SUGGESTIONs are inline: the README/quantize.py/export_quantized_megatron_to_hf.py notes still say "Qwen3-VL only" although this PR registers and tests Qwen3.5-VL, and the hardcoded zero_centered_gamma=True for GDN's out_norm would benefit from an assertion rather than trusting the convention.

What I checked and found correct

  • Gated-attention QKV slicing (_qkv_slicing): group_dim reduces to heads_per_group + 2 when attention_output_gate is unset, and qkv_total_dim, k_slice, v_slice and the bias path are all bit-identical to the old expressions on that path — the non-gated regression risk is genuinely nil. For the gated path, _take's cat(..., dim=1) on [heads, head_size, hidden] yields per-head [q_i; gate_i] rows, which matches HF's q_proj.view(..., num_heads, 2 * head_dim) + chunk(2, dim=-1) layout.
  • _gated_delta_net_slicing: scale splitting along dim 0 matches the weight split; the qformat is None branch correctly rewrites the fused exclude_modules entry into the four per-projection names (_record_excluded_module strips the trailing dot, so the entries are consistent with _qkv_slicing's).
  • with_language_model_prefix / LLAVA_VISION_PREFIXES: LLAVA_VISION_PREFIXES is exactly load_multimodal_components' existing default, so the LLaVA passthrough path is unchanged; the is_multimodal / vision_passthrough_prefixes refactor preserves the previous Qwen3-VL and LLaVA behavior.
  • export_extra_modules goes through save_pretrained_extra_modules, so the new completeness check is not reached for eagle/medusa — no false positive there.
  • moe_grouped_gemm already exists on load_mbridge_model_from_hf, so the new call-site kwarg in quantize.py / export_quantized_megatron_to_hf.py is valid.
  • The recipe additions (*self_attention.conv1d*) reach the Megatron path through get_quant_config, and assert_no_quantizers_matching pins them against future name drift.

On CodeRabbit's findings

I did not re-litigate the four it posted, but I independently traced #2 (distill.py:435-439) and it is real: load_modelopt_megatron_checkpoint may now restore state onto student.language_model, after which ModeloptStateManager.transfer_state_dict(student, student.language_model) reads from a root that has none. For the documented QAD flow --student_megatron_path is a full-VLM PTQ checkpoint (vision weights present → restore onto the root → transfer is correct), so the two changes only collide for a language-model-only student checkpoint — worth a guard, or at least a comment recording why that combination can't occur.

Risk assessment

Moderate-to-high. The VLM enablement and the six bug fixes are well-targeted, and the self-verifying export is the right instinct — the diff is unusually well evidenced. The risk is concentrated in the two new fail-loudly guards: both are broader than the six architectures they were validated against, and both convert a previously-working flow into a hard error for archs CI never exercises. Narrowing their blast radius would make this a low-risk change.

@coderabbitai coderabbitai Bot left a comment

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.

Warning

CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.

Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.

👉 Steps to fix this

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Inline comments:
In `@CHANGELOG.rst`:
- Around line 20-21: Reduce the changelog entry describing automatic MoE expert
layout selection to two sentences or fewer, while preserving its key details
about TEGroupedMLP, SequentialMLP, the override flag, model-config-driven
consistency, and activation-scale behavior.

In `@examples/megatron_bridge/distill.py`:
- Around line 366-368: Update the MoE provider setup before model construction
so HybridModelProvider also assigns provider.hybrid_stack_spec using
get_te_hybrid_stack_spec(moe_grouped_gemm=moe_grouped_gemm), alongside the
existing provider.moe_grouped_gemm assignment. Preserve the current expert-count
guard and match the configuration used by load_mbridge_model_from_hf().
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 2a25bfe9-a1bd-4865-98ea-2e879a204892

📥 Commits

Reviewing files that changed from the base of the PR and between 0afd0b6 and 317ff2f.

📒 Files selected for processing (11)
  • CHANGELOG.rst
  • examples/megatron_bridge/README.md
  • examples/megatron_bridge/distill.py
  • examples/megatron_bridge/export_distilled_megatron_to_hf.py
  • examples/megatron_bridge/export_quantized_megatron_to_hf.py
  • examples/megatron_bridge/quantize.py
  • modelopt/torch/export/plugins/mcore_qwen35vl.py
  • modelopt/torch/export/unified_export_megatron.py
  • modelopt/torch/utils/plugins/mbridge.py
  • tests/examples/megatron_bridge/test_qad.py
  • tests/gpu_megatron/torch/export/plugins/test_moe_layout_choice.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • examples/megatron_bridge/README.md
  • modelopt/torch/export/plugins/mcore_qwen35vl.py

Included review availability: Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Comment thread CHANGELOG.rst Outdated
Comment thread examples/megatron_bridge/distill.py Outdated
@kevalmorabia97
kevalmorabia97 force-pushed the kmorabia/mbridge-qwen3vl-quantized-hf-export branch from 66f508d to 0208a12 Compare August 28, 2026 21:26

@coderabbitai coderabbitai Bot left a comment

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.

Warning

CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.

Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.

👉 Steps to fix this

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Inline comments:
In `@tests/_test_utils/torch/transformers_models.py`:
- Around line 457-460: Keep the conversion_mapping import inside
_match_released_nemotron_h and add a brief comment explaining that it is a
version-specific optional lazy import, loaded only when the Nemotron-H helper
runs because it is unavailable in Transformers 4.57.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 3b5fdaef-91ae-4db1-9703-28de46583dff

📥 Commits

Reviewing files that changed from the base of the PR and between 66f508d and 0208a12.

📒 Files selected for processing (7)
  • CHANGELOG.rst
  • examples/megatron_bridge/distill.py
  • modelopt/torch/export/unified_export_megatron.py
  • modelopt/torch/utils/plugins/mbridge.py
  • tests/_test_utils/torch/transformers_models.py
  • tests/examples/megatron_bridge/test_qad.py
  • tests/examples/megatron_bridge/test_quantize_export.py

Included review availability: Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Comment thread tests/_test_utils/torch/transformers_models.py Outdated
kevalmorabia97 and others added 3 commits August 28, 2026 14:55
Enables the PTQ / QAD -> unified HuggingFace export path for Qwen3-VL,
and fixes a silent VLM QAD state loss found along the way.

- GPTModelExporter only unwrapped MCore LLaVAModel, so Megatron-Bridge's
  Qwen3VLModel was rejected. Unwrap any wrapper exposing .language_model.
- A VLM QAD checkpoint holds the language model only (distill_submodule),
  so load it into .language_model rather than the full VLM wrapper.
- PTQ anchors the ModelOpt state on the VLM root but QAD checkpoints only
  the language model, so the state was dropped and the export came out
  unquantized. Move it to .language_model on QAD restore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
Builds on the Qwen3-VL enablement: data-drives the VLM export mapping,
adds Qwen3.5-VL, closes a silent expert-drop bug, and makes the tests
check exported content rather than just that files exist.

Export mapping is now table-driven
- Vision-tower passthrough prefixes move into
  all_mcore_hf_vision_passthrough_mapping; the exporter no longer
  branches on the Qwen3-VL architecture string.
- with_language_model_prefix moves to mcore_custom so any VLM mapping
  can be derived from its text-model mapping.

Qwen3.5-VL (MoE)
- GatedDeltaNetSlicing splits the fused in_proj into HF's
  in_proj_qkv/_z/_b/_a, using the module's own split sections.
- Megatron's GDN out_norm is zero-centered; add 1.0 on export, matching
  Megatron-Bridge's RMSNorm2ZeroCenteredRMSNormMapping on import.
- Emit shared_experts.gate_weight.

Silent expert drop
- The MoE dispatch had no else branch, so an architecture without an
  experts.linear_fc1 rule (e.g. Qwen3MoeForCausalLM) exported a valid
  looking checkpoint with zero routed experts. Both quantize.py and the
  exporter now raise, and --no_moe_grouped_gemm is plumbed through
  quantize.py / distill.py / the export script as the way out.

Export verification
- assert_exported_checkpoint_matches compares an exported checkpoint
  against its source: key set, shapes (accounting for NVFP4 uint8
  packing), safetensors index, and values. Wired into the two example
  tests and the unit test; it reproduces both the expert drop and the
  zero-centered-gamma bug above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
Widening export coverage turned up a real bug: Qwen3.5's gated
full-attention layers exported a wrong q/k/v split.

- Gated attention packs a per-head output gate next to every query head,
  so a query group is [q, gate, k, v]. _qkv_slicing assumed [q, k, v]
  and split 192 rows as 96/48/48 instead of 128/32/32. It now derives
  the group stride from config.attention_output_gate and concatenates
  the gate into q, matching Megatron-Bridge's split_qkv_weights. The
  non-gated path is unchanged.
- test_qad's qwen3_5_moe_vl case pins layer_types so it covers both
  decoder kinds; auto-generated types are all linear-attention at this
  depth. Layer count is unchanged, so CI cost is not.
- Add Qwen3-MoE to the export matrix -- the architecture whose routed
  experts were silently dropped had no export test at all.
- assert_exported_checkpoint_matches grows allow_unexpected for tensors
  the Megatron test fixture adds but tiny HF configs lack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review — round 5

Findings: CRITICAL: 0 · IMPORTANT: 2 · SUGGESTION: 1

Prior rounds — all resolved on 67cdc97b

Round-4 finding Status
IMPORTANT #1: export_distilled_megatron_to_hf.py built the VLM branch with the wrong MoE layout Fixed — now moe_grouped_gemm=not args.no_moe_grouped_gemm, mirroring distill.py's unquantized branch, plus a new raise when any checkpoint has_modelopt_state
IMPORTANT #2: _verify_exported_keys skipped entirely for a hub repo id Fixed — snapshot_download of the index, local_files_only=_is_hf_hub_offline(), try/except so an unreachable source doesn't fail a good export
SUGGESTION 3: _grouped_mlp_packing silently ignored unexpected per-expert suffixes Fixed — handled tuple + assert not unhandled
SUGGESTION 4: NVFP4 per-expert scale merge took a plain max Fixed — _merge_nvfp4_expert_scales rescales each expert's block scales by s2_i / s2_max
SUGGESTION 5: no detection of a layout-mismatched checkpoint Addressed, but the new guard is half-dead — see IMPORTANT 1 below

New findings

1. [IMPORTANT Compatibility] mbridge.py:235ckpt_grouped can never be true (comment)

TEGroupedMLP stores fused expert weights on the child linear_fc1/linear_fc2, so the sharded key is ...mlp.experts.linear_fc1.weight0, never ...mlp.experts.weight0 (this repo's own copy_weights_from_grouped_to_non_grouped documents that format at tests/_test_utils/torch/megatron/utils.py:256-268). The guard therefore fires only for sequential checkpoint → grouped model and is silent for grouped → sequential — which is exactly the direction this PR creates, since use_moe_grouped_gemm() now returns False for Qwen3MoeForCausalLM / DeepseekV3ForCausalLM / GptOssForCausalLM / Llama4ForConditionalGeneration while every pre-PR checkpoint for those architectures holds grouped tensors. Under assume_ok_unexpected the experts silently keep their random init and _verify_exported_keys cannot see it (keys all present, values wrong). No test covers the guard — test_moe_layout_choice.py only asserts the export-mapping dict.

2. [IMPORTANT Export] unified_export_megatron.py:478 — MTP source weights false-positive the self-check (comment)

re.search(r"\.layers\.(\d+)\.", key) is prefix-agnostic, so mtp.layers.0.* in the source index is read as base-model layer 0. All three escapes miss it (index 0 < num_layers; the whole mtp. prefix family is absent from exported_modules; not rotary_emb), and a default export legitimately omits MTP — those tensors only ship with --export_extra_modules, which goes through save_pretrained_extra_modules() and never reaches this check. Result: a hard RuntimeError after the shards are written, with a message pointing at a non-existent missing rule. mtp.layers.N. is this PR's own _mtp_prefix output for the Qwen3.5/VLM and nemotron_h paths. DeepSeek-V3 is accidentally safe (its MTP is model.layers.61, caught by >= num_layers).

3. [SUGGESTION] The self-check no-ops silently on unsharded checkpoints (comment) — only the index is downloaded, so an unsharded source yields source == set(); and line 463 returns when the export itself has no index. Both skips are worth a warn_rank_0.

Carried over, not re-posted inline

The MoE layout switch remains one-way (raised in rounds 2 and 3): use_moe_grouped_gemm(force_sequential=...) can force SequentialMLP but nothing can force grouped GEMM back on for an architecture whose export rule is missing. That is a deliberate safety default and reasonable to keep, but users with an existing grouped-GEMM checkpoint for one of the four affected architectures currently have no supported way to reload it — the same population affected by IMPORTANT 1.

Verified correct (no action)

  • _merge_nvfp4_expert_scales is mathematically sound: merged_bs_i · s2_max == bs_i · s2_i, so the effective per-block scale is exactly preserved and it strictly improves on the previous max-only merge. E4M3 underflow of a rescaled block scale would need ~1e-5 cross-expert amax ratios.
  • Gated-attention _qkv_slicing reduces exactly to the pre-PR expressions when attention_output_gate is unset; group_dim, k_slice/v_slice offsets, and the gated=[output_gate, False, False] threading through weight / per-block scale / bias are consistent.
  • _gated_delta_net_slicing: the in_proj_split_names assert pins the fused order, split_sizes matches, and the keep_bf16 bookkeeping for in_proj_a/in_proj_b is consistent across to_quantized_weight, _weight_scale, _weight_scale_2, and the replicated input_scale; the fused entry is correctly pulled out of exclude_modules and re-recorded per projection.
  • _grouped_mlp_packing's NUL marker does not leak into hf_quant_config.json: _grouped_mlp_slicing passes the unformatted template to _get_quantized_state, so _record_excluded_module's "{" in layer_name guard suppresses it. is_mtp is applied once, at the top.
  • _mtp_prefix's count=1 plus the explicit model.language_model. case; with_language_model_prefix passing non-CustomModuleMapping flags through unchanged; hf_checkpoint_utils imports and exception set; the is_first_stage_main_rank / barrier / is_writer_rank ordering in save_pretrained.
  • export_distilled_megatron_to_hf.py's new raise is safe for plain (unquantized) distillation, since has_modelopt_state ignores kd_loss-only state.
  • The new test helpers are not vacuous: _expected_shape halves only the last dim for uint8, _unpack_nvfp4 reads the low nibble first, assert_safetensors_index_consistent cross-checks index/shards both ways, bit_exact_prefixes asserts it matched at least one tensor, and the value loop separates copied-through tensors (torch.equal) from quantized ones.
  • CHANGELOG.rst entries are appropriately scoped and user-facing.

Risk

Moderate. The new functionality (Qwen3.5-VL GatedDeltaNet + gated attention + packed experts, per-expert NVFP4 scale merging, VLM QAD state transfer) reads as correct and is well covered by the new content-level export assertions. The residual risk is concentrated in the two guards added this round: one cannot fire in the direction that matters, and the other can fire when it should not. Both are small, localized fixes.

Also worth confirming before merge: .experts.linear_fc\d+\.weight\d+ against a real grouped-GEMM state_dict_metadata, and whether the Qwen3.5-35B-A3B run used --export_extra_modules (if not, and its index has mtp. keys, finding 2 should have already reproduced).

kevalmorabia97 and others added 2 commits August 30, 2026 03:31
`test_distill_vlm` hit its 360s cap and `test_quantize_and_export` hit the 300s default
on the CI runner. Both pass locally with room to spare -- 151s and 93s respectively --
so this is runner speed, not a regression: CI is consistently 2.4-3.2x slower per test
than a local GPU box, and it also runs under `--cov` with subprocess coverage.

GPU count is not the factor. The suite takes 25m17 on one GPU and 27m32 on two, so the
1-GPU PR runner is not what makes CI slow.

Raised by one 60s step each rather than setting MODELOPT_QA_TEST_TIMEOUT globally, so a
genuine slowdown still surfaces as a failure instead of being masked suite-wide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
…heck

The expert-layout guard added last commit was dead code. It matched
`.experts.weight0` / `.local_experts.`, but a Megatron distributed checkpoint stores
experts as `...mlp.experts.experts.N.linear_fcM.*` regardless of layout. Verified against
two real checkpoints of the same model, one written with grouped GEMM and one with
SequentialMLP: both produce identical expert key shapes, so neither pattern (nor the
`experts.linear_fcN.weightM` form suggested in review) ever appears.

The same evidence undermines the mismatch it was guarding against: since both layouts
serialize to the same keys, a grouped checkpoint loaded into a SequentialMLP model still
matches its expert weights by key, so the feared "experts keep their random init" outcome
does not occur. The only difference is where the input-quantizer amax lives (shared per
layer vs per expert). Rather than ship a check that cannot fire, it is removed.

`_verify_exported_keys` also silently no-opped on unsharded checkpoints: it fetched only
`*.safetensors.index.json`, which an unsharded repo does not have, and returned early
when the export itself was a single file -- the common case for small models. Both sides
now handle the unsharded form, and every remaining skip path warns, so a skip is
distinguishable from a pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
@kevalmorabia97

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment thread modelopt/torch/export/unified_export_megatron.py Outdated
Comment on lines +293 to +295
trust_remote_code = is_safe_repo(
trust_remote_code=args.trust_remote_code, hf_path=args.hf_model_name_or_path
)

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] This silently redefines what the user's --trust_remote_code flag means, and the redefined value then drives every downstream consumer.

trust_remote_code here is no longer "what the user asked for" — it is is_safe_repo's verdict, and it is propagated to load_mbridge_model_from_hf, use_moe_grouped_gemm (→ AutoConfig), AutoProcessor.from_pretrained, the tokenizer save path, and export_mcore_gpt_to_hf. Same pattern at export_quantized_megatron_to_hf.py:113. Both directions are a behavior change for existing users:

  • If is_safe_repo can return True for a repo the user did not pass --trust_remote_code for (e.g. an owner/name allowlist), then remote code executes without the opt-in the flag exists to require.
  • If it can return False when the user did pass the flag, then --trust_remote_code stops working for legitimate custom-code repos — including private/local paths — and the failure surfaces as an unrelated AutoConfig/AutoProcessor error, not as "we overrode your flag."

Either way the flag's help text (action="store_true", no help string) and the docstring no longer describe what happens, and there is no message telling the user their flag was overridden. megatron.bridge isn't installed in this environment so I can't read the helper to tell which direction applies — could you confirm the semantics and then:

  1. document them in the --trust_remote_code help text on both scripts, and
  2. print_rank_0 a one-line notice when the resolved value differs from args.trust_remote_code, so an override is visible in the log.

If the override can flip FalseTrue, that also deserves a CHANGELOG.rst note, since it changes when arbitrary repo code runs. This is also unrelated to the PR's stated Qwen3.5-VL scope, so it's easy for a user to miss.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Checked the helper you could not read — it is an identity pass-through for these call sites, so neither failure direction is possible.

megatron.bridge.models.hf_pretrained.utils.is_safe_repo:

def is_safe_repo(hf_path: str, trust_remote_code: bool | None) -> bool:
    if trust_remote_code is not None:
        ...          # warns when False
        return trust_remote_code
    ...              # warns, then disables

It returns trust_remote_code verbatim whenever it is not None, and only defaults to False for None. Our scripts pass args.trust_remote_code from action="store_true", which is always a real bool — never None. So it cannot return True without the flag, and cannot return False with it set; the only thing it adds is a warning when remote code is disabled.

So there is no undocumented override to log — the resolved value is always equal to args.trust_remote_code. Happy to add a one-line comment at the call sites saying it is a pass-through that warns, if you think the indirection is misleading on its own; I did not want to document semantics that do not exist.

Comment thread modelopt/torch/export/unified_export_megatron.py
Comment thread modelopt/torch/export/unified_export_megatron.py Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review — round 6 (full review)

Reviewed the full diff (25 files) against origin/main: all 6 modelopt/ files, all 6 examples/megatron_bridge/ scripts, both modelopt_recipes/ units, the test additions, CHANGELOG.rst, and the workflow timeout bump. No scoping instructions in the trigger comment, so this is a full pass.

Findings: CRITICAL: 0 · IMPORTANT: 2 · SUGGESTION: 2

IMPORTANT

  1. Self-check downloads full weights for unsharded source reposunified_export_megatron.py:452-460. allow_patterns includes model.safetensors, so for a single-file repo the new _verify_exported_keys pulls the entire weight file just to read tensor names. export_quantized_megatron_to_hf.py passes load_weights=False specifically to avoid fetching source weights, so on the LLM path this is the only thing that touches them — a multi-GB download tacked onto the end of an already-completed export (e.g. Qwen/Qwen3.5-0.8B, the dense VLM this PR validates). Sharded repos only match the index, so CI (local fixture dirs) never sees it. huggingface_hub.get_safetensors_metadata(repo_id).weight_map is header-only and covers both layouts; the pinned huggingface_hub>=0.24.0 has it.

  2. is_safe_repo silently redefines --trust_remote_codequantize.py:293, same at export_quantized_megatron_to_hf.py:113. The resolved value drives load_mbridge_model_from_hf, use_moe_grouped_gemm, AutoProcessor, the tokenizer save, and export_mcore_gpt_to_hf. If the helper can return True without the flag, remote code runs without the user opt-in the flag exists to require; if it can return False with the flag set, --trust_remote_code stops working for legitimate custom-code repos and fails as an unrelated AutoConfig error. Neither direction is documented in the flag help, the docstrings, or CHANGELOG.rst, and nothing logs the override. megatron.bridge is not installed in the review environment so I could not read the helper — please confirm the semantics, document them, and log when the resolved value differs from args.trust_remote_code.

SUGGESTION

  1. _merge_nvfp4_expert_scales has no zero guard (~1805-1811) — an all-zero expert group gives merged_scale_2 == 0, NaN block scales, and via to_quantized_weight a corrupted packed tensor for every expert in the layer. Pre-existing on the _grouped_mlp_packing path, but this PR also routes _pack_name_remapping through it. clamp_min(torch.finfo(torch.float32).tiny) on the denominator.

  2. Self-check raises on one rank only (442-445) — export_mcore_gpt_to_hf is public API without the example script's except BaseException: dist.abort() wrapper, so a real detection hangs peers at the next collective instead of producing the clear error it was written to produce. All-gather the message after the existing barrier.

Carried over from round 5

Round-5 IMPORTANT 2 appears unaddressed on ba0c3b5a. _verify_exported_keys still does re.search(r"\.layers\.(\d+)\.", key), which is prefix-agnostic: a source index containing mtp.layers.0.* is read as base-model layer 0, survives the >= num_layers and exported_modules filters, and hard-fails a legitimate default export that intentionally omits MTP (export_extra_modules=False). An mtp./extra-module escape alongside the existing rotary_emb one would close it. I could not read the inline-comment threads on this PR (the gh calls needed were denied in this environment), so if you already replied explaining why this cannot fire, treat this as unread rather than as a repeat.

Minor notes (no action required)

  • _grouped_mlp_packing's handled tuple includes ".output_scale", but _grouped_mlp_slicing skips output_scale (line ~1477), so that entry is dead and the assert not unhandled drift check is slightly weaker than it reads.
  • The MoE layout choice is one-way: use_moe_grouped_gemm can drop to SequentialMLP for an arch with no experts.linear_fc1 rule, and --no_moe_grouped_gemm can force sequential, but there is no way to force grouped GEMM back on. Fine as a safety default; worth knowing if someone adds a rule out-of-band.

Verified as correct

Gated-attention QKV slicing including the bias and per-block-scale paths, and its exact reduction to pre-PR behavior when attention_output_gate is unset; _grouped_mlp_packing marker/collect/assert logic and the FP8-vs-NVFP4 branch; _merge_nvfp4_expert_scales algebra itself; _mtp_prefix count=1 rewrite; with_language_model_prefix non-mapping flag passthrough; _verify_exported_keys forgiveness of DeepSeek e_score_correction_bias, vision-tower, and tied-embedding keys; load_modelopt_megatron_checkpoint's VLM .language_model redirect and additive return type; transfer_state_dict guarding; zero_centered_gamma handling; and the new test fixtures are non-vacuous (test_moe_layout_choice.py would catch a rule-book regression that flips the layout).

Risk: moderate. The export-correctness core of the PR — packing, slicing, scale merging, VLM unwrapping — held up under review. Both IMPORTANTs are in the surrounding plumbing: one is a wall-clock/bandwidth regression on a documented invocation, one is a security-relevant flag whose new semantics are undocumented. Neither is hard to fix.

… failure

The export self-check fetched `model.safetensors` for unsharded source repos, so reading
tensor names pulled the whole weight file -- a multi-GB download appended to an already
finished export, on the one path that deliberately passes `load_weights=False` to avoid
touching source weights. It now reads the safetensors headers via
`get_safetensors_metadata`, which covers sharded and unsharded repos alike. Verified on
`Qwen/Qwen3.5-0.8B`: the export completes, the check runs, and nothing lands in the hub
cache; dropping 75 tensors from the exported index makes it raise.

`export_mcore_gpt_to_hf` is public API with no `dist.abort()` wrapper, so a check that
raised only on the writer rank would hang peers at the next collective rather than report
the error. The result is all-gathered (not broadcast -- the writer is not necessarily rank
0, and `src` must match on every rank) so all ranks raise together.

`_merge_nvfp4_expert_scales` now clamps the merged global scale, since an all-zero expert
group would otherwise divide by zero and corrupt every expert in the layer. Also drops
`.output_scale` from the packing drift check, which `_grouped_mlp_slicing` never emits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
@kevalmorabia97

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment thread modelopt/torch/export/plugins/hf_checkpoint_utils.py
Comment thread modelopt/torch/export/unified_export_megatron.py Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review — round 7 (cb75d6c1)

Findings: 0 CRITICAL · 1 IMPORTANT · 1 SUGGESTION

Most impactful

  1. [IMPORTANT Performance] load_multimodal_components (plugins/hf_checkpoint_utils.py:217-232) now accepts a repo id, but allow_patterns=["*.safetensors", ...] downloads every shard just to extract the vision tower, then discards the rest. On the documented VLM invocation (a repo id passed to quantize.py) that is tens of GB pulled for a few hundred MB of model.visual.* — the same class of waste _verify_exported_keys was fixed for in this very commit. Suggested a two-stage index-then-shards download inline, plus filtering all_shard_files by prefixes so a partial snapshot cannot safe_open a missing shard.
  2. [SUGGESTION] The new failure-sharing block (unified_export_megatron.py:449) catches only RuntimeError. Anything else out of _verify_exported_keys (malformed source index -> JSONDecodeError/KeyError, or OSError) escapes on the writer rank while all peers block in all_gather_object — the exact hang this commit was written to prevent. One-word fix.

Previous rounds — now resolved

  • Self-check no longer downloads source weights: get_safetensors_metadata(...).weight_map is header-only.
  • A writer-rank verification failure is now all_gather_object-ed, so all ranks raise together instead of deadlocking (modulo the exception-type gap above).
  • _merge_nvfp4_expert_scales guards division by zero with .clamp_min(torch.finfo(torch.float32).tiny).
  • _gated_delta_net_slicing asserts in_proj_split_names ordering rather than trusting it.
  • Not re-raising the is_safe_repo / --trust_remote_code point: is_safe_repo is already the established convention at mbridge.py:155 and export_quantized_megatron_to_hf.py:113, so quantize.py adopting it is consistent, not new policy.
  • The MTP false-positive concern in the self-check is mitigated: _get_mtp_state_dict() runs unconditionally on the last-stage main rank, and with no live MTP _copy_mtp_state_dict_from_pretrained() copies every source mtp.* key into the export, so they never appear in source - exported. EAGLE/Medusa go through save_pretrained_extra_modules, which does not run the self-check.

Verified correct (checked, no action needed)

  • Gated-attention QKV slicing. group_dim = 2*heads_per_group + 2 with per-head [q, gate] interleaving matches transformers' view(..., num_heads, head_dim*2).chunk(2, dim=-1). Bias and per-block-scale paths share _take, and with attention_output_gate=False the arithmetic reduces bit-identically to pre-PR.
  • Qwen3.5 packed experts. Megatron's [gate; up] fc1 ordering matches HF gate_up_proj and the _pack_qwen3_5_moe_experts fixture; PackNameRemapping(transpose=False) matches the [E, out, in] layout.
  • _grouped_mlp_packing marker hygiene. _grouped_mlp_slicing receives the unformatted "\x00pack\x00" template, and the "{" in layer_name guard in _record_layer_quant_config/_record_excluded_module keeps the marker out of hf_quant_config.json. handled plus assert not unhandled makes a new suffix loud instead of silently dropped.
  • _gated_delta_net_slicing keep_bf16 bookkeepingin_proj_a/in_proj_b skipped for weight quantization, _scale, _scale_2 and replicated input_scale, and the fused entry retracted from exclude_modules. _get_weight_bias yields only weight/bias, so the replication loop cannot leak layer_norm_weight.
  • MoE dispatch else: raise NotImplementedError sits after both the local_experts and experts.linear_fc1 branches, so a silently-dropped expert layout is now an error.
  • Index/self-check ordering. save_safetensors_by_layer_index writes model.safetensors.index.json on rank 0 after its internal barrier, and the new self-check's own barrier() precedes the writer-rank read. No race.
  • MoE layout agreement across quantize.py / distill.py / both export scripts / prune_minitron.py, with test_moe_layout_choice.py pinning exportability per arch.
  • New test utils (assert_exported_checkpoint_matches, _unpack_nvfp4, assert_no_quantizers_matching) are non-vacuous.

Non-blocking notes (no inline comment)

  • The MoE layout switch is one-way: nothing can force grouped GEMM back on for an arch without an experts.linear_fc1 rule. Fine today, but worth an escape hatch if someone wants calibration speed over exportability.
  • handled's ".output_scale" entry is dead — _grouped_mlp_slicing never emits output_scale.
  • Offline asymmetry: get_safetensors_metadata has no local_files_only, so under HF_HUB_OFFLINE the self-check silently warns and skips, while load_multimodal_components honours _is_hf_hub_offline().
  • distill.py passes raw args.trust_remote_code to use_moe_grouped_gemm while quantize.py passes the is_safe_repo-resolved value — a duplicate AutoConfig load under a different @cache key, and distill.py can fail on a custom-code repo where quantize.py succeeds.

Scope

25 files changed (+1416/-241). Reviewed all 6 changed modelopt/ files, all 5 changed examples/megatron_bridge/ scripts plus the README, both modelopt_recipes/ YAMLs, and the new/changed test utilities and tests. Not opened beyond their diff hunks: .github/workflows/example_tests.yml, CHANGELOG.rst.

I could not read the existing inline review threads (the gh api .../pulls/2276/comments call was blocked in this environment), so if either finding above was already discussed and settled, treat it as a duplicate rather than a new objection.

Overall risk: moderate

The algorithmically hard parts — gated QKV slicing, GatedDeltaNet splitting, packed grouped-MLP quant-scale merging, zero-centered gamma — all check out, and the new export self-check plus per-arch round-trip assertions turn the previously-silent "expert weights just vanished" failure into a hard error. The one IMPORTANT is a cost/UX regression on the repo-id VLM path, not a correctness break.

…he catch

`load_multimodal_components` accepted a repo id by downloading every `*.safetensors`
shard, then keeping the handful matching the vision prefixes. On the documented VLM export
path that is the whole checkpoint pulled to recover a small tower: for
Qwen3.5-35B-A3B the 333 vision tensors live in 2 of 14 shards. It now fetches the index
first, resolves which shards actually hold matching keys, and downloads only those; the
sharded read is filtered the same way so a partial snapshot never opens a missing shard.

Same waste class as the export self-check fixed in cb75d6c, and introduced by the same
change of mine that taught this helper to accept repo ids.

The writer-rank guard caught only `RuntimeError`, so a malformed index raising
`JSONDecodeError`/`OSError` inside the self-check would escape on that rank while every
other rank blocked forever in the `all_gather_object` below -- the hang the block exists
to prevent, reached by a different exception type. It now catches `Exception`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
@kevalmorabia97

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment thread modelopt/torch/utils/plugins/mbridge.py
Comment thread modelopt/torch/export/plugins/hf_checkpoint_utils.py
Comment thread modelopt/torch/export/unified_export_megatron.py

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review — round 8 (598d4775)

Findings: 0 CRITICAL · 1 IMPORTANT · 2 SUGGESTION

Full review (trigger comment carried no scoping instructions). 25 files changed; reviewed all 6 modelopt/ files, all 6 examples/megatron_bridge/ scripts, both modelopt_recipes/ units, CHANGELOG.rst, the new tests/_test_utils/torch/export/unified_checkpoint.py and test_moe_layout_choice.py, and the hunks of the remaining test files.

Round-7 findings — both resolved

Round-7 finding Status
IMPORTANT: load_multimodal_components downloaded every shard to extract the vision tower Fixed — two-stage index-then-shards snapshot_download, and all_shard_files is now filtered by prefixes so a partial snapshot cannot safe_open a missing shard
SUGGESTION: the failure-sharing block caught only RuntimeError, so anything else escaped on the writer rank while peers blocked in all_gather_object Fixed — except Exception

New this round

1. [IMPORTANT Compatibility] The MoE expert-layout flip has no load-side guard (comment) — modelopt/torch/utils/plugins/mbridge.py:225

Pre-PR, quantize.py never passed moe_grouped_gemm, so load_mbridge_model_from_hf's default True applied unconditionally and every Megatron checkpoint held TEGroupedMLP experts. Post-PR, use_moe_grouped_gemm() returns False for Qwen3MoeForCausalLM / DeepseekV3ForCausalLM / GptOssForCausalLM / Llama4ForConditionalGeneration, so export_quantized_megatron_to_hf.py and distill.py's quantized branch build SequentialMLP and load those checkpoints into it. The key names differ, the load is non-strict (assume_ok_unexpected), so the routed experts keep random init — and neither _verify_exported_keys (key sets only) nor assert_exported_checkpoint_matches (same-run checkpoints only) can see it.

The PR's backward-compat argument — those architectures previously exported a checkpoint with no expert weights, so nothing working is removed — holds for HF export, but not for the two other uses of a Megatron-format checkpoint from quantize.py: re-running export after upgrading, and QAD via --student_megatron_path. Neither was broken before. test_moe_layout_choice.py's own assertion message names this failure mode but only guards future drift. The suggested fix is bidirectional and uses _checkpoint_keys, which the function already computes — note the earlier round's version keyed off ...mlp.experts.weight0, which TEGroupedMLP never writes, and so was silent for exactly the grouped-to-sequential direction this PR creates.

2. [SUGGESTION] (comment) — with the new shard filter, prefixes matching nothing gives wanted == [], so load_multimodal_components downloads no shards and returns {} with no error; _verify_exported_keys cannot catch a dropped vision tower because its \.layers\.(\d+)\. filter skips every model.visual.blocks.N.* key. Plus two minor items in the same try: requests exceptions subclass OSError, so a transient hub failure is reported as an invalid path, and a malformed index raises an uncaught KeyError.

3. [SUGGESTION] (comment) — _merge_nvfp4_expert_scales guards merged_scale_2 but not merged_scale: scale_i * (s2_i / s2_max) cast back to E4M3 flushes to 0 below 2**-9, and to_quantized_weight then divides by it. Same clamp this release already added on the ONNX NVFP4 path.

Verified correct (checked, no action needed)

  • _gated_delta_net_slicing. Split sizes come from in_proj_split_sections with an ordering assert; per-block/per-channel weight_scale splits along the same output dim as the weight; keep_bf16 (in_proj_a / in_proj_b) stores the high-precision weight, skips _scale / _scale_2 / input_scale, and records an exclusion. The qformat is None branch correctly replaces the fused exclude entry with the four per-HF-name ones, and _record_excluded_module dedupes so the double-record for a/b is harmless.
  • Gated-attention QKV slicing. group_dim = 2*heads_per_group + 2 with _take concatenating gate_slice on dim 1 produces per-head [q, gate], matching transformers' view(..., num_heads, head_dim*2).chunk(2, dim=-1). Applied consistently to weight, per-block scale, and bias; the non-gated path reduces to the previous indices exactly.
  • _grouped_mlp_packing. is_mtp prefix is rewritten once (inner call passes is_mtp=False); record_quant_config=False plus recording against the packed prefix avoids the per-expert hf_quant_config keys; collect(".weight") / (".weight_scale") do not cross-match _scale_2; the handled/unhandled assert catches any per-expert suffix without a packing rule; _grouped_mlp_slicing does store weight_scale_2, so the NVFP4 branch is live.
  • _merge_nvfp4_expert_scales math. merged_scale * merged_scale_2 == scale_i * s2_i per expert, so the effective dequant scale is preserved and the re-quantization absorbs the E4M3 representation error.
  • _mtp_prefix. count=1 plus the explicit model.language_model. to mtp. case fixes the VLM double-replacement without changing LLM prefixes.
  • with_language_model_prefix. Passes non-CustomModuleMapping values (use_packed_local_experts) through unchanged, so the shared helper is safe for the Qwen3.5 mapping.
  • _verify_exported_keys. num_layers is the global count on TransformerConfig, so the depth-prune skip is right under PP; the exported_modules prefix escape correctly absorbs both source-side quantizer artifacts and fused-module splits like in_proj into in_proj_qkv/_z/_b/_a. The unconditional torch.distributed.barrier() before the gather matches the two pre-existing barriers in the same function.
  • assert_exported_checkpoint_matches. The bit-exact branch (matching dtype and no weight_scale) is what makes the BF16-kept in_proj_a/in_proj_b and the copied-through vision tower load-bearing; bit_exact_prefixes is exercised from test_qad.py:133.

Risk

Moderate, concentrated in one place. The export-path work is well covered — the self-check, the NotImplementedError for unmapped fused experts, the dropped-state raise, and content-level checkpoint comparison are all a clear improvement over what they replace, and the real-model validation table is unusually thorough for this area. The residual risk is the silent side of the layout default change on checkpoints that already exist, which is a small guard rather than a design question.

I previously argued this guard was unnecessary because both expert layouts serialise to
identical keys. That was wrong, and the comparison behind it was flawed: the two
checkpoints I diffed were not actually built the two different ways. Building the same
tiny Qwen3.5-MoE through the bridge both ways shows the layouts are clearly distinct:

    grouped     TEGroupedMLP   decoder.layers.0.mlp.experts.linear_fc1.weight0
    sequential  SequentialMLP  decoder.layers.0.mlp.experts.local_experts.0.linear_fc1.weight

So a checkpoint written one way and loaded into the other leaves the routed experts at
random init, since the load is non-strict -- and nothing downstream can see it, because
every key the exporter looks for is present. `load_modelopt_megatron_checkpoint` already
reads the checkpoint metadata, so it now compares the stored layout against the built
model and raises. It fires only when a layout is positively identified, so unfamiliar
naming stays silent rather than blocking a load.

Verified in both directions: feeding a real grouped model's keys to a SequentialMLP model
raises, and the matching layout passes the guard untouched.

`load_multimodal_components` now raises when the prefixes match nothing, instead of
silently exporting a randomly initialised vision tower -- with the new shard filter that
case downloads nothing and returns empty, and neither the export self-check nor the
caller would notice. Its two download stages no longer share one except clause, so a hub
outage or full disk is not reported as an invalid path, and a malformed index is caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
@kevalmorabia97

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment thread modelopt/torch/utils/plugins/mbridge.py Outdated
Comment thread tests/gpu_megatron/torch/export/test_unified_export_megatron.py

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review — round 9

Reviewed the diff against origin/main (25 files, +1473/−241), prioritising modelopt/examples/tests/.

Findings: 0 CRITICAL, 1 IMPORTANT, 1 SUGGESTION. Both are posted inline.

IMPORTANT

  • modelopt/torch/utils/plugins/mbridge.py:238-243 — the new expert-layout load guard false-positives and deadlocks at --pp_size > 1. checkpoint_keys is the global key set of the whole checkpoint; model_grouped is derived from named_parameters() of the modules this rank owns, i.e. this PP stage only. On any stage that holds no MoE layer, model_grouped is False while ckpt_grouped is True, so the guard raises even though the layouts match. And because the raise fires on only a subset of ranks while the rest enter the collective _load_model_weights_from_checkpoint, the symptom is a hang rather than the error message. This is reachable on the path export_quantized_megatron_to_hf.py documents in its own module docstring (--pp_size 2; the load is at line 142), for exactly the mixed architectures this PR adds coverage for — a NemotronH hybrid_override_pattern run of M/* layers landing on one stage, or the first_k_dense_replace dense prefix of DeepSeek-V3 at high PP. Suggested fix (all-reduce both flags so the verdict is collective, and gate on whether the model owns experts at all) is in the inline comment.

SUGGESTION

  • tests/gpu_megatron/torch/export/test_unified_export_megatron.py:269-272 — swapping the two nemotron NVFP4 params for nemotron_h leaves no NVFP4 coverage for a plain dense GPTModel. The nemotron_h fixture is Mamba / MoE / attention / MoE, so no NVFP4 weight reaches the dense-MLP rules, and the remaining dense params are FP8-only or unquantized. nemotron_h is genuinely the stronger case; the note is only that it replaces rather than adds.

Verified as correct (no finding)

Most of the budget went into the new algorithm-level code, all of which checks out:

  • Gated attention in _qkv_slicing — the per-head query/gate interleave matches what HF reconstructs via view(..., num_heads, 2 * head_dim) plus chunk, and the slice arithmetic reduces exactly to the original when attention_output_gate is off.
  • _merge_nvfp4_expert_scales — the rescaling invariant holds (each rescaled per-expert block scale times the merged global scale equals the original block scale times that expert own global scale), and clamp_min(finfo.tiny) covers the all-zero-expert case.
  • _grouped_mlp_packing — correctly delegates to _grouped_mlp_slicing for the EP all_gather_object and the per-expert quantizers, keys by global expert id, and its marker prefix contains a format brace, so _record_layer_quant_config / _record_excluded_module early-return instead of leaking marker names into hf_quant_config.json. The unhandled assertion is a good guard against a silently dropped per-expert tensor.
  • _gated_delta_net_slicing — the fused in_proj exclude entry is correctly rewritten into the four HF projections, NVFP4 block scales split along the output dim (matching split_sizes), weight_scale_2 is asserted scalar, and bias splits while input_scale replicates. keep_bf16 for in_proj_a / in_proj_b writes the unquantized weight (_get_quantized_state returns the raw weight plus separate scales), so the BF16 claim in the CHANGELOG holds. Recording those two as excluded twice when qformat is None is harmless, since _record_excluded_module dedupes.
  • with_language_model_prefix — audited every CustomModuleMapping subclass in mcore_custom.py; all 14 take exactly target_name_or_prefix and func_kwargs, so the type(m)(...) reconstruction cannot silently drop a constructor argument. The non-CustomModuleMapping passthrough correctly preserves the use_packed_local_experts flag.
  • _verify_exported_keys — comparing module prefixes rather than tensor names, restricted to decoder layer indices, correctly tolerates source-side quantization artifacts, depth-pruned models, tied lm_head, MTP layers indexed at num_layers, and vision towers keyed under model.visual.blocks.N. Wrapping it in try/except and sharing the result via all_gather_object so every rank raises together is the right call for public API.
  • _mtp_prefix — the model.language_model. special case correctly avoids producing mtp.language_model..
  • Recipe aliases*self_attention.conv1d* matches only GatedDeltaNet (nothing in a regular Megatron attention block has conv1d directly under self_attention), and the pre-existing *router* / *output_layer* patterns already back the new assert_no_quantizers_matching assertions in test_qad.py.
  • The new empty-result ValueError in load_multimodal_components — checked whether it could hard-fail pre-existing LLaVA exports. It cannot regress anything that worked: the default prefixes are unchanged, and the only newly-failing case previously produced a checkpoint with no vision tower at all.
  • _pack_name_remapping has no EP gather, so use_packed_local_experts at EP > 1 would pack only the local experts — not reachable, since export_quantized_megatron_to_hf.py:129 pins expert parallelism to 1, and this is pre-existing behaviour for llama4/gptoss rather than something this PR introduces.

Risk

Low-to-moderate. The design is sound, and the three new self-checks (_verify_exported_keys, the quantizer-tensors-without-state raise, the empty-vision-tower raise) each convert a previously silent wrong checkpoint into a loud failure, which is the right direction. The one IMPORTANT finding is in a guard added by the head commit and is a small, local fix; everything else I could reach verified clean.

…ort test

The layout guard added in 515f36a compared a global key set against this rank's
parameters. `named_parameters()` only covers the local pipeline stage, so at `--pp_size >
1` a stage holding no MoE layer saw `model_grouped=False` against `ckpt_grouped=True` and
raised -- on a subset of ranks, while the others entered the collective load, so the
symptom was a hang rather than the message. Reachable on the `--pp_size 2` invocation the
export script documents. Both flags are now all-reduced, and the guard only applies when
the model owns experts at all, so every rank reaches the same verdict.

`mbridge.py` never imported `torch`, which the all-reduce needs; the example suite caught
it as a `NameError` across all three export cases.

Nothing in CI would have caught the original bug: `test_quantize_and_export` scaled its
quantize step with `num_gpus` but hardcoded the export step to one process, and the other
tests that reach this loader pin `pp_size=1`, so the collective path never ran on either
the 1-GPU PR runner or the 2-GPU nightly. The export step now scales like its quantize
step -- unchanged on one GPU, real PP=2 coverage on two.

Also drops the dense `qwen3` case from that test: `qwen3_moe` already covers the non-VLM
script path and its non-expert layers, `qwen3vl` covers a dense decoder, and this was the
case that exceeded the 300s cap in CI. Restores a dense NVFP4 param in
`test_unified_export_megatron`, since swapping Nemotron for NemotronH left no NVFP4 weight
reaching the dense-MLP rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
@kevalmorabia97

Copy link
Copy Markdown
Collaborator Author

/claude review

@claude

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

Claude review — round 10 (full review)

Scope: full review (the trigger comment carried no scoping instructions). 25 changed files, +1486/−241, against origin/main, head 449952a9.

Findings this round: 0 CRITICAL, 0 IMPORTANT, 0 new SUGGESTIONs.

What I verified

The head commit is the fix for the one IMPORTANT from the previous round, and it holds up:

  • Expert-layout guard is now collective (modelopt/torch/utils/plugins/mbridge.py). model_has_experts / model_grouped come from named_parameters(), which is PP-stage-local, so they are all_reduce(MAX)'d before the verdict. checkpoint_keys is already global (every rank reads the same state_dict_metadata), so both sides of the comparison are rank-invariant and every rank raises or none does. int32 + ReduceOp.MAX on the default group is valid under NCCL. The new RuntimeError for "quantizer tensors but no restorable ModelOpt state" is likewise driven by global data, so it cannot diverge either.
  • The VLM redirect is collective too. any(get_language_model(m)[1] ...) is local but not any(key.startswith("language_model.") ...) is global, and Megatron-Bridge instantiates the same VLM wrapper class on every PP stage (with vision_model=None off the first stage), so the redirect decision agrees across ranks.
  • _grouped_mlp_packing — the "\x00pack\x00" marker path is sound: the brace in the marker prefix suppresses quant-config recording inside _grouped_mlp_slicing, the per-expert key split recovers the expert id correctly, and the unhandled assertion fails loudly rather than silently dropping an unexpected per-expert tensor (e.g. an expert bias).
  • _merge_nvfp4_expert_scales preserves the dequant invariant exactly: merged_scale * merged_scale_2 == scales[i] * scales_2[i] in FP32 before the E4M3 cast, so reconstructed weights are unchanged apart from block-scale rounding.
  • MoE dispatch guard (unified_export_megatron.py:722-729) — elif "experts.linear_fc1" in self.rules: ... else: raise NotImplementedError(...). Qwen3.5 has the rule and exports fused experts; a Qwen3-MoE built as TEGroupedMLP now fails loudly instead of silently exporting with every routed expert missing. _populate_rule_book keeps bool values (use_packed_local_experts: True) through with_language_model_prefix, so the packed-expert flag survives into the rule book.
  • _verify_exported_keys — the exported_modules prefix escape plus the rotary_emb / depth-pruned-layer skips close the false-positive-on-quantized-HF-source problem from an earlier round, and the failure is shared with all_gather_object so the self-check itself cannot hang.
  • load_multimodal_components now raises when no tensor matches prefixes, closing the silently-dropped-vision-tower gap raised earlier.
  • Test coverage matches what the PR claims: test_quantize_export.py runs the export leg at pp_size=num_gpus across qwen3_moe / qwen3vl / nemotron_h, and assert_exported_checkpoint_matches dequantizes NVFP4 and FP8 back to a tolerance comparison instead of just asserting files exist.

Carried over from earlier rounds (not re-raised inline)

  • _merge_nvfp4_expert_scales does not clamp_min merged_scale the way it does merged_scale_2. Because the ratio scales_2[i] / merged_scale_2 <= 1, per-block scales are always scaled down, so a block whose scale already sits near the E4M3 subnormal floor can flush to zero and lose that block. Previously logged as a SUGGESTION; it remains a tail case, and the invariant above is otherwise exact.
  • _verify_exported_keys compares language-model keys only, so it cannot detect a vision tower dropped wholesale — the new raise in load_multimodal_components covers the realistic path.

Paths I did not open

CHANGELOG.rst, .github/workflows/example_tests.yml, modelopt_recipes/configs/** (recipe YAML alias additions), and examples/megatron_bridge/README.md — config/docs/CI metadata, deprioritized per the review order. Everything under modelopt/ in the diff was read, along with the new test utilities and the changed example scripts.

Risk

Low. The remaining changes are additive per-architecture mappings behind arch-keyed rule lookups, and the two behaviour changes that touch shared code (with_language_model_prefix moving to mcore_custom.py, the MoE dispatch else: raise) are either pass-through-preserving or convert a silent-wrong-output into a loud failure.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review passed — no blocking issues found. LGTM

Rescaling a per-expert block scale onto the merged global scale only ever shrinks it
(the ratio is at most 1), so a block already near the E4M3 subnormal floor could flush to
zero on the cast and take its weights with it. `merged_scale_2` was already guarded;
`merged_scale` now is too. Values that do not underflow are unchanged.

Re-validated both real-model runs on the final code rather than trusting the earlier
numbers, since the scale merge has changed since they were taken:

  Nemotron-3.5-Lightning-30B-A3B, NVFP4 4o6, the path this touches
    3519 quantizers / 69GB checkpoint / 21GB export -- all identical to before
    MMLU 0.7825 +/- 0.0105 (gate 0.75, previously 0.7748)

  Qwen3.5-0.8B, FP8 dense VLM
    export verified against the reference: keys, shapes, values, vision tower bit-exact
    MMLU 0.4832 +/- 0.0127 (BF16 0.4895, previously 0.4678)

Both deltas sit inside their stderr, so the conclusion is that the scale-merge rework and
the download/guard changes cost no accuracy -- not that they improved it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant