Skip to content

feat(quant): export quantized GatedDeltaNet input projections to HF - #6714

Merged
kevalmorabia97 merged 1 commit into
mainfrom
kmorabia/gdn-quant-export
Oct 29, 2026
Merged

kevalmorabia97 merged 1 commit into
mainfrom
kmorabia/gdn-quant-export

Conversation

@kevalmorabia97

Copy link
Copy Markdown
Contributor

What does this PR do ?

save_hf_pretrained_modelopt / export_hf_weights_modelopt (#6657, #5812) now export a quantized GatedDeltaNet in_proj (Qwen3.5 / Qwen3.6) instead of raising NotImplementedError for GDNLinearMappingSeparate. The output matches ModelOpt's export_mcore_gpt_to_hf bit for bit.

Part of #4804 (PR 4 of its plan, the Qwen3.6-35B-A3B pilot); tracked as OMNIML-5977.

Changelog

  • in_proj_qkv and in_proj_z are exported packed with their scales. Their rows come from the mapping's own split (split_gdn_linear_weights + _split_gdn_grouped_to_separate) applied to row indices, so the scales follow the same TP layout as the weights.
  • in_proj_b and in_proj_a (the beta / alpha gates) are written in BF16 and listed in quantization_config.ignore. Megatron fuses them into the one quantized in_proj, so a recipe can't exclude them with a quantizer pattern; ModelOpt's exporter keeps them in BF16 the same way.
  • The ModelOpt export hook passes outputs whose export spec is explicitly None through unchanged, instead of failing.

Tests

  • Unit: the GDN state selection recovers every HF row of a Megatron in_proj built with merge_gdn_linear_weights at TP1 and TP2, and leaves the gates unquantized; the export hook passes unquantized outputs through. Both fail without this change.
  • Functional (L0, 2 GPUs): test_modelopt_hf_export.py adds a toy Qwen3.5 (one GatedDeltaNet and one full-attention layer) in NVFP4: TP2 and PP2 write the TP1 checkpoint bit for bit, and ignore lists exactly the embedding, LM head, conv1d, in_proj_a and in_proj_b. The export script keeps conv1d unquantized, as ModelOpt's recipes do.

Run in nvcr.io/nvidian/nemo:26.10.rc9 with this branch overlaid:

  • With nvidia-modelopt 0.48.0rc1 (this repo's pin): tests/unit_tests/models/test_modelopt_utils.py (78 passed) and test_modelopt_hf_export.py (4 passed, including the new Qwen3.5 case).
  • With ModelOpt main: ModelOpt's tests/gpu_megatron/torch/export/test_quantized_mbridge_export.py (Test Megatron-Bridge quantized HF export against the Megatron exporter NVIDIA/Model-Optimizer#3066) extended to a tiny Qwen3.5-VL-MoE with every linear layer quantized, NVFP4 and FP8: the bridge export at TP2 matches export_mcore_gpt_to_hf at TP1 bit for bit (weights, scales, config.json, quantization_config; FP8 input_scale within 2% at TP2). Once this merges, I'll bump ModelOpt CI's Megatron-Bridge commit and switch that test's Qwen3.5-VL case from NVFP4 MLP-only to full NVFP4.

Not covered here: KV-cache export (waits for the nvidia-modelopt 0.49.0rc0 pin), Qwen3-Next's GDNLinearMapping, and Mamba (MambaInProjMapping, OMNIML-5978).

Before your PR is "Ready for review"

Pre checks:

  • Make sure you read and followed Contributor guidelines
  • Did you write any new necessary tests?
  • Did you add or update any necessary documentation? N/A
  • Does the PR affect components that are optional to install? (Ex: Numba, Pynini, Apex etc)
    • Reviewer: Does the PR have correct import guards for all optional libraries?

Additional Information

🤖 Generated with Claude Code

save_hf_pretrained_modelopt now splits a quantized GatedDeltaNet in_proj (Qwen3.5) into HF's in_proj_qkv / in_proj_z with their scales, taking the rows from the mapping's own split. The beta / alpha gates are written in BF16 and listed in ignore, as ModelOpt's export_mcore_gpt_to_hf does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com>
@kevalmorabia97 kevalmorabia97 added feature New capabilities, enhancements, or enablement work needs-review PR is ready for code review and waiting on a reviewer area:quant Quantization (PTQ, QAT, FP8 recipes) labels Oct 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Automatic Claude reviews have been retired. To request a pull-request review, post a comment containing:

/review

Add model=claude to use a Claude reviewer (the default is model=codex). mode=light|strict selects the review depth; for example, /review model=claude mode=strict. Comment /review help for all options.

@kevalmorabia97

Copy link
Copy Markdown
Contributor Author

/review

@svcnvidia-nemo-ci svcnvidia-nemo-ci left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed all four changed files and traced the affected GDN mappings, quantization-spec planning, export-hook lifecycle, and export callers. No verified findings at light review depth.

Static inspection only; tests were not executed.

Suggested test cases

Note: formal approval is not available for this request, so this review is posted as a comment only.

Review generated by codex. This was a full review. Request a full review with /review reset=true.

@kevalmorabia97

Copy link
Copy Markdown
Contributor Author

/ok to test cf6f1cd

@mxinO

mxinO commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Why do we leave the alpha/beta projections in BF16 here? ModelOpt fake-quantizes the entire fused Megatron in_proj, including those rows and their input, but export writes the raw gate weights and marks them unquantized. That seems to introduce a fake-quant/inference mismatch. I see the existing ModelOpt exporter does the same, but could we clarify the reason?

@kevalmorabia97

Copy link
Copy Markdown
Contributor Author

Good catch. The gates stay in BF16 so the exported checkpoint matches what ModelOpt produces for HF PTQ and what serving expects:

You're right that this leaves a mismatch: Megatron fake-quantizes those rows, and the export doesn't. Its effect is limited:

  • Calibration is unaffected. Max calibration collects amax with quantization off, and the in_proj input amax is shared, so in_proj_qkv / in_proj_z get the same input_scale either way.
  • The deployed gates are more precise than what was simulated. So a Megatron-side fake-quant eval is slightly pessimistic, and QAT trains these rows under noise that the export then removes. Neither should cost accuracy.

The proper fix is for ModelOpt's Megatron GDN support to leave those rows unquantized during fake quant, so simulation matches export. That's a ModelOpt change, so I'll track it there as a follow-up rather than in this PR.

@kevalmorabia97
kevalmorabia97 merged commit e42042f into main Oct 29, 2026
104 checks passed
@kevalmorabia97
kevalmorabia97 deleted the kmorabia/gdn-quant-export branch October 29, 2026 17:31
kevalmorabia97 added a commit to NVIDIA/Model-Optimizer that referenced this pull request Oct 30, 2026
…ant (#3124)

### What does this PR do?

Type of change: Bug fix

Megatron-Core fuses GatedDeltaNet's `[q, k, v, z, beta, alpha]` into one
`in_proj` (a `TELayerNormColumnParallelLinear`) behind one weight and
one input quantizer, so Megatron fake quant quantizes the beta / alpha
gate rows. Every export keeps them in BF16:

- `export_mcore_gpt_to_hf`;
- Megatron-Bridge's `save_hf_pretrained_modelopt`
(NVIDIA-NeMo/Megatron-Bridge#6714);
- HF PTQ, whose recipes disable `*linear_attn.in_proj_a*` /
`*in_proj_b*`, since vLLM's `in_proj_ba` is too narrow for FP4 kernels.

PTQ evaluation and QAD / QARL of Qwen3.5 / Qwen3.6 therefore trained
against quantization error on rows that deployment doesn't have. This
was raised in NVIDIA-NeMo/Megatron-Bridge#6714 (comment).

`_QuantGatedDeltaNet` now marks the gate rows of its `in_proj`, by name
from `in_proj_split_names` / `in_proj_split_sections`, local to the TP
rank. For those rows:

- **Forward:** the quantized TE LayerNormLinear computes the output from
the unquantized weight and the unquantized LayerNorm output, i.e. what
deployment runs. The quantizers' pre-quant scale and rotation still
apply, so an AWQ / SmoothQuant scale the stored weight compensates for
stays correct (`apply_without_qdq` applies a quantizer's transforms
without its QDQ).
- **Backward:** the weight gradient of those rows comes from the
unquantized input, and the input gradient from their unquantized weight.
With no pre-quant scale or rotation on `in_proj`'s quantizers, both
match BF16; with them, see the known limitation below.
- **`fold_weight`** and the vLLM fake-quant exporter
(`export_mcore_gpt_to_hf_vllm_fq`) keep the rows unquantized, so
`--vllm_fakequant_export` and the library export match too.

The `qkv` / `z` rows are unchanged, and the fake-quant weight cache
still hits, since nothing is modified in place. Other `in_proj` layer
types (non-TE) warn and stay fully quantized.

Not changed: weight calibration (`max` runs `weight_only_quantize` on
the whole weight) still includes the gate rows in the `in_proj` weight
amax. Both exporters use that same amax for `in_proj_qkv` / `in_proj_z`,
so fake quant and export stay consistent. Excluding the rows would need
a calibration-wide change; GPTQ writes back through
`iter_weights_for_calibration`.

Why mark rows in the fused GEMM rather than the alternatives:

- **Splitting `in_proj` into quantized and BF16 linears** would change
Megatron-Core's GatedDeltaNet module structure, its sharded checkpoint
layout and the TP / CP split of `in_proj`, in Megatron-Core or in a
ModelOpt fork of the module.
- **The quantizer enable / disable mechanisms** act on a whole
quantizer, i.e. on all of `in_proj`; there is no per-row switch.

Computing the few gate rows separately inside the existing GEMM patch
keeps the module and checkpoints unchanged and costs one small extra
GEMM.

Known limitation (pre-existing, every row): the TE LayerNormLinear
backward does not apply the input quantizer's pre-quant scale / rotation
derivatives in dgrad, nor weight-quantizer transform derivatives in
wgrad. The gate rows follow the same convention; a fix for all rows is
tracked in OMNIML-6311.

### Usage

No change to how models are quantized: it applies whenever a
Megatron-Core GatedDeltaNet model is. Adds the helper
`modelopt.torch.quantization.utils.apply_without_qdq`.

### Testing

- New `test_in_proj_keeps_gates_unquantized` and
`test_in_proj_keeps_gates_unquantized_tp2` (TP1 and TP2), with FP8 W8A8
on `in_proj`:
- the gate rows of the output equal the unquantized output, while the
other rows don't;
- with a loss on the gate rows only, the input and gate-weight gradients
match BF16;
  - `VllmFqGPTModelExporter` writes the gate weights unquantized;
- with a power-of-two pre-quant input scale and the weight divided by
it, the gate output is unchanged;
  - `fold_weight` keeps the gate weights.

Each check fails without its fix: on `main`, 21 / 32 gate outputs
differ; without the backward fix, the gate-weight gradient; without the
exporter change, the exported gate rows; without the input transforms,
the scaled-input gate output. The input-gradient check guards the dgrad
path, which already used the unquantized gate weight.
- New `test_mcore_vllm_export_gdn` runs `export_mcore_gpt_to_hf_vllm_fq`
on a tiny Qwen3.5-VL. The model is CPU-offloaded, and its `in_proj`
weight quantizer has a weight-only `rotate_back`, which only runs on
CUDA.
- The written `in_proj_b` / `in_proj_a` weights equal the Megatron rows
with only that transform applied.
- They have no quantizers in `quantizer_state.pth` /
`quant_recipe.yaml`, while `in_proj_qkv` / `in_proj_z` keep their input
quantizers.
- It fails without the exporter fix, and with the gate rows outside
`_fakequant_weight`'s CUDA staging (`Expected x.is_cuda()`).
  - It runs at TP1, like the other Qwen3.5 export tests.
-
`tests/gpu_megatron/torch/quantization/plugins/test_megatron_gated_delta_net.py`,
`test_transformer_engine.py`,
`tests/gpu_megatron/torch/export/test_unified_export_megatron.py`,
`test_quantized_mbridge_export.py` on 2× RTX 6000 Ada in
`nemo:26.10.rc9` (fla-core 0.5.1), with two Megatron-Core versions:
- the Megatron-Bridge pin of this repo's CI, 0652ea7: 129 passed, 4
skipped, 1 failed;
- Megatron-Bridge#6714 (Megatron-LM a296523510, as in Megatron-Bridge
main e42042fa8): the same.

The failure, `test_matches_megatron_exporter[llama-nvfp4]`, fails the
same way on `main` in that container: TE's fused RoPE has no kernel for
this GPU.
- On the latest commit: `test_megatron_gated_delta_net.py` and
`test_vllm_fakequant_megatron_export.py`, 11 passed, in the same
container with the CI Megatron-Bridge pin.
- Cost at Qwen3.6-35B-A3B `in_proj` size (2048 → 12352, 64 gate rows,
8192 tokens, FP8 W8A8 fwd + bwd): the gate GEMM and weight concat add
about 0.07 ms per call, within run-to-run noise of the ~10 ms total.
Peak memory is unchanged.

### Before your PR is "*Ready for review*"

- Is this change backward compatible?: ✅ (numerics of the gate rows
change to match the exported checkpoint)
- 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?: ❌

### Additional Information

Requested for the Qwen3.6 QAD / QARL studies.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


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

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed fake quantization of Gated DeltaNet so beta and alpha gates
remain in BF16 during evaluation, quantization-aware distillation,
weight folding, and vLLM export. This keeps gate values and outputs
consistent with the unquantized reference.
* Other input-projection layers continue to be quantized; a warning is
shown when their gates are also quantized.
<!-- 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.5 <noreply@anthropic.com>

This branch was previously deployed

1 inactive deployment
test — cf6f1cda Deployed Oct 28, 2026 by copy-pr-bot[bot] via cicd-wait-in-queue #23378
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:quant Quantization (PTQ, QAT, FP8 recipes) feature New capabilities, enhancements, or enablement work needs-review PR is ready for code review and waiting on a reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants