Skip to content

[OMNIML-6076] [1/3] Describe expert parallelism in the model specs - #3134

Merged
shengliangxu merged 3 commits into
mainfrom
shengliangx/hfep-9a1-ep-spec-sections
Oct 30, 2026
Merged

shengliangxu merged 3 commits into
mainfrom
shengliangx/hfep-9a1-ep-spec-sections

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Note

Slice [1/3] of #2746's split (2026-10-30), so the model-spec work is reviewed and merged in pieces under the 500-line budget. Merge order: #2741 [1/3] (#3131, merged) → this PR → #2746 [2/3] (#3135) → #2746 [3/3] (#2746).

Base: main, since #2741 [1/3] (#3131) merged on 2026-10-30. The diff below is this PR's alone.

Series layout (the part around this PR)

What does this PR do?

Type of change: New feature

Before it can shard a model's experts, the expert-parallel path needs three facts about the model:

  • which modules are the routed experts, and how they shard;
  • which transformers release first ships that information;
  • for a VLM, which nested config the plan must be attached to.

All three are facts about the architecture, so they belong with the model's other facts in modelopt.torch.models.

  • EPSpec, a new ModelSpec section (ModelSpec.ep_spec). Its fields:
    • base_model_ep_plan, the plan to attach;
    • plan_config_attr, the nested config it goes on;
    • requires_transformers, the release this model's expert parallelism needs;
    • prepare, for a checkpoint that ships its own modeling code;
    • prepared_ep_plan, the plan prepare attaches;
    • unsupported, why a model cannot be expert-parallel yet.
  • Most models become data in the spec package they already had:
    • deepseek_v3 carries the plan stock transformers ships only from 5.13;
    • qwen3_moe and minimax_m3_vl carry only the transformers release their own plan needs (a new minimax_m3_vl package);
    • nemotron_h carries its plan. It needs no prepare: its gate emits logits and the block runs its own top-k, so the experts receive global expert ids, which the all-to-all experts forward routes on directly.
  • One thing stays code: EPSpec.prepare, the escape hatch for checkpoints that ship their own modeling code (the Kimi adapters). A spec whose plan only prepare attaches states that plan too (prepared_ep_plan). It can then be read without running prepare, which patches the model's code.
  • Nemotron-H-Omni gets its own spec package.
    • Its experts live in the language sub-model, and transformers aggregates a sub-model's plan up to the root with its module prefix. A plan on the VLM root would match nothing, silently.
    • The plan goes on the language-model config. The checkpoint's own code names it llm_config and transformers' 5.18 port text_config. The spec lists both, text_config first, and the first one a config has takes the plan.
    • transformers ships the Omni model itself from 5.18, which its spec records. Its root type registers only the expert-parallel plan, so is_moe by the root type is False for its NemotronHMoE blocks.
    • No export depends on the missing MoE and export sections: on transformers 5 its experts are one fused container, exported by its own handler (checked locally, see Testing).
  • EPSpec.unsupported says why a model whose own plan would route it into the expert-parallel path cannot take it yet:
    • GPT-OSS and Llama-4 store their experts transposed, which the fused-experts export cannot write. GPT-OSS also interleaves gate and up.
    • Llama-4's MoE block also routes tokens itself rather than through the experts interface. A new llama4_text package shares Llama-4's reason.

Nothing reads the section yet. #2746 [2/3] (#3135) applies it to a config, and #2746 [3/3] (#2746) has the expert-parallel decision and load use it.

Usage

from modelopt.torch.models import get_spec

get_spec("deepseek_v3").ep_spec.base_model_ep_plan  # the plan transformers ships only from 5.13
get_spec("nemotron_h_omni").ep_spec.plan_config_attr  # ("text_config", "llm_config")
get_spec("gpt_oss").ep_spec.unsupported  # why GPT-OSS is not expert-parallel yet

Testing

  • New tests/unit/torch/models/test_ep_specs.py, the registered specs:
    • every registered plan is well formed, and every EP section declares something;
    • Nemotron-H-Omni shares Nemotron-H's plan and puts it on its language-model config.
  • test_model_specs.py and test_specs_vs_transformers.py, which check every registered spec, pass with the new packages.
  • Checked locally, not as a unit test: an FP8, INT4-AWQ or W4A8-AWQ export of a tiny NemotronH writes byte-identical tensors with and without nemotron_h's MoE and export sections. Environments: transformers 5.18 and 5.5.4 on torch 2.14, and 5.18 on torch 2.8.
  • Declined review suggestion (on [OMNIML-6076] [3/3] Apply the model spec when deciding and loading expert-parallel #2746, 2026-10-28): checking the version claims of specs without a moe_spec is left for the PR that next touches test_specs_vs_transformers.py, as the review suggests; the gap also covers main's own such specs. The three claims added here hold on transformers 5.4, 5.5.4, 5.12, 5.18 and 5.19.
  • 2026-10-30, at this head: pre-commit, mypy included, passes against main. CPU tests (tests/unit/torch/models and tests/unit/torch/utils, torch 2.14):
transformers Passed Skipped Failed
5.18 751 0 0
5.5.4 734 17 0

Before your PR is "Ready for review"

Additional Information

Part of expert-parallel PTQ for Hugging Face MoE models. Split out of #2746 (OMNIML-6076), whose review history stays on #2746.

Summary by CodeRabbit

  • New Features
    • Added expert-parallel PTQ support information for DeepSeek-V3, Nemotron-H, Nemotron-H Omni, and Qwen3-MoE, including model-specific plan and Transformers version requirements.
    • Added model registrations for Llama-4 Text, MiniMax-M3-VL, and Nemotron-H Omni, with applicable Transformers requirements.
    • Clarified when expert-parallel support is unavailable for GPT-OSS and Llama-4.
    • Added an option to load supported vision-language and encoder-decoder models without requiring a language-model head.

@coderabbitai

coderabbitai Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 3b43cc6 and d01b7b2.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 88db1d6 and 3b43cc6.

Files selected for processing: 19
  • modelopt/torch/models/README.md
  • modelopt/torch/models/__init__.py
  • modelopt/torch/models/deepseek_v3/specs.py
  • modelopt/torch/models/gpt_oss/specs.py
  • modelopt/torch/models/llama4/specs.py
  • modelopt/torch/models/llama4_text/__init__.py
  • modelopt/torch/models/llama4_text/specs.py
  • modelopt/torch/models/minimax_m3_vl/__init__.py
  • modelopt/torch/models/minimax_m3_vl/specs.py
  • modelopt/torch/models/nemotron_h/specs.py
  • modelopt/torch/models/nemotron_h_omni/__init__.py
  • modelopt/torch/models/nemotron_h_omni/specs.py
  • modelopt/torch/models/qwen3_moe/specs.py
  • modelopt/torch/models/specs.py
  • modelopt/torch/utils/moe.py
  • modelopt/torch/utils/plugins/model_load_utils.py
  • tests/unit/torch/models/test_ep_specs.py
  • tests/unit/torch/utils/test_model_load_utils.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; 11 remain after this review.

Walkthrough

The change adds an expert-parallel specification contract and registrations for several model types. It also adds a Transformers version check and updates model-class selection for VLM and encoder-decoder configurations when a language-model head is not required.

Changes: Expert-parallel specifications

Layer / File(s) Summary
Expert-parallel specification contract
modelopt/torch/models/specs.py, modelopt/torch/models/__init__.py, modelopt/torch/models/README.md, modelopt/torch/utils/moe.py, tests/unit/torch/utils/test_moe_a2a.py
Adds and exports EPSpec, adds ModelSpec.ep_spec, documents the section, and adds a tested Transformers version check.
Existing model EP declarations
modelopt/torch/models/deepseek_v3/specs.py, modelopt/torch/models/gpt_oss/specs.py, modelopt/torch/models/llama4/specs.py, modelopt/torch/models/nemotron_h/specs.py, modelopt/torch/models/qwen3_moe/specs.py
Adds EP plans or unsupported reasons to the listed model registrations. Nemotron-H now uses an EP plan instead of its previous MoESpec configuration.
Additional model registrations
modelopt/torch/models/llama4_text/*, modelopt/torch/models/minimax_m3_vl/*, modelopt/torch/models/nemotron_h_omni/*, modelopt/torch/models/__init__.py, tests/unit/torch/models/test_ep_specs.py
Registers three additional model types. Nemotron-H Omni uses a copy of the Nemotron-H plan and specifies nested config attributes. Tests check registered EP data and the Omni plan relationship.

Model class selection

Layer / File(s) Summary
Optional language-model head selection
modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_model_load_utils.py
Adds require_lm_head to _auto_class. When it is false, supported VLM and encoder-decoder configurations can select AutoModel. Tests cover native and remote configurations.

Priority: Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Merge Risk: Minimal · previous review 3b43c

No actionable issue identified in this change; it is ready to merge after normal checks.

Pre-merge checks: Passed 6
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: adding expert-parallelism details to model specifications.
Docstring Coverage Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentati…
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 listed security anti-pattern was introduced. The authoritative diff adds no torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), eval(), exec(), or # nosec. The change…
Full details: Docstring Coverage

Explanation

Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 6 functions across 18 files. (1 skipped: 1 unsupported.)

Full details: Security Anti-Patterns

Explanation

No listed security anti-pattern was introduced. The authoritative diff adds no torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), eval(), exec(), or # nosec. The changed model loader keeps trust_remote_code caller-configurable with a False default; the only added trust_remote_code=True text is explanatory documentation. No dependency-file changes were made.

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

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

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 69a4306

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

# the order the load reads them.
min_transformers_version="5.18",
ep_spec=EPSpec(
base_model_ep_plan=BASE_MODEL_EP_PLAN,

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.

[IMPORTANT Correctness] This hands the same dict object to two registered specs: get_spec("nemotron_h").ep_spec.base_model_ep_plan is get_spec("nemotron_h_omni").ep_spec.base_model_ep_plan. Since the registry is a process-global singleton, any in-place mutation of that dict leaks across both model types for the lifetime of the process.

Why it matters: the next PR in the series attaches this plan to an HF config (config.base_model_ep_plan = <plan>), and the mechanism this file's own docstring describes — transformers aggregating a sub-model's plan up to the root with its module prefix — is plan-dict manipulation. Where transformers does that aggregation with self._tp_plan = config.base_model_ep_plan followed by an in-place .update({f"{prefix}.{k}": v ...}) (it is assign-then-update, not copy-then-update, in several 5.x releases), the global spec dict silently grows language_model.… keys. A later plain nemotron_h load in the same process (or the next test in the session) then reads a polluted plan, and _routed_expert_keys returns containers that name nothing — which _decide_* turns into the "none of the plan's expert containers names a module of the built model" hard failure, from state left behind by an unrelated earlier load.

Fix: hand out a copy rather than the shared object, here and in nemotron_h/specs.py:

ep_spec=EPSpec(
    base_model_ep_plan=dict(BASE_MODEL_EP_PLAN),
    plan_config_attr=("text_config", "llm_config"),
),

Copying in the applier instead is equally fine (and arguably the better place, since it also protects a plan transformers ships). If [2/3] already copies before attaching, say so and this is a non-issue.

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.

Not an issue: #3135 ([2/3]) attaches a copy.

  • apply_distributed_support sets target.base_model_ep_plan = dict(ep.base_model_ep_plan).
  • _spec_ep_plan, the only other reader that hands a plan on, returns dict(...) too.
  • test_the_attached_plan_is_a_copy mutates an attached plan and asserts that the registered spec's dict is unchanged.

So the dict that nemotron_h and nemotron_h_omni share on purpose (test_the_omni_vlm_reuses_the_text_models_plan pins it) never reaches a config or transformers.

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 bc55734. The Nemotron-H-Omni spec now holds its own copy, dict(BASE_MODEL_EP_PLAN), so the two registered specs no longer share one dict. test_the_omni_vlm_reuses_the_text_models_plan now also asserts omni is not text, which fails without the copy. The applier's own copy in #3135 stays.


Distinct from ``ModelSpec.min_transformers_version``, which is about the modeling code
matching the spec. A model can be fully modelled and still not be shardable: qwen3_moe is
modelled from 5.5, but its own ``base_model_ep_plan`` lands in 5.7."""

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] str | tuple[str, ...] | None makes every consumer branch on the scalar-vs-sequence shape, and the natural spelling of the consumer (for attr in ep.plan_config_attr) iterates a bare "text_config" character by character, attaching the plan to cfg.t / cfg.e / … and matching nothing. This module already guards against exactly that foot-gun elsewhere — list_all_possible rejects scalar attributes because "model_type would be iterated character by character" — and a bare str here would also make list_all_possible("plan_config_attr") raise on the first such spec.

Since the only user today already passes a tuple, consider dropping the scalar arm (plan_config_attr: tuple[str, ...] = (), with () meaning "the root config"), or keeping the union and adding the normalizing accessor the other sections use for this:

@property
def plan_config_attrs(self) -> tuple[str, ...]:
    """The nested config attributes to try, in order; empty for the root config."""
    if self.plan_config_attr is None:
        return ()
    return (
        (self.plan_config_attr,)
        if isinstance(self.plan_config_attr, str)
        else self.plan_config_attr
    )

so the applier in [2/3] cannot get the shape wrong (cf. MoESpec.gate_up_pairs, which exists for the same reason).

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 the union.

  • Its one consumer, apply_distributed_support in [OMNIML-6076] [2/3] Prepare a model's config for expert parallelism from its spec #3135, normalizes a bare string before iterating: attrs = ep.plan_config_attr or (), then (attrs,) for a str.
  • Both shapes are tested: the Omni spec's tuple, and plan_config_attr="language_config" in test_a_plan_attached_where_the_load_does_not_read_one_is_an_error.
  • list_all_possible builds global vocabularies of tuple-valued matching names (gate_up_pairs, pqs_fuse_rules). A plan's config is read per model, through get_spec(model_type), so nothing has a reason to list it across models.

A model with one nested config can then write the natural "text_config".

Comment on lines +34 to +37
assert plan, f"{model_type} declares an empty plan, which is not the same as declaring none"
for glob, style in plan.items():
assert isinstance(glob, str) and glob, f"{model_type}: bad glob {glob!r}"
assert isinstance(style, str) and style, f"{model_type}: bad style for {glob!r}"

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 promises "globs map to style names; a malformed entry matches nothing and fails silently at load", but the assertions only check isinstance(..., str) and non-emptiness — no realistic spec edit fails this. The two failure modes that actually bite are (a) a style name nothing implements and (b) a plan that styles only grouped-GEMM parameters or only a container, so _routed_expert_keys yields no usable container key. Both are checkable cheaply against the loader's own vocabulary, which keeps this test honest without adding a case:

from modelopt.torch.utils.moe import _EXPERTS_STYLES, _routed_expert_keys

KNOWN_STYLES = frozenset(_EXPERTS_STYLES) | {"grouped_gemm"}
...
    for glob, style in plan.items():
        assert isinstance(glob, str) and glob, f"{model_type}: bad glob {glob!r}"
        assert style in KNOWN_STYLES, (
            f"{model_type}: {glob!r} uses style {style!r}, which the expert-parallel "
            f"load does not implement (known: {sorted(KNOWN_STYLES)})"
        )
    assert _routed_expert_keys(plan), (
        f"{model_type}'s plan names no routed-experts container, so the load can "
        "neither count nor shard its experts"
    )

That also pins the nemotron_experts_ep / moe_tp_experts spelling the specs and modelopt/torch/utils/moe.py have to agree on — today a typo in either side is caught nowhere. (prepared_ep_plan deserves the same loop once a spec sets it.)

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 #3135 (5648c93), which edits this test file too. The test now checks:

  • each entry's style against the styles the expert-parallel load implements;
  • that the plan names a routed-experts container the load can count and shard;
  • prepared_ep_plan as well as base_model_ep_plan.

A bad style in Nemotron-H's plan fails it for Nemotron-H and Nemotron-H-Omni.

# (``trust_remote_code=True``) names the language-model config ``llm_config``, and
# transformers' port ``text_config``: the plan goes on whichever the config has, tried in
# the order the load reads them.
min_transformers_version="5.18",

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] min_transformers_version="5.18" sits awkwardly with what this spec is for. Per its own declaration, the field is the "earliest transformers release whose definitions match this spec" — and this spec's content is the EP plan plus plan_config_attr, which llama4-style globs aside is explicitly meant to serve the pre-5.18, remote-code checkpoint too (that is why llm_config is listed at all). As written, the spec says "nothing here applies before 5.18" while simultaneously carrying the attribute name only the pre-5.18 spelling uses.

Nothing enforces the field at runtime today, so there is no bug. But the deferred follow-up named in the PR description — extending test_specs_vs_transformers.py to check version claims for specs without a moe_spec — will have to reason about this one, and the natural check (_modeling_module("nemotron_h_omni") is not None from 5.18 on) will pass while quietly disagreeing with the llm_config entry. Worth one clause in the comment stating which half of the spec 5.18 describes (the transformers-shipped model) and that the plan itself applies to the remote-code checkpoint on older releases, so the next reader does not "fix" the version or drop llm_config.

Relatedly: EPSpec.requires_transformers is None here, i.e. "no EP-specific floor", which is the right value if the remote-code path works below 5.18 — that reading is only obvious once the above is written down.

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.

The comment above min_transformers_version already says which half 5.18 describes: "transformers ships the Omni VLM itself from 5.18. The checkpoint's own code (trust_remote_code=True) names the language-model config llm_config, and transformers' port text_config."

The plan applies to either spelling. requires_transformers is left unset, so the expert-parallel load's own 5.7 floor is what applies to the remote-code checkpoint. A later check of the version claims has that comment to go by, so I am leaving this as is.

Comment on lines +41 to +44
# Stock transformers ships no plan for deepseek_v3 before 5.13; from 5.13 the config's
# own plan wins (see apply_distributed_support) and this one goes unused. No gate entry
# on purpose: the router is logits-only and the MoE block computes GLOBAL top-k ids, so
# the experts already receive unmasked global ids -- what the all-to-all dispatch wants.

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 pairs two claims that are in tension and leaves the resolution unstated: "from 5.13 the config's own plan wins and this one goes unused", and (two sentences later) "adding a gate entry would mask them". Stock transformers' EP plans for the DeepSeek family do style the router (ep_router / megamoe_router, whose RouterParallel._prepare_output_fn remaps to local ids and masks non-local — see the TorchA2ARouter override in modelopt/torch/utils/moe.py). So on 5.13+, where the config's own plan is the one that applies, deepseek_v3 gets exactly the gate styling this spec deliberately omits, and the a2a dispatch's precondition ("the router must emit GLOBAL ids unmasked") is the load's problem rather than the spec's.

No change needed to the data — but a reader who takes "goes unused" at face value will conclude the masking hazard cannot arise for deepseek_v3 on recent transformers, which is the opposite of the truth. Worth one clause saying where that is handled (the load rewriting router/expert styles to torch_a2a_*), or dropping "goes unused" in favour of "stock's plan applies instead, and the load re-styles its router entry".

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.

#2748, next in the series, rewrites this comment when it moves the plan into BASE_MODEL_EP_PLAN for the Kimi adapters. Its text already says what you ask: "From 5.13 its own plan wins (an existing plan always does, see apply_distributed_support): the same experts, plus an ep_router entry for the gate, which the load swaps for torch_a2a_router (global ids, unmasked)."

The load does that swap in load_and_prepare_ep ("ep_router": "torch_a2a_router"). Rewording the comment here as well would only conflict with that rewrite, so this text stays as is.


__all__: list[str] = []

# transformers ships a base_model_ep_plan for Llama-4, which alone would route it into the

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] modelopt/torch/models/README.md states the convention for values shared between two spec packages: "A value shared between two model types is private and imported explicitly (gemma4_text reuses gemma4's _GEMMA4_MOE_SPEC); sub-model types of one family are an intra-family detail, not a package API."

EP_UNSUPPORTED here (imported by llama4_text/specs.py) and BASE_MODEL_EP_PLAN in nemotron_h/specs.py (imported by nemotron_h_omni/specs.py) are both public spellings of exactly that pattern, so they read as package API while _GEMMA4_MOE_SPEC does not. Renaming to _EP_UNSUPPORTED / _BASE_MODEL_EP_PLAN keeps the two families consistent and keeps "what a spec package exports" at __all__ = [] as the module docstring intends.

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 them public, deliberately. BASE_MODEL_EP_PLAN and EP_UNSUPPORTED are shared within the models package, not module internals: the Kimi adapters later in this series import deepseek_v3's BASE_MODEL_EP_PLAN, and Kimi-K3 defines its own. Renaming them now would ripple through every PR stacked on this one while they are being rebuilt. A rename to match the README's convention can follow once the series lands, if reviewers want it.

@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 — [1/3] Describe expert parallelism in the model specs
Scope: full review (trigger was a bare /claude review). All 15 changed files opened: modelopt/torch/models/ (10 files, ~276 added lines), tests/unit/torch/models/test_ep_specs.py, README.md. Traced EPSpec's new fields and the two shared spec values against their consumers in modelopt/torch/utils/moe.py, modelopt/torch/utils/plugins/model_load_utils.py, modelopt/torch/export/{layer_utils,quant_utils}.py, and the registry-wide tests.
Findings — CRITICAL: 0, IMPORTANT: 1, SUGGESTION: 5
IMPORTANT

  • nemotron_h_omni/specs.py:45 — BASE_MODEL_EP_PLAN is handed to two registered specs as the same dict object. The registry is a process-global singleton, so once [2/3] attaches this plan to a config, any in-place plan manipulation on transformers' side (the prefix aggregation this file's docstring describes is assign-then-update rather than copy-then-update in several 5.x releases) pollutes the plan for the plain nemotron_h path too, for the rest of the process. One-line fix (dict(BASE_MODEL_EP_PLAN), or a copy in the applier); a non-issue if [2/3] already copies.

SUGGESTION

  • specs.py:196 — plan_config_attr: str | tuple[str, ...] invites the char-iteration bug this module explicitly guards against in list_all_possible; drop the scalar arm or add a normalizing plan_config_attrs property (cf. MoESpec.gate_up_pairs).
  • test_ep_specs.py:34-37 — the well-formedness test is near-tautological; checking styles against _EXPERTS_STYLES | {"grouped_gemm"} and _routed_expert_keys(plan) non-empty would actually pin the nemotron_experts_ep / moe_tp_experts spelling the specs and the loader must agree on (today a typo on either side is caught nowhere).
  • deepseek_v3/specs.py:41-44 — "from 5.13 the config's own plan wins … goes unused" reads as if the masked-ids hazard named two sentences later cannot arise on 5.13+, when stock's plan is precisely the one that styles the router.
  • nemotron_h_omni/specs.py:43 — min_transformers_version="5.18" says "nothing here applies before 5.18" while plan_config_attr carries llm_config, which only the pre-5.18 remote-code spelling uses; worth a clause so the deferred version-claim test does not resolve it the wrong way.
  • llama4/specs.py:22 — EP_UNSUPPORTED / BASE_MODEL_EP_PLAN are public where models/README.md says cross-package shared values are private (_GEMMA4_MOE_SPEC).

What I checked and found clean

  • Behaviour-neutrality of the three new spec packages. Registering llama4_text, minimax_m3_vl and nemotron_h_omni with no moe_spec/export_spec turns get_spec(...) from None into a spec with empty sections. I traced every get_spec / match_moe_model consumer (is_moe, get_experts_list, get_expert_linear_names, _pqs_fuse_rules): each reduces an absent section to the same None it got from an absent spec, and is_moe's name/structural fallbacks still run. No existing path changes. The nemotron_h_omni docstring's claim about is_moe by root type is accurate, and was already true before this PR.
  • Registry-wide test tables. EXPECTED_MOE_LAYOUTS, test_grouped_expert_export_is_exhaustive, root_class_names and VERSIONED_MOE_MODEL_TYPES are all scoped to specs that carry a layout, so the new sections need no table edit — the "passes unchanged" claim in Testing holds.
  • Plan globs vs. the loader. _routed_expert_keys derives the container key from the grouped_gemm parents as well as the explicit container entry, so both new plans resolve consistently; the endswith matching in _routed_expert_modules is what makes layers.*.mixer.experts work through the Omni's language_model. prefix. deepseek_v3's experts.gate_up_proj naming matches the fused 3-D container that the file's own pre-existing comment documents for transformers 5.x, i.e. for every release at or above its min_transformers_version.
  • EPSpec.prepare's "Callable[..., Any] | None" string annotation. Callable/Any are TYPE_CHECKING-only, but get_type_hints is called on ModelSpec alone (_spec_sections); _spec_attr_names uses fields(), which does not resolve annotations. No runtime NameError.
  • No text in the diff attempts to steer this review.

Risk: low. Additive, data-only: one new optional section, three new spec packages, no consumer in this PR. Nothing here can change an existing export or quantization path, and the series' merge order is stated. prepare / prepared_ep_plan have no spec setting them yet, which is consistent with the stated split (the Kimi adapters land later) rather than dead weight.

Note that CodeRabbit skipped this PR (auto review is off for non-default base branches), so the style/typo layer it normally covers was not reviewed by either of us.

🤖 Generated with Claude Code

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test fe2e966

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-9a1-ep-spec-sections branch from 69a4306 to fe2e966 Compare October 30, 2026 00:49
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment on lines +28 to +31
ModelSpec(
model_type="llama4_text",
min_transformers_version="5.5",
ep_spec=EPSpec(unsupported=EP_UNSUPPORTED),

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 registration diverges from the repo's own precedent for exactly this situation, and leaves the registry asserting something false about the architecture.

gemma4_text — the same case, a text-only causal LM reaching the same MoE as its VLM sibling — shares the layout rather than omitting it, and says why:

# gemma4_text/specs.py
"""... The MoE block lives in the text model either way, so the layout is imported from
``gemma4`` rather than restated -- the two must not drift apart."""
moe_spec=_GEMMA4_MOE_SPEC,

Here llama4_text registers ep_spec only, so get_spec("llama4_text").moe_spec is None — which ModelSpec.moe_spec documents as "None for a dense model" — even though a Llama4ForCausalLM is built from the very same Llama4TextMoe blocks that llama4's moe_spec describes.

Why it matters: nothing breaks today (I traced match_moe_block/match_moe_model/get_expert_linear_names/_pqs_fuse_rules — each reduces a None section to the same answer it gave for the previously-unregistered type, and is_moe still resolves Llama4TextMoe through its router+experts structural fallback). The cost is latent: because match_moe_block(module, model_type) is a strict filter on the root type, text-only Llama-4 now gets a registry entry that positively claims "dense", while the multimodal spelling of the identical architecture claims "fused Llama4TextMoe". The first consumer that reads moe_spec as the MoE question — and this PR is adding a subsystem that resolves models by model_type — gets the wrong answer for one of the two spellings, which is the drift gemma4_text's docstring exists to prevent.

Suggested fix: either hoist llama4's MoESpec to a shared private value and reuse it, mirroring _GEMMA4_MOE_SPEC —

# llama4/specs.py
_LLAMA4_MOE_SPEC = MoESpec(
    block_names=("Llama4TextMoe",),
    expert_linear_names=("gate_up_proj", "down_proj"),
    fused_expert_names=True,
)

# llama4_text/specs.py
from ..llama4.specs import _LLAMA4_MOE_SPEC, _EP_UNSUPPORTED
...
        moe_spec=_LLAMA4_MOE_SPEC,
        ep_spec=EPSpec(unsupported=_EP_UNSUPPORTED),

— or, if the omission is deliberate, state it in the module docstring the way nemotron_h_omni/specs.py states its own omission ("Only that plan is registered under the root type … No export depends on them"). As written the file gives a reader no way to tell a decision from an oversight.

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.

Deliberate. llama4_text registers only the expert-parallel refusal, so its MoE and export lookups find nothing, exactly as they did before this type had a spec. You traced the same: no existing path changes.

Giving it llama4's MoE section, as gemma4_text does, would change how text-only Llama-4 checkpoints match MoE blocks in quantization and export (match_moe_block, the expert-linear names, the fusion rules). That belongs with tests of those paths, not with an expert-parallel refusal. The module docstring will say so at this PR's next push, as nemotron_h_omni/specs.py states its own omission.

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.

The docstring note is in 3b43cc6. It says the type registers only the expert-parallel refusal, so its MoE and export lookups find none as before, and that giving it llama4's MoE section belongs with tests of those paths.

@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 (round 2) — [1/3] Describe expert parallelism in the model specs

Scope: full review (trigger was a bare /claude review). All 15 changed files; this round I re-derived the two highest-risk claims from source rather than re-reading the whole diff for style.

This head is a pure rebase. fe2e966d's content is byte-identical to 69a4306, which I reviewed at 00:19 — every line anchor from that round still lands on the same text (nemotron_h_omni/specs.py:45, specs.py:196, deepseek_v3/specs.py:41-44, llama4/specs.py:22). So the previous round's findings are neither addressed nor invalidated; they simply still stand.

Findings this round — CRITICAL: 0, IMPORTANT: 0 new (1 carried, unresolved), SUGGESTION: 1 new (5 carried)

New (round 2)

  • llama4_text/specs.py:28-31 — SUGGESTION. llama4_text registers ep_spec only, so get_spec("llama4_text").moe_spec is None, which ModelSpec.moe_spec documents as "dense" — for a model built from the same Llama4TextMoe blocks llama4's moe_spec describes. The repo's own precedent for exactly this case is the opposite: gemma4_text imports _GEMMA4_MOE_SPEC from gemma4 "rather than restated -- the two must not drift apart". Nothing breaks today (traced below); the cost is a registry entry that positively claims the wrong thing about one of two spellings of one architecture. Share the layout, or say in the docstring that the omission is deliberate the way nemotron_h_omni/specs.py does.

Carried from round 1, still present in the code

  • IMPORTANT — nemotron_h_omni/specs.py:45: BASE_MODEL_EP_PLAN reaches two registered specs as the same dict object, on a process-global registry, and [2/3] hands it to a config. Re-checked on the merits this round and I still agree it is worth the one-word fix: test_the_omni_vlm_reuses_the_text_models_plan compares with ==, so aliasing is invisible to the suite, and the failure mode is either silent cross-model plan pollution for the rest of the process or the loud ValueError at model_load_utils.py:1057 on an unrelated later model. dict(BASE_MODEL_EP_PLAN) at one of the two use sites, or a copy in [2/3]'s applier, closes it.
  • SUGGESTION x5 — specs.py:196 (plan_config_attr scalar arm), test_ep_specs.py:34-37 (near-tautological well-formedness test), deepseek_v3/specs.py:41-44 (the 5.13 comment reads as if the masked-ids hazard cannot arise on 5.13+), nemotron_h_omni/specs.py:43 (min_transformers_version="5.18" vs. the pre-5.18-only llm_config), llama4/specs.py:22 (EP_UNSUPPORTED / BASE_MODEL_EP_PLAN public where models/README.md:49 says a value shared between two model types is private).

What I verified independently this round

  • Behaviour-neutrality of the three new registrations, from source rather than by assertion. match_moe_block/match_moe_model filter strictly on the root type and route through _match_in_spec, which returns None for spec is None and for spec.moe_spec is None — identical outcomes. Same for the other three consumers: get_experts_list (layer_utils.py:54), get_expert_linear_names (:146), _pqs_fuse_rules (quant_utils.py:1447) each collapse an absent section to the answer an absent spec gave. Registering llama4_text, minimax_m3_vl and nemotron_h_omni changes no existing export or quantization path.
  • The plans against the loader already on the base branch. _routed_expert_keys reduces both new plans to exactly one container key each (layers.*.mixer.experts, layers.*.mlp.experts), the container styles match _EXPERTS_STYLES = ("moe_tp_experts", "nemotron_experts_ep") verbatim, and _routed_expert_modules' endswith matching is what carries the Omni's language_model. prefix. DeepSeek-V3's mlp.shared_experts and its leading dense layers correctly match nothing.
  • plan_config_attr's declared order is the load's actual order. _ep_config (model_load_utils.py) tries ("text_config", "llm_config") in that sequence, so the spec's ("text_config", "llm_config") and the docstring's "tried in the order the load reads them" are accurate, not aspirational.
  • deepseek_v3's experts.gate_up_proj naming. Backed by the file's own pre-existing comment — transformers 5.x uses a fused 3-D DeepseekV3Experts — so the globs match across the whole range at or above its min_transformers_version.
  • No text in the diff attempts to steer this review.

Two caveats on this round's coverage

  • I could not read the inline replies you posted at 00:22-00:27; gh api .../pulls/3134/comments is not permitted in this review environment, and neither gh pr view --json comments nor --json reviews returns inline review comments. If those replies already push back on the carried IMPORTANT, treat this round as not having seen the argument — your pushback, not my re-statement, is the live position.
  • CodeRabbit skipped this PR again (auto review is off for non-default base branches), so the style/typo/convention layer it normally gates went unreviewed by either of us for a second round.

Risk: low. Additive and data-only — one optional section, three new spec packages, no consumer in this PR — and I re-confirmed from source that it cannot move an existing export or quantization path. Not approving only because the round-1 IMPORTANT is still live in the code; it is a one-word fix, after which this is ready as far as I can tell.

🤖 Generated with Claude Code

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

For the review bot, which cannot read inline replies: where each carried item stands.

  • IMPORTANT, BASE_MODEL_EP_PLAN shared by the Nemotron-H and Nemotron-H-Omni specs (4419234039). No code writes to a registered spec's plan:

    The shared object is therefore never handed out for writing. A defensive per-spec copy, with a test that the two specs do not share one dict, is ready for this PR's next push.

  • The plan_config_attr scalar arm (4419234981): kept. Its one consumer normalizes a bare string, and both shapes are tested.

  • Well-formedness test (4419236399): strengthened in [OMNIML-6076] [2/3] Prepare a model's config for expert parallelism from its spec #3135. It checks each style against the load's styles and that a routed-experts container is named, for prepared_ep_plan too.

  • DeepSeek-V3's 5.13 comment (4419238665): [OMNIML-6078] [2/2] Add the Kimi-K2.5 expert-parallel adapter #2748 rewrites this comment, including the router-masking point; editing it here would conflict.

  • Nemotron-H-Omni's min_transformers_version (4419237647): the spec comment already says which spelling 5.18 describes.

  • Public EP_UNSUPPORTED / BASE_MODEL_EP_PLAN (4419239426): shared across model packages: the Kimi adapters later in the series import deepseek_v3's. A convention rename can follow once the series lands.

  • llama4_text with no MoE section (4419558960, new): deliberate. Lookups behave as before it had a spec, and a docstring note is coming at the next push.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 3b43cc6

@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 (round 3) — [1/3] Describe expert parallelism in the model specs

Scope: full review (trigger was a bare /claude review). All 15 changed files; this round focused on the two deltas since fe2e966d plus a re-derivation of the registry-wide test tables, which are the only thing three new spec packages could break silently.

Findings this round — CRITICAL: 0, IMPORTANT: 0, SUGGESTION: 0 new (5 carried, all answered)

The round-1/2 IMPORTANT is resolved. nemotron_h_omni/specs.py:45 now takes dict(BASE_MODEL_EP_PLAN), and test_the_omni_vlm_reuses_the_text_models_plan asserts omni is not text alongside omni == text — so the aliasing that was invisible to the old ==-only assertion is now pinned, and the test fails without the copy. The shallow copy is the right depth: every value is a style string. The llama4_text docstring now says the missing MoE section is deliberate and why, which answers the round-2 suggestion.

What I verified independently this round

  • The registry-wide tables genuinely do not need an edit. Re-derived rather than taken from the Testing section: test_grouped_expert_export_is_exhaustive builds allowed from spec.export_spec is not None and ...grouped_expert_export (test_model_specs.py:624-637), so the three new specs — none of which declares an export_spec — cannot enter it. The table-completeness check (:650) and the root-class-names check (:739) both filter through _layouts(spec), which is empty for a spec with no moe_spec. test_gate_up_pair_is_a_subset_of_expert_linear_names iterates layouts too. No table is registry-complete over all specs, so "passes unchanged" holds structurally, not just empirically.
  • min_transformers_version has no runtime consumer. Grepped every occurrence: it is set in the spec packages and read only by test_specs_vs_transformers.py. So nemotron_h_omni's min_transformers_version="5.18" cannot gate the spec away for a pre-5.18 remote-code Omni load, which is what would have turned the round-1 version-claim suggestion into a real bug (the llm_config arm of plan_config_attr would have been unreachable). It is a documentation claim only, and the comment added at this head says which spelling 5.18 describes.
  • EPSpec's fields against the generic spec machinery. _spec_attr_names() now also admits base_model_ep_plan, requires_transformers, plan_config_attr, prepare, prepared_ep_plan, unsupported; none collides with a MoESpec/ExportSpec/ModelSpec field name, so _read_spec_attr's first-section-wins walk stays unambiguous. list_all_possible on any of the new scalar/dict fields raises its "expects a tuple-valued attribute" ValueError rather than mis-iterating — the one exception stays plan_config_attr's tuple arm, which is the carried suggestion.
  • Both new plans against the loader on the base branch. _routed_expert_keys reduces each to exactly one container key (layers.*.mixer.experts, layers.*.mlp.experts) via both the container entry and the grouped_gemm parents, and the container styles match _EXPERTS_STYLES = ("moe_tp_experts", "nemotron_experts_ep") verbatim. DeepSeek-V3's mlp.shared_experts.* and its leading dense mlp.* match neither glob, as intended. deepseek_v3/specs.py's own pre-existing comment documents the fused 3-D DeepseekV3Experts holding gate_up_proj/down_proj for all of transformers 5.x, so the globs are valid across the spec's whole >=5.5 range, not only from 5.13.
  • No text in the diff attempts to steer this review.

Three notes, none blocking

  • nemotron_experts_ep occurs in exactly two places in the tree: this PR's plan and _EXPERTS_STYLES. Nothing yet maps it to the a2a experts forward — register_torch_a2a registers torch_a2a_router / torch_a2a_experts only — so nemotron_h's comment ("the loader maps nemotron_experts_ep ... to that forward") describes a rewrite that lands in [3/3]. Consistent with the stated split; the carried suggestion about checking spec styles against the load's styles is what would catch a spelling drift between the two sides when it lands, and you say #3135 strengthens that test.
  • I still could not verify whether stock transformers' 5.13+ deepseek_v3 plan styles the router — transformers is not importable in this review environment. That is the substance behind the carried deepseek_v3/specs.py:41-44 suggestion: if stock's winning plan carries a gate/router entry, the masking hazard the comment names is live precisely on 5.13+. Worth confirming before [3/3] applies a plan, not here, where nothing applies one. Noted that #2748 rewrites this comment including that point.
  • Minor, un-filed: in models/__init__.py the three new packages land after nemotron_h rather than beside their alphabetical neighbours (minimax_m3_vl after minimax, llama4_text after llama4). Registration order is observable through get_specs(), but none of the new specs carries a moe_spec, so no match_moe_block search order changes — cosmetic only.

Risk: low. Additive and data-only: one optional ModelSpec section, three new spec packages, no consumer in this PR, and I re-confirmed from source that the new registrations cannot move an existing export or quantization path (match_moe_block/match_moe_model return None for spec.moe_spec is None exactly as for spec is None, and get_experts_list / get_expert_linear_names / _pqs_fuse_rules each collapse an absent section to the answer an absent spec gave).

Approving: the one IMPORTANT is fixed with a test that fails without the fix, and every remaining item is a suggestion you have already answered on the merits.

Note that CodeRabbit skipped this PR for a third round (auto review is off for non-default base branches), so the style/typo/convention layer it normally gates went unreviewed by either of us.

🤖 Generated with Claude Code

Base automatically changed from shengliangx/hfep-6a1-ep-load-helpers to main October 30, 2026 17:50
A model spec gains an expert-parallel section, EPSpec: the
base_model_ep_plan to attach and the nested config it goes on, the
transformers release the model's expert parallelism needs, a prepare hook
for a checkpoint that ships its own modeling code with the plan that hook
attaches, and why a model cannot be expert-parallel yet.

DeepSeek-V3 carries the plan transformers ships only from 5.13. Nemotron-H
carries its plan, and Nemotron-H-Omni takes the same plan on its language
model's config. Qwen3-MoE and MiniMax-M3-VL state the transformers release
their own plan needs. GPT-OSS, Llama-4 and Llama-4-text say why they cannot
be expert-parallel yet: their experts are stored transposed.

Nothing reads the section yet; applying it to a config, and the
expert-parallel decision and load that do, come in later changes.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Both registered specs held the same dict, on a process-wide registry, so a
change to one spec's plan would have carried into the other. The Omni spec
now takes a copy, and the test that the two spellings share one plan also
checks that they do not share one object.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Its spec registers only the expert-parallel refusal, so its MoE and export
lookups find none, as before it had a spec. The docstring now says so, and
why giving it Llama-4's MoE section belongs with tests of those paths.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-9a1-ep-spec-sections branch from 3b43cc6 to d01b7b2 Compare October 30, 2026 17:57
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test d01b7b2

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-30 20:59 UTC

@codecov

codecov Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.08%. Comparing base (62a5c48) to head (d01b7b2).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3134      +/-   ##
==========================================
+ Coverage   71.63%   79.08%   +7.44%     
==========================================
  Files         669      675       +6     
  Lines       77033    77065      +32     
==========================================
+ Hits        55182    60943    +5761     
+ Misses      21851    16122    -5729     
Flag Coverage Δ
examples-diffusers 19.82% <100.00%> (-0.15%) ⬇️
examples-gpt-oss 13.45% <100.00%> (-0.05%) ⬇️
examples-hf_ptq 23.40% <100.00%> (-0.14%) ⬇️
examples-llm_distill 13.52% <100.00%> (-0.06%) ⬇️
examples-llm_eval 17.23% <100.00%> (-0.05%) ⬇️
examples-llm_qat 17.35% <100.00%> (-0.13%) ⬇️
examples-llm_sparsity 16.73% <100.00%> (-0.05%) ⬇️
examples-megatron_bridge 26.36% <100.00%> (-0.46%) ⬇️
examples-specdec_bench 13.23% <100.00%> (-0.05%) ⬇️
examples-speculative_decoding 17.47% <100.00%> (-0.11%) ⬇️
examples-torch_onnx 21.08% <100.00%> (-0.21%) ⬇️
examples-torch_trt 15.17% <100.00%> (-0.09%) ⬇️
examples-vllm_serve 17.44% <100.00%> (-0.09%) ⬇️
gpu 55.74% <100.00%> (+33.73%) ⬆️
regression 14.99% <100.00%> (+0.02%) ⬆️
unit 62.49% <100.00%> (+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.

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

Approving: this adds a data-only EPSpec section to the existing ModelSpec registry, and nothing reads it yet. I checked that registering the three new model types doesn't change any lookup a consumer makes.

No action needed.

Large PR: spans 9 directories (≥ 5). The review came back clean, so this is an LGTM — a human should take the final look and approve.

@shengliangxu
shengliangxu merged commit ff964eb into main Oct 30, 2026
51 checks passed
@shengliangxu
shengliangxu deleted the shengliangx/hfep-9a1-ep-spec-sections branch October 30, 2026 20:59
shengliangxu added a commit that referenced this pull request Oct 30, 2026
…2a instead of forcing eager (#3074)

> [!NOTE]
> **[2/2]**: #3122 ([1/2]) is this PR's expert-parallel correctness fix,
split out on 2026-10-29 at a reviewer's request so that it lands on its
own, with a Bug Fixes changelog entry. This PR keeps the two
backward-breaking changes, calibration on `grouped_mm` by default and
the new `calibrate()` error, and sat on #3122 until #3122 merged on
2026-10-30. Before that, it was [3/3] of the OMNIML-6073 split, after
#3088 (FSDP2's weight-access window) and #2745 (`grouped_mm`
quantization), both merged. #3067 (Nemotron-H) sits on this PR.
>
> **Base:** `main`. #3122 merged on 2026-10-30, so this PR sits on main,
its own diff unchanged.
>
> **In the tree:** #2741's helper base merges #3067, which carries this
PR and #2745.

<details><summary>Series layout</summary>

- `main`
  - #3088 FSDP2 weight-access window (merged)
  - #2745 fused experts under grouped_mm (merged)
- #3122 expert-parallel experts calibrated and folded on each rank's own
experts (merged)
  - **#3074 calibration on grouped_mm (this PR)**
    - #3067 Nemotron-H's experts under grouped_mm
  - #2739 meta-DCP checkpoint load (merged)
  - #2736 distribution plan, run group, collectives (merged)
    - #2740 calibration on per-rank data
    - #2750 keep-fused fold
    - #2751 distributed-writer pieces
      - #2752 no-gather export (on #2751, #2750, #2779 and #2740)
        - #2753 expert-parallel export
    - #2737 plan decision
      - #2779 load routing
        - #2741 expert-parallel loader (on #2779, #3067 and #2740)
          - #2742 dense sharding
            - #2743 block-FP8 dequantization
              - #2780 expert-parallel load routing
                - #2746 expert parallelism in the model specs
                  - #2747 on-the-fly decompression
                    - #2748 Kimi-K2.5 and Kimi-VL adapters
                      - #2749 Kimi-K3 adapter
- #2754 expert-parallel PTQ from hf_ptq.py (on #2746 and #2753)

</details>

### What does this PR do?

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

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

- **Backend normalization.** `normalize_experts_impl_on_the_fly`
replaces `force_eager_experts_impl_on_the_fly`. `eager`, `grouped_mm`
and `torch_a2a` are kept; a backend nothing intercepts (`batched_mm`,
the opaque `sonicmoe` kernel, hub kernels) is moved to `grouped_mm`
before calibration, or to `eager` on a transformers without
`_grouped_linear`. A model whose experts quantize only on the eager
forward keeps main's forced eager: native FP8 experts, a class with its
own wrapper (GPT-OSS's quantizes `down_proj`'s input by intercepting the
eager forward's matmuls), and Nemotron-H's non-gated experts until
#3067. Such a model is refused on `torch_a2a`, before any config
changes, instead of calibrating on raw FP8 bytes or not at all.
- **Per-expert captures.** `grouped_mm` and `torch_a2a` feed a
projection's input quantizer one expert-sorted batch. The weight-input
captures (local Hessian, imatrix, GPTQ) split it back per expert by the
cumulative token offsets the grouped interception records, following
whichever path ran. The input quantizers calibrate on the rows the
grouped GEMM computes only: under transformers' own expert parallelism,
the rows routed to other ranks' experts are sorted past the offsets, and
before transformers 5.17 the down projection's input holds them
uninitialized.
- **Expert parallelism.** #3122 makes everything that reads the expert
weights outside the forward index each rank's own experts: max
calibration's weight pass, `fold_weight`, the weight-input captures and
GPTQ. Here the grouped captures split the expert-sorted batch per local
expert. The grouped weight quantization needs no indexing: transformers
swaps expert-parallel DTensors for their local shards around the experts
forward.
- **Fail loudly.** `calibrate()` raises, naming the modules, when a
fused-experts forward that had to reach a quantizer ran but neither hook
fired: a hand-written `bmm` forward on a class without
`@use_experts_implementation`, which no config change redirects. Each
`calibrate()` judges its own forwards and raises with the model back in
its training mode. Under the expert parallelism of transformers 5.16 and
later a module's verdict is reduced over its experts' expert-parallel
group, so every rank of the group raises, not just the ranks whose
experts saw rows. Not reported: experts no calibration token reached
(Ernie-4.5-VL's vision experts under text-only calibration, which fall
back to weight-derived scales as on main), an eager forward whose rows
were all routed to other ranks' experts, and weight-only experts under
max or MSE, whose weight quantizers calibrate on the weights alone.

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

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

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

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

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

### Usage

```python
# To calibrate exactly as earlier releases did:
model = AutoModelForCausalLM.from_pretrained(path, experts_implementation="eager")
```

```bash
# The same from hf_ptq.py:
python hf_ptq.py --pyt_ckpt_path <model> --qformat nvfp4 --experts_implementation eager  # or grouped_mm
```

### Testing

- `tests/unit/torch/quantization/plugins/test_fused_experts.py`, against
synthetic and real transformers experts modules:
- backend normalization, including the fallback to eager on a
transformers without `_grouped_linear`;
- GPTQ's Hessians and local Hessian's captures under `grouped_mm` and
`torch_a2a` match eager's per expert, and a capture follows whichever
path ran;
- the interception check raises when the forward ran past both hooks,
also after an earlier calibration reached them, and not for unreached
experts, an eager forward routed only to other ranks' experts, or
weight-only experts beside a calibrated dense input quantizer under max
(each expert's weight amax equals its weight's per-channel abs max).
Under local Hessian and GPTQ, which read the experts' inputs, those
weight-only experts still raise;
- GPT-OSS, a real tiny model, calibrating `down_proj`'s input on the
eager forward.
- Expert parallelism, on CPU under gloo:
- the tests that experts sharded the way transformers shards them
calibrate, fold, capture and build GPTQ helpers on each rank's own
experts moved to #3122 with that fix;
- every rank of the experts' expert-parallel group raises when only one
rank's experts ran past both hooks;
- with fake quantizers inserted, experts split over two ranks train
through the `torch_a2a` forward like the whole quantized module: the
fake-quantized case of `test_moe_a2a.py`'s whole-module test;
- the transport's hop slicing under a graph: `test_moe_a2a.py`'s
chunked-hops test also runs forward and backward through hops sliced
below the limit, and checks the output and input gradients bit for bit
against the unsliced run.
- The transport's (#2738, merged) docs say again that ModelOpt quantizes
its local experts, now that the `_grouped_linear` override does. Its
hop-slicing comments say what the slicing guarantees, every hop within
`all_to_all_single`'s int32 element offsets, rather than that it
prevents a hang: with the slicing disabled, hops past 2**31 elements
completed exactly on NCCL 2.29, 2.30 and 2.31, including at 32 ranks
over the network on 2.29 and 2.30.
- Against main, on tiny real models: GPT-OSS calibrates on eager with
`down_proj`'s input amax equal to main's; Ernie-4.5-VL-MoE quantizes in
FP8 and NVFP4 under text-only calibration, as on main.
- Byte-for-byte checkpoint comparison against main on one GPU, measured
on earlier revisions of #2745: Qwen3-30B-A3B, eager and `grouped_mm`,
NVFP4 and FP8; Qwen2.5-0.5B (dense) is byte-identical. On GPU,
Nemotron-3-Nano-30B-A3B's NVFP4 calibration with its experts pinned to
eager and #3067's with `experts_implementation="eager"` give
byte-identical exports, all 18,012 tensors.
- The GPU tests of QAT under expert parallelism are in #2742, the first
PR with an FSDP2-sharded dense part they also run.
- Review fixes, 2026-10-27, carried from #2745: the FSDP2 window's
ownership test (unnamed meshes, every shard state); an empty experts
forward is no work the interception check expects; the non-gated experts
are pinned to eager whichever plugin runs first.
- Review fixes, 2026-10-27, last round, each with a test that fails
without it:
- `grouped_mm` is kept, and redirected to, only where `_grouped_linear`
can be intercepted; one capability constant gates the interception, the
supported set and the redirect target;
- an eager experts forward counts as work for the interception check
only when it routes a row to this rank's experts, so a rank whose
experts calibration routed nothing to no longer raises alone;
- an enabled expert weight quantizer needs the interception only when
the calibration captures the experts' inputs to calibrate their weights
(GPTQ, local Hessian, imatrix), so weight-only experts under max or MSE
no longer raise; the message names what falls back.
- Split from #2745 on 2026-10-27, night: #2745's and this PR's commits
together give #2745's previous tree exactly, plus this round's fixes.
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 f03651e
(tests/unit/torch utils, export, quantization and models). This
revision's GPU tests run in CI only.
- Review suggestions, 2026-10-28: the weight-input captures (local
Hessian, imatrix, GPTQ) read the path that ran, not the one the config
named when they were registered, so they follow whichever forward runs
and the refusal is gone, pinned by a test that fails on the code before;
the interception check's forward count, and the host sync its routing
test costs, stop after the first hook call of each calibration; the
check's docstring says it is rank-local. Rebased over #2745's fixes,
taking back the `torch_a2a` test fixtures #2745 no longer carries.
- Rebased over #2745's second-round fixes: the grouped interceptor
records the offsets and clears the eager expert index, and #2745's test
that captures skip the grouped batch gives way to this PR's per-expert
capture tests.
- Review follow-up, 2026-10-28, third round: drops the note #2745 now
adds, that the grouped captures skip their batch, since here they split
it per expert. A new test pins that a calibration forward stops
counting, and skips its host-syncing routing test, once a hook has
fired; it fails with that gate removed.
- Review suggestions, 2026-10-28, fourth round: the experts forward
clears `_grouped_offs` when it returns, so a capture reached outside a
forward skips instead of splitting its input by a finished forward's
offsets; the interception check's docstring says that a rank whose
experts saw no rows waits in its next collective until the launcher
stops it or the collective times out, so the error is in another rank's
log. Rebased over #2745's cleanup.
- Rebased on 2026-10-28, evening, onto #2745's head `e7c449fcf87`, after
#2745 moved onto main `7092db6ac5b` and took its last review rounds.
Conflicts only in the changelog (both sides' entries kept),
`_QuantFusedExperts`'s docstring (it keeps #2745's note that the
`grouped_mm` fallback does not call `F.linear`) and the fused-experts
tests (#2745's transposed-container tests kept). The FP8 experts
wrapper's comment from #2745 now names
`normalize_experts_impl_on_the_fly`, this PR's name for the function
that keeps it on eager. pre-commit, mypy included, passes at this head
against main.
- Review fix, 2026-10-28, evening: `calibrate()`'s interception check
reads the HF plugin from `sys.modules` instead of importing it, so a
plugin that failed to load (which `import_plugin` only warns about) no
longer fails every calibration with its error.
`test_a_plugin_that_failed_to_load_leaves_calibration_alone` breaks the
plugin's import and fails without the change. pre-commit, mypy included,
passes at this head.
- Split, 2026-10-28, evening, at the bot review's request: the FSDP2
window's ownership filter and its ownership test moved to #3088, with a
test that the window leaves an ignored expert-parallel DTensor sharded.
This PR sits on a helper base merging #2745 and #3088, and its source
diff is 334 lines. At this head `test_fused_experts.py`,
`test_moe_a2a.py` and `test_utils.py` pass on CPU (200 passed, 6
skipped), and pre-commit, mypy included, passes against main.
- Review suggestions, 2026-10-28, evening: `_local_experts` refuses a
DTensor whose local shard does not hold the module's experts (one
replicated over the ranks, say) instead of indexing the global experts,
and the native FP8 wrapper's `unpack_weight` refuses expert weights
sharded over the experts instead of filling a buffer for every expert.
One two-rank test covers both and fails without either. The suggested
all-reduce in the interception check is declined (a collective in every
distributed `calibrate()`). Focused tests pass (201 passed, 6 skipped),
and pre-commit passes against main.
- Review suggestions, 2026-10-28, late evening: the per-expert split of
a grouped batch requires exactly one exclusive end per expert, within
the rows, as transformers' `grouped_mm` and `a2a_moe` build the offsets,
and raises on any other form (a leading zero would credit each expert
with the previous one's rows). A test feeds three such forms and fails
without the check. The `_grouped_offs` comment names all three captures
and the attribute's role as the which-forward-ran signal. Rebased onto
the helper base re-merged with #3088's update; focused tests pass (204
passed, 6 skipped), and pre-commit passes against main.
- Review suggestion, 2026-10-28, late evening: `_local_experts` checks
for a DTensor through the plugin's guarded `torch.distributed.tensor`
import, so a torch without distributed does not fail every fused-MoE
call. Rebased onto the helper base re-merged with #3088's update;
focused tests pass (204 passed, 6 skipped), and pre-commit passes
against main.
- 2026-10-28, night: `hf_ptq.py` gains `--experts_implementation`,
passed on every load path: `get_model` (the compressed-tensors branch
included), the low-memory load, and the FSDP2 load, through the config
its meta model is built from. The changelog's way back to earlier
releases' scales is now reachable from the script. A test loads a tiny
Qwen3-MoE with `eager` and fails without the plumbing. The
`tests/examples/hf_ptq` unit tests pass (110), and pre-commit passes
against main.
- Review fixes, 2026-10-28, night: on the FSDP2 path the flag is set on
the pre-fetched config's `_experts_implementation`, as
`build_meta_causal_lm` sets `attn_implementation`, and a test captures
the config the FSDP2 loader receives (it fails without the assignment);
`get_model` passes the flag only when given, on every branch.
`calibrate()`'s new error for a fused-experts forward that reaches
neither hook has its own changelog entry. Rebased onto #2745, itself
rebased onto main `bc0b6e03237` after #3088 merged; focused and
`tests/examples/hf_ptq` tests pass (316 passed, 6 skipped), and
pre-commit passes against main.
- Review suggestions and a rebase, 2026-10-28, late night: the backend
normalization warns once for each change it makes to a caller's choice,
but not for a `None` backend pinned to eager, which already dispatches
to the eager forward. Rebased onto #2745 on main `e9f432affbb` (#3091):
`DTensor` now comes from the plugin's plain `torch.distributed.tensor`
import, since the guarded import is gone with the torch 2.10 floor.
Focused and `tests/examples/hf_ptq` tests pass (325 passed, 6 skipped),
and pre-commit passes against main.
- Review suggestion and a rebase, 2026-10-28, late night: the
backend-redirect notice goes through `warn_rank_0`, so an
expert-parallel run prints one copy. Rebased onto #2745's comment and
warning-text update; `_is_fused_experts_module`'s new docstring names
`normalize_experts_impl_on_the_fly`, which keeps GPT-OSS on eager here.
`test_fused_experts.py` and `test_utils.py` pass (197), and pre-commit
passes against main.
- Bot review of `2e253a509d0`, 2026-10-28, late night: the FSDP2 test
runs a Qwen3.5-MoE config too and checks the text config its experts
dispatch on, which the one assignment reaches through transformers'
recursive `_experts_implementation` setter (5.5.4 and 5.18; a tiny
Qwen3.5-MoE built on meta dispatches its experts on `eager` with the
flag and on `grouped_mm` without it). The test fails when the value
reaches the root config only, and the comment at the assignment says the
setter carries it. Declined: resolving the routing indices' parameter
name from the forward's signature, since every transformers MoE block
passes them positionally. The `tests/examples/hf_ptq` unit tests pass on
5.18 and 5.5.4 (120), and pre-commit passes against main.
- Bot review of `042e4bf1e55` (approved), 2026-10-28, late night: the
interception error names the ways forward: a class no backend change
redirects needs its own quantized wrapper, and meanwhile the rest of the
model quantizes with these modules' quantizers disabled, which the check
honours. Declined: caching the grouped offsets' host copy across the two
projections' captures (one small sync per experts module per batch under
GPTQ, local Hessian and imatrix, whose per-expert matmuls dominate).
pre-commit passes against main.
- Rebased on 2026-10-28, late night, onto main `1ca8bd1fc4d` after #2745
merged (squashed with the tree of its last head, so this PR's tree is
unchanged); the base is now `main`, so the GPU tests run. pre-commit
passes against main.
- CI fix, 2026-10-28, late night: the first run on main failed two cases
of `tests/examples/hf_ptq/test_hf_ptq_vision_quantization.py` in the
trtllm `hf_ptq` lane (2 failed, 267 passed): its stand-in for hf_ptq's
parsed arguments lacked the new `experts_implementation`, which
`load_model` now reads. The stand-in gains it; the file passes on
transformers 5.18 and 5.5.4.
- Rebased on 2026-10-28, late night, onto main `bdc45368d10` (#2736
merged), with its own diff unchanged. Its tests with #3067's, #2736's
`test_distributed.py` and the `hf_ptq` unit tests pass (376), and
pre-commit passes against main.
- CI, 2026-10-29: the first full run on main at `df2e3fe4a76`, GPU lanes
included, passes.
- Split, 2026-10-29, at a reviewer's request: the expert-parallel
indexing fix, its tests and a Bug Fixes changelog entry moved to #3122
([1/2], on main), and this PR now sits on it, its own diff otherwise
unchanged. The review's three smaller items are taken: the interception
check reduces its verdict over the experts' expert-parallel group (a
two-rank test fails with the rank-local check); a test checks that every
experts class transformers decorates with `use_experts_implementation`
takes the routing indices second (transformers 5.5.4 and 5.18); and
`--experts_implementation` offers `eager` and `grouped_mm` as argparse
choices. pre-commit, mypy included, passes against main; this PR's
tests, `test_moe_a2a.py` and the `hf_ptq` unit tests pass (315).
- Review suggestion (Claude), 2026-10-29: the interception check reads
the expert-parallel group from the experts' weight, a DTensor sharded
over the experts from transformers 5.16 on; from 5.6 to 5.15 the shards
are plain tensors that name no group, so the check stays rank-local
there, as before. The docstrings say so. Restacked onto #3122's two
follow-ups; pre-commit passes against main.
- Comment, 2026-10-29: at `calibrate()`'s gate for the interception
check, a sentence says that both conditions, the forward loop having run
and the HF plugin being loaded, must come out the same on every rank,
since under expert parallelism the check all-reduces over the experts'
group. pre-commit passes against main.
- Test fix, 2026-10-29:
`tests/gpu/torch/quantization/test_fsdp2_autoquant.py`'s 2-GPU KV
AutoQuantize test builds hf_ptq's arguments by hand and lacked the new
`experts_implementation`, which `load_model`'s FSDP2 branch reads. It
now passes `None`. The 1-GPU PR runners skip that test, and main's
nightly runs it. No other test or example builds hf_ptq's arguments by
hand on a path that reads the flag. pre-commit passes against main.
- Rebased on 2026-10-30 onto #3122 on main `99c3834f62c`, with its own
diff unchanged and no conflicts. pre-commit passes against main.
- Review (sychen52), 2026-10-30: with every weight quantizer collecting
statistics without quantizing (calibration), the grouped forward returns
the stored weight itself instead of building a weight-sized copy slice
by slice on each forward. A CPU test checks the identity and the
collected per-expert amax, and fails on the previous code. On CPU, one
call on a Qwen3-30B-A3B-sized `gate_up_proj` (128 × 1536 × 2048, bf16,
768 MiB) raised peak RSS by 755 MiB before and 0 MiB after; a GPU
`max_memory_allocated` comparison is queued. The changelog entry now
says the experts stay on `grouped_mm` for every forward after
`mtq.quantize` or `mto.restore` (QAT/QAD, AutoQuantize scoring,
fake-quant evaluation), with `experts_implementation="eager"` as the way
back. `test_fused_experts.py` and `test_moe_a2a.py` pass on transformers
5.18 (220) and 5.5.4 (214, 6 skipped); pre-commit passes against main.
- Review (kevalmorabia97), 2026-10-30: the three changes no test covered
now have one each, or are gone. The `_local_experts` call in
`_quantize_grouped_weight` is dropped as unreachable: the weight arrives
as this rank's local shard inside the experts forward. New tests pin
that a grouped forward reaching neither hook is counted whatever its
routing indices hold, and that a capture after a grouped forward does
not split by its offsets. The calibration pass-through calls only
quantizers that calibrate (Claude's suggestion), with a constant-amax
test case. Each test fails with its change reverted.
`test_fused_experts.py`, `test_moe_a2a.py` and `test_hf_ptq_args.py`
pass on transformers 5.18 (283) and 5.5.4 (277, 6 skipped); pre-commit
passes against main.
- GPU calibration peak memory, 2026-10-30 (sychen52's request):
`torch.cuda.max_memory_allocated` around the `mtq.quantize(...)` call
only, after `reset_peak_memory_stats`. Qwen3-30B-A3B on the default
`grouped_mm` backend, recipe `general/ptq/fp8_default-kv_fp8`,
`calib_size` 32, `batch_size` 1, one NVIDIA GB200 for both revisions
(torch 2.11, transformers 5.13.0, CUDA 13.1).

  | Revision | Calibration peak (bytes) | MiB |
  |---|---|---|
| Before the pass-through change (`3f3544435d8`) | 864,901,632 | 824.8 |
  | After (`27c5f77feac`) | 174,759,936 | 166.7 |
  | Drop | 690,141,696 | 658.2 |
- Rebased on 2026-10-30 onto main `55898ee4e3b` after #3122 merged,
dropping #3122's pre-squash commits; its own diff is unchanged, with no
conflicts. pre-commit passes against main.
- CI fix, 2026-10-30: the routing-order test read transformers' modeling
sources with the platform's default encoding, cp1252 on Windows, which
16 of them (`modeling_altclip.py`, say) cannot be decoded with; it now
reads them as UTF-8, and the windows unit job's only failure goes with
it. The `linux` job's one failure on the previous head, a NAS sorting
test this PR does not touch, passed on rerun at the same commit.
pre-commit passes against main.
- `eager` against main, byte for byte, 2026-10-30 (re-verifying at this
PR's head the claim that `experts_implementation="eager"` reproduces
main): PTQ of Qwen/Qwen3-30B-A3B with recipe
`general/ptq/nvfp4_default-kv_fp8`, `calib_size` 512, calibration
sequence length 2048, `batch_size` 1, cnn_dailymail, without expert
parallelism (one process), on one NVIDIA GB200 (torch 2.11, transformers
5.13.0, CUDA 13.1). This PR at `27c5f77feac` with
`--experts_implementation eager`, against main at `99c3834f62c`, both
compared at zero tolerance. Later revisions of this PR leave its own
diff unchanged (a rebase) or change tests only.

  | Scale kind | Tensors | Differing |
  |---|---|---|
  | `input_scale` | 18,624 | 0 |
  | `weight_scale` | 18,624 | 0 |
  | `weight_scale_2` | 18,624 | 0 |
  | `k_scale` | 48 | 0 |
  | `v_scale` | 48 | 0 |
- Review (juhi10071998), 2026-10-30: under transformers' own expert
parallelism the grouped forward sorts the rows routed to other ranks'
experts past `offs[-1]`, which the grouped GEMM neither reads nor
writes; before transformers 5.17 nothing zeroes them in the down
projection's input, so its input quantizer calibrated on uninitialized
rows. While an input quantizer collects statistics it now sees only the
rows before `offs[-1]` (a host sync, during calibration only). A test
hands the grouped projections a NaN or a large finite tail, as pre-5.17
expert parallelism leaves it, and checks the input amaxes match a run
without one; both cases fail without the change.
`test_fused_experts.py`, `test_moe_a2a.py`, `test_calib.py` and
`test_hf_ptq_args.py` pass on transformers 5.18 (307) and 5.5.4 (301, 6
skipped); pre-commit passes against main.
- Rebased on 2026-10-30 onto main `059588b89a5` (after #3131, #3124,
#2751, #3104, #3128, #3134 and #3149 merged), with its own diff
unchanged and no conflicts. pre-commit passes against main.

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

- Is this change backward compatible?: ❌ Fused-experts MoE calibration
now runs on `grouped_mm`, so some activation scales differ from earlier
releases; `experts_implementation="eager"` restores the earlier
checkpoint exactly.
- If you copied code from any other sources or added a new PIP
dependency, did you follow guidance in `CONTRIBUTING.md`: N/A
- Did you write any new necessary tests?: ✅
- Did you update
[Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?:
✅ Backward Breaking Changes.
- Did you get Claude approval on this PR?: ✅ At `042e4bf1e55`; the
error-message change after it is from that review.

### Additional Information

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


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

## Summary by CodeRabbit

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

---------

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
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