Skip to content

Commit 905cb1b

Browse files
Export expert-parallel Megatron-Bridge MoE checkpoints, and fix exporter and QAD gaps
- export_quantized_megatron_to_hf.py --ep_size: grouped-GEMM experts are gathered to EP rank 0 and one rank per pipeline stage writes the shards; source-checkpoint loads (MTP copy, vision tower) run only on that writer. SequentialMLP experts raise at EP>1. - Experts-only grouped-GEMM exports are now marked quantized (weight_attr_names covers TEGroupedLinear's GroupedQuantizer). - expert_bias is exported FP32; MLA keeps the source head_dim; the copied vision tower is listed in exclude_modules; generation_config.json is written without re-validation. - QAD: freeze the MTP head when distillation skips its loss, so DDP doesn't wait for its grads. - test_quantize_export covers TP, CP, PP and EP. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
1 parent ad8cd63 commit 905cb1b

10 files changed

Lines changed: 150 additions & 46 deletions

File tree

‎CHANGELOG.rst‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Changelog
3636

3737
- Add an end-to-end W4A4 NVFP4 PTQ and QAD tutorial for Qwen3.6-35B-A3B also covering evaluation and vLLM throughput benchmarking. See `examples/megatron_bridge/tutorials/Qwen3.6-35B-A3B/README.md <https://github.com/NVIDIA/Model-Optimizer/tree/main/examples/megatron_bridge/tutorials/Qwen3.6-35B-A3B/>`_ for details.
3838
- Add ``--mlflow <tracking-uri>`` to the ``examples/megatron_bridge`` scripts that write a checkpoint -- ``prune_minitron.py``, ``quantize.py``, ``distill.py``, ``export_quantized_megatron_to_hf.py`` and ``export_distilled_megatron_to_hf.py`` (MLflow's own ``MLFLOW_TRACKING_URI`` is honoured too). Each run records the invocation, its arguments as searchable params and its log -- a distillation records its training metrics instead of the rank-0 log -- and writes ``.experiment.json`` into the checkpoint it produced, so a pruning, a quantization, the distillation that refines its checkpoint and the export that deploys it can be traced to one another; uploading the checkpoints themselves stays off unless ``--mlflow_log_checkpoints`` is passed.
39+
- Add ``--ep_size`` to ``examples/megatron_bridge/export_quantized_megatron_to_hf.py`` so large MoE models with grouped-GEMM experts can be exported with their experts sharded across GPUs. Checkpoints built with ``--no_moe_grouped_gemm`` must still be exported at ``--ep_size 1``.
3940

4041
*Misc*
4142

@@ -90,6 +91,7 @@ Changelog
9091

9192
**Bug Fixes**
9293

94+
- Fix Megatron unified HF export of MoE models with grouped-GEMM experts when only the experts are quantized (e.g. ``nvfp4_experts_only-*`` recipes): ``hf_quant_config.json`` and the ``quantization_config`` in ``config.json`` were not written, so the quantized experts were served as unquantized weights. Re-export such checkpoints.
9395
- Fix Megatron-Core checkpoint saving for quantized grouped MoE experts when tensor and expert parallelism are both enabled.
9496
- Fix shared ONNX export metadata and Diffusers attention policy: every ``NVFP4QuantExporter`` post-process now upgrades the default-domain opset to at least 23, all FP8 custom-op exports re-run ONNX shape/type inference after setting output metadata, and quantized SDPA derives FP8 MHA enablement from the live Q/K/V quantizers instead of honoring a caller-set ``_disable_fp8_mha`` attribute.
9597
- Fix ONNX FP16 conversion failing to preserve public output types when type inference changes a graph output declaration before output casts are inserted.

‎examples/megatron_bridge/export_quantized_megatron_to_hf.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
2323
The HuggingFace unified exporter does not gather tensor-parallel-sharded weights, so this script
2424
always loads the checkpoint at tensor_model_parallel_size=1 (re-sharding from whatever TP was used
25-
during quantization). Use --pp_size to shard a large model across GPUs for export.
25+
during quantization). Use --pp_size and/or --ep_size to shard a large model across GPUs for
26+
export; --ep_size needs grouped-GEMM experts (not --no_moe_grouped_gemm).
2627
2728
Example usage to export an FP8 checkpoint produced by quantize.py:
2829
@@ -110,8 +111,14 @@ def get_args() -> argparse.Namespace:
110111
help="Export extra modules such as Medusa heads, EAGLE, or MTP.",
111112
)
112113

113-
# Only Pipeline parallelism is supported for export
114+
# Only pipeline and expert parallelism are supported for export
114115
parser.add_argument("--pp_size", type=int, default=1, help="Pipeline parallel size")
116+
parser.add_argument(
117+
"--ep_size",
118+
type=int,
119+
default=1,
120+
help="Expert parallel size (grouped-GEMM experts only); shards the experts across ranks",
121+
)
115122
parser.add_argument(
116123
"--num_layers_in_first_pipeline_stage",
117124
type=int,
@@ -156,7 +163,7 @@ def main(args: argparse.Namespace):
156163
provider_overrides={
157164
"tensor_model_parallel_size": 1, # Tensor parallelism is not supported
158165
"pipeline_model_parallel_size": args.pp_size,
159-
"expert_model_parallel_size": 1, # Expert parallelism is not supported
166+
"expert_model_parallel_size": args.ep_size,
160167
"expert_tensor_parallel_size": 1, # Expert tensor parallelism is not supported
161168
"num_layers_in_first_pipeline_stage": args.num_layers_in_first_pipeline_stage,
162169
"num_layers_in_last_pipeline_stage": args.num_layers_in_last_pipeline_stage,

‎modelopt/torch/distill/plugins/megatron.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,11 @@ def _sharded_state_dict(self, *args, **kwargs) -> "ShardedStateDict":
590590
# An MTP head left out of quantization is exempt from that: there is no quantization
591591
# error to recover there, and its CE materialises an fp32 [seq, vocab] tensor.
592592
skip_mtp_loss = _mtp_excluded_from_quantization(model)
593+
if skip_mtp_loss:
594+
# Freeze the untrained MTP head: DDP's overlapped grad reduce asserts on params with no grad.
595+
for name, param in model.named_parameters():
596+
if "mtp" in name.split("."):
597+
param.requires_grad_(False)
593598

594599
def _compute_student_lm_loss(self, labels, logits) -> Tensor:
595600
self._lm_loss_call_count += 1

‎modelopt/torch/export/quant_utils.py‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -452,12 +452,6 @@ def uses_iq_quantization(module) -> bool:
452452
453453
This reads ``num_bits`` directly rather than resolving each layer's full format, so an
454454
unrelated unsupported quantizer elsewhere in the model cannot turn the check into an error.
455-
456-
Known gap, shared with ``get_quantization_format``: ``weight_attr_names`` yields nothing for
457-
a TEGroupedLinear, whose parameters are ``weight0..N`` while its quantizer is a single
458-
``GroupedQuantizer`` under ``weight_quantizer``. Neither function sees such a module, so an
459-
experts-only IQ model reports no format at all -- not just here. Closing it belongs in
460-
``weight_attr_names``, where it affects every format, rather than in this helper.
461455
"""
462456
for weight_name in weight_attr_names(module):
463457
weight_quantizer = representative_weight_quantizer(module, weight_name)

‎modelopt/torch/export/unified_export_megatron.py‎

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,9 @@ def __init__(
177177
# Update hf_config
178178
self._hf_text_config.num_hidden_layers = language_model.config.num_layers
179179
self._hf_text_config.hidden_size = language_model.config.hidden_size
180-
self._hf_text_config.head_dim = language_model.config.kv_channels
180+
# MLA's kv_channels is the V head dim, not HF's head_dim (e.g. glm5_next derives it from RoPE).
181+
if not getattr(language_model.config, "multi_latent_attention", False):
182+
self._hf_text_config.head_dim = language_model.config.kv_channels
181183
self._hf_text_config.num_attention_heads = language_model.config.num_attention_heads
182184
self._hf_text_config.num_key_value_heads = language_model.config.num_query_groups
183185
self.is_multimodal = isinstance(model, LLaVAModel)
@@ -201,7 +203,10 @@ def __init__(
201203
del self._hf_config.quantization_config
202204
self.all_rules = self._populate_rule_book()
203205
self.rules = self.all_rules[self.arch]
204-
self.exclude_modules = []
206+
# The vision tower is copied through unquantized, so deployments must not treat it as such.
207+
self.exclude_modules = [
208+
prefix.removesuffix(".") + "*" for prefix in self.vision_passthrough_prefixes or ()
209+
]
205210
self.layer_config_dict = {}
206211

207212
if not hasattr(model, "_modelopt_state"):
@@ -316,6 +321,11 @@ def save_pretrained(
316321
# medusa_heads and eagle_module only exist in the last stage.
317322
is_last_stage_main_rank = pp_rank == pp_size - 1 and tp_rank == 0
318323
is_writer_rank = self._is_sidecar_writer_rank(is_last_stage_main_rank)
324+
# One writer per pipeline stage: other TP / DP / EP ranks hold the same layers (EP>1 ranks
325+
# hold no gathered experts at all), and writing them too would race on the same files.
326+
writes_layers = (
327+
tp_rank == 0 and get_data_parallel_rank() == 0 and get_expert_model_parallel_rank() == 0
328+
)
319329

320330
quantization_format = self._get_quantization_format(self.model)
321331
if self._any_rank_uses_iq_quantization():
@@ -369,7 +379,11 @@ def save_pretrained(
369379
self._hf_pretrained_model_name,
370380
trust_remote_code=self.trust_remote_code,
371381
)
372-
generation_config.save_pretrained(save_directory)
382+
# Pass it through unvalidated: save_pretrained rejects some shipped configs
383+
# (e.g. GLM-5.3-Flash sets top_p without do_sample) on newer transformers.
384+
generation_config.to_json_file(
385+
os.path.join(save_directory, "generation_config.json")
386+
)
373387
except OSError:
374388
pass
375389
# Hub-ID / None source: fetch tokenizer files via AutoTokenizer.
@@ -394,8 +408,9 @@ def save_pretrained(
394408
except (OSError, ValueError, ImportError):
395409
pass
396410

397-
# MTP load mutates per-rank layer_state_dicts, so it runs on every last-stage main rank.
398-
mtp_state_dict = self._get_mtp_state_dict()
411+
# The live MTP export runs EP collectives, so every last-stage main rank joins it; the
412+
# collective-free copy from the source checkpoint only runs on the writer.
413+
mtp_state_dict = self._get_mtp_state_dict(copy_from_pretrained=writes_layers)
399414
if len(mtp_state_dict) > 0:
400415
layer_state_dicts[self.model.config.num_layers].update(mtp_state_dict)
401416
print(f"Successfully loaded {len(mtp_state_dict)} MTP tensors")
@@ -433,7 +448,7 @@ def save_pretrained(
433448
# Add multimodal components to state_dict. Since only support decoder model quantization,
434449
# no changes will be made to the multimodal components. We copy the multimodal components
435450
# from the pretrained model directly to the state_dict to avoid implementing the export logic.
436-
if is_first_stage_main_rank:
451+
if is_first_stage_main_rank and writes_layers:
437452
# layer_state_dicts is keyed by layer_number (1-indexed), so the first
438453
# decoder layer on this (first) PP stage is the smallest key, not 0.
439454
# Merge the multimodal components into that shard so they land in a file
@@ -461,9 +476,8 @@ def save_pretrained(
461476
json.dump(config_dict, f, indent=4)
462477
torch.distributed.barrier()
463478

464-
# save_safetensors(state_dict, save_directory)
465479
save_safetensors_by_layer_index(
466-
layer_state_dicts=layer_state_dicts,
480+
layer_state_dicts=layer_state_dicts if writes_layers else {},
467481
total_layers=self.model.config.num_layers,
468482
save_directory=save_directory,
469483
name_template="model-{:05d}-of-{:05d}",
@@ -733,6 +747,15 @@ def _get_transformer_layer_state_dict(self, layer, layer_id, is_mtp=False):
733747
layer.mlp.shared_experts.gate_weight, layer_id, is_mtp=is_mtp
734748
)
735749
if hasattr(layer.mlp.experts, "local_experts"):
750+
# SequentialMLP rules index experts by local position, so EP>1 would collide.
751+
if (
752+
torch.distributed.is_initialized()
753+
and get_expert_model_parallel_world_size() > 1
754+
):
755+
raise NotImplementedError(
756+
"Export at expert parallel size > 1 needs grouped-GEMM experts; "
757+
"export SequentialMLP (--no_moe_grouped_gemm) checkpoints at EP=1."
758+
)
736759
if not self.rules.get("use_packed_local_experts", False):
737760
for expert_id, expert in enumerate(layer.mlp.experts.local_experts):
738761
self.rules["local_experts.linear_fc1"](
@@ -772,12 +795,12 @@ def _get_transformer_layer_state_dict(self, layer, layer_id, is_mtp=False):
772795
self.rules["linear_fc1"](layer.mlp.linear_fc1, layer_id, is_mtp=is_mtp)
773796
self.rules["linear_fc2"](layer.mlp.linear_fc2, layer_id, is_mtp=is_mtp)
774797

775-
def _get_mtp_state_dict(self) -> dict[str, torch.Tensor]:
776-
"""Export the live MTP module, or copy it from the pretrained model if absent."""
798+
def _get_mtp_state_dict(self, copy_from_pretrained: bool = True) -> dict[str, torch.Tensor]:
799+
"""Export the live MTP module, or copy it from the pretrained model if absent (and allowed)."""
777800
model = getattr(self, "model", None)
778801
mtp = getattr(model, "mtp", None)
779802
if mtp is None or not hasattr(mtp, "layers") or len(mtp.layers) == 0:
780-
return self._copy_mtp_state_dict_from_pretrained()
803+
return self._copy_mtp_state_dict_from_pretrained() if copy_from_pretrained else {}
781804

782805
# Inner layers reuse the base walker with is_mtp=True (retargets backbone -> mtp).
783806
saved_state_dict = self._state_dict
@@ -1089,7 +1112,8 @@ def _get_weight_bias(
10891112
and module.expert_bias is not None
10901113
and module.expert_bias.numel() > 0
10911114
):
1092-
name_to_value["expert_bias"] = module.expert_bias.to(dtype).cpu()
1115+
# FP32 like Megatron's buffer and HF's e_score_correction_bias: it decides expert routing.
1116+
name_to_value["expert_bias"] = module.expert_bias.float().cpu()
10931117

10941118
return name_to_value
10951119

@@ -1585,12 +1609,18 @@ def _grouped_mlp_slicing(
15851609
torch.save(local_expert_state, _buf)
15861610
local_bytes = _buf.getvalue()
15871611
del _buf
1588-
gathered_bytes: list = [None] * ep_size
1589-
torch.distributed.all_gather_object(
1590-
gathered_bytes, local_bytes, group=get_expert_model_parallel_group()
1612+
# Gather to EP rank 0 only, which writes the shards: holding every expert on every EP
1613+
# rank multiplies host memory by EP (a full GLM-5.3-Flash export OOMs at EP4).
1614+
ep_group = get_expert_model_parallel_group()
1615+
gathered_bytes: list | None = [None] * ep_size if ep_rank == 0 else None
1616+
torch.distributed.gather_object(
1617+
local_bytes,
1618+
gathered_bytes,
1619+
dst=torch.distributed.get_global_rank(ep_group, 0),
1620+
group=ep_group,
15911621
)
15921622
del local_bytes
1593-
for b in gathered_bytes:
1623+
for b in gathered_bytes or ():
15941624
# weights_only=False: our own torch.save output from a sibling EP rank
15951625
# in this job's collective, not user-supplied.
15961626
s_loaded = torch.load(io.BytesIO(b), map_location="cpu", weights_only=False)

‎modelopt/torch/quantization/utils/core_utils.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,18 +247,24 @@ def representative_weight_quantizer(module: nn.Module, weight_name: str = "weigh
247247
def weight_attr_names(module: nn.Module) -> "Generator[str, None, None]":
248248
"""Get the weight param attribute names in a converted module, non-recursive.
249249
250-
Covers three layouts:
250+
Covers four layouts:
251251
252252
- standard ``nn.Linear``: ``weight`` + ``weight_quantizer``.
253253
- custom per-weight quantizer (e.g. ``Llama4TextExperts`` with ``gate_up_proj`` +
254254
``gate_up_proj_weight_quantizer``).
255255
- fused-experts ``nn.ModuleList`` quantizers (``_QuantFusedExperts`` with
256256
``<first_proj>`` + ``<first_proj>_weight_quantizers`` plural list).
257+
- TEGroupedLinear: ``weight0..N`` behind one ``GroupedQuantizer``, reported as ``weight``.
257258
"""
259+
from ..nn import GroupedQuantizer # local: ..nn imports this utils package at module scope
260+
258261
# standard: "weight" + "weight_quantizer" (singular) or "weight_quantizers" (plural)
259262
if getattr(module, "weight", None) is not None:
260263
if representative_weight_quantizer(module, "weight") is not None:
261264
yield "weight"
265+
elif isinstance(getattr(module, "weight_quantizer", None), GroupedQuantizer):
266+
# TEGroupedLinear: ``weight0..N`` share one GroupedQuantizer and there is no ``weight``.
267+
yield "weight"
262268

263269
# per-parameter custom attr names
264270
for name, _ in module.named_parameters(recurse=False):

‎tests/examples/megatron_bridge/test_quantize_export.py‎

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,30 +41,47 @@
4141

4242

4343
@pytest.mark.parametrize(
44-
("create_model", "model_kwargs"),
44+
("create_model", "model_kwargs", "recipe", "quantize_parallelism", "export_parallelism"),
4545
[
46-
# MoE: routed experts used to be dropped silently from the export.
47-
(create_tiny_qwen3_moe_dir, _DENSE_KWARGS),
48-
# Dense VLM: only the language model is quantized, vision is copied through.
49-
(create_tiny_qwen3vl_dir, {}),
50-
# Mamba hybrid + MoE, and the one architecture that keeps grouped-GEMM experts.
51-
(create_tiny_nemotron_h_dir, {}),
46+
# MoE: routed experts used to be dropped silently from the export. Calibrates with context
47+
# parallelism (not on the VLM: M-RoPE CP needs nemo:26.10).
48+
(
49+
create_tiny_qwen3_moe_dir,
50+
_DENSE_KWARGS,
51+
"general/ptq/nvfp4_default-kv_fp8",
52+
"cp_size",
53+
"pp_size",
54+
),
55+
# Dense VLM: only the language model is quantized, vision is copied through. Keeps the
56+
# FP8 script-to-checkpoint coverage.
57+
(create_tiny_qwen3vl_dir, {}, "general/ptq/fp8_default-kv_fp8", "tp_size", "pp_size"),
58+
# Mamba hybrid + MoE with grouped-GEMM experts: exported with its experts sharded
59+
# across ranks (EP), resharded from the TP-quantized checkpoint.
60+
(create_tiny_nemotron_h_dir, {}, "general/ptq/nvfp4_default-kv_fp8", "tp_size", "ep_size"),
5261
],
5362
ids=["qwen3_moe", "qwen3vl", "nemotron_h"],
5463
)
5564
@pytest.mark.timeout(360) # quantize + export in one test; 1-gpu CI exceeds the default 300s
56-
def test_quantize_and_export(tmp_path: Path, num_gpus, create_model, model_kwargs):
65+
def test_quantize_and_export(
66+
tmp_path: Path,
67+
num_gpus,
68+
create_model,
69+
model_kwargs,
70+
recipe,
71+
quantize_parallelism,
72+
export_parallelism,
73+
):
5774
"""Quantize a tiny model via a YAML recipe and export it to a unified HF checkpoint."""
5875
hf_model_path = create_model(tmp_path, with_tokenizer=True, **model_kwargs)
59-
megatron_path = tmp_path / "fp8_megatron"
60-
hf_export_path = tmp_path / "fp8_hf"
76+
megatron_path = tmp_path / "quantized_megatron"
77+
hf_export_path = tmp_path / "quantized_hf"
6178

6279
# Step 1: quantize and save a Megatron checkpoint
6380
quantize_cmd = extend_cmd_parts(
6481
["torchrun", f"--nproc_per_node={num_gpus}", "quantize.py", "--skip_generate"],
6582
hf_model_name_or_path=hf_model_path,
66-
recipe="general/ptq/fp8_default-kv_fp8",
67-
tp_size=num_gpus,
83+
recipe=recipe,
84+
**{quantize_parallelism: num_gpus},
6885
calib_dataset_name="cnn_dailymail",
6986
calib_num_samples=4,
7087
calib_batch_size=2,
@@ -81,7 +98,7 @@ def test_quantize_and_export(tmp_path: Path, num_gpus, create_model, model_kwarg
8198
hf_model_name_or_path=hf_model_path,
8299
megatron_path=megatron_path,
83100
export_unified_hf_path=hf_export_path,
84-
pp_size=num_gpus,
101+
**{export_parallelism: num_gpus},
85102
)
86103
run_example_command(export_cmd, example_path="megatron_bridge", setup_free_port=True)
87104
assert (hf_export_path / "config.json").exists()

0 commit comments

Comments
 (0)