Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c5a03a5
Support quantized Qwen3-VL export to unified HF from Megatron-Bridge
kevalmorabia97 Aug 28, 2026
d1f077c
Add Qwen3.5-VL export, fail loudly on unexportable MoE, verify exports
kevalmorabia97 Aug 28, 2026
ef4761d
Cover gated attention and MoE export, fixing the gated QKV split
kevalmorabia97 Aug 28, 2026
12e4fcb
Verify checkpoint contents the existing assertions could not see
kevalmorabia97 Aug 28, 2026
4c0082c
Fix GatedDeltaNet quantizer exclusions on Megatron-Core
kevalmorabia97 Aug 28, 2026
18db85a
Make the exporter verify its own output
kevalmorabia97 Aug 28, 2026
2baab5e
Unify VLM detection and fail loudly on dropped ModelOpt state
kevalmorabia97 Aug 28, 2026
a2f0fe0
Address review, and pick the MoE expert layout automatically
kevalmorabia97 Aug 28, 2026
7f745d9
Fix Qwen3.5 export: packed experts, NVFP4 packing, MTP names, repo ids
kevalmorabia97 Aug 29, 2026
c262cc6
Keep grouped GEMM for unquantized distillation students, guard QAD ex…
kevalmorabia97 Aug 29, 2026
5e1a141
Cover packed-expert and quantized hybrid export in the fast test suite
kevalmorabia97 Aug 29, 2026
215d4aa
Address review: quant-config recording, GDN guards, VLM probe, teache…
kevalmorabia97 Aug 29, 2026
67cdc97
Address review: distilled-export layout, hub-id self-check, NVFP4 sca…
kevalmorabia97 Aug 30, 2026
622371c
Raise the two example-test timeouts that fail on CI runners
kevalmorabia97 Aug 30, 2026
ba0c3b5
Address review: drop a guard that cannot fire, cover unsharded self-c…
kevalmorabia97 Aug 30, 2026
cb75d6c
Address review: read source headers instead of downloading, share the…
kevalmorabia97 Aug 30, 2026
598d477
Address review: fetch only the shards holding vision weights, widen t…
kevalmorabia97 Aug 30, 2026
515f36a
Add the expert-layout load guard, correcting an earlier wrong pushback
kevalmorabia97 Aug 30, 2026
449952a
Make the expert-layout guard collective, and exercise PP>1 in the exp…
kevalmorabia97 Aug 30, 2026
d817d50
Clamp rescaled NVFP4 block scales away from the E4M3 floor
kevalmorabia97 Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/example_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ jobs:
with:
docker_image: "nvcr.io/nvidia/nemo:26.08"
example: megatron_bridge
timeout_minutes: 60
timeout_minutes: 75
pip_install_extras: "[hf,puzzletron,dev-test]"
runner: ${{ startsWith(github.ref, 'refs/heads/pull-request/') && 'linux-amd64-gpu-rtxpro6000-latest-1' || 'linux-amd64-gpu-rtxpro6000-latest-2' }}
allow_failure: ${{ contains(format(',{0},', vars.ALLOW_FAILURE_EXAMPLE_TESTS), ',megatron_bridge,') }}
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Changelog
- Add SFT-masked data support to ``examples/megatron_bridge/distill.py``: ``--sft --sft_dataset_root <dir>`` distills on raw prompt-completion JSONL (``{"input", "output"}`` records) with the loss masked to the response tokens, using Megatron-Bridge's ``FinetuningDatasetConfig`` and the model's own HuggingFace tokenizer instead of the pretraining ``GPTDataset`` and ``NullTokenizer``.
- Add per-expert weight quantization for Transformer Engine ``TEGroupedMLP`` (fused MoE experts): each expert now has its own ``weight_quantizer`` (a ``GroupedQuantizer`` holding one ``TensorQuantizer`` per expert) with an independent ``amax``, instead of a single shared ``amax`` across all experts. Applies to ``mtq.quantize`` calibration, HF / Megatron export, and QAD.
- Add opt-in ``torch.compile`` execution for Transformer Engine grouped-linear per-expert weight quantizers while preserving their native checkpoint amax shapes. Set ``MODELOPT_TEGROUPED_COMPILE_WEIGHT_LOOP=1`` before quantized-module conversion; the default path remains eager.
- Add HuggingFace unified export of quantized Qwen3-VL and Qwen3.5-VL checkpoints (PTQ or QAD) via ``examples/megatron_bridge/export_quantized_megatron_to_hf.py``, Qwen3.5-VL additionally covering GatedDeltaNet linear-attention layers and MoE shared experts. Only the language model is quantized; the vision tower is copied from the source HuggingFace checkpoint.
- Megatron-Bridge scripts now choose the MoE expert layout automatically from the model config: the faster fused ``TEGroupedMLP`` (grouped GEMM) unless the architecture cannot export it to HuggingFace, in which case ``SequentialMLP`` keeps the checkpoint exportable and ``--no_moe_grouped_gemm`` forces it explicitly. For the affected architectures this changes MoE activation scales from one shared scale to per-expert.

*Misc*

Expand Down Expand Up @@ -46,6 +48,11 @@ Changelog

- Avoid querying CUDA/Blackwell capability when ``NVFP4QTensor.quantize`` uses its CPU path or has the optional TensorRT-LLM fast path disabled.
- Fix NVFP4 ONNX export to quantize FP4 weights with the published FP8 block scales, matching eager ModelOpt packed weights. Block scales below ``2**-9`` are now clamped to that minimum, and non-finite or negative scales raise an error.
- Fix Megatron-Bridge Quantization Aware Distillation of a vision-language model silently discarding the ModelOpt state, so the distilled checkpoint restored no quantizers and exported as an unquantized model. Re-run QAD to regenerate any affected checkpoint.
- Fix Megatron-Core HuggingFace export silently omitting fused (grouped GEMM) MoE experts for architectures without an ``experts.linear_fc1`` rule (e.g. ``Qwen3MoeForCausalLM``), which produced a valid-looking checkpoint containing no expert weights. The exporter now raises instead of writing that checkpoint; the scripts also avoid the situation by selecting ``SequentialMLP`` for those architectures.
- Fix GatedDeltaNet (Qwen3.5) quantizer exclusions on Megatron-Core: the recipe patterns name the HuggingFace ``linear_attn`` module, so the ``conv1d`` was calibrated and the alpha / beta gate projections were exported in FP8. ``conv1d`` now has a ``self_attention`` alias in the default disabled-quantizer units, and the alpha / beta projections are exported in BF16 (they share Megatron's fused ``in_proj`` quantizer and cannot be disabled by name).
- Megatron-Core HuggingFace export now verifies its own output: if the exported checkpoint is missing tensors the source checkpoint has (an architecture whose module has no export rule), it raises instead of writing a valid-looking checkpoint. Depth-pruned models and tied embeddings are accounted for.
- Loading a Megatron checkpoint that holds quantizer tensors but no restorable ModelOpt state now raises instead of silently loading the model unquantized.
- Update HuggingFace checkpoint export to use name-based tied-weight deduplication instead of the previous address-based approach. The address-based deduplication could incorrectly drop an untied weight that happened to share memory with a tied one, producing an incomplete checkpoint (observed as a false positive on MiniMax-M2.7).
- Fix EAGLE-3 training with context parallelism (``--cp_size > 1`` in ``examples/speculative_decoding``), which failed to start on ``accelerate >= 1.13`` and then raised ``got mixed torch.Tensor and DTensor``.
- Polygraphy minimum dependency upgraded to ``0.53.4`` to solve ONNX AutoCast failures when marking optional graph outputs.
Expand Down
2 changes: 1 addition & 1 deletion examples/megatron_bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ For a vision-language model (e.g. Qwen3.5-VL, Gemma3-VL), `quantize.py` automati
- A **text** dataset runs text-only calibration of the language model (vision tower idle).

> [!NOTE]
> HuggingFace unified export (`export_quantized_megatron_to_hf.py`) of a quantized VLM is not yet supported; the quantized VLM is saved in Megatron checkpoint format only.
> HuggingFace unified export (`export_quantized_megatron_to_hf.py`) of a quantized VLM covers **Qwen3-VL** and **Qwen3.5-VL**. Other VLMs such as Gemma3-VL are saved in Megatron checkpoint format only.

## Distillation

Expand Down
67 changes: 52 additions & 15 deletions examples/megatron_bridge/distill.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,18 @@
from megatron.core.datasets.utils import get_blend_from_list
from megatron.core.distributed import DistributedDataParallelConfig
from megatron.core.utils import unwrap_model
from transformers import AutoConfig, AutoTokenizer
from transformers import AutoTokenizer

import modelopt.torch.distill as mtd
import modelopt.torch.utils.distributed as dist
from modelopt.torch.opt.conversion import ModeloptStateManager
from modelopt.torch.utils import print_args, print_rank_0, warn_rank_0
from modelopt.torch.utils.plugins.mbridge import load_modelopt_megatron_checkpoint
from modelopt.torch.utils.plugins.mbridge import (
is_vlm_config,
load_modelopt_megatron_checkpoint,
set_moe_expert_layout,
use_moe_grouped_gemm,
)

with contextlib.suppress(ModuleNotFoundError):
import modelopt.torch.puzzletron.plugins.mbridge # noqa: F401
Expand Down Expand Up @@ -92,6 +98,15 @@ def get_args():
help="HuggingFace model name or path for the teacher (e.g. Qwen/Qwen3-8B)",
)
parser.add_argument("--trust_remote_code", action="store_true", help="Trust remote code")
parser.add_argument(
"--no_moe_grouped_gemm",
action="store_true",
help=(
"Force SequentialMLP for MoE experts instead of the fused TEGroupedMLP (grouped GEMM). "
"By default grouped GEMM is used unless the architecture cannot export it to "
"HuggingFace, in which case SequentialMLP is selected automatically."
),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
parser.add_argument(
"--student_megatron_path",
type=str,
Expand Down Expand Up @@ -326,11 +341,26 @@ def _tokenizer_prepends_bos(args) -> bool:


def main(args: argparse.Namespace):
student_has_modelopt_state = args.student_megatron_path is not None and has_modelopt_state(
args.student_megatron_path
)
# A quantized student pins the layout: it must match what quantize.py wrote, so reuse the same
# data-driven choice. An unquantized (e.g. pruned) student exports via Megatron-Bridge, which
# reads either layout, so it keeps the faster grouped GEMM.
moe_grouped_gemm = (
use_moe_grouped_gemm(
args.student_hf_path,
trust_remote_code=args.trust_remote_code,
force_sequential=args.no_moe_grouped_gemm,
)
if student_has_modelopt_state
else not args.no_moe_grouped_gemm
)
checkpoint_dir = os.path.join(args.output_dir, "checkpoints")
tensorboard_dir = os.path.join(args.output_dir, "tb_logs")

# Build student and teacher model providers
def _build_model_provider(hf_path, load_weights=True):
def _build_model_provider(hf_path, load_weights=True, moe_grouped_gemm=True):
bridge = AutoBridge.from_hf_pretrained(hf_path, trust_remote_code=args.trust_remote_code)
provider = bridge.to_megatron_provider(load_weights=load_weights)

Expand All @@ -343,6 +373,7 @@ def _build_model_provider(hf_path, load_weights=True):
provider.expert_model_parallel_size = args.ep_size
provider.expert_tensor_parallel_size = 1 # Expert tensor parallelism is not supported
provider.seq_length = args.seq_length
set_moe_expert_layout(provider, moe_grouped_gemm)
if args.sft:
# A response-only loss mask needs per-token reduction to combine across CP ranks.
# Must stay in sync with ``average_in_collective=not args.sft`` on the DDP config.
Expand All @@ -358,17 +389,21 @@ def _build_model_provider(hf_path, load_weights=True):
# The student structure is always built from --student_hf_path. When --student_megatron_path is
# given, the HF weights are skipped (they are overwritten by the Megatron checkpoint, loaded into
# the built student inside the patched provide() below).
student_has_modelopt_state = args.student_megatron_path is not None and has_modelopt_state(
args.student_megatron_path
)
# Only the student's layout is pinned -- it must match --student_megatron_path (see quantize.py).
student_provider = _build_model_provider(
args.student_hf_path, load_weights=args.student_megatron_path is None
args.student_hf_path,
load_weights=args.student_megatron_path is None,
moe_grouped_gemm=moe_grouped_gemm,
)
if student_has_modelopt_state:
# Gradient accumulation fusion is not supported with ModelOpt quantized models. Disable it
# before the model is built so the student's linear layers are constructed accordingly.
student_provider.gradient_accumulation_fusion = False
teacher_provider = _build_model_provider(args.teacher_hf_path)
# The teacher only runs forward, is loaded from HF, and is hidden from the checkpoint
# (``expose_minimal_state_dict``), so it keeps the faster grouped GEMM regardless.
teacher_provider = _build_model_provider(
args.teacher_hf_path, moe_grouped_gemm=not args.no_moe_grouped_gemm
)

# The KD losses compare logits elementwise over the vocab dim, so both output layers must have
# the same padded width. A shared tokenizer does not imply it: the HF configs can disagree.
Expand All @@ -390,10 +425,7 @@ def _build_model_provider(hf_path, load_weights=True):

# HF VLM configs expose ``vision_config``; Megatron-Bridge nests the text model under
# ``language_model`` (used as ``distill_submodule`` below).
is_vlm = hasattr(
AutoConfig.from_pretrained(args.student_hf_path, trust_remote_code=args.trust_remote_code),
"vision_config",
)
is_vlm = is_vlm_config(args.student_hf_path, trust_remote_code=args.trust_remote_code)

if is_vlm:
warn_rank_0(
Expand All @@ -420,9 +452,14 @@ def _restore_student_hook(model_chunks):
print_rank_0(
f"Loading student weights from Megatron checkpoint {args.student_megatron_path}"
)
load_modelopt_megatron_checkpoint(
[unwrap_model(model_chunks[0])], args.student_megatron_path
)
student = unwrap_model(model_chunks[0])
loaded = load_modelopt_megatron_checkpoint([student], args.student_megatron_path)
if is_vlm and student_has_modelopt_state and loaded[0] is student:
# PTQ stores the state on the VLM root (it quantizes and saves the whole VLM), but
# only ``language_model`` is distilled and checkpointed here, so move it there to
# keep the quantizers across the QAD checkpoint's save / restore. Resuming from a
# language-model-only checkpoint already restores it there.
ModeloptStateManager.transfer_state_dict(student, student.language_model)
return model_chunks

distill_provider.register_pre_wrap_hook(_restore_student_hook, prepend=True)
Expand Down
27 changes: 23 additions & 4 deletions examples/megatron_bridge/export_distilled_megatron_to_hf.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,14 @@

import torch
from megatron.bridge import AutoBridge
from megatron.bridge.training.post_training.checkpointing import has_modelopt_state
from transformers import AutoConfig

import modelopt.torch.utils.distributed as dist
from modelopt.torch.export import copy_hf_ckpt_remote_code
from modelopt.torch.utils import print_args, print_rank_0
from modelopt.torch.utils.plugins.mbridge import (
is_vlm_config,
load_mbridge_model_from_hf,
load_modelopt_megatron_checkpoint,
)
Expand Down Expand Up @@ -213,6 +215,16 @@ def get_args() -> argparse.Namespace:
"correct for homogeneous students; unused for VLMs.",
)
parser.add_argument("--trust_remote_code", action="store_true", help="Trust remote code")
parser.add_argument(
"--no_moe_grouped_gemm",
action="store_true",
help=(
"Force SequentialMLP for MoE experts instead of the fused TEGroupedMLP (grouped GEMM). "
"By default grouped GEMM is used unless the architecture cannot export it to "
"HuggingFace, in which case SequentialMLP is selected automatically. VLMs only: the "
"LLM path reads the expert layout from the checkpoint and ignores this flag."
),
)
parser.add_argument("--tp_size", type=int, default=1, help="Tensor parallel size")
Comment thread
kevalmorabia97 marked this conversation as resolved.
parser.add_argument("--pp_size", type=int, default=1, help="Pipeline parallel size")
parser.add_argument("--ep_size", type=int, default=1, help="Expert parallel size")
Expand All @@ -226,10 +238,15 @@ def get_args() -> argparse.Namespace:

def main(args: argparse.Namespace):
checkpoint_export_paths: list[tuple[Path, Path]] = _get_checkpoint_export_paths(args)
is_vlm = hasattr(
AutoConfig.from_pretrained(args.student_hf_path, trust_remote_code=args.trust_remote_code),
"vision_config",
)
# This path drops quantization, so a QAD checkpoint would export silently unquantized.
# ``has_modelopt_state`` ignores ``kd_loss``, so plain distillation still passes.
quantized = [str(p) for p, _ in checkpoint_export_paths if has_modelopt_state(str(p))]
if quantized:
raise ValueError(
f"{quantized[0]} is quantized; this script exports full precision only and would drop "
"the quantizers. Use export_quantized_megatron_to_hf.py instead."
)
is_vlm = is_vlm_config(args.student_hf_path, trust_remote_code=args.trust_remote_code)

if is_vlm:
# Build the full VLM (vision tower / projector + original LM from HF), then overwrite the LM
Expand All @@ -238,6 +255,8 @@ def main(args: argparse.Namespace):
_bridge, _provider, _model, full_model, _tokenizer = load_mbridge_model_from_hf(
hf_model_name_or_path=args.student_hf_path,
trust_remote_code=args.trust_remote_code,
# Mirrors distill.py's unquantized branch
moe_grouped_gemm=not args.no_moe_grouped_gemm,
provider_overrides={
"tensor_model_parallel_size": args.tp_size,
"pipeline_model_parallel_size": args.pp_size,
Expand Down
19 changes: 16 additions & 3 deletions examples/megatron_bridge/export_quantized_megatron_to_hf.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from modelopt.torch.utils.plugins.mbridge import (
load_mbridge_model_from_hf,
load_modelopt_megatron_checkpoint,
use_moe_grouped_gemm,
)


Expand All @@ -71,6 +72,15 @@ def get_args() -> argparse.Namespace:
help="Directory to write the exported HuggingFace (unified) checkpoint to.",
)
parser.add_argument("--trust_remote_code", action="store_true")
parser.add_argument(
"--no_moe_grouped_gemm",
action="store_true",
help=(
"Force SequentialMLP for MoE experts instead of the fused TEGroupedMLP (grouped GEMM). "
"By default grouped GEMM is used unless the architecture cannot export it to "
"HuggingFace, in which case SequentialMLP is selected automatically."
),
)
parser.add_argument(
"--export_extra_modules",
action="store_true",
Expand Down Expand Up @@ -108,6 +118,11 @@ def main(args: argparse.Namespace):
_bridge, _provider, model, _unwrapped_model, _tokenizer = load_mbridge_model_from_hf(
hf_model_name_or_path=args.hf_model_name_or_path,
trust_remote_code=trust_remote_code,
moe_grouped_gemm=use_moe_grouped_gemm(
args.hf_model_name_or_path,
trust_remote_code=trust_remote_code,
force_sequential=args.no_moe_grouped_gemm,
),
provider_overrides={
"tensor_model_parallel_size": 1, # Tensor parallelism is not supported
"pipeline_model_parallel_size": args.pp_size,
Expand Down Expand Up @@ -144,9 +159,7 @@ def main(args: argparse.Namespace):
print_rank_0(
f"Exporting to HuggingFace (unified) checkpoint at {args.export_unified_hf_path}..."
)
# TODO (OMNIML-5366): quantized-VLM HF export. export_mcore_gpt_to_hf's per-arch mappings don't
# cover Qwen3.5-VL / Gemma3-VL; See if Megatron-Bridge's AutoBridge.export_hf_weights_quant can be
# used instead.
# TODO: Gemma3-VL is not in export_mcore_gpt_to_hf's per-arch mappings yet.
export_mcore_gpt_to_hf(
unwrapped_model,
args.hf_model_name_or_path,
Expand Down
5 changes: 2 additions & 3 deletions examples/megatron_bridge/prune_minitron.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
print_rank_0,
warn_rank_0,
)
from modelopt.torch.utils.plugins.mbridge import load_mbridge_model_from_hf
from modelopt.torch.utils.plugins.mbridge import get_language_model, load_mbridge_model_from_hf
from modelopt.torch.utils.plugins.megatron_calibration import (
get_megatron_calibration_forward_loop,
get_megatron_vlm_calibration_forward_loop,
Expand Down Expand Up @@ -430,8 +430,7 @@ def main(args: argparse.Namespace):

# For VLMs (e.g. Qwen3-VL), only the language model is pruned; the vision tower is left intact.
# hidden_size is shared with the vision->LM projector, so it is skipped
language_model = getattr(unwrapped_model, "language_model", unwrapped_model)
is_vlm = language_model is not unwrapped_model
language_model, is_vlm = get_language_model(unwrapped_model)
if is_vlm:
warn_rank_0(
"VLM detected: pruning model.language_model only; all non-language-model components "
Expand Down
Loading
Loading