Files
Model-Optimizer/examples/megatron_bridge/quantize.py
T
Keval MorabiaandClaude Opus 5 61757c9781 Support quantized Qwen3-VL / Qwen3.5-VL (dense + MoE) export from Megatron-Bridge and verify exported checkpoints (#2276)
### 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

3. **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`.
4. **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.
5. **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`).
6. **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.

7. **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).
8. **`_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`.
9. **`_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.
10. **`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

```bash
# 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](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?:
✅
- 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](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 13:29:59 +00:00

485 lines
21 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Example script for post-training quantization (PTQ) of a GPT / Mamba model using ModelOpt on a
Megatron-Bridge model (loaded from HF).
The process is as follows:
1. Load a pretrained HuggingFace model into a Megatron-Core model via Megatron-Bridge.
2. Apply ModelOpt quantization (fake-quant) with calibration on a few samples from a dataset.
The quantization format is specified either by a short --quant_cfg alias or a --recipe YAML.
3. (Optional) Compress weights to a real low-bit representation.
4. Save the quantized model as a Megatron checkpoint (with ModelOpt state). The checkpoint can be
reloaded for further training (QAT / distillation) or converted to a HuggingFace (unified)
checkpoint for deployment with `export_quantized_megatron_to_hf.py` (for TensorRT-LLM / vLLM / SGLang).
Tensor / pipeline / expert parallelism are all supported here — the Megatron checkpoint is saved
sharded and can be re-sharded on load (e.g. `export_quantized_megatron_to_hf.py` reloads it at TP=1 for the HF export).
Example usage to quantize Qwen3-8B to NVFP4 on 2 GPUs (Tensor Parallelism = 2):
1024 samples from default dataset are used for calibration (sequence length = 4096).
torchrun --nproc_per_node 2 quantize.py \
--hf_model_name_or_path Qwen/Qwen3-8B \
--quant_cfg nvfp4 \
--tp_size 2 \
--calib_batch_size 1 \
--seq_length 4096 \
--export_megatron_path /tmp/Qwen3-8B-NVFP4-megatron
Equivalent run using a YAML recipe (authoritative for quant_cfg + algorithm + KV-cache config):
torchrun --nproc_per_node 2 quantize.py \
--hf_model_name_or_path Qwen/Qwen3-8B \
--recipe general/ptq/nvfp4_default-kv_fp8 \
--tp_size 2 \
--calib_batch_size 1 \
--seq_length 4096 \
--export_megatron_path /tmp/Qwen3-8B-NVFP4-megatron
To convert the saved Megatron checkpoint to a deployable HuggingFace checkpoint, use
`export_quantized_megatron_to_hf.py`.
To see the full usage for advanced configurations, run:
torchrun --nproc_per_node 1 quantize.py --help
See `README.md` in this directory for more details.
"""
import argparse
import copy
import gc
import torch
from megatron.bridge.models.hf_pretrained.utils import is_safe_repo
from transformers import AutoProcessor
import modelopt.torch.quantization as mtq
import modelopt.torch.utils.distributed as dist
from modelopt.recipe import ModelOptPTQRecipe, load_recipe
from modelopt.recipe.presets import KV_CACHE_NONE, KV_QUANT_CFG_CHOICES, QUANT_CFG_CHOICES
from modelopt.torch.utils import print_args, print_rank_0, warn_rank_0
from modelopt.torch.utils.dataset_utils import get_supported_datasets
from modelopt.torch.utils.plugins.mbridge import (
get_language_model,
load_mbridge_model_from_hf,
use_moe_grouped_gemm,
)
from modelopt.torch.utils.plugins.megatron_calibration import (
get_megatron_calibration_forward_loop,
get_megatron_vlm_calibration_forward_loop,
)
from modelopt.torch.utils.plugins.megatron_generate import megatron_generate
from modelopt.torch.utils.vlm_dataset_utils import get_supported_vlm_datasets
# Default calibration datasets when --calib_dataset_name is not set
DEFAULT_TEXT_CALIB_DATASET = "cnn_nemotron_v2_mix" # cnn_dailymail + nemotron-post-training-v2
DEFAULT_VLM_CALIB_DATASET = "nemotron_vlm_dataset_v2"
# The --quant_cfg / --kv_cache_quant CLI vocabularies are discovered from the preset
# YAMLs (shared with the hf_ptq examples via modelopt.recipe.presets). --quant_cfg
# additionally accepts any full config name from ``mtq.config.choices`` (e.g.
# ``FP8_DEFAULT_CFG``); see get_quant_config below.
# TODO: Add AutoQuantize (mtq.auto_quantize) support to automatically search a per-layer mix of
# quantization formats that meets a target compression / accuracy constraint, instead of applying a
# single fixed --quant_cfg / --recipe to the whole model.
def get_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--hf_model_name_or_path", type=str, required=True)
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_megatron_path",
type=str,
required=True,
help="Path to save the quantized model in Megatron checkpoint format (with ModelOpt state).",
)
# Parallelism arguments. Data parallelism is implicit: DP = world_size / (tp * pp * cp).
# e.g. `torchrun --nproc_per_node 8 quantize.py --tp_size 2` runs with DP=4.
parser.add_argument("--tp_size", type=int, default=1, help="Tensor parallel size")
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")
parser.add_argument("--cp_size", type=int, default=1, help="Context parallel size")
# Quantization arguments
parser.add_argument(
"--recipe",
type=str,
default=None,
help=(
"PTQ recipe YAML file or builtin name (e.g. 'general/ptq/fp8_default-kv_fp8'). "
"When set, --quant_cfg, --kv_cache_quant, --weight_only, and --moe_calib_experts_ratio "
"are ignored; the recipe is authoritative for quant_cfg, algorithm, and KV-cache config."
),
)
parser.add_argument(
"--quant_cfg",
type=str,
default=None,
help=(
f"Quantization config. Preset names: {', '.join(QUANT_CFG_CHOICES)}. "
"You can also pass any full config name exposed by modelopt (e.g. FP8_DEFAULT_CFG). "
"Ignored when --recipe is set."
),
)
parser.add_argument(
"--kv_cache_quant",
type=str,
default=KV_CACHE_NONE,
choices=[KV_CACHE_NONE, *KV_QUANT_CFG_CHOICES],
help="KV-cache quantization config to apply on top of --quant_cfg. Ignored when --recipe is set.",
)
parser.add_argument(
"--weight_only",
action="store_true",
help="Disable input (activation) quantization, i.e. weight-only quantization.",
)
parser.add_argument(
"--compress",
action="store_true",
help="Compress weights to a real low-bit representation (instead of fake quantization).",
)
parser.add_argument(
"--moe_calib_experts_ratio",
type=float,
default=None,
help=(
"Fraction of experts (in (0.0, 1.0]) to calibrate per forward pass for MoE models. "
"Lower values speed up calibration of models with many experts; ignored for dense models."
),
)
# Calibration dataset arguments (matched to hf_ptq.py)
parser.add_argument(
"--calib_dataset_name",
type=str,
default=None,
help=(
"Calibration dataset. If unset, it is auto-selected by model type: a text dataset "
f"({DEFAULT_TEXT_CALIB_DATASET}) for language models, and an image-text dataset "
f"({DEFAULT_VLM_CALIB_DATASET}) for VLMs. Passing a text dataset for a VLM estimates importance from text "
f"only. Text dataset options: {get_supported_datasets()}; VLM (image) dataset options: "
f"{get_supported_vlm_datasets()}."
),
)
parser.add_argument(
"--calib_num_samples", type=int, default=1024, help="Number of samples for calibration"
)
parser.add_argument("--calib_batch_size", type=int, default=1, help="Calibration batch size")
parser.add_argument(
"--seq_length",
type=int,
default=4096,
help="Calibration sequence length (text only; ignored for image-text VLM calibration).",
)
# Post-quantization generation (sanity check) arguments
parser.add_argument(
"--prompts",
type=str,
default="Hello!|Born in California, Soyer trained as a",
help="Prompts to sanity-check the quantized model. Use | to separate batches.",
)
parser.add_argument(
"--osl",
type=int,
default=32,
help="Output sequence length for the generation sanity check.",
)
parser.add_argument(
"--skip_generate",
action="store_true",
help="Skip the post-quantization generation sanity check.",
)
args = parser.parse_args()
if args.moe_calib_experts_ratio is not None and not (0.0 < args.moe_calib_experts_ratio <= 1.0):
parser.error("--moe_calib_experts_ratio must be in the range (0.0, 1.0].")
print_args(args)
return args
def get_quant_config(args: argparse.Namespace) -> dict:
"""Build the ModelOpt quantization config dict from the parsed arguments."""
if args.recipe is not None:
# A YAML recipe is authoritative: it encodes quant_cfg + algorithm + KV-cache config
# directly, so the --quant_cfg / --kv_cache_quant / --weight_only / --moe_calib_experts_ratio
# customizations below are skipped.
print_rank_0(f"Using recipe {args.recipe} for quantization")
if (
args.kv_cache_quant != KV_CACHE_NONE
or args.weight_only
or args.moe_calib_experts_ratio is not None
):
warn_rank_0(
"--kv_cache_quant / --weight_only / --moe_calib_experts_ratio are ignored when "
"--recipe is set; the recipe is authoritative."
)
recipe = load_recipe(args.recipe)
if not isinstance(recipe, ModelOptPTQRecipe):
raise TypeError(
f"Expected a PTQ recipe but got {type(recipe).__name__} from {args.recipe}"
)
return recipe.quantize.model_dump()
if args.quant_cfg in QUANT_CFG_CHOICES:
mtq_config = QUANT_CFG_CHOICES[args.quant_cfg]
elif args.quant_cfg in mtq.config.choices:
mtq_config = getattr(mtq, args.quant_cfg)
else:
raise ValueError(
f"Unsupported --quant_cfg '{args.quant_cfg}'. Choose a preset name "
f"({', '.join(QUANT_CFG_CHOICES)}) or a full config name from {mtq.config.choices}."
)
# Deepcopy so we don't mutate a shared module-level config (the ``mtq.config.choices``
# full-name branch returns one; QUANT_CFG_CHOICES already hands back a fresh copy), and
# normalize the inner quant_cfg to the list format so we can safely append customizations below.
mtq_config = copy.deepcopy(mtq_config)
mtq_config["quant_cfg"] = mtq.normalize_quant_cfg_list(mtq_config["quant_cfg"])
if args.weight_only:
mtq_config["quant_cfg"].append({"quantizer_name": "*input_quantizer", "enable": False})
if args.kv_cache_quant != KV_CACHE_NONE:
kv_cache_quant_cfg = KV_QUANT_CFG_CHOICES[args.kv_cache_quant]["quant_cfg"]
mtq_config = mtq.utils.update_quant_cfg_with_kv_cache_quant(mtq_config, kv_cache_quant_cfg)
# For MoE models, optionally calibrate only a fraction of experts per forward pass for speed.
if args.moe_calib_experts_ratio is not None:
algorithm = mtq_config.get("algorithm")
if isinstance(algorithm, str):
mtq_config["algorithm"] = {
"method": algorithm,
"moe_calib_experts_ratio": args.moe_calib_experts_ratio,
}
elif isinstance(algorithm, dict):
algorithm["moe_calib_experts_ratio"] = args.moe_calib_experts_ratio
else:
warn_rank_0(
f"Quantization algorithm {algorithm!r} does not support moe_calib_experts_ratio; ignoring."
)
return mtq_config
def main(args: argparse.Namespace):
trust_remote_code = is_safe_repo(
trust_remote_code=args.trust_remote_code, hf_path=args.hf_model_name_or_path
)
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,
)
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=moe_grouped_gemm,
provider_overrides={
"tensor_model_parallel_size": args.tp_size,
"pipeline_model_parallel_size": args.pp_size,
"expert_model_parallel_size": args.ep_size,
"context_parallel_size": args.cp_size,
"expert_tensor_parallel_size": 1, # Expert tensor parallelism is not supported
"pipeline_dtype": torch.bfloat16,
"seq_length": args.seq_length,
"gradient_accumulation_fusion": False, # not supported
},
init_model_parallel=True,
)
# Only the language model is quantized (vision tower + projector stay full precision)
language_model, is_vlm = get_language_model(unwrapped_model)
if is_vlm:
warn_rank_0(
"VLM detected: quantizing `model.language_model` only (vision tower left in full precision)."
)
# Auto-select the calibration dataset by model type when not explicitly provided.
if args.calib_dataset_name is None:
args.calib_dataset_name = (
DEFAULT_VLM_CALIB_DATASET if is_vlm else DEFAULT_TEXT_CALIB_DATASET
)
# Infer the calibration modality from the dataset: the known image-text datasets require a VLM, everything
# else is text. Passing a text dataset for a VLM estimates importance from text only (vision tower idle).
use_image_calib = args.calib_dataset_name in get_supported_vlm_datasets()
if use_image_calib and not is_vlm:
raise ValueError(
f"Calibration dataset '{args.calib_dataset_name}' is image-text and requires a VLM; "
"pass a text dataset for a language model."
)
if is_vlm and not use_image_calib:
warn_rank_0(
f"Text-only calibration on a VLM (dataset '{args.calib_dataset_name}'): the language "
"model's calibration statistics will not see vision tokens."
)
print_rank_0(f"Using calibration dataset: {args.calib_dataset_name}")
mtq_config = get_quant_config(args)
# Quantize only the language model: disable quantizers on every top-level submodule that is not
# the language model (vision tower + projector). Skip aliases of language-model submodules (e.g.
# Qwen's ``self.decoder = language_model.decoder``) so the LM's own layers stay enabled.
if is_vlm:
lm_module_ids = {id(m) for m in language_model.modules()}
non_lm_children = sorted(
name
for name, child in unwrapped_model.named_children()
if name != "language_model" and id(child) not in lm_module_ids
)
for name in non_lm_children:
# Anchor to the child subtree (top-level child of the quantized root) so a short non-LM
# name cannot accidentally match a language-model quantizer path by substring.
mtq_config["quant_cfg"].append({"quantizer_name": f"{name}.*", "enable": False})
print_rank_0(f"Disabling quantizers on non-language-model submodules: {non_lm_children}")
# KV-cache quantization is incompatible with weight compression. Validate on the *resolved*
# config (KV-cache quantizers are named ``*[kv]_bmm_quantizer``) so this also covers
# recipe-driven KV-cache configs, not just the --kv_cache_quant flag.
if args.compress and any(
isinstance(entry, dict) and "bmm_quantizer" in str(entry.get("quantizer_name", ""))
for entry in mtq.normalize_quant_cfg_list(mtq_config["quant_cfg"])
):
raise ValueError("--compress cannot be combined with KV-cache quantization.")
print_rank_0(f"Quantizing the model with: {args.recipe or args.quant_cfg}")
if "awq" in str(mtq_config.get("algorithm")):
print_rank_0(
"AWQ calibration can take longer than other methods; reduce --calib_num_samples to speed it up."
)
# Dynamic and weight-only configs need no activation statistics, so skip both the
# (potentially expensive) calibration dataset download and the calibration forward pass.
if not mtq.need_calibration(mtq_config):
warn_rank_0("Dynamic or weight-only quantization detected; skipping calibration.")
forward_loop = None
elif not use_image_calib:
text_forward_loop = get_megatron_calibration_forward_loop(
tokenizer,
dataset_name=args.calib_dataset_name,
num_samples=args.calib_num_samples,
seq_length=args.seq_length,
batch_size=args.calib_batch_size,
pack=True, # Megatron pretraining-style global-stream document packing
)
# Run text prefill on the language model: we quantize the root (a VLM root forward expects
# vision inputs), but text calibration must drive the inner LM. For plain LMs these are the same.
def forward_loop(_model=None):
text_forward_loop(language_model)
else:
# VLMs: drive the full VLM forward on image-text pairs so the language model's quantizers
# see vision-conditioned activations (we still quantize the LM only).
processor = AutoProcessor.from_pretrained(
args.hf_model_name_or_path, trust_remote_code=trust_remote_code
)
forward_loop = get_megatron_vlm_calibration_forward_loop(
unwrapped_model, # full VLM (vision encoder + projector + language model)
processor,
dataset_name=args.calib_dataset_name,
num_samples=args.calib_num_samples,
batch_size=args.calib_batch_size,
)
if hasattr(unwrapped_model, "calibration_mode"):
# Some model wrappers (e.g. distillation/speculative) gate calibration behind a flag.
unwrapped_model.calibration_mode = True
mtq.quantize(unwrapped_model, mtq_config, forward_loop)
unwrapped_model.calibration_mode = False
else:
mtq.quantize(unwrapped_model, mtq_config, forward_loop)
# Free calibration/quantization memory before generate
gc.collect()
torch.cuda.empty_cache()
if args.compress:
mtq.compress(unwrapped_model)
print_rank_0("Weights are now compressed to low-bit!")
# Save the quantizer summary alongside the checkpoint for later inspection. Only the master
# rank writes the file to avoid a multi-rank race on the same path.
if dist.is_master():
mtq.print_quant_summary(unwrapped_model, args.export_megatron_path)
bridge.save_megatron_model(
model,
args.export_megatron_path,
hf_tokenizer_path=args.hf_model_name_or_path,
hf_tokenizer_kwargs={"trust_remote_code": trust_remote_code},
)
if is_vlm:
print_rank_0(
f"\nSaved quantized VLM to {args.export_megatron_path} in Megatron format. To deploy this "
"model, convert it to a Unified HF ckpt with export_quantized_megatron_to_hf.py."
)
else:
print_rank_0(
f"\nSaved quantized model to {args.export_megatron_path} in Megatron format. To deploy this model "
"(TensorRT-LLM / vLLM / SGLang), convert it to a Unified HF ckpt with export_quantized_megatron_to_hf.py"
)
# Sanity-check generation with the fake-quantized model. Skipped when --compress is set: the
# weights are now real low-bit and megatron_generate may not support compressed forward for
# every quant format.
if args.compress and not args.skip_generate:
warn_rank_0(
"Skipping the post-quantization generation sanity check because --compress is set."
)
if not args.skip_generate and not args.compress:
print_rank_0("\nTesting quantized model with custom prompts...")
# Sanity-check text generation on the quantized language model.
language_model.eval()
for idx, prompt in enumerate(args.prompts.split("|")):
tokens = tokenizer(prompt, return_tensors="pt")
# enable_kv_cache=False avoids pre-allocating the static KV cache: this is a short sanity-check
# generation and the KV-cache allocation can OOM tight quantization runs on large MoE models.
generated_ids = megatron_generate(
language_model, tokens.input_ids.cuda(), osl=args.osl, enable_kv_cache=False
)
generated_texts = tokenizer.batch_decode(generated_ids)
print_rank_0(f"\nPrompt {idx + 1}: {prompt}\nGenerated: {generated_texts}")
print_rank_0("\nDone!")
if __name__ == "__main__":
dist.setup()
args = get_args()
try:
main(args)
except BaseException:
dist.abort() # peers may be stuck in a collective this rank will never reach
finally:
dist.cleanup()