Skip to content

[OMNIML-6073] [3/3] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager - #3074

Open
shengliangxu wants to merge 31 commits into
mainfrom
shengliangx/hfep-8a-grouped-mm-calibration
Open

shengliangxu wants to merge 31 commits into
mainfrom
shengliangx/hfep-8a-grouped-mm-calibration

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Note

[3/3] of the OMNIML-6073 split: #3088 (FSDP2's weight-access window), #2745 (grouped_mm quantization) and this PR (calibration on grouped_mm and torch_a2a). It was split out of #2745 on 2026-10-27, night, and the FSDP2 window moved out of it into #3088 on 2026-10-28, at the bot review's request. #3067 (Nemotron-H) sits on this PR.

Base: main. #3088, #2745 and #2736 merged on 2026-10-28, so this PR sits on main, rebased with its own diff unchanged.

In the tree: #2741's helper base merges #3067, which carries this PR and #2745.

Series layout

What does this PR do?

Type of change: New feature, with a backward-incompatible change to MoE calibration (see the changelog entry)

#2745 quantizes the grouped_mm experts forward, transformers' default, while calibration still runs every model on eager. This PR moves calibration onto grouped_mm and the torch_a2a expert-parallel forward, and makes everything that reads the experts' inputs or weights during calibration work there. Each gap below otherwise failed silently, by collecting no amax or the wrong one rather than by raising.

  • Backend normalization. normalize_experts_impl_on_the_fly replaces force_eager_experts_impl_on_the_fly. eager, grouped_mm and torch_a2a are kept; a backend nothing intercepts (batched_mm, the opaque sonicmoe kernel, hub kernels) is moved to grouped_mm before calibration, or to eager on a transformers without _grouped_linear. A model whose experts quantize only on the eager forward keeps main's forced eager: native FP8 experts, a class with its own wrapper (GPT-OSS's quantizes down_proj's input by intercepting the eager forward's matmuls), and Nemotron-H's non-gated experts until [OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067. Such a model is refused on torch_a2a, before any config changes, instead of calibrating on raw FP8 bytes or not at all.
  • Per-expert captures. grouped_mm and torch_a2a feed a projection's input quantizer one expert-sorted batch. The weight-input captures (local Hessian, imatrix, GPTQ) split it back per expert by the cumulative token offsets the grouped interception records, and a capture fed by the other path than the experts' config selects is refused rather than attributed to the wrong experts.
  • Expert parallelism. Each rank holds only its slice of experts, so expert weight quantizers are indexed by local position.
    • transformers keeps those expert weights as DTensors sharded over the experts. Outside the forward, max calibration's weight pass and fold_weight indexed them by local position too, which on a DTensor reads the global expert; with the dense part FSDP2-sharded, FSDP2's weight-access window had first gathered every expert onto every rank.
    • Either way, every rank but rank 0 calibrated each expert as the max with rank 0's expert at the same local index, so an expert's weight scale came out too large wherever rank 0's expert had the larger one, and folding would have written the wrong experts.
    • The quantizers, captures and folds now index the rank's own shard, and [OMNIML-6073] [1/3] Gather only FSDP2's own parameters in its weight-access window #3088 makes FSDP2's window gather only the parameters FSDP2 manages, in any shard state.
  • Fail loudly. calibrate() raises, naming the modules, when a fused-experts forward that had to reach a quantizer ran but neither hook fired: a hand-written bmm forward on a class without @use_experts_implementation, which no config change redirects. Each calibrate() judges its own forwards and raises with the model back in its training mode. Not reported: experts no calibration token reached (Ernie-4.5-VL's vision experts under text-only calibration, which fall back to weight-derived scales as on main), an eager forward whose rows were all routed to other ranks' experts, and weight-only experts under max or MSE, whose weight quantizers calibrate on the weights alone.

Behavior change. main forced MoE calibration onto eager; transformers resolves an unrequested backend to grouped_mm, so fused-experts models now calibrate there. A single-GPU Qwen3-30B-A3B export, NVFP4 or FP8, matches main in every weight and weight scale, but 4,647 activation scales move, by up to 10.2%. main's own repeat is byte-identical, and loading with experts_implementation="eager" reproduces main's checkpoint byte for byte (all 74,835 tensors for NVFP4 and 56,211 for FP8), so the backend alone moves them. The changelog says so.

Moved activation scales, Qwen3-30B-A3B NVFP4, 1 GPU Tensors
Experts' down_proj input (33 layers, 128 experts each) 4,224
Experts' shared gate_proj and up_proj input (1 layer) 256
Attention input and KV-cache 167
Total, in 45 of 48 layers (none in layers 0, 1 and 5) 4,647

The largest moves are 10.2% on the experts (layer 25's down_proj input) and 7.8% on attention.

Why they move. Both forwards feed an input quantizer the same rows: a single GPU has no padding rows (transformers' sentinel rows exist only under its own expert parallelism, and it zeroes them), the routing weights apply after down_proj on both, and neither casts. They round the block's output differently. Eager adds a token's 8 expert outputs into a bf16 buffer with index_add_, rounding after each add, while grouped_mm_experts_forward sums them in fp32 and rounds once, and its GEMMs run other kernels. From the first MoE block's output on, the residual stream differs by rounding, and where a router's 8th and 9th scores nearly tie, the routing flips. A flipped token feeds down_proj another expert's activations, and down_proj's input scale is a max over routed (token, expert) rows, so it moves by more than rounding would. The moved scales fit that: the experts' shared gate_proj/up_proj input, whose rows are every token however it is routed, moved in one layer only, against 33 for down_proj.

Why grouped_mm off expert parallelism too, rather than eager there and grouped_mm on torch_a2a only: it is the forward transformers runs the model on when nothing else is asked for, its fp32 sum is the more precise of the two combines, and expert-parallel calibration runs the grouped path anyway (torch_a2a runs the local experts through _grouped_linear), so a model calibrates on one forward whether or not it is sharded. experts_implementation="eager" keeps earlier releases' checkpoints.

Usage

# To calibrate exactly as earlier releases did:
model = AutoModelForCausalLM.from_pretrained(path, experts_implementation="eager")
# The same from hf_ptq.py:
python hf_ptq.py --pyt_ckpt_path <model> --qformat nvfp4 --experts_implementation eager

Testing

Before your PR is "Ready for review"

  • Is this change backward compatible?: ❌ Fused-experts MoE calibration now runs on grouped_mm, so some activation scales differ from earlier releases; experts_implementation="eager" restores the earlier checkpoint exactly.
  • 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?: ✅ Backward Breaking Changes.
  • Did you get Claude approval on this PR?: ✅ At 042e4bf1e55; the error-message change after it is from that review.

Additional Information

Split out of #2745 (part 3 of 21 of expert-parallel PTQ for Hugging Face MoE models), which merged on 2026-10-28.

Summary by CodeRabbit

  • Behavior Changes
    • Hugging Face MoE calibration now uses Transformers’ default grouped_mm backend instead of forcing eager execution. Calibrated activation scales may differ; set experts_implementation="eager" to use the earlier calibration behavior.
    • Eager-only expert wrappers continue to use eager execution. Using them with torch_a2a raises an error.
  • Bug Fixes
    • Improved calibration support for grouped and expert-parallel execution, including local expert weights and experts receiving no routed inputs.
    • Calibration now reports when fused experts run without interception or required inputs are not captured.
    • Improved FSDP2 parameter handling during weight access and writeback.
  • New Features
    • Added calibration support for newer Transformers fused-expert implementations, including Qwen3-MoE and native FP8 experts.

@shengliangxu
shengliangxu requested review from a team as code owners October 27, 2026 23:40
@shengliangxu
shengliangxu requested review from kevalmorabia97 and removed request for a team October 27, 2026 23:40
@coderabbitai

coderabbitai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 3390594 and f1a1bc3.

Files selected for processing: 1
  • modelopt/torch/quantization/plugins/huggingface.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 10 remain after this review.

Walkthrough

Hugging Face MoE calibration supports grouped execution when available. Calibration captures grouped inputs and GPTQ Hessians, checks fused-expert interception, and uses rank-local expert weights. FSDP2 writeback skips DTensor parameters that are not owned by its FSDP units.

Changes: MoE Calibration and Expert-Parallel Support

Layer / File(s) Summary
Backend normalization and interception checks
modelopt/torch/quantization/plugins/huggingface.py, modelopt/torch/quantization/model_quant.py, tests/unit/torch/quantization/plugins/test_fused_experts.py, CHANGELOG.rst
Backend normalization preserves supported implementations and redirects unsupported ones according to grouped-linear availability. Calibration tracks whether its forward loop ran and checks interception for applicable fused experts. Tests cover normalization and missed interception.
Grouped execution capture and GPTQ
modelopt/torch/quantization/model_calib.py, modelopt/torch/quantization/plugins/huggingface.py, modelopt/torch/utils/moe.py, tests/unit/torch/quantization/plugins/test_fused_experts.py, tests/unit/torch/utils/test_moe_a2a.py
Capture hooks split grouped batches by expert offsets for input calibration and GPTQ. Tests cover grouped capture, Hessian behavior, fake quantization, and transport gradients. MoE documentation describes quantization of the grouped linear operation.
Rank-local weights and distributed validation
modelopt/torch/quantization/plugins/huggingface.py, modelopt/torch/quantization/model_calib.py, modelopt/torch/quantization/utils/core_utils.py, tests/unit/torch/quantization/plugins/test_fused_experts.py
Expert-weight lookup and FP8 scale access use local shards. FSDP2 writeback skips DTensor parameters not owned by FSDP units. Tests cover expert-parallel calibration, folding, hooks, and GPTQ helpers.

Priority: Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Calibration
  participant FusedExperts
  participant GroupedLinearHook
  participant CaptureHooks
  Calibration->>FusedExperts: run calibration forward
  FusedExperts->>GroupedLinearHook: pass grouped inputs and expert offsets
  GroupedLinearHook->>CaptureHooks: split inputs by expert offsets
  CaptureHooks->>Calibration: collect per-expert inputs or Hessians
Loading

Suggested reviewers: cjluo-nv, chadvoegele

Merge Risk: Minimal · previous review f1a1b

No actionable issue was identified in the reviewed calibration and expert-parallel paths. The PR is mergeable after normal checks.

Pre-merge checks: Passed 5
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns Passed No security anti-patterns were introduced. The reviewed diff changes no example Python files and no pyproject.toml or requirements.txt files. Added-line searches found no torch.load(..., weights_only=…
Title check Passed The title clearly and specifically describes the main change: fused-expert calibration now supports grouped_mm and torch_a2a instead of forcing eager execution.
Full details: Security Anti-Patterns

Explanation

No security anti-patterns were introduced. The reviewed diff changes no example Python files and no pyproject.toml or requirements.txt files. Added-line searches found no torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), hardcoded trust_remote_code=True, eval/exec on input, or # nosec comments. The only eval-related addition is the safe model.eval() call in a test.

Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@shengliangxu
shengliangxu requested review from kaix-nv and removed request for a team October 27, 2026 23:40
This was referenced Oct 27, 2026
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test e5a7530

The interception check reads the forward count only for a module whose hooks
never fired, so once one has, the count is not needed. Testing an eager
forward's routing syncs the host, so it now runs only until the first hook
call of each calibration rather than on every forward, inference and QAT
included. The check's outcome is unchanged.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…ured

The weight-input captures (local Hessian, imatrix, GPTQ) chose their hook from
config._experts_implementation when they were registered and raised when the
other path ran. The interceptors already record the path that ran:
_grouped_offs is set by the grouped one and cleared by the eager one. One hook
now reads it at call time, so the captures follow whichever forward ran and the
refusal and the second hook are gone.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Under data parallelism the forward count depends on each rank's data, so a
bypassing backend raises only on the ranks whose experts saw rows.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Here the captures split the grouped batch per expert, so local Hessian,
imatrix and GPTQ calibrate the experts' weights from their inputs on
grouped_mm as on eager.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The interception check passes once a hook fired, so the forward stops
counting and skips its routing test, which syncs the host. The test fails
with that gate removed.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The weight-input captures pick their branch by _grouped_offs, which the
interceptors set before every input quantizer call. Left set after the
forward, a capture reached any other way would split its input by offsets
from a finished forward instead of skipping it, and the module would keep
the offsets tensor alive between forwards.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A rank whose experts saw no rows does not raise; it waits in its next
collective until the launcher stops it or the collective times out, so the
error is in another rank's log.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…omment

The comment #2745 added names the function by the name it has there;
this PR renames it, and it still keeps such a model on eager.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
calibrate() imported the HF plugin for its interception check and caught
ImportError only. A plugin whose body fails otherwise (a transformers
version skew raising AttributeError, say) is one import_plugin only warns
about, and the import reran it and failed every calibration with that
error. The check now reads the plugin from sys.modules: one that did not
load installed no fused-experts wrappers to check. A test breaks the
plugin's import and calibrates; it fails without the change.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The weight-access window's ownership filter moved to its own PR, with
its tests in test_utils.py; the expert-parallel calibration tests here
still run under it, with the dense part FSDP2-sharded.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
_local_experts took a DTensor whose global expert count differs from the
module's for a shard over the experts. A weight placed otherwise, every
expert replicated, say, has a local shard that still holds every expert,
and indexing it by local expert read the global ones without a word. It
now raises, naming the placement. The native FP8 wrapper's unpack_weight
also refuses weights sharded over the experts: it filled a buffer for
every expert from this rank's experts. One two-rank test covers both and
fails without either check.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The weight-input captures split an expert-sorted batch by the grouped
offsets, read as exclusive cumulative ends, one per expert, as
transformers' grouped_mm forward and a2a_moe build them. Offsets of
another form (a leading zero, too few entries, ends past the rows)
would credit experts with other experts' rows without a word; the
split now raises. A test feeds each form and fails without the check.
The _grouped_offs comment names every capture that reads it, and its
other job: telling a capture which forward ran.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The plugin imports from torch.distributed.tensor under a guard, since a
torch without distributed lacks it. _local_experts, on every grouped
projection and expert weight read, reached DTensor through an unguarded
attribute chain instead, which would fail every fused-MoE quantization
there. It now uses the guarded import, as tensor_quantizer does.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The offsets check covered the count of ends and the last one, but ends
out of order passed it and credited an expert with another expert's
rows. It now also requires the ends never to decrease, from zero, and
the test gains that case, which fails without it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A first end of zero is fine (expert 0 got no rows); what the check
refuses is the inclusive [0, c0, c0 + c1, ...] form, one end too many.
The docstring and the test case's id now say so.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Calibration now runs fused experts on the backend transformers picks,
grouped_mm by default, and the way back to earlier releases' scales is
loading with experts_implementation="eager", which hf_ptq.py had no way
to pass. The flag reaches every load path: get_model (the compressed-
tensors branch included), the low-memory load, and, through the config
the meta model is built from, the FSDP2 one. A test loads a tiny
Qwen3-MoE with eager and fails without the plumbing; the changelog entry
names the flag.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
calibrate() now raises when a fused-experts forward ran but reached
neither quantizer hook, where main exported weight-derived input scales
with a warning: a script that used to produce a checkpoint now stops.
The Backward Breaking Changes section says so.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…ly when given

The FSDP2 load builds its meta model from a pre-fetched config, so the
flag is set on that config's _experts_implementation, as
build_meta_causal_lm sets a pre-fetched config's attn_implementation,
rather than through an AutoConfig kwarg. A test captures the config the
FSDP2 loader receives and fails without it. get_model passes the flag
on its compressed-tensors branch only when given, as on every other
load, instead of an explicit None.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
normalize_experts_impl_on_the_fly rewrote the experts backend without a
word. On main it always forced eager, but a caller can now ask for one
(a load's experts_implementation, hf_ptq's --experts_implementation),
and an unintercepted backend redirected to grouped_mm, or experts that
quantize on the eager forward only, override that choice. Each change
now warns once, naming both backends and why; a supported value, the
common case, stays quiet. The redirect test expects the warning and
fails without it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
None already dispatches to the eager F.linear forward, so moving it to
eager for experts that quantize on the eager forward only changes
nothing; it warned anyway, for every such model, through the experts'
own config too. The FP8 test now covers a grouped_mm and a None
backend, and counts the warnings: one, then none.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Every rank redirects its experts alike, so an expert-parallel run
printed one copy of the notice per rank.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Transformers' _experts_implementation setter carries the value to the
sub-configs, so the one assignment on the pre-fetched config also
reaches the text config Qwen3.5-MoE's experts dispatch on. The FSDP2
test now runs a VLM config too and checks its text config; it fails if
the value reaches the root config only.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A class no backend change redirects needs its own quantized wrapper;
meanwhile the rest of the model quantizes with these modules'
quantizers disabled, which the check honours.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…tation

load_model now reads args.experts_implementation; the test's stand-in
for hf_ptq's parsed arguments lacked it, so both of its cases failed
with an AttributeError in the trtllm hf_ptq lane.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
shengliangxu added a commit that referenced this pull request Oct 29, 2026
Rebased onto #3074 on main, the fused-experts tests import #2745's
quant_utils helpers; get_quantization_format was used only by the
non-gated tests that moved to the Nemotron-H test file.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-8a-grouped-mm-calibration branch from d50b2c3 to ec9e09c Compare October 29, 2026 16:21
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test ec9e09c

shengliangxu added a commit that referenced this pull request Oct 29, 2026
A two-or-more-GPU test of the expert-parallel calibration lifecycle on stock
transformers: a tiny Qwen3-MoE loaded with its experts sharded over every rank
by transformers' own expert-parallel load on the torch_a2a styles, seeded with
the experts' groups, quantized with FP8 through distributed_forward_loop, each
rank on its own batch. Every expert's weight scales and every activation scale
match the whole model calibrated on every rank's batch. On transformers >= 5.16
quantizer insertion drops the transport's forward wrappers, which the loop
re-plants: the test fails without the re-plant, and without the conversion
keeping torch_a2a (#3074). The scales' syncs and the test's gathers give up in
seconds on bounded groups.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
@juhi10071998

Copy link
Copy Markdown
Contributor

Review: I'm blocking on evidence and scope, not code correctness. The code looked careful.

1. The default backend change has no accuracy data, only scale deltas. The PR shows 4,647 activation scales moving by up to 10.2% on Qwen3-30B-A3B and attributes it to combine rounding that flips near-tied top-k routes. That explains the change, but doesn't show whether it matters. Before the default flips I'd ask for:

  • downstream accuracy for eager vs grouped_mm on at least one model, NVFP4 (e.g. GSM8K/MMLU on Qwen3-30B-A3B);
  • a noise floor: how much down_proj input amax moves under eager when only the calibration subset or seed changes. If that is also around 10%, the backend move is within noise. If it's around 1%, the claim that grouped_mm is the "more precise" path needs data.

2. No maintainer other than the author has signed off. Every approval so far is from a bot, and the bot's own latest review still lists the maintainer sign-off on the grouped_mm default as open.

3. The evidence comes from older revisions. The byte-for-byte checkpoint comparisons, including "experts_implementation="eager" reproduces main", were measured on earlier revisions of #2745. Since eager is the advertised way back to the old behaviour, please re-verify it at this head.

4. No determinism check for grouped_mm. The PR shows main's eager repeat is byte-identical, but not a grouped_mm repeat, and not a cross-architecture comparison. torch._grouped_mm takes different kernel paths on sm90, sm100 and sm120, so calibrated scales could become hardware-dependent.

5. Scope: the EP correctness fix should land on its own. Indexing EP DTensors by global expert, which inflates expert weight scales and makes fold_weight write the wrong experts on non-zero ranks, is a silent wrong-result bug. It's bundled here with two backward-breaking changes: the new hard error in calibrate() and the default flip. Splitting it out, with a Bug Fixes changelog entry, would let it land now, and would let the default flip be reverted on its own if item 1 shows a regression.

Smaller points, not blocking:

  • assert_fused_experts_intercepted is rank-local, so a rank that doesn't raise waits in its next collective until timeout. Reducing over the experts' own EP group, which every member already joins for the a2a, avoids this without a world-wide collective.
  • forward() reads top_k_index as args[1] by position. A class with a different argument order would compare routing weights against num_experts and could falsely raise. A test that checks this ordering across transformers' fused-experts classes would guard it.
  • --experts_implementation has no choices, so a typo is only caught late, inside transformers.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants