Skip to content

[OMNIML-6073] [2/3] Quantize fused experts under grouped_mm; leave transposed layouts unquantized - #2745

Merged
shengliangxu merged 26 commits into
mainfrom
shengliangx/hfep-8-fused-experts
Oct 28, 2026
Merged

shengliangxu merged 26 commits into
mainfrom
shengliangx/hfep-8-fused-experts

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Note

The expert-parallel PTQ series is now a tree instead of one stack (re-cut 2026-10-27, from main 1521053), so PRs that do not depend on each other can be reviewed and merged in parallel. This PR was [3/21]. Rebased onto main 7c3c868 later the same day, after #2739 merged, and onto main f03651e that night.

Base: main. It can be reviewed and merged on its own.

[2/3] of the OMNIML-6073 split (2026-10-28), made at the bot review's request on #3074: #3088 (FSDP2's weight-access window, merged on 2026-10-28), this PR, and #3074, which sits on both.

Split (2026-10-27): Nemotron-H's non-gated experts moved to #3067. Later that night the rest split in two: this PR quantizes the grouped_mm forward, and #3074, stacked on it, switches calibration onto that forward (backend normalization, the per-expert captures, expert-parallel indexing, the interception check). Here calibration still runs every model on eager, as main forces. #3067 is now stacked on #3074.

Series layout

What does this PR do?

Type of change: New feature

transformers' default experts implementation is grouped_mm, which hands transformers.integrations.moe._grouped_linear one expert-sorted batch per projection instead of calling the expert matmul once per expert, so the fused-experts wrapper's eager F.linear interception had nothing to hook. This PR quantizes that forward. #3074 switches calibration onto it; until then nothing changes for a user's calibration.

  • grouped_mm. _QuantFusedExperts now also replaces _grouped_linear for the duration of its forward. Each expert's slice of the 3-D weight passes its own weight quantizer and the batch passes the projection's shared input quantizer, so the grouped forward calibrates and fake-quantizes the same quantizers as eager and exports the same keys. Without autograd (calibration, evaluation) the fake-quantized slices are written into one copy, expert by expert; under autograd (QAT) they are stacked, which briefly holds two.
  • Eager-only wrappers. The grouped forward never reaches get_expert_weight, so a wrapper that defines its weights there (native FP8 experts dequantize in it) must say it quantizes on the eager forward only (_eager_only). Defining one that does not is refused, and an eager-only wrapper's grouped forward raises rather than quantize the stored weight. Nemotron-H's non-gated experts keep main's wrapper, eager-only, until [OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067.
  • Transposed experts ((num_experts, in, out)) without a wrapper of their own are left unquantized, with a warning, also when a gated container does not set is_transposed: its shapes give it away. The fused-experts export would split gate and up along the wrong axis for them and write wrong weights; main did so silently. GPT-OSS and Llama-4 keep their own wrappers. _fused_experts_wrapper_class is renamed _fused_experts_wrapper.

