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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
31 changes: 27 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,14 +59,17 @@

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,
use_moe_grouped_gemm,
)

# Megatron-Bridge checkpoint iteration directories use names like ``iter_0000100``.
Expand Down Expand Up @@ -213,6 +216,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 +239,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 +256,11 @@ 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,
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,
),
Comment on lines +259 to +263

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 picks a different MoE expert layout than distill.py wrote for exactly the checkpoints this script accepts.

distill.py:350-358 uses use_moe_grouped_gemm(...) only when the student has ModelOpt state; otherwise it uses not args.no_moe_grouped_gemm (i.e. grouped GEMM by default):

moe_grouped_gemm = (
    use_moe_grouped_gemm(..., force_sequential=args.no_moe_grouped_gemm)
    if student_has_modelopt_state
    else not args.no_moe_grouped_gemm
)

This script now rejects checkpoints with ModelOpt state (lines 244-249), so every checkpoint it handles is one distill.py built on the not args.no_moe_grouped_gemm branch. Here it unconditionally calls use_moe_grouped_gemm(...), which returns False for any MoE architecture with no experts.linear_fc1 export rule (e.g. Qwen3VLMoeForConditionalGeneration, which resolves to all_mcore_hf_export_mapping.get(arch, {}){}).

Impact: for a MoE VLM whose grouped experts aren't HF-exportable, distill.py writes experts.weight0..N (TEGroupedMLP) and this script builds SequentialMLP (local_experts.<i>.linear_fc1.weight) before load_modelopt_megatron_checkpoint. Best case that's a confusing DCP key mismatch; worst case the loader tolerates the mismatch and you export the freshly-initialized HF weights instead of the distilled ones — the same silent-corruption shape this PR is fixing elsewhere.

Suggested fix: mirror distill.py's unquantized branch, since this script only handles unquantized students and the Bridge export path reads either layout:

            moe_grouped_gemm=not args.no_moe_grouped_gemm,

and drop the now-unused use_moe_grouped_gemm import.

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