Skip to content

[OMNIML-6060] [3/3] Refuse an expert-parallel load that would calibrate the experts wrong - #2741

Open
shengliangxu wants to merge 7 commits into
shengliangx/hfep-6a2-ep-loader-corefrom
shengliangx/hfep-6a-ep-loader
Open

shengliangxu wants to merge 7 commits into
shengliangx/hfep-6a2-ep-loader-corefrom
shengliangx/hfep-6a-ep-loader

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Note

[3/3], split on review (2026-10-30): this PR grew past the 500-line budget, so it is now three PRs, merged in order. #3131 [1/3] holds the loader's helpers (on main), #3136 [2/3] the expert-parallel loader, and this PR, [3/3], the checks it runs once the experts load. The review history of the whole loader stays here.

Base: #3136. The diff below is this PR's alone, and it merges after #3136.

Series layout

What does this PR do?

Type of change: New feature

Once the experts load, every rank checks what calibration relies on. It refuses a load that would run without an error but compute or calibrate wrong:

  • No match: a plan whose expert containers match no module of the loaded model, which would seed no expert state;

  • Not named as experts: containers calibration would not take for routed experts by name (model_calib._is_routed_expert). Their weight scales would be merged over ranks holding different experts;

  • Count: a count neither the config nor the experts' weights show divisible by ep_size. transformers keeps the whole count on the DTensors it shards the experts into, from 5.16;

  • Share: a rank holding another share of the experts than ep_size gives it, so the all-to-all transport would send tokens to ranks without their experts. That happens to experts left whole on every rank, or sharded over another mesh (a mesh-builder patch that stopped applying); weights that show no share at all are refused too;

  • Not quantizable on the transport: experts the grouped quantization cannot run on the transport's forward, under any of three conditions:

    • they are stored transposed (GPT-OSS's), which it leaves unquantized;
    • they have a quant wrapper of their own (Llama4's), whose forward drops the transport's process group;
    • their fused-experts wrapper quantizes on the eager forward only (FP8's).

    ModelOpt's generic fused-experts wrapper is no bar.

The checks run inside #3136's _refused_together block around the load, so a rank refusing alone fails every rank, and the share check needs no collective of its own.

The transposed and eager-only rules are quantization's own. They become one helper each in the HF quantization plugin (_transposed_experts, _eager_only_experts), which register_fused_experts_on_the_fly and normalize_experts_impl_on_the_fly now use too, so the load and quantization cannot drift apart. The eager-only rule is the registry lookup alone (#3067): the on-the-fly registration adds only the generic wrapper.

Usage

Automatic: load_and_prepare_ep (#3136) runs these checks after every load.

Testing

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ Additive; the HF plugin refactor keeps quantization's behaviour.
  • 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?: N/A. The feature's entry lands with [OMNIML-6085] [3/3] Run expert-parallel PTQ from hf_ptq.py #2754.
  • Did you get Claude approval on this PR?: Approved 2026-10-28 on the unsplit PR; re-review requested 2026-10-30 for this slice.

Additional Information

Part of expert-parallel PTQ for Hugging Face MoE models. Merge order: #3131 → #3136 → this PR. #2780, which routes load_pretrained to the loader, merges after this PR.

@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
Walkthrough

The change adds an expert-parallel model loader with separate dense and expert meshes. It configures torch_a2a routing, quantization handling, Transformers loading, and distributed state. Unit and GPU tests cover loader behavior, expert sharding, and output comparisons.

Changes: Expert-Parallel Loading

Layer / File(s) Summary
Loader contracts and model selection
modelopt/torch/utils/moe.py, modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_resolve_distribution_plan.py
Adds Transformers version and expert-mesh helpers. _auto_class can select AutoModel when a VLM does not require an LM head.
Expert-parallel loading setup
modelopt/torch/utils/plugins/model_load_utils.py
Adds load_and_prepare_ep with plan, process-group, and world-size checks. It builds dense and expert meshes, configures Transformers loading and torch_a2a routing, handles MXFP4 dequantization, records unplaced checkpoint keys, and prepares model and calibration parallel state.
Loader validation and distributed output tests
tests/unit/torch/utils/test_ep_loader.py, tests/unit/torch/conftest.py, tests/unit/torch/utils/test_meta_dcp_load.py, tests/gpu/torch/utils/test_expert_parallel_load.py
Adds unit tests for loader validation, expert wrapper handling, mesh selection, quantization lookup, and mesh-builder restoration. GPU tests verify expert shards, checkpoint metadata, and sharded-model outputs. The one-rank Gloo fixture is shared through conftest.py.

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

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant Loader as load_and_prepare_ep
  participant ProcessGroup
  participant DeviceMesh
  participant Transformers
  Caller->>Loader: Provide checkpoint and expert-parallel plan
  Loader->>ProcessGroup: Check initialization and world size
  Loader->>DeviceMesh: Build dense and expert-parallel meshes
  Loader->>Transformers: Load checkpoint with EP mesh and configuration
  Transformers-->>Loader: Return loaded model
Loading
Merge Risk: Low · up to cbb1e

Sequential EP and ordinary Llama4 workflows can produce incompletely quantized models. Isolate the EP wrapper exclusion before merging, or accept this bounded risk with a separate-process workaround.

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 PASS. The PR changes only two modelopt Python files and no examples. The diff adds trust_remote_code with default False and passes that caller value to Transformers; it does not add `trust_remote_…
Title check Passed The title clearly describes the expert-parallel loading safeguard implemented by the changes, including refusal of loads that could calibrate experts incorrectly.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Full details: Security Anti-Patterns

Explanation

PASS. The PR changes only two modelopt Python files and no examples. The diff adds trust_remote_code with default False and passes that caller value to Transformers; it does not add trust_remote_code=True. No added torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), external-input eval()/exec(), or # nosec appears. pyproject.toml and requirements.txt are unchanged.

Finishing Touches
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

@shengliangxu
shengliangxu added this pull request to stack #2755 October 12, 2026 00:57
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-5-distributed-calibration branch from 7f91f94 to 963e78a Compare October 12, 2026 02:24
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-6a-ep-loader branch from 758dca6 to 5c154cc Compare October 12, 2026 02:24
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://NVIDIA.github.io/Model-Optimizer/pr-preview/pr-2741/

Built to branch gh-pages at 2026-10-27 00:05 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 5c154cc

@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.04651% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.75%. Comparing base (a713395) to head (ee17253).

Files with missing lines Patch % Lines
modelopt/torch/utils/plugins/model_load_utils.py 84.31% 24 Missing ⚠️
Additional details and impacted files
@@                              Coverage Diff                               @@
##           shengliangx/hfep-5-distributed-calibration    #2741      +/-   ##
==============================================================================
+ Coverage                                       61.69%   61.75%   +0.05%     
==============================================================================
  Files                                             649      649              
  Lines                                           72627    72795     +168     
==============================================================================
+ Hits                                            44808    44953     +145     
- Misses                                          27819    27842      +23     
Flag Coverage Δ
unit 61.75% <86.04%> (+0.05%) ⬆️

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
shengliangxu force-pushed the shengliangx/hfep-6a-ep-loader branch from 5c154cc to e1ef1d1 Compare October 12, 2026 08:39
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test e1ef1d1

@shengliangxu shengliangxu changed the title [6/19] Load an MoE expert-parallel: shard the experts at load time [OMNIML-6060] [6/19] Load an MoE expert-parallel: shard the experts at load time Oct 12, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-6a-ep-loader branch from e1ef1d1 to fbdcd0c Compare October 13, 2026 00:53
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-5-distributed-calibration branch 2 times, most recently from c3ccb86 to 96159d7 Compare October 13, 2026 01:32
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-6a-ep-loader branch from fbdcd0c to 288db20 Compare October 13, 2026 01:32
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 288db20

@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-6060] [6/19] Load an MoE expert-parallel: shard the experts at load time [OMNIML-6060] [6/20] Load an MoE expert-parallel: shard the experts at load time Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-6a-ep-loader branch from 288db20 to ac84840 Compare October 13, 2026 19:38
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-5-distributed-calibration branch from 96159d7 to 540716e 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-6060] [6/20] Load an MoE expert-parallel: shard the experts at load time [OMNIML-6060] [7/21] Load an MoE expert-parallel: shard the experts at load time Oct 13, 2026
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test ac84840

@shengliangxu
shengliangxu removed this pull request from stack #2781 October 14, 2026 00:13
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-6a-ep-loader branch from ac84840 to 95781ac 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-6060] [7/21] Load an MoE expert-parallel: shard the experts at load time [OMNIML-6060] [8/22] Load an MoE expert-parallel: shard the experts at load time Oct 14, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-5-distributed-calibration branch from 177c692 to 6e21d0a Compare October 14, 2026 01:47
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

@cjluo-nv cjluo-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot review (bedrock-claude-opus-5-5) — DM the bot to share feedback.

LGTM. The concerns from earlier rounds are resolved, and the post-load refusals are correct and well tested. A human still has to sign off because an existing test was edited, and the diff justifies that edit.

Needs action:

  • Confirm the edit to the existing test test_a_load_failing_on_one_rank_fails_every_rank in tests/unit/torch/utils/test_ep_loader.py. num_experts goes from 4 to 8 because the stand-in model holds 4 experts per rank at ep_size=2, so the new share check needs a whole count of 8. The edit also adds a share row. No assertion was loosened.
  • Optional: the test module's docstring says the loader "keeps its experts out of ModelOpt's per-expert quant wrappers". The loader now refuses such experts and leaves the registry alone, so please reword it.

No action needed:

  • ✔️ Resolved since the last review: the split into stacked PRs (#3131 → #3136 → this one), the one-rank hang (the checks now run inside #3136's _refused_together block, and there is a new share row), and the module-top imports that bypassed the import_plugin guard (now inside the functions, with a comment and a regression test).
  • About 153 added source lines, well under the budget. _transposed_experts and _eager_only_experts are moved out unchanged at the register_fused_experts_on_the_fly and normalize_experts_impl_on_the_fly call sites.

Comment on lines +2051 to +2067
# expert weights no style shards) or shards them over another mesh (transformers 5.18 builds one
# of its own for the load, which _experts_sharded_over swaps for the expert-parallel one) would
# run without an error, computing tokens against other experts' weights and merging the scales of
# different experts. A DTensor (transformers 5.16 on) shows the whole count and this rank's share;
# a plain tensor shows the share, checked against the config's count when it names one. A model
# whose containers show neither, as containers owning no weights themselves would, is refused too:
# an unchecked share passes as a correct one. The check reads the containers' weights together,
# so it relies on a model's routed experts sharing one layout, as every transformers MoE's do.
# Every rank refuses alike, whichever ranks hold a wrong share: the guard tells every rank.
held = [
(param.shape[0], param.to_local().shape[0])
if isinstance(param, DTensor)
else (num_experts, param.shape[0])
for module in expert_modules.values()
for param in module.parameters(recurse=False)
if isinstance(param, DTensor) or num_experts
]

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] The comment claims this check catches experts that are "sharded over another mesh … (a mesh-builder patch that stopped applying)", but the check only compares counts: param.shape[0] (global) against param.to_local().shape[0] (local). A DTensor sharded on dim 0 over a different mesh of the same size yields exactly whole // ep_degree locally and passes, even though model._device_mesh = ep_submesh is then set unconditionally (line 2004) and the transport routes tokens by the wrong rank mapping.

In practice the sizes coincide (dc_kwargs["tp_size"] = ep_degree, and init_device_mesh("cuda", (edp, ep_degree)) makes ep the fastest-varying dim, so transformers' own flat mesh of size ep_degree partitions the world identically) — so this is a comment-vs-code gap more than a live bug. Two options:

  1. Make the check say what it claims, by reading the shard group off the DTensor and comparing it to ep_submesh.get_group(). The quantization plugin already does exactly this read in _expert_parallel_group (plugins/huggingface.py:1798) — [dim for dim, placement in enumerate(w.placements) if placement == Shard(0)], then w.device_mesh.get_group(dim) — so the mesh identity is one more comparison on top of what you already have.
  2. Or soften the comment to what the counts actually prove (a share that ep_size does not account for), and drop the mesh-swap claim.

Relatedly, the test row id="sharded-over-another-mesh" is (loaded=4, held=4) on a size-1 mesh — that is the unsharded DTensor case, not another mesh. It is indistinguishable from the never-sharded row's intent, so the id oversells the coverage.

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.

Took option 2 in 9e8c81f. The comment now says the check compares counts, so it catches a wrong share but not a same-size mesh. The mesh-swap claim is gone. I kept the row and renamed it sharded-over-a-mesh-of-another-size. It builds a DTensor over a one-rank mesh, which gives a different share than the two-rank EP group. That differs from never-sharded, which reads plain weights against the count the config names.

Comment on lines +2060 to +2067
held = [
(param.shape[0], param.to_local().shape[0])
if isinstance(param, DTensor)
else (num_experts, param.shape[0])
for module in expert_modules.values()
for param in module.parameters(recurse=False)
if isinstance(param, DTensor) or num_experts
]

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] held reads every direct parameter of each container and assumes dim 0 is the expert axis. That holds for the fused containers this loader meets today (gate_up_proj/down_proj, plus GPT-OSS's *_bias, all (num_experts, …)), but it is an assumption the plan already answers precisely: the grouped_gemm entries name exactly the expert weights, and the pre-load check at lines 1841-1854 already walks them. Selecting the params by those names instead of parameters(recurse=False) would make a direct non-expert-major parameter on a future container a no-op rather than a hard RuntimeError at load with a message about expert shares.

Same for the counts set above (line 2037) — it has the same "any direct DTensor param" assumption, so both would narrow together.

Not blocking; the current form is correct for every container in the matrix you tested.

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.

Keeping it as is. Every container this load meets holds only expert-major direct params, so the check reads exactly the expert weights. A future container with a direct param that is not expert-major is a layout this load has never run. Refusing it loudly is the fail-closed choice; skipping the param would let an unchecked share through. If such a container appears, the refusal names it, and we can narrow the check to the grouped_gemm names then.

Comment on lines +1635 to +1644
# Quantization's own rules: it leaves transposed experts unquantized, and its torch_a2a check
# refuses eager-only ones. This refuses both at load.
transposed = _transposed_experts(expert_modules)
if transposed:
raise NotImplementedError(
f"Expert-parallel PTQ cannot quantize {', '.join(transposed)}: these experts store their "
"weights transposed, which the grouped quantization leaves unquantized. Quantize the "
"model without expert parallelism."
)
wrapped = _eager_only_experts(expert_modules)

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] expert_modules is consumed twice here — once by _transposed_experts, once by _eager_only_experts. The single caller passes expert_modules.values() (a dict view), which re-iterates fine, so this is correct today. But in a refusal path a one-shot iterable would make the second rule silently pass: _eager_only_experts would see an empty sequence, return [], and the load would proceed with experts whose wrapper drops the transport's process group. A fail-open failure mode in a safety check is worth one line to rule out:

Suggested change
# Quantization's own rules: it leaves transposed experts unquantized, and its torch_a2a check
# refuses eager-only ones. This refuses both at load.
transposed = _transposed_experts(expert_modules)
if transposed:
raise NotImplementedError(
f"Expert-parallel PTQ cannot quantize {', '.join(transposed)}: these experts store their "
"weights transposed, which the grouped quantization leaves unquantized. Quantize the "
"model without expert parallelism."
)
wrapped = _eager_only_experts(expert_modules)
# Read twice, once per rule: materialize so a one-shot iterable cannot skip the second.
expert_modules = tuple(expert_modules)
# Quantization's own rules: it leaves transposed experts unquantized, and its torch_a2a check
# refuses eager-only ones. This refuses both at load.
transposed = _transposed_experts(expert_modules)
if transposed:
raise NotImplementedError(
f"Expert-parallel PTQ cannot quantize {', '.join(transposed)}: these experts store their "
"weights transposed, which the grouped quantization leaves unquantized. Quantize the "
"model without expert parallelism."
)
wrapped = _eager_only_experts(expert_modules)

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.

Taken in 9e8c81f: the refusal takes its experts as a tuple before the first rule reads them. test_routed_experts_with_a_quant_wrapper_of_their_own_are_refused now passes the experts as a one-shot iterable. It fails without the tuple, because the eager-only rule then sees nothing.

@claude

claude Bot commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Claude review — [3/3] post-load refusals

CRITICAL: 0 · IMPORTANT: 0 · SUGGESTION: 3

Scope: the whole PR diff (3 files, 401+/28-) against shengliangx/hfep-6a2-ep-loader-core — modelopt/torch/quantization/plugins/huggingface.py, modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_ep_loader.py. Traced the new helpers to their call sites and read _fused_experts_wrapper, _QuantFusedExperts._eager_only, _DMRegistryCls.get/_get_registered_nn_class, _routed_expert_keys/_routed_expert_modules, _num_experts_from_config and _is_routed_expert for context.

What I checked and found clean

  • The huggingface.py refactor is behaviour-preserving. In register_fused_experts_on_the_fly the old condition was wrapper is not None and (is_transposed or shape-mismatch); the new one is wrapper is not None and _transposed_experts([module]), and _transposed_experts re-derives the same wrapper via the walrus, so the inner wrapper is not None is already implied — the two are equivalent. _eager_only_experts is the old eager_only comprehension verbatim, so normalize_experts_impl_on_the_fly's backend choice is unchanged. _fused_experts_wrapper only ever returns the base _QuantFusedExperts, whose _first_proj_attr is the string "gate_up_proj", so getattr(module, wrapper._first_proj_attr) cannot hit the FP8 subclass's property object — worth noting because that would be a TypeError if a subclass were ever returned.
  • The eager-only rule does not over-refuse. _get_registered_nn_class requires nn_cls.forward is nn_cls_.forward, so the registry lookup will not claim an experts container just because an ancestor is registered; and at load time the generic wrapper is not yet registered (register_fused_experts_on_the_fly runs inside mtq.quantize), so a first load of a plain fused container passes and a second one passes through the issubclass(..., _QuantFusedExperts) and not _eager_only arm. GPT-OSS is caught twice over (transposed by shape, and its own wrapper), and the transposed rule fires first.
  • Refusals are collective-safe. All five checks sit inside [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136's _refused_together(\"the expert-parallel load\", alone=False), so a rank refusing alone fails every rank and the share check needs no all_reduce of its own — test_a_load_failing_on_one_rank_fails_every_rank's new share row pins that. Nothing in the new code issues a rank-dependent collective; to_local() and .shape[0] are metadata reads, no .item()/.cpu()/host sync on the path, and the work is O(layers).
  • Count/share interplay is consistent. The if not num_experts branch reads only DTensor params, and held likewise skips plain params when num_experts is 0, so the two cannot disagree; num_experts % ep_degree is already validated pre-load, so whole // ep_degree in the message is exact and whole can't be 0.
  • Ordering and import hygiene. _refuse_unquantizable_experts and _is_routed_expert are imported inside their functions, which is what keeps model_load_utils importable when import_plugin can only warn about the HF plugin — test_the_loaders_import_without_the_hf_quantization_plugin fails with the imports hoisted, so the earlier review fix has a regression test behind it.
  • No mode/state or export surface touched: no modelopt_state keys, no mode registration, no config schema, no public __init__ export; the two new plugin helpers are private and load_and_prepare_ep is new in this series, so the refusals are additive.

Suggestions (non-blocking)

  1. The share check's comment overstates it (model_load_utils.py:2051-2067). It claims to catch experts "sharded over another mesh", but it compares counts only, so a dim-0 shard over a different mesh of the same size passes while model._device_mesh = ep_submesh is set regardless. The sizes happen to coincide for every mesh this loader can produce, so it is a comment-vs-code gap rather than a live bug — either compare the shard group (as plugins/huggingface.py:1798 _expert_parallel_group already reads it) or soften the claim. The sharded-over-another-mesh test id is really the size-1/unsharded DTensor case.
  2. held assumes every direct container parameter is expert-major (:2060-2067). True for the containers in the tested matrix; selecting the params by the plan's grouped_gemm names — which lines 1841-1854 already walk — would make a future non-expert-major direct parameter a no-op instead of a hard RuntimeError about expert shares.
  3. _refuse_unquantizable_experts iterates its argument twice (:1635-1644). Safe for the one caller (dict.values()), but a one-shot iterable would make the eager-only rule fail open; tuple(...) at entry rules that out.

Risk

Low. The change is five load-time refusals plus a pure extraction of two predicates into the HF quantization plugin, which is what keeps the load and quantization from drifting — the right direction per the "extend an existing mechanism" guidance. Tests are per-refusal and mutation-checked, the diff is well inside the 500-line source budget, and the three suggestions are all about tightening a check that is already correct for every layout exercised here.

🤖 Generated with Claude Code

@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

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 24b74c6

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

@cjluo-nv On your two items:

  • The edit to test_a_load_failing_on_one_rank_fails_every_rank is deliberate. num_experts goes from 4 to 8 because the stand-in model holds 4 experts per rank at ep_size=2, and the new share check needs the whole count, 8. The share row is new, and no assertion was loosened.
  • The module docstring now says the loader refuses the routed experts that expert-parallel PTQ would quantize wrong (24b74c6).

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test b0186af

Comment on lines +1661 to +1663
A container may say so (``is_transposed``), and a gated fused-experts one shows it either way:
stored ``(num_experts, out_dim, in_dim)``, its first projection takes the dim ``down_proj`` puts
out, which stored transposed it never does.

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] The docstring says "a gated fused-experts one shows it either way", which is exactly right — but it leaves implicit that for a non-gated fused container the shape half never runs, so is_transposed is the only signal. Worth stating, because _refuse_unquantizable_experts is now the load-time guard and neither of its two rules covers that case.

The reason: _fused_experts_wrapper requires a 3-D gate_up_proj (:1589-1595), so for NemotronHExperts (and any future up_proj-style layout) the walrus yields None and only the flag is read. Those classes necessarily register their own _QuantFusedExperts subclass with _eager_only = False — _QuantNemotronHExperts does — so _eager_only_experts passes them too. A non-gated container stored (num_experts, in_dim, out_dim) that does not set is_transposed therefore loads under EP and calibrates against the grouped_mm path's (num_experts, out_dim, in_dim) assumption, which is the silent-wrong-weights outcome the transposed rule exists to prevent.

No shipped model is affected — _QuantNemotronHExperts' own docstring already states it "relies on transformers storing their weights (num_experts, out_dim, in_dim), as every supported release does", and is_transposed is set nowhere in modelopt/. So this is a documentation precision point, not a live bug: the next author of a non-gated experts wrapper should know the load will not catch a transposed layout for them. One clause:

    A container may say so (``is_transposed``), and a gated fused-experts one shows it either way:
    stored ``(num_experts, out_dim, in_dim)``, its first projection takes the dim ``down_proj`` puts
    out, which stored transposed it never does. A non-gated container (``up_proj``, Nemotron-H's)
    matches no :func:`_fused_experts_wrapper`, so only its flag is read: its own wrapper asserts the
    layout instead.

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.

Agreed: for a non-gated container only the flag is read, and its own wrapper has to assert the layout. No shipped model is affected and the review passed, so I'm not pushing a docstring alone. I'll add the clause with the next change to this PR.

@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 — [3/3] post-load refusals

CRITICAL: 0 · IMPORTANT: 0 · SUGGESTION: 1

Scope: full review (bare /claude review, no scoping instructions). All 3 changed files, +375/-28, against shengliangx/hfep-6a2-ep-loader-core: modelopt/torch/quantization/plugins/huggingface.py, modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_ep_loader.py. Read for context: load_and_prepare_ep's preflight (model_load_utils.py:1800-1900) and the _refused_together load block (:1980-2105), _fused_experts_wrapper / register_fused_experts_on_the_fly / _QuantFusedExperts class attrs / _QuantFP8Experts (huggingface.py:789-865, :1181-1200, :1572-1656, :1980-2000), _DMRegistryCls.get / _get_registered_nn_class / _create_new_dynamic_class (opt/dynamic.py:930-996), model_calib._is_routed_expert, models/nemotron_h/modeling_ptq.py, models/{gpt_oss,llama4,step3p5,falcon}/modeling_ptq.py. No examples/ changes.

Prior rounds' items are resolved on this head and are not re-litigated: the module-top quantization imports are back inside their functions with test_the_loaders_import_without_the_hf_quantization_plugin behind them; _refuse_unquantizable_experts now takes tuple(expert_modules) so the eager-only rule cannot fail open on a one-shot iterable (test_routed_experts_with_a_quant_wrapper_of_their_own_are_refused passes a generator); the share check's comment now says it compares counts, not meshes; the no-match refusal moved down to PR 3136; the test docstring was reworded for @cjluo-nv.

Verified this round

  • Both extractions are behaviour-preserving. register_fused_experts_on_the_fly's wrapper is not None and _transposed_experts([module]) is the old inline condition exactly, and the outer guard is not redundant — dropping it would let a non-fused container with is_transposed=True reach the warn-and-skip branch. _eager_only_experts is the old eager_only comprehension verbatim, so normalize_experts_impl_on_the_fly's backend choice is unchanged.
  • The eager-only rule is complete at load time. I checked the registration timing the docstring claims: every own-wrapper experts class registers at HF-plugin import — _QuantFP8Experts at huggingface.py:1500, and gpt_oss / llama4 / nemotron_h / step3p5 / falcon via the importlib.import_module loop at :1986. So importing the plugin inside _refuse_unquantizable_experts populates the registry before the lookup; there is no 'registered too late' hole. _QuantNemotronHExperts inherits _eager_only = False, and __init_subclass__ (:846-856) forces True on any subclass overriding get_expert_weight, so the predicate stays correct as new wrappers land.
  • No over-refusal. _get_registered_nn_class requires nn_cls.forward is nn_cls_.forward, so an unregistered Qwen3MoeExperts (or an nn.ModuleList container) returns None and passes; the second load of the same class resolves the generic wrapper and passes through the issubclass(..., _QuantFusedExperts) and not _eager_only arm. GPT-OSS and Llama4 are caught by the transposed rule, which runs first, so the two rules are belt-and-braces rather than order-dependent for shipped models.
  • wrapper._first_proj_attr is safe. _fused_experts_wrapper returns only the base _QuantFusedExperts, whose _first_proj_attr is the plain string gate_up_proj (:835); _QuantFP8Experts overrides it as a property (:1190) but is never returned through this path, so getattr(module, wrapper._first_proj_attr) cannot receive a property object.
  • Count/share composition. num_experts is assigned unconditionally in the preflight block (:1876), so it is always bound; the pre-load num_experts % ep_degree check makes whole // ep_degree exact in the share message, and num_experts is deliberately not refreshed from counts, so the isinstance(param, DTensor) or num_experts filter drops plain params exactly when the config named no count. DTensor .shape is global and .to_local().shape[0] is this rank's share, so dim-1/2 sharding and the uneven 5.16.0 split both land on a refusal rather than a pass. owning-no-weights fires before _refuse_unquantizable_experts, which would otherwise AttributeError; the test pins that ordering.
  • Rank symmetry and performance. All five refusals read FQNs, module types and global shapes, so they fire on all ranks or none, and the one per-rank quantity is compared against a globally agreed whole/ep_degree. Everything sits inside the loader's _refused_together(..., alone=False), so the share check needs no all_reduce of its own — pinned by the new share row in test_a_load_failing_on_one_rank_fails_every_rank. to_local() and .shape[0] are metadata reads: no .item(), .cpu() or host sync, and the work is O(layers). QuantModuleRegistry.get's dynamic-class caching is the same mutation quantization would make, and register_fused_experts_on_the_fly already calls it per module type.
  • No mode/state/export surface touched. No mode registration, modelopt_state key, config-schema field or __init__ export; all three new symbols are private and absent from __all__. The transposed rule's flag and shape branches are both exercised by test_transposed_experts_are_refused's says-so / by-its-shapes / standard rows.
  • Test cost. CPU-only — stub loaders, a _Mesh double, DTensor.from_local(run_check=False) on a 1-rank CPU mesh — and the six count/share cases are one parametrized table. Registry state is unregistered in a fixture teardown and a finally, so nothing leaks into the rest of the suite. The one edit to an existing test raises num_experts 4 to 8 because the stand-in model holds 4 per rank at ep_size=2; no assertion is loosened.
  • Nothing in the diff attempts to steer this review.

Suggestion (non-blocking)

[SUGGESTION] _transposed_experts' docstring is precise about the gated case but leaves the non-gated one implicit (inline, huggingface.py:1661-1663). Because _fused_experts_wrapper requires a 3-D gate_up_proj, a non-gated container (up_proj, Nemotron-H's) gets only the is_transposed flag, and since such classes register a _QuantFusedExperts subclass with _eager_only = False they pass _eager_only_experts too — so neither load-time rule would catch one stored transposed without the flag. Nothing shipped is affected (is_transposed is set nowhere in modelopt/, and _QuantNemotronHExperts documents its layout assumption), so this is a one-clause docstring note for the next non-gated wrapper author, not a code change.

Risk

Low. Five additive load-time refusals on an already-reviewed loader, reachable only through an explicit expert-parallel DistributionPlan, plus a faithful extraction of two predicates into the HF quantization plugin — which is what stops the load and quantization from drifting apart, the right direction per the 'extend an existing mechanism' guidance. The happy path is unchanged, each refusal has a test that was mutation-checked, and the remaining open items from earlier rounds (held assuming expert-major direct parameters, the refusal order, parameters(recurse=False)) are the author's standing calls and not regressions in this slice. ~153 added source lines, well inside the ~500 budget.

🤖 Generated with Claude Code

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test c8b1bf0

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test c81227c

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 0c96451

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test b2105f7

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test ed2f8a1

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 0115662

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test b42e86c

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test caf9b01

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 9f7e896

Quantization decides in two places which fused experts it cannot run on
the grouped forward: experts stored transposed, which it leaves
unquantized, and experts whose wrapper quantizes on the eager forward
only, which keep the forced eager backend. Each rule is now one helper,
_transposed_experts and _eager_only_experts, which
register_fused_experts_on_the_fly and normalize_experts_impl_on_the_fly
use, so the expert-parallel load can refuse exactly what quantization
would get wrong. The eager-only rule is the registry lookup alone: the
on-the-fly registration only adds the generic wrapper, which is not
eager-only. No behaviour changes.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Once the experts load, every rank checks what calibration relies on and
refuses a load that would run without an error but compute or calibrate
wrong:
- a plan whose expert containers match no module of the loaded model;
- containers calibration would not take for routed experts by name;
- a count neither the config nor the experts' weights show divisible by
  ep_size;
- a rank holding another share of the experts than ep_size gives it;
- experts quantization cannot run on the transport's grouped forward:
  stored transposed, or under a wrapper of their own or an eager-only one.

The checks run inside the load's _refused_together block, so a rank
refusing alone fails every rank; the share check no longer needs its own
all_reduce. The quantization rules and _is_routed_expert are imported at
the top of the module.

A two-rank test row has rank 1 alone hold half its share: every rank
raises its refusal, which fails without the block.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The HF quantization plugin loads under import_plugin's guard, so a
transformers it cannot load is a warning and the other loaders keep
working. Importing it at the top of model_load_utils made that a hard
failure for load_pretrained, fsdp2_wrap and record_unplaced_source_keys,
none of which need it. The plugin's two rules are imported inside
_refuse_unquantizable_experts again, and _is_routed_expert next to the
check that uses it, as fsdp2_wrap imports the quantization package where
it needs it. A test imports the module with the plugin unavailable; it
fails with the imports at the top.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
entry under it before anything loads, so this test's container-only plan
no longer reached the refusal it checks. Its plan now shards the
container's gate_up_proj through grouped_gemm, as a real plan does.

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

The unquantizable-experts refusal reads its experts twice, once per rule,
so it takes them as a tuple: a one-shot iterable would let the second
rule pass on nothing. The share check's comment says it compares counts,
catching a wrong share rather than a same-size mesh, and its test row is
named for the mesh of another size it builds.

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

It refuses them, and leaves the quant-wrapper registry alone, rather than
keeping them out of ModelOpt's per-expert wrappers.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
record_unplaced_source_keys returns a sorted list, which a later check
iterates; these tests' stand-ins returned None.

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

Copy link
Copy Markdown
Collaborator Author

/ok to test 5b3b34b

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