Testing

  • tests/unit/torch/quantization/plugins/test_fused_experts.py, against synthetic experts that run transformers' real grouped_mm forward:
    • with the quantizers disabled, the converted grouped forward matches the unconverted one;
    • calibrating on grouped_mm gives every quantizer the amax eager calibration gives it, and fails without the grouped interception (the previous test pins only that the interception changes nothing while every quantizer is disabled);
    • without autograd, one fake-quantized expert slice is alive at a time;
    • an eager-only wrapper's grouped forward raises, and defining a wrapper that overrides get_expert_weight without _eager_only is refused;
    • transposed experts, declared or detected by their shapes, are left unquantized with a warning.
  • tests/unit/recipe/test_qwen3_8_recipe.py's stand-in experts now store gate_up_proj in Qwen3-MoE's (experts, 2 * intermediate, hidden) layout; the transposed layout they used is now detected and left unquantized.
  • Split on 2026-10-27, night: this PR's two commits and [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074's together give the previous head's tree exactly, plus this round's three review fixes, which are in [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074. Pre-commit, mypy included, and this PR's unit tests pass at this head on transformers 5.18 and 5.5.4 and on torch 2.8, and the series' PRs merged together add no unit-test failure over main.
  • The earlier rounds' review fixes and measurements (backend normalization, expert parallelism, the interception check, the byte-for-byte checkpoint comparisons against main) moved with their code to [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074's description.
  • Review fixes, 2026-10-28: the grouped forward's passthrough shortcut applies only when every weight quantizer would pass its slice through unchanged, so a disabled quantizer's pre-quant scale or rotation applies as on eager; a new test checks the grouped forward's fake-quantized output and gradients against eager under autograd; the two grouped tests unregister their experts in a finally; the torch_a2a test fixtures moved to [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074, with the tests that drive them; under autograd the grouped forward takes the one-launch stacked NVFP4 fake quant first, as eager does, and the GPU test that pins its equivalence runs on both backends (in CI; not run locally). The shortcut's test fails without the change, and the autograd test fails with that branch broken.
  • Review fixes, 2026-10-28, second round: the grouped interceptor clears the eager loop's expert index, so the weight-input captures keyed on it (local Hessian, imatrix, GPTQ) skip the grouped batch instead of crediting all of it to one expert; the new test fails without the change, and [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074 splits that batch per expert. Without autograd the grouped forward takes the one-launch stacked NVFP4 kernel too, and the GPU test now checks that the backend under test ran and that the no-autograd output matches the per-expert path. The eager-only refusal names expert parallelism, and a changelog entry covers the transposed-layout fix. Not taken: an is_transposed refusal in the interceptor, and carrying transposed experts through the export unquantized, which is a follow-up (see the threads).
  • Review suggestion, 2026-10-28, third round: the class docstring says that on grouped_mm the weight-input captures keyed on one expert skip the batch, so local Hessian, imatrix and GPTQ calibrate the experts' weights without their inputs there, which [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074 lifts. The disabled-quantizer test gains a rotation case, which fails with the shortcut's rotation check removed.
  • Declined review suggestion, 2026-10-28, third round: forwarding _grouped_linear's arguments opaquely in the interceptor. Its signature is the same in transformers 5.4 through 5.19, [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074 reads offs, and a reordered or renamed parameter raises a TypeError that the CPU tests running transformers' grouped forward catch.
  • Review suggestion, 2026-10-28, fourth round: _fused_experts_wrapper loses a body comment that repeated its docstring, and _is_quant_fused_experts_module stays where main has it, 16 fewer lines of diff. Noted for restoring: a modelopt_state saved before this change for a model with transposed generic fused experts no longer restores (ApplyModeError, unmatched quantizer keys), since their type is no longer registered; it would have exported wrong weights.
  • Declined review suggestions, 2026-10-28, fourth round: a hasattr guard in the GPU test, since _grouped_linear is in every transformers release the pin allows; and dropping the changelog's export clause, since _prepare_moe_inputs does raise NotImplementedError for an MoE block whose experts nothing converted.
  • Review fixes, 2026-10-28, fifth round: the changelog entry also says that a modelopt_state saved for such a model by an earlier release no longer restores; the GPU test imports transformers' moe module under a guard and skips only its grouped_mm case where _grouped_linear is missing; the per-slice fake quant walks the weight and its quantizers in a strict zip, so a length mismatch raises instead of leaving rows unwritten. The export clause stays: on transformers 5.18 a tiny Aria quantized with FP8_DEFAULT_CFG keeps its experts unconverted, and export_hf_checkpoint raises NotImplementedError for them.
  • Declined review suggestion, 2026-10-28, fifth round: an is_transposed assert in the grouped interceptor. Registration refuses a transposed container, and the transposed experts with wrappers of their own (GPT-OSS, Llama-4) are not _QuantFusedExperts.
  • Review fixes, 2026-10-28, from the bot review of 271765f8ff3: without autograd the grouped forward's per-slice buffer takes the quantizers' output dtype, so a pre-quant scale wider than the weights (FP32 on BF16) gives the same forward with and without autograd; a BF16/FP32 test that compares the two grad modes fails without the change. New tests pin that the export refuses an MoE block whose transposed experts were left unquantized, and that a state saved when an earlier release quantized them no longer restores. That restore break is now under Backward Breaking Changes, and the backward-compatible box says so.
  • Rebased onto main 7092db6ac5b on 2026-10-28 (conflicts only in CHANGELOG.rst and the fused-experts test imports). Review suggestions, same day: without autograd the per-slice buffer moves to the dtype torch.stack promotes the slices to, so experts that disagree on dtype give the same forward in both grad modes (the widening test gains a case where only a later expert is widened); the class docstring says the grouped_mm fallback does not call F.linear.
  • Review suggestions, 2026-10-28, last rounds (comments only): force_eager_experts_impl_on_the_fly's docstring says why calibration stays on eager now that grouped_mm is intercepted; the FP8 experts wrapper's comment says that this forcing, not the grouped interceptor's refusal, keeps it on eager; and the transposed check's comment says the shape test tells only gated layouts apart, so a non-gated container is caught by its own is_transposed. pre-commit, mypy included, passes at e7c449fcf87.
  • Rebased on 2026-10-28, night, onto main bc0b6e03237 (after [OMNIML-6073] [1/3] Gather only FSDP2's own parameters in its weight-access window #3088 merged), without conflicts; this PR's own diff is unchanged. pre-commit passes against main, and test_fused_experts.py with test_utils.py pass (165).
  • Rebased on 2026-10-28, night, onto main e9f432affbb (Bump minimum torch to 2.10 and drop older-torch code paths #3091, the torch 2.10 floor). Bump minimum torch to 2.10 and drop older-torch code paths #3091 made _get_fsdp2_mesh call fully_shard.state, so [OMNIML-6073] [1/3] Gather only FSDP2's own parameters in its weight-access window #3088's two ownership tests, which counted those calls, fail on main. This PR carries the fix, counting _fsdp2_param_groups, the scan itself, instead. The same commit is in [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736 and [OMNIML-6073] [2/3] Quantize fused experts under grouped_mm; leave transposed layouts unquantized #2745: whichever merges first lands it, and the other's copy drops out on rebase. The conflict with main was the plugin's guarded Shard import, which Bump minimum torch to 2.10 and drop older-torch code paths #3091 made a plain import. test_fused_experts.py and test_utils.py pass (165), and pre-commit passes against main.
  • Review suggestions, 2026-10-28, late night (comments and the warning's text only): the transposed-experts warning says export_hf_checkpoint will refuse their MoE block. _is_fused_experts_module's docstring says why it still matches transposed containers: the forced eager keeps GPT-OSS's own wrapper on the forward whose bmm/matmul it intercepts. With the filter moved into _fused_experts_wrapper, a tiny GPT-OSS stays on grouped_mm and its down_proj input quantizer gets no amax. _fused_expert_projections' docstring says the grouped forward clears the expert index. Declined: raising instead of warning for transposed experts (registration runs before the config applies, so it would also block recipes that leave the experts alone), an is_transposed raise in the grouped interceptor (registration never lets the flag through), and reading _first_proj_attr off the module. Focused tests pass, and pre-commit passes against main.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ❌ A modelopt_state saved by an earlier release for a model with transposed generic fused experts (for example Aria's) no longer restores: those experts are now left unquantized with a warning, since the generic wrapper exported them wrong. Everything else is unchanged; calibration still runs on eager.
  • 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?: ✅ Bug Fixes: transposed fused experts without a wrapper are left unquantized, and the export refuses their MoE block. Backward Breaking Changes: a modelopt_state saved for such a model no longer restores. The calibration change's entry is in [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074.
  • Did you get Claude approval on this PR?: ✅ On 2026-10-28, at 3cfe6e048a6; the comment and warning-text changes after it are under review.

Additional Information

Part 3 of 21 of expert-parallel PTQ for Hugging Face MoE models. It is based on main and merges first; #3074 and #3067 are stacked on it.

@copy-pr-bot

copy-pr-bot Bot commented Oct 12, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Oct 12, 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
  • Configuration used: Repository: NVIDIA/Model-Optimizer/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Enterprise
  • Run ID: 57622c8a-2115-413f-9fc1-86b04e66d1d8
📥 Commits

Reviewing files that changed from the base of the PR and between eb9c0ae and 691ece4.

📒 Files selected for processing: 2
  • tests/unit/torch/quantization/plugins/test_fused_experts.py
  • tests/unit/torch/utils/test_moe_a2a.py

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

Walkthrough

Fused-expert quantization now supports grouped dispatch and backend normalization, including ungated NemotronH experts. Calibration captures grouped expert inputs, validates interception when its forward loop runs, and handles transposed weights and local expert shards.

Changes: MoE Calibration

Layer / File(s) Summary
Backend selection and fused-expert interception
modelopt/torch/quantization/plugins/huggingface.py, tests/unit/torch/quantization/plugins/test_fused_experts.py, modelopt/torch/export/moe_utils.py, modelopt/torch/models/step3p5/modeling_ptq.py
Wrappers intercept eager and grouped execution. Backend normalization preserves supported implementations and redirects unsupported implementations to grouped_mm, with separate handling for modules that have a registered wrapper. Ungated NemotronH experts use _QuantNemotronHExperts. Tests cover backend normalization, wrapper selection, transposed layouts, and forward equivalence.
Grouped calibration and interception validation
modelopt/torch/quantization/model_calib.py, modelopt/torch/quantization/model_quant.py, modelopt/torch/quantization/plugins/huggingface.py, tests/unit/torch/quantization/plugins/test_fused_experts.py, CHANGELOG.rst
Calibration hooks use grouped offsets to capture inputs for enabled experts. calibrate checks interception only when its forward loop ran. Weight calibration and folding handle transposed orientations. Tests compare grouped and eager calibration and cover interception checks. The changelog documents the grouped_mm calibration backend and the eager setting.
Sharded expert calibration and folding
modelopt/torch/quantization/utils/core_utils.py, tests/unit/torch/quantization/plugins/test_fused_experts.py, tests/unit/torch/utils/test_moe_a2a.py
FSDP2 weight access skips DTensor parameters whose named mesh dimensions do not match the FSDP mesh. Tests cover expert-parallel gradients, per-rank calibration and folding, and hook registration without collectives.

Priority: Normal

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

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Calibration
  participant QuantFusedExperts as _QuantFusedExperts
  participant GroupedLinear as _grouped_linear
  Calibration->>QuantFusedExperts: run expert forward
  QuantFusedExperts->>GroupedLinear: intercept grouped linear call
  GroupedLinear-->>QuantFusedExperts: return grouped output
  QuantFusedExperts-->>Calibration: return quantized output
Loading
Merge Risk: Minimal · up to 691ec

This change adds tests for fused-expert quantization under grouped_mm and expert parallelism. No remaining test-hygiene or correctness concerns were established, so merge risk is minimal.

Pre-merge checks: Passed 5
Check name Status Explanation
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 stated security anti-pattern was introduced. The PR changes only modelopt Python files and tests; it adds no examples or dependency-file changes. Added modelopt lines contain no unsafe torch.load, …
Title check Passed The title clearly summarizes the primary change: quantizing fused experts under grouped_mm while leaving transposed layouts unquantized.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Full details: Security Anti-Patterns

Explanation

No stated security anti-pattern was introduced. The PR changes only modelopt Python files and tests; it adds no examples or dependency-file changes. Added modelopt lines contain no unsafe torch.load, pickle-enabled numpy.load, hardcoded trust_remote_code=True, eval/exec on external input, or # nosec comments. The existing trust_remote_code=True call in huggingface.py is unchanged from the base revision.

✨ Finishing Touches: 💡 2
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks: 💡
  • 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 added this pull request to stack #2755 October 12, 2026 00:57
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-7-amax-sync branch from 06a5f2d to 5f115f2 Compare October 12, 2026 02:35
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-8-fused-experts branch from 6e6ac9c to bad2de9 Compare October 12, 2026 02:35
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-28 22:47 UTC

@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.74%. Comparing base (bb13106) to head (75265cb).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2745      +/-   ##
==========================================
+ Coverage   73.82%   79.74%   +5.92%     
==========================================
  Files         662      662              
  Lines       75949    75979      +30     
==========================================
+ Hits        56069    60590    +4521     
+ Misses      19880    15389    -4491     
Flag Coverage Δ
examples-diffusers 19.93% <26.41%> (+0.03%) ⬆️
examples-gpt-oss 13.47% <18.86%> (+0.04%) ⬆️
examples-hf_ptq 23.48% <45.28%> (-0.01%) ⬇️
examples-llm_distill 13.54% <18.86%> (+0.03%) ⬆️
examples-llm_eval 17.24% <26.41%> (+0.05%) ⬆️
examples-llm_qat 17.40% <26.41%> (+0.01%) ⬆️
examples-llm_sparsity 16.75% <18.86%> (+0.02%) ⬆️
examples-megatron_bridge 26.53% <26.41%> (-0.19%) ⬇️
examples-specdec_bench 13.25% <18.86%> (+0.04%) ⬆️
examples-speculative_decoding 17.48% <26.41%> (-0.02%) ⬇️
examples-torch_onnx 21.21% <26.41%> (-0.01%) ⬇️
examples-torch_trt 15.21% <26.41%> (+0.03%) ⬆️
examples-vllm_serve 17.45% <26.41%> (+0.05%) ⬆️
gpu 59.30% <90.56%> (+24.50%) ⬆️
regression 15.04% <18.86%> (+0.02%) ⬆️
unit 61.87% <98.11%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test bad2de9

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-7-amax-sync branch from 5f115f2 to d6a4b30 Compare October 12, 2026 08:39
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-8-fused-experts branch from bad2de9 to 16e5c17 Compare October 12, 2026 08:39
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 16e5c17

@shengliangxu shengliangxu changed the title [10/19] Quantize fused experts under grouped_mm, ungated and transposed layouts [OMNIML-6073] [10/19] Quantize fused experts under grouped_mm, ungated and transposed layouts Oct 12, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-7-amax-sync branch from d6a4b30 to e28bd09 Compare October 13, 2026 00:53
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-8-fused-experts branch 2 times, most recently from 5c0d5fa to 698f459 Compare October 13, 2026 01:32
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 698f459

@shengliangxu
shengliangxu removed this pull request from stack #2755 October 13, 2026 17:42
@shengliangxu
shengliangxu added this pull request to stack #2775 October 13, 2026 17:43
@shengliangxu shengliangxu changed the title [OMNIML-6073] [10/19] Quantize fused experts under grouped_mm, ungated and transposed layouts [OMNIML-6073] [10/20] Quantize fused experts under grouped_mm, ungated and transposed layouts Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-8-fused-experts branch from 698f459 to eaa41c2 Compare October 13, 2026 19:38
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-7-amax-sync branch from 235ecbd to 9572dc5 Compare October 13, 2026 19:38
@shengliangxu
shengliangxu removed this pull request from stack #2775 October 13, 2026 19:38
@shengliangxu
shengliangxu added this pull request to stack #2781 October 13, 2026 19:40
@shengliangxu shengliangxu changed the title [OMNIML-6073] [10/20] Quantize fused experts under grouped_mm, ungated and transposed layouts [OMNIML-6073] [11/21] Quantize fused experts under grouped_mm, ungated and transposed layouts Oct 13, 2026
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test eaa41c2

@shengliangxu
shengliangxu removed this pull request from stack #2781 October 14, 2026 00:13
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-7-amax-sync branch from 9572dc5 to 6e7ca4f Compare October 14, 2026 00:14
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-8-fused-experts branch from eaa41c2 to 3ff1d97 Compare October 14, 2026 00:14
@shengliangxu
shengliangxu added this pull request to stack #2786 October 14, 2026 00:14
@shengliangxu shengliangxu changed the title [OMNIML-6073] [11/21] Quantize fused experts under grouped_mm, ungated and transposed layouts [OMNIML-6073] [12/22] Quantize fused experts under grouped_mm, ungated and transposed layouts Oct 14, 2026
…efusal

The torch_a2a expert-parallel forward runs the grouped linear too, so loading
with experts_implementation="eager" alone does not avoid it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The grouped interceptor clears the expert index, so on grouped_mm local
Hessian, imatrix and GPTQ calibrate the experts' weights without their
inputs, while max calibration is unaffected. The class docstring said both
backends calibrate the same quantizers without that exception.

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

The passthrough shortcut skips only quantizers that would leave their slice
unchanged; the rotation case fails with its rotate_is_enabled term dropped.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
_fused_experts_wrapper's body comment said again, at greater length, what
its docstring says about act_fn, and _is_quant_fused_experts_module had
moved above _get_fused_experts_quantizer_attr_names with nothing calling
for it. Both now read as on main.

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

Restore converts the model as quantize does, which no longer registers the
transposed experts type, so a saved state's quantizer keys for those experts
match no module (ApplyModeError). The entry now tells users so.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The test imported transformers.integrations.moe and read its private
_grouped_linear unconditionally, while the plugin and the unit tests treat
both as optional. It now skips only the grouped_mm case without them, and
spies on _grouped_linear only where it exists.

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

The per-slice loops ran over num_experts while the stacked kernel goes by the
weight's rows: a weight with more rows than quantizers would have sent
unwritten rows into the grouped matmul. A strict zip makes any mismatch fail.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Without autograd the per-slice path wrote each fake-quantized slice into a
buffer of the weight's dtype, so a pre-quant scale wider than the weights
(FP32 scales on a BF16 model) was rounded back to BF16, while the autograd
path's stack kept FP32: the same forward computed from different weights in
the two grad modes. The buffer now takes the quantizers' output dtype. The
new test runs the grouped forward both ways on BF16 weights with FP32 input
and weight scales and fails without the change.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The changelog says the Hugging Face export refuses an MoE block whose
transposed experts were left unquantized, and that a state saved when an
earlier release quantized them no longer restores. One test runs the
export's MoE preparation on a block is_moe recognizes and expects
NotImplementedError; the other quantizes such a container the way an
earlier release did, then restores its state into a fresh model and expects
ApplyModeError.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A modelopt_state saved for a model with transposed generic fused experts no
longer restores; that is a break for whoever holds one, not a bug fix, so
it gets its own entry there.

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

Without autograd the per-slice buffer took the first slice's dtype, so an
expert whose pre-quant scale widens it past the first one's had its slice
narrowed, while the autograd path's torch.stack promotes every slice to the
widest dtype. The buffer now moves to the promoted dtype when a later slice
is wider. The widening test gains a case where only a later expert is
widened; it fails without the change.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Installing the F.linear and _grouped_linear patches together is safe only
because the grouped_mm fallback does not route through F.linear; the class
docstring now says so, and that the CPU tests running the fallback pin it.

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

force_eager_experts_impl_on_the_fly's docstring said every non-eager backend
bypasses the quantizer hooks; after this change only batched_mm and sonicmoe
do. grouped_mm is intercepted, and calibration stays on eager until the
per-expert input captures follow it there.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Its _eager_only refusal sits in the grouped interceptor, which transformers'
FP8 grouped_mm forward never calls, so for this wrapper the flag cannot
fire; force_eager_experts_impl_on_the_fly, on convert and restore, is what
keeps it on eager. The comment now says so.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The shape test separates the layouts of a gated container, whose first
projection is 2 * intermediate; a non-gated container's two layouts have the
same shapes, so only its own is_transposed gives it away.

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

@juhi10071998 juhi10071998 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.

Thanks for this, the grouped_mm interception is a nice piece of work and the comments explaining the layout assumptions are really helpful. I left three inline notes. The first two are the ones I'd most like your thoughts on, since both can end in a quiet wrong result rather than an error.

# puts out. That separates the layouts for a gated container, whose first projection is
# `2 * intermediate`, whether it says so or not; a non-gated one's two layouts have the
# same shapes, so only its own `is_transposed` gives it away.
or getattr(module, wrapper._first_proj_attr).shape[-1] != module.down_proj.shape[1]

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.

Nice catch on the gated case, and I appreciate that the comment above spells out the limit for non-gated containers.

That limit is what worries me a bit. For a non-gated container the two layouts have identical shape relationships (transposed: up (E,H,I) / down (E,I,H), so up.shape[-1] == down.shape[1]; non-transposed: up (E,I,H) / down (E,H,I), which also satisfies it). So the shape check can't tell them apart, and the guard is only as good as the container exposing is_transposed. A transposed non-gated class without that attribute would still be registered as _QuantNonGatedFusedExperts, and then hit the wrong-axis export slice this PR is trying to avoid.

A couple of thoughts, happy to be told I'm missing something:

  • Would it make sense to fail loudly rather than warnings.warn here? As written, PTQ runs through calibration with those experts unquantized and the problem only shows up later (or not at all, if the warning scrolls by). If skipping is intentional, maybe say in the warning that the model will export with unquantized experts.
  • Since the filter lives here and not in _fused_experts_wrapper, _is_fused_experts_module (and force_eager_experts_impl_on_the_fly) still see these modules as fused. Is that intended? Moving the check into _fused_experts_wrapper would keep all three consistent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks. I took part of this in 75265cb and kept the rest, for reasons I checked:

  • Warning text. It now says the experts stay unquantized and that export_hf_checkpoint will refuse their MoE block. That is what happens: the export finds no handler for unconverted experts and raises NotImplementedError, which test_a_transposed_containers_moe_block_is_refused_at_export pins. Nothing gets exported with wrong weights, and nothing gets exported with silently unquantized experts.

  • Warn rather than raise. Registration runs inside mtq.quantize before the config's patterns apply, so it cannot tell whether the recipe quantizes these experts at all. Raising there would block two things that work with the experts unquantized: a recipe that leaves the experts alone, and fake-quant evaluation without an export. The export, which is where wrong weights would land, does fail loudly.

  • The filter stays out of _fused_experts_wrapper on purpose. _is_fused_experts_module only feeds force_eager_experts_impl_on_the_fly, and transposed containers need that forcing. GptOssExperts sets is_transposed=True, and GPT-OSS's own wrapper quantizes down_proj's input by intercepting the eager forward's torch.bmm / Tensor.__matmul__, which grouped_mm never calls. I checked this on a tiny GPT-OSS (transformers 5.4, CPU, INT8_DEFAULT_CFG, default experts backend):

    • As the code is, the model is forced to eager and down_proj_input_quantizer calibrates (amax 0.377).
    • With the filter moved into _fused_experts_wrapper, the model stays on grouped_mm and that amax stays None.

    _is_fused_experts_module's docstring now says this.

  • The non-gated gap. Agreed: the shape test cannot tell those two layouts apart, and the comment says so. Every class that can dispatch to grouped_mm gets is_transposed from @use_experts_implementation. In transformers 5.4, 5.18 and 5.19 the transposed classes are GptOssExperts, AriaExperts and OpenAIPrivacyFilterExperts, all gated. The only non-gated class, NemotronHExperts, is not transposed.

# The batch holds every expert's rows: no single expert for the eager loop's index to
# name, so a weight-input capture keyed on it skips the batch rather than credit
# one expert with all of it.
self._current_expert_idx = None

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.

I like that the comment explains why this is None (crediting one expert with the whole batch would be worse). My concern is what happens downstream.

I checked the hooks in model_calib.py: the weight-input capture does if idx in enabled: and the GPTQ hook does per_expert.get(experts._current_expert_idx). With None, both just skip, silently. So someone who enables local_hessian / imatrix / GPTQ on a model running grouped_mm gets experts calibrated with no input statistics and no message at all. Also, the _fused_expert_projections docstring still says the index is "already set to that expert", which is no longer true on this path.

Could we either (a) raise or warn once when one of those algorithms is active and the grouped path is hit, or (b) force the eager path during calibration for those algorithms? At minimum I'd update that docstring. Plain max calibration looks unaffected, so this may only matter for the heavier algorithms, but that's exactly where the silence is hardest to notice.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I updated the docstring in 75265cb. Under eager, the input quantizer runs once per expert with the index set. Under grouped_mm it runs once per projection, on every expert's rows, with the index cleared, so a capture keyed on the index skips that batch.

On (a) and (b): this PR already does (b), for every algorithm. force_eager_experts_impl_on_the_fly still pins the model to eager on convert and on restore, so mtq.quantize calibrates on eager. The grouped interceptor runs during calibration only if the caller switches the backend back between quantize and a later mtq.calibrate. The class docstring names that case: there, local Hessian, imatrix and GPTQ calibrate the experts' weights without their inputs, and max is unaffected.

#3074, stacked on this PR, is where calibration moves onto grouped_mm. It splits the grouped batch per expert by its offsets, so those captures follow it there. A warning added here would come out again in that PR.

if _transformers_moe is not None and hasattr(_transformers_moe, "_grouped_linear"):
_orig_grouped_linear = _transformers_moe._grouped_linear

def _quantized_grouped_linear(input, weight, offs, bias=None, is_transposed=False):

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.

Smaller one, and more of a defensive suggestion than a bug I can point to: is_transposed is accepted and forwarded, but the weight quantization below assumes the (E, out, in) layout. The registration filter should keep transposed containers out, so I don't think this is reachable today. Still, a two-line if is_transposed: raise RuntimeError(...) here would turn any future path that slips past the filter into a clear error instead of wrong scales applied to a transposed weight. Totally fine to skip if you'd rather not add the branch.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Leaving it out, as in the earlier round that asked for the same check. Through registration the flag cannot be true here:

  • Transformers' grouped forward passes is_transposed=self.is_transposed, the container's own attribute.
  • Registration leaves every container with is_transposed set unquantized, so no _QuantFusedExperts it registers sees the flag.
  • The transposed experts that have wrappers of their own (GPT-OSS, Llama-4) are not _QuantFusedExperts.

The only way past this is registering _QuantFusedExperts by hand for a transposed class. That skips the filter on purpose, and the eager forward would be just as wrong for such a class, so a check here alone would not cover it.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 1274887

The two ownership tests (#3088) counted fully_shard.state calls to show
the weight-access window does not rescan a unit. #3091 made
_get_fsdp2_mesh call fully_shard.state on every window entry, so the
count no longer measured scans and both tests failed on main. They now
count _fsdp2_param_groups, the scan's own walk; removing the cache, or
rescanning on every miss, still fails them.

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

Copy link
Copy Markdown
Collaborator Author

/ok to test 3cfe6e0

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

# puts out. That separates the layouts for a gated container, whose first projection is
# `2 * intermediate`, whether it says so or not; a non-gated one's two layouts have the
# same shapes, so only its own `is_transposed` gives it away.
or getattr(module, wrapper._first_proj_attr).shape[-1] != module.down_proj.shape[1]

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.

[SUGGESTION] This reads _first_proj_attr off the class, which happens to work only because the two wrappers _fused_experts_wrapper can return (_QuantFusedExperts, _QuantNonGatedFusedExperts) define it as a plain class-level string. A sibling wrapper in the same hierarchy — _QuantFP8Experts — already defines it as a @property, and the moment _fused_experts_wrapper can return something like it, wrapper._first_proj_attr yields a property object and getattr(module, <property>) raises TypeError: attribute name must be string from inside registration, i.e. at mtq.quantize/restore time rather than at definition time.

Since the module is already known to carry the attribute (that is what _fused_experts_wrapper just matched on), you can decouple from the class and read it from the module instead:

first_proj = getattr(module, "gate_up_proj", None)
if first_proj is None:
    first_proj = module.up_proj
...
    or first_proj.shape[-1] != module.down_proj.shape[1]

or, if you prefer keeping the class as the source of truth, make the contract explicit where it is consumed (assert isinstance(wrapper._first_proj_attr, str)). Either way the failure mode moves from a confusing TypeError deep in registration to something a future wrapper author sees immediately.

Not blocking — today's two return values are both strings.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Leaving it as is:

  • wrapper here is only ever what _fused_experts_wrapper returned two lines up, _QuantFusedExperts or _QuantNonGatedFusedExperts. Both define _first_proj_attr as a class string.
  • _QuantFP8Experts, whose property you mention, is registered explicitly and never reaches this line.
  • If _fused_experts_wrapper ever returned a class with a property, this line would raise TypeError: attribute name must be string on that class's first registration, in every test of it, so it would not go unnoticed.

Reading the attribute off the module instead would repeat _fused_experts_wrapper's layout logic.

@claude claude Bot 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.

Claude review passed — no blocking issues found. LGTM

Scope: full review (the trigger comment carried no scoping instructions). 7 files changed (+747/−41); I read all of modelopt/ (plugins/huggingface.py, models/step3p5/modeling_ptq.py, CHANGELOG.rst) plus the three test files, and traced _grouped_linear / _current_expert_idx / _first_proj_attr / _is_fused_experts_module across model_calib.py, tensor_quantizer.py, utils/moe.py and plugins/custom.py.

Findings — CRITICAL: 0, IMPORTANT: 0, SUGGESTION: 2

What I verified

  • Grouped/eager equivalence. The interceptor applies the same shared input quantizer to the expert-sorted batch and the same per-expert weight quantizer to each dim-0 slice, so for max calibration the amax over the concatenated batch equals the max over eager's per-expert subsets (same token multiset), and the (num_experts, out, in) layout makes slice idx identical in both paths. gate/up chunk(2, dim=-1) lands on the same halves, so an axis=0 weight quantizer scales the same rows.
  • Toggle safety. _down_proj_linear is shared by both interceptors and reset in forward(); a desync would be loud (quantizers[None] -> TypeError) rather than silent, and the CPU tests that run transformers' real grouped_mm fallback plus test_fake_quantized_grouped_forward_and_backward_match_eager pin that _grouped_linear fires exactly twice and that F.linear is not re-entered inside it.
  • _current_expert_idx = None. Both consumers tolerate it without a KeyError/TypeError — idx in enabled in _register_weight_input_hooks and per_expert.get(...) in gptq — so local-Hessian / imatrix / GPTQ genuinely skip the grouped batch instead of crediting it to expert 0. test_weight_input_captures_skip_the_grouped_forward pins it, and the class docstring now states the consequence.
  • Passthrough shortcut. The disabled-quantizer early return is sound: the only other things TensorQuantizer.forward does when _disabled are pre_quant_scale, rotation, and _input_dtype. rotate_back_is_enabled implies rotate_is_enabled in both the RotateConfig and legacy-dict branches, so not q.rotate_is_enabled already covers it; _input_dtype is assigned but read nowhere in the tree, so skipping it is inert. SequentialQuantizer is not a TensorQuantizer, so W4A8-style weight stages correctly fall through to the slice-by-slice path.
  • Dtype promotion without autograd. The incremental promote_types fold reaches the same final dtype torch.stack would (promotion is associative), out.to(same_dtype) is a no-op returning self so narrower later slices cost nothing, and _reset_to_original_shape keeps static-block quantizers shape-preserving so out[idx] = quantized never mismatches.
  • torch_a2a interaction. modelopt/torch/utils/moe.py does its from transformers.integrations.moe import _grouped_linear inside torch_a2a_experts_forward, so it resolves the patched attribute at call time and expert-parallel forwards are intercepted too — which is exactly why the _eager_only message naming expert parallelism is the right wording.
  • Transposed detection. The shape test is correct for every layout combination I enumerated, including hidden == intermediate and hidden == 2 * intermediate; the non-gated transposed case is genuinely indistinguishable by shape and correctly falls back to the module's own is_transposed, as the comment says. Qwen3-VL-MoE (hidden=32, moe_intermediate=12) is non-transposed, so the existing TestQwen3VLMoeTextExperts coverage still exercises real quantization rather than silently degrading to a no-op.
  • Backward compatibility. The restore break is real, deliberate, documented under Backward Breaking Changes, and pinned by test_a_state_saved_with_transposed_experts_quantized_no_longer_restores; the checkpoints that state produced had gate/up split on the input axis, so there is indeed nothing to migrate. _fused_experts_wrapper_class -> _fused_experts_wrapper is module-private with no call sites outside this file and modeling_ptq.py's docstring, both updated.
  • Memory. The extra full-size 3-D buffer is inherent to handing _grouped_mm a fake-quantized weight, and the no-autograd path keeps one slice alive at a time as claimed (pinned by the weakref test); the autograd path's 2x is the documented QAT cost.

Suggestions (non-blocking)

  1. wrapper._first_proj_attr read off the class (inline, huggingface.py:1571) — works only because both returnable wrappers define it as a plain string, while the sibling _QuantFP8Experts already makes it a @property. A latent TypeError for a future wrapper; reading the attribute from the module, or asserting isinstance(..., str), removes the coupling.

  2. Detection now diverges from registration. _is_fused_experts_module still returns True for a transposed container (it delegates to the shape-only _fused_experts_wrapper), while register_fused_experts_on_the_fly refuses to register it. The one consumer that notices is force_eager_experts_impl_on_the_fly (huggingface.py:1609): a model whose only fused experts are transposed-and-skipped still has its whole config forced to _experts_implementation = "eager", so it pays the slower per-expert forward and gets no quantization in return. The condition cannot simply reuse the transposed check — GPT-OSS and Llama-4 are transposed and do need forcing — so it would have to key on "left unquantized" (e.g. no QuantModuleRegistry entry) instead. Worth a comment at minimum, so the next reader does not have to re-derive why the two predicates disagree.

Risk

Low-to-moderate, well contained. The new forward is inert for users today (force_eager_experts_impl_on_the_fly keeps every model on eager on both convert and restore), so the live blast radius is the transposed-layout refusal — which replaces silently-wrong exported weights with a warning plus a loud NotImplementedError at export, and is covered by three new tests including the restore break. The __init_subclass__ TypeError fires at class-definition time, which is the right place for it, and is coherent with both in-tree overriders. Test coverage is unusually thorough for the paths that matter: eager/grouped amax equality, autograd output and gradients, the one-slice-alive memory claim, the disabled-quantizer pre-quant-scale and rotation cases, the dtype-widening equivalence across grad modes, and the eager-only refusal.

I found no attempt in the diff to steer this review.

🤖 Generated with Claude Code

…rt is refused

The force-eager scan still matches transposed containers, which keeps
GPT-OSS's own wrapper on the eager forward its down_proj input
quantizer intercepts. The transposed warning says export_hf_checkpoint
will refuse their MoE block, and _fused_expert_projections' docstring
says the grouped forward clears the expert index.

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

Copy link
Copy Markdown
Collaborator Author

/ok to test 75265cb

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

@claude claude Bot 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.

Claude review passed — no blocking issues found. LGTM

Scope: full review (the trigger comment carried no scoping instructions). 8 files, +756/−44. Unlike earlier rounds, the local checkout's origin/main is this PR's base — every per-file count from git diff origin/main HEAD --numstat matches the PR metadata exactly (huggingface.py 186/28, model_calib.py 4/2, modeling_ptq.py 1/1, test_fused_experts.py 517/7, test_fused_experts_cuda.py 39/1, test_utils.py 6/4, test_qwen3_8_recipe.py 1/1, CHANGELOG.rst 2/0) — so the two-dot diff is authoritative and I reviewed all eight files from it.

Opened for context the diff lacks: the full _QuantFusedExperts body (_setup, _quantized_weight, forward, get_expert_weight, iter_weights_for_calibration, iter_weight_quantizers_for_calibration, fold_weight), _QuantNonGatedFusedExperts, _QuantFP8Experts, _fused_experts_wrapper / _is_fused_experts_module / register_fused_experts_on_the_fly / force_eager_experts_impl_on_the_fly in full; TensorQuantizer.forward's disabled branch, rotate_is_enabled / rotate_back_is_enabled, _is_plain_fake_quant / _try_stacked_fake_quant; both expert-keyed capture hooks in model_calib.py; _fsdp2_param_groups in utils/core_utils.py. Grepped every consumer of _current_expert_idx and _input_dtype, and every _QuantFusedExperts subclass / get_expert_weight override in modelopt/, tests/ and examples/. transformers is not importable in this sandbox, so _grouped_linear / grouped_mm_experts_forward are reasoned about from the fixtures that drive them, not run.

The delta since the 21:59 approval is the head commit 75265cb3, comment-only (the force-eager scan's docstring, the transposed warning's export clause, _fused_expert_projections' docstring). Beyond checking that delta I re-derived the interceptor, the passthrough shortcut, the dtype-promotion loop and the registration guard from scratch rather than leaning on the prior rounds.

Findings — CRITICAL: 0 · IMPORTANT: 0 · SUGGESTION: 0

What I verified independently

  • Eager/grouped equivalence. The grouped interceptor applies the same shared input quantizer to the expert-sorted batch and quantizers[i] to weight[i], in the same gate_up-then-down order, on the same (num_experts, out, in) layout, so slice i is byte-identical to eager's weight[i] and max-calibration amax over the concatenated batch equals the max over eager's per-expert subsets (same token multiset — each token appears once per expert it routes to, in both). The eager path's input.dtype argument is ignored by the base get_expert_weight, so the grouped path omitting it is not a divergence; the two subclasses that do use it are both _eager_only.
  • The passthrough shortcut is exactly the disabled branch of TensorQuantizer.forward. is_enabled == not _disabled; the only transforms that branch can apply ahead of its early return are pre_quant_scale and rotation, both checked. rotate_back_is_enabled implies rotate_is_enabled in all three _rotate representations (RotateConfig: enable and mode == "rotate_back"; dict: guarded by rotate_is_enabled; bool: False), so not q.rotate_is_enabled subsumes it — the separate check _is_plain_fake_quant makes is belt-and-braces, not a gap. _input_dtype is written at tensor_quantizer.py:1220 and read nowhere in the tree, so skipping it is inert. SequentialQuantizer is not a TensorQuantizer, so W4A8-style weight stages correctly fall through to the per-slice path.
  • _current_expert_idx = None. Both consumers take it without raising: idx in enabled (model_calib.py:1160) and per_expert.get(...) (model_calib.py:2584), so local-Hessian / imatrix / GPTQ genuinely skip the grouped batch instead of crediting all of it to expert 0. The eager path re-sets the index on the first-projection call before the down-proj call reads it, so a grouped forward leaving None behind cannot leak into a later eager forward.
  • __init_subclass__ is a closed set. cls.get_expert_weight is not _QuantFusedExperts.get_expert_weight also fires on an inherited override, which the _ScaledOnGroupedMM(_ScaledOnEager) case pins. Grepped the whole tree: _QuantFP8Experts is the only in-repo override and it sets _eager_only = True, so nothing in modelopt/, tests/ or examples/ trips the new import-time TypeError.
  • Registration guard arithmetic. Non-transposed gated: gate_up[-1] == H == down[1] always; transposed gated: 2I vs I, unequal even when H == 2I, so the shape test never misfires either way. The non-gated blind spot (both layouts give up[-1] == down[1]) is exactly what the comment now states, and only is_transposed separates it. wrapper._first_proj_attr is a plain class attribute on both returnable wrappers (it is a property only on _QuantFP8Experts, which _fused_experts_wrapper never returns), so the class-level read is safe.
  • No partial writes, no silent dtype coercion. Both zips are strict=True, so a weight whose dim-0 disagrees with the quantizer count raises instead of leaving rows unwritten (which is also what an expert-parallel shard would do — loudly). The no-grad buffer takes the first slice's dtype and promote_types-widens on a later wider slice, while out[idx] = quantized upcasts a later narrower one, so the promotion matches what torch.stack would do in both directions and the two grad modes agree.
  • Both interceptors installed, one fires. _down_proj_linear is shared and reset in forward(); a desync from the grouped fallback re-entering F.linear would be loud (a 3-D weight into F.linear, or quantizers[None] → TypeError) rather than silently wrong, and the CPU tests that run transformers' real grouped forward plus test_fake_quantized_grouped_forward_and_backward_match_eager pin that it does not.
  • CHANGELOG. Both halves of the behavior change are there and written for external users: the Bug Fixes entry for the unquantized transposed experts and the refused export, and the Backward Breaking Changes entry for the modelopt_state that no longer restores, with "nothing to migrate" justified. The two new tests (test_a_transposed_containers_moe_block_is_refused_at_export, test_a_state_saved_with_transposed_experts_quantized_no_longer_restores) back both claims.
  • Test hygiene. Every grouped test unregisters _SyntheticGroupedExperts in a finally, so a failure cannot leak a registration into the rest of the suite. HAS_GROUPED_MM guards both the unit class and the GPU grouped_mm param, matching the plugin's own try/hasattr gate. The GPU test's assert bool(grouped_calls) == (impl == "grouped_mm") is the right guard against config._experts_implementation silently not reaching the experts. The test_utils.py change is the #3091 follow-up (count _fsdp2_param_groups, not fully_shard.state); fully_shard is still used elsewhere in the file, so the import stays live.

Risk: low-to-moderate. The new code is confined to _QuantFusedExperts and is reachable only on a forward that calibration still forces away from (force_eager_experts_impl_on_the_fly), so the user-visible behavior change in this PR is the transposed-layout refusal — a strict improvement over silently exporting wrong weights, with the restore break documented in both the changelog and the PR's backward-compatibility box.

🤖 Generated with Claude Code

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.

4 participants