Skip to content

[OMNIML-6076] [2/3] Prepare a model's config for expert parallelism from its spec - #3135

Open
shengliangxu wants to merge 10 commits into
mainfrom
shengliangx/hfep-9a2-ep-spec-applier
Open

shengliangxu wants to merge 10 commits into
mainfrom
shengliangx/hfep-9a2-ep-spec-applier

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Note

Slice [2/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) → #2746 [1/3] (#3134) → this PR → #2746 [3/3] (#2746).

Base: #2746 [1/3] (#3134): the spec sections this PR reads. It also needs #2741 [1/3]'s require_transformers, on main since #3131 merged. 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

modelopt.torch.utils.moe gains the one applier of the model specs' expert-parallel section (EPSpec, #2746 [1/3]), and the checks around it.

  • apply_distributed_support(model_type, hf_config, *, ckpt_path=None, trust_remote_code=False) prepares a config for the expert-parallel load from the model's spec, in this order:
    1. It refuses a model its spec marks unsupported. The refusal names a single process first, and FSDP2 (--use_fsdp2 or MODELOPT_FSDP_SIZE=world) only where the FSDP2 load supports the checkpoint: an unquantized causal LM with no MTP head it cannot build. GPT-OSS ships MXFP4, which the FSDP2 load refuses, Llama-4's checkpoint is multimodal, and --use_fsdp2 refuses Nemotron-H's MTP weights.
    2. It checks the transformers floor: the expert-parallel load's own 5.7 (_EP_MIN_TRANSFORMERS), or the spec's later release. The load's floor holds for a model with no spec too. Its refusal offers the same ways around expert parallelism.
    3. It attaches the spec's plan to whichever config carries the MoE.
    4. It runs the spec's prepare, after the plan is attached.
  • An existing plan always wins wherever the load reads it (_ep_config): on the root, or on a nested config other than the one the spec would take. Overwriting it would substitute our idea of the layout for the model's.
  • A plan goes where the load reads it.
    • The applier refuses a spec whose plan_config_attr is neither text_config nor llm_config, the two nested configs the expert-parallel load reads a plan and the expert count from (_SUB_CONFIG_ATTRS, which _ep_config and _num_experts_from_config now read too). Attaching it elsewhere would be read as no plan at all and plan the MoE as dense.
    • plan_config_attr may list several spellings, the first one the config has taking the plan. A config with none of them is refused, naming them all.
    • The spellings are checked for every spec, whether the spec or its prepare supplies the plan, before anything is attached or prepared.
  • Read-only helpers:
    • check_distributed_support is the floor and refusal check alone, for a config already prepared with its plan.
    • _spec_refuses_ep is its predicate.
    • _spec_ep_plan is the plan a spec would supply: its own, or the one it states prepare attaches, read without running prepare. A model below its spec's transformers floor keeps its plan; a spec refusing expert parallelism supplies none.

Nothing calls these yet. #2746 [3/3] (#2746) has the expert-parallel decision and load call them.

Usage

from modelopt.torch.utils.moe import apply_distributed_support

hf_config = apply_distributed_support(hf_config.model_type, hf_config, ckpt_path=path)
# raises NotImplementedError for gpt_oss / llama4 / llama4_text, before any weight loads

Testing

  • tests/unit/torch/models/test_ep_specs.py gains the applier's tests:
    • the applier's version floor, the load's floor for a model with no spec, plan attachment, and prepare ordering;
    • existing-plan precedence wherever the load reads the plan: the root, or a nested config beside the one the spec would take;
    • a spec attaching its plan where the load reads none is refused with the config untouched, whether the spec or its prepare supplies the plan;
    • the Omni plan lands under either name (llm_config or text_config), and on text_config for a config with both;
    • GPT-OSS, Llama-4 and Llama-4-text are refused with their configs untouched, naming a single process first, and a refusal comes before the rest of the section;
    • the plan a spec would supply, read without running prepare.
  • Mutation-checked: each of these fails a test:
    • attaching a plan where the load reads none;
    • reading the Omni plan off llm_config alone, or trying llm_config before text_config;
    • checking a plan spelling only when the spec supplies the plan itself;
    • checking for an existing plan on the spec's target rather than where the load reads it;
    • hiding the plan of a spec below its transformers floor;
    • skipping the load's floor for a model with no spec;
    • dropping the EP-unsupported branch.
  • Postponed review suggestion (on [OMNIML-6076] [3/3] Apply the model spec when deciding and loading expert-parallel #2746, 2026-10-28): EPSpec.prepare runs before the plan is known, so a load that ends up not expert-parallel keeps its effects. No spec here sets prepare. It is to be settled in [OMNIML-6078] [2/2] Add the Kimi-K2.5 expert-parallel adapter #2748, which registers the first ones (Kimi-VL, Kimi-K2.5).
  • 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 769 0 0
5.5.4 740 29 0
  • Review round, 2026-10-30 (5648c93): the well-formedness test checks each plan entry's style against the load's styles and that the plan names a routed-experts container, for prepared_ep_plan too; a new test checks every spec puts its plan on a config the load reads (a bad style or an unread spelling fails them); check_distributed_support drops a redundant local import. Pre-commit passes against main; tests/unit/torch/{models,utils} pass on transformers 5.18 (777) and 5.5.4 (748, 29 skipped).
  • Rebased on 2026-10-30 (fe16583) onto main 99c3834 through [OMNIML-6060] [1/3] Add require_transformers, and build a VLM without its LM head #3131's rebase; no conflicts, this PR's own diff unchanged. Pre-commit passes against main.
  • 2026-10-30 (1bd24c0): rebased on [OMNIML-6076] [1/3] Describe expert parallelism in the model specs #3134's review round; this PR's own diff unchanged. Pre-commit passes against main; tests/unit/torch/models and test_moe_a2a.py pass on transformers 5.18 (273) and 5.5.4 (255, 18 skipped).
  • Review round, 2026-10-30 (3894689):
    • Without transformers, the registry and plan checks run again; only the tests that read the installed release skip. test_ep_specs.py runs 30 tests and skips 14 with transformers not importable, where it skipped the whole module before.
    • The FSDP2 way around expert parallelism also names an MTP head the load cannot build.
    • Pre-commit passes against main. tests/unit/torch/models and test_moe_a2a.py pass on transformers 5.18 (273) and 5.5.4 (255, 18 skipped).
  • Rebased on 2026-10-30 (da3aeca) onto [OMNIML-6076] [1/3] Describe expert parallelism in the model specs #3134's rebase onto main 62a5c48, after [OMNIML-6060] [1/3] Add require_transformers, and build a VLM without its LM head #3131 merged: no conflicts, this PR's own diff unchanged (range-diff). Pre-commit passes against main; tests/unit/torch/models and test_moe_a2a.py pass on transformers 5.18 (312) and 5.5.4 (282, 19 skipped).
  • Rebased on 2026-10-30 (9dd1d1e) onto main ff964eb after [OMNIML-6076] [1/3] Describe expert parallelism in the model specs #3134 merged; this PR's own diff is unchanged (range-diff identical). Pre-commit passes against main; tests/unit/torch/models and test_moe_a2a.py pass on transformers 5.18 (312) and 5.5.4 (282, 19 skipped).
  • Rebased on 2026-10-30 (85885c3) onto main 059588b, which moved only by ci: establish a shared review baseline and adopt it in Claude reviews #3149, a CI change. This PR's own diff is unchanged (range-diff identical). Pre-commit passes against main. tests/unit/torch/models and test_moe_a2a.py pass on transformers 5.18 (312) and 5.5.4 (282, 19 skipped).
  • Rebased on 2026-10-30 (8d2bd3c) onto main d464d30 ([OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074 and fix(export): support stateless constant KV scales in functional capture #3121 since 059588b). This PR's own diff is unchanged (range-diff identical). Pre-commit passes against main. tests/unit/torch/models and test_moe_a2a.py pass on transformers 5.18 (314) and 5.5.4 (283, 20 skipped).
  • CodeRabbit nitpick, 2026-10-31 (41b8a6a): the test of the spec's prepare hook now has it return a replacement config that carries the plan over, and checks that the returned config is that one and holds the plan. Pre-commit passes against main. tests/unit/torch/models and test_moe_a2a.py pass on transformers 5.18 (314) and 5.5.4 (283, 20 skipped).
  • CodeRabbit outside-diff item, 2026-10-31 (7c0be5b): apply_distributed_support finds a plan the config already carries before requiring the nested config the spec names. A config with a plan of its own, at its root included, keeps it; with no plan anywhere, a config without the nested config is still refused. test_a_plan_at_the_root_needs_no_nested_config fails without the change. Pre-commit passes against main. tests/unit/torch/models and test_moe_a2a.py pass on transformers 5.18 (315) and 5.5.4 (283, 21 skipped).
  • Rebased on 2026-10-31 (2b3dfc6) onto main e352cac, without conflicts; its own diff is unchanged. Pre-commit passes against main. tests/unit/torch/models, test_moe_a2a.py and test_model_load_utils.py pass on transformers 5.18 (382) and 5.5.4 (339, 32 skipped).
  • Rebased on 2026-11-01 (289b626) onto main cf38ec3, without conflicts; its own diff is unchanged. Pre-commit passes against main. tests/unit/torch/models, test_moe_a2a.py and test_model_load_utils.py pass on transformers 5.18 (382) and 5.5.4 (339, 32 skipped).
  • Review round, 2026-11-01 (ccab53a), the three follow-ups from the code owner's review:
    • apply_distributed_support refuses a prepare that returns None, or a config with no plan where the load reads one, and names the model type (b29d579). The new test's two cases fail without the check.
    • The registered-plans check accepts ep_router, and its failure names the styles the load keeps (202a805).
    • _spec_ep_plan's docstring covers a plan that ships with transformers (ccab53a).
    • Pre-commit passes against main. tests/unit/torch/models, test_moe_a2a.py and test_model_load_utils.py pass on transformers 5.18 (384) and 5.5.4 (339, 34 skipped). The check changes nothing for [OMNIML-6076] [3/3] Apply the model spec when deciding and loading expert-parallel #2746: its tests with the check applied pass on 5.18 (588).
  • Review round, 2026-11-01 (f9c898a): the registry test refuses a spec declaring both a base and a prepared plan. Test-only. Pre-commit passes against main. test_ep_specs.py passes on transformers 5.18 (47) and 5.5.4 (32, 15 skipped).
  • Rebased on 2026-11-01 (4cfbddf) onto main 51645db, without conflicts; its own diff is unchanged. Pre-commit passes against main. tests/unit/torch/models, test_moe_a2a.py and test_model_load_utils.py pass on transformers 5.18 (384) and 5.5.4 (339, 34 skipped).

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 model-aware expert-parallel loading support for compatible models, including models with nested configurations.
    • Loading checks model support and Transformers version compatibility, and explains when expert-parallel loading is unavailable, including alternative loading options.
    • Existing model-provided expert-parallel plans are preserved; supported models can use their declared plans.
    • Model-specific preparation is applied when available, helping ensure the appropriate expert-parallel configuration is used.

@coderabbitai

coderabbitai Bot commented Oct 30, 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 expert-parallel support checks and helpers to apply or select plans from model specs. The helpers handle Transformers version requirements, nested config locations, existing plans, unsupported models, and optional preparation hooks.

Changes: Expert-Parallel Support

Layer / File(s) Summary
Discover specs and check support
modelopt/torch/utils/moe.py, tests/unit/torch/models/test_ep_specs.py
The utilities use shared nested-config attributes, retrieve model specs lazily, and check unsupported status and Transformers version requirements. Tests validate plan declarations and support requirements.
Validate and apply config plans
modelopt/torch/utils/moe.py, tests/unit/torch/models/test_ep_specs.py
The applier preserves existing plans, validates plan locations, attaches a spec plan when appropriate, and then runs an optional prepare hook. Tests cover plan precedence, config validation, and hook ordering.
Select spec plans
modelopt/torch/utils/moe.py, tests/unit/torch/models/test_ep_specs.py
The plan selector returns an empty plan for absent or refusing specs. Otherwise, it returns a copy of the declared plan or falls back to the prepared plan. Tests confirm selection does not run preparation.

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

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant apply_distributed_support
  participant check_distributed_support
  participant hf_config
  participant prepare_hook
  Caller->>apply_distributed_support: model_type and hf_config
  apply_distributed_support->>check_distributed_support: validate support
  apply_distributed_support->>hf_config: preserve or attach plan
  apply_distributed_support->>prepare_hook: pass config and keyword arguments
  prepare_hook-->>apply_distributed_support: prepared config
  apply_distributed_support-->>Caller: return config
Loading

Suggested reviewers: aanoosheh

Merge Risk: Low · previous review 41b8a

A valid existing EP plan can cause model loading to fail when a nested config is absent; fix this precedence edge case before relying on the helper.

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 names the main change: preparing a model's Hugging Face config for expert parallelism from its EP spec. The PR adds apply_distributed_support and helpers that attach the spec's plan to the c…
Docstring Coverage Passed Docstring coverage is 90.00% 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 only modelopt package change is modelopt/torch/utils/moe.py; no examples, pyproject.toml, or requirements files changed. Added code contains no …
Full details: Title check

Explanation

The title names the main change: preparing a model's Hugging Face config for expert parallelism from its EP spec. The PR adds apply_distributed_support and helpers that attach the spec's plan to the config the load reads, then run the spec's prepare hook. The title matches this primary change. The "[2/3]" tag refers to the stacked PR sequence and does not add noise that hides the change. The title does not mention the support checks or the Transformers version floor, but it does not need to cover every detail.

Full details: Docstring Coverage

Explanation

Docstring coverage is 90.00% 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 10 functions across 2 files.

Full details: Security Anti-Patterns

Explanation

No listed security anti-pattern was introduced. The only modelopt package change is modelopt/torch/utils/moe.py; no examples, pyproject.toml, or requirements files changed. Added code contains no torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), eval, exec, or # nosec. trust_remote_code is an exposed parameter defaulting to False, not hardcoded to True.

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

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test c38ccce

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment thread modelopt/torch/utils/moe.py Outdated
f"{model_type} cannot be quantized expert-parallel yet: {ep.unsupported} Quantize it "
f"{without_ep}."
)
from packaging.version import parse

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] parse is already imported at module top (modelopt/torch/utils/moe.py:32, used by require_transformers), so this local import is redundant — it rebinds the same name with no effect. CONTRIBUTING's Keep imports at the top of the file allows a function-level import only for a named reason (circular import, optional dependency, heavy import), none of which applies to packaging, which is already a hard import here.

Suggested change
from packaging.version import parse

For contrast, the two function-level imports that are justified — from modelopt.torch.models import get_spec in _ep_spec and from transformers... in _routed_expert_modules — the latter carries its reason as a comment; _ep_spec's does not. Worth a one-liner there too (importing modelopt.torch.models pulls in every per-model subpackage for its registration side effect, which this module shouldn't do at import time).

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 5648c93. check_distributed_support no longer imports parse locally; it uses the module-level import. _ep_spec's local import now says why it is local: importing modelopt.torch.models registers every model spec, which importing this module should not do.

@@ -58,3 +83,211 @@ def test_the_omni_vlm_puts_its_plan_on_the_nested_config():
"""Its experts live in the language sub-model, and the globs are written against that tree."""
assert get_spec("nemotron_h_omni").ep_spec.plan_config_attr == ("text_config", "llm_config")
assert get_spec("nemotron_h").ep_spec.plan_config_attr is None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[SUGGESTION] The plan_config_attr ⊆ _SUB_CONFIG_ATTRS invariant is enforced only at runtime inside apply_distributed_support, and register() does no validation of it (modelopt/torch/models/specs.py:320-330 only checks for duplicate model types). So a new spec that writes plan_config_attr="language_config" or misspells "text_configs" passes every test in this file and only blows up when someone actually loads that model multi-rank with EP — which CI never does.

The applier's own test (test_a_plan_attached_where_the_load_does_not_read_one_is_an_error) covers the mechanism via a synthetic _probe spec, but not the registered data. A one-line data assertion alongside the other EP_MODEL_TYPES spec tests would close that gap cheaply:

@pytest.mark.parametrize("model_type", EP_MODEL_TYPES)
def test_a_plan_goes_where_the_load_reads_one(model_type):
    """A spelling the load does not read is read as no plan at all, and the MoE planned dense."""
    attrs = get_spec(model_type).ep_spec.plan_config_attr or ()
    attrs = (attrs,) if isinstance(attrs, str) else attrs
    assert not set(attrs) - set(moe._SUB_CONFIG_ATTRS), f"{model_type}: {attrs}"

This is also the kind of check that stays correct if _SUB_CONFIG_ATTRS ever grows a third spelling, since both sides read the same tuple.

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 5648c93. test_a_plan_goes_where_the_load_reads_one checks every registered spec's plan_config_attr against _SUB_CONFIG_ATTRS, so a spelling the load does not read fails CI instead of a multi-rank load. With language_config on the Omni spec, the test fails.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude review passed — no blocking issues found. LGTM

Scope: full review (trigger comment carried no scoping instructions). 2 changed files, both reviewed in full: modelopt/torch/utils/moe.py (+140/-6) and tests/unit/torch/models/test_ep_specs.py (+235/-2). Read the base-branch EPSpec definition (modelopt/torch/models/specs.py:163-231), get_spec, the five registered EP sections (deepseek_v3, qwen3_moe, gpt_oss, llama4/llama4_text, nemotron_h_omni), and the one existing consumer of the refactored helpers (modelopt/torch/utils/plugins/model_load_utils.py:1044-1047).

Findings

  • CRITICAL: 0
  • IMPORTANT: 0
  • SUGGESTION: 2 — a redundant function-level from packaging.version import parse (already imported at moe.py:32), and a missing data-level assertion that every registered spec's plan_config_attr is a spelling the load actually reads.

What I verified

  • The _SUB_CONFIG_ATTRS extraction is behavior-preserving. _ep_config and _num_experts_from_config both read text_config then llm_config, same order as before, so the existing load_and_prepare_ep call sites are unaffected. The applier's refusal of any other spelling closes a silent-failure mode — a plan on the wrong config matches no parameter and the MoE is planned dense — which is the right trade.
  • Validate-before-mutate holds on every refusal path. check_distributed_support runs first, then the plan_config_attr spelling check, then target resolution, and only then the attach and prepare. Each of the three refusals (unsupported, version floor, unreadable/absent target) leaves the config untouched, and the tests assert vars(config) == {} rather than just the raise.
  • _spec_refuses_ep's except RuntimeError does catch the unsupported refusal — NotImplementedError subclasses RuntimeError. The inline comment saying so is worth keeping; it reads as a bug otherwise.
  • Existing-plan precedence is checked where the load reads a plan, not where the spec would write one. getattr(_ep_config(hf_config), "base_model_ep_plan", None) covers the root and either nested config, so a stock plan on llm_config is not shadowed by ours on text_config. This is the subtle case and it is both implemented and tested (on-another-nested-config). An explicitly-empty {} plan is treated as absent, consistently with _ep_config's own truthiness test.
  • The floor arithmetic. max(_EP_MIN_TRANSFORMERS, stated or _EP_MIN_TRANSFORMERS, key=parse) compares parsed versions, not strings, so "5.18" correctly beats "5.7" — the string comparison would have inverted it. A model with no spec still gets the load's floor, via get_spec returning None → getattr(None, "ep_spec", None) → None.
  • nemotron_h_omni has no requires_transformers but min_transformers_version="5.18", so the floor stays at 5.7 for it. That is deliberate per the spec's comment (pre-5.18 the checkpoint loads through its own code, which names the config llm_config), and the dual-spelling plan_config_attr plus the ("llm_config",)/("text_config",)/both parametrization cover it.
  • _EP_MIN_TRANSFORMERS = "5.7" is a new module constant that both the applier and the test read, so the skip marker and the enforced floor cannot drift.
  • The attached plan is copied (dict(ep.base_model_ep_plan)), so two models prepared in one process cannot share a mutable dict and the registered spec cannot be mutated through a config — tested directly.
  • The user-facing error text points at flags that exist: --use_fsdp2 in examples/hf_ptq/hf_ptq.py and MODELOPT_FSDP_SIZE in modelopt/torch/utils/env.py.
  • prepare ordering and config hand-back are tested, including that a returned replacement config propagates and that prepare sees the plan already attached. The EPSpec.prepare docstring already carries the obligation to forward base_model_ep_plan when it returns a different config, so the one gap there is documented rather than silent.
  • Backward compatibility: purely additive public surface, no modelopt_state schema touched, no mode registration, no export path, no changed defaults. _spec_refuses_ep and _spec_ep_plan have no caller yet, which the description states and [3/3] supplies — acceptable for a declared split, not dead code.

Risk

Low. Nothing in the repository calls the new functions yet, the two refactored helpers keep their exact read order, and every refusal path is covered by a test with a stated mutation that breaks it. The one genuine residual risk is spec-data drift rather than logic: a future plan_config_attr typo is caught only when that model is loaded expert-parallel, which is what the test suggestion above addresses.

🤖 Generated with Claude Code

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 5648c93

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test fe16583

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

Copy link
Copy Markdown
Collaborator Author

/ok to test 1bd24c0

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

Bot review (gpt-6.1-sol) — DM the bot to share feedback.

Comment: the module-wide Transformers skip removes existing dependency-free spec coverage, and the FSDP2 fallback advice misses a known refusal.

Needs action:

  • Restore Transformers-free registry and plan checks in tests/unit/torch/models/test_ep_specs.py; limit dependency skips to tests that actually need Transformers (inline).
  • Correct check_distributed_support's FSDP2 advice to account for unsupported unquantized causal-LM checkpoints, including Nemotron-H MTP (inline).
  • Update the PR description's check order: unsupported is checked before the Transformers floor, not after it.

No action needed:

  • Strengthening the existing plan assertions and extending them to prepared_ep_plan is justified; the blanket dependency skip is not.

import pytest

from modelopt.torch.models import get_spec, get_specs
transformers = pytest.importorskip("transformers")

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.

Bot comment.

This module-wide skip also suppresses all the pre-existing registry/plan tests when Transformers is absent. Those tests still need no Transformers: the model-spec package registers data independently of HF, and _routed_expert_keys is also dependency-free. noxfile.py::partial_unit(subset="torch") collects this directory without installing the HF extra, so this quietly removes coverage from that lane without a corresponding behavior change. Please keep the declarative checks runnable there and scope the dependency skip to the applier/version tests (or move those tests into a separate module).

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. The module-wide skip took the registry and plan checks out of a run without transformers, though they need none of it.

Fixed in bdd0448. transformers is now imported optionally, and only the tests that read the installed release skip without it (needs_transformers and needs_ep_transformers).

With transformers not importable, test_ep_specs.py now runs 30 tests and skips 14. Before this change it skipped the whole module.

Comment thread modelopt/torch/utils/moe.py Outdated
without_ep = (
"without expert parallelism: in a single process, or FSDP2-sharded (--use_fsdp2 in "
"examples/hf_ptq/hf_ptq.py, or MODELOPT_FSDP_SIZE=world) if the checkpoint is an "
"unquantized causal LM"

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.

Bot comment.

Being an unquantized causal LM is not sufficient for the advertised FSDP2 fallback. For example, check_distributed_support("nemotron_h") below 5.7 emits this advice, but an unquantized Nemotron-H checkpoint containing MTP tensors then fails in examples/hf_ptq/example_utils.py::validate_fsdp2_supported, which explicitly refuses Nemotron-H MTP. Could the advice say “a checkpoint supported by the FSDP2 loader” or explicitly exclude MTP, rather than making this condition sound sufficient? The single-process alternative can remain.

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: being an unquantized causal LM is necessary, not sufficient.

Fixed in 3894689. The advice now offers FSDP2 "if the FSDP2 load supports the checkpoint, which takes an unquantized causal LM with no MTP head it cannot build (such as Nemotron-H's)". The single-process alternative still comes first.

#2746 [3/3] offers the same way out when an MoE would be left whole on every rank, and it now uses the same wording (d1dbf75).

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 3894689

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

Answers to the bot review of 1bd24c0, at 3894689:

  • Transformers-free registry and plan checks: fixed in bdd0448. The module no longer skips as a whole; only the tests that read the installed transformers skip without it.
  • FSDP2 advice: fixed in 3894689. FSDP2 is offered when the FSDP2 load supports the checkpoint, which excludes an MTP head it cannot build, such as Nemotron-H's.
  • Check order in the description: corrected. The unsupported refusal comes first, then the transformers floor.

Tests run (CPU, torch 2.14):

Run Passed Skipped Failed
test_ep_specs.py, transformers not importable 30 14 0
tests/unit/torch/models + test_moe_a2a.py, transformers 5.18 273 0 0
same, transformers 5.5.4 255 18 0

Before bdd0448, the first row skipped the whole module. Pre-commit, mypy included, passes against main.

Comment thread modelopt/torch/utils/moe.py Outdated
Comment on lines +257 to +262
if ep.base_model_ep_plan and not getattr(_ep_config(hf_config), "base_model_ep_plan", None):
target.base_model_ep_plan = dict(ep.base_model_ep_plan)

if ep.prepare is not None:
hf_config = ep.prepare(hf_config, ckpt_path=ckpt_path, trust_remote_code=trust_remote_code)
return hf_config

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 declarative path is guarded strictly against the silent-dense-MoE failure, but the prepare path is not — and it can reproduce the identical failure.

The unread / missing-target ValueErrors above exist because "attaching it elsewhere would be read as no plan at all and plan the MoE as dense." A prepare hook reaches the same end state with no error at all:

  • it returns a different config object without carrying over the base_model_ep_plan just attached to target (exactly the obligation EPSpec.prepare's docstring places on the spec author) — _ep_config() on the returned config then finds no plan, and the MoE is planned dense;
  • it mutates in place and forgets to return, so apply_distributed_support hands the caller None, which surfaces as an unrelated AttributeError deep in the load.

Both are spec-authoring mistakes, which is precisely the class the unread check already refuses to let through silently. A post-condition costs two lines and keeps the two paths symmetric:

    if ep.prepare is not None:
        prepared = ep.prepare(hf_config, ckpt_path=ckpt_path, trust_remote_code=trust_remote_code)
        if prepared is None:
            raise ValueError(f"{model_type}'s spec prepare returned no config to load.")
        if _spec_ep_plan(model_type) and not getattr(
            _ep_config(prepared), "base_model_ep_plan", None
        ):
            raise ValueError(
                f"{model_type}'s spec prepare returned a config carrying no expert-parallel plan "
                "where the load reads one; a prepare returning another config must carry over the "
                "base_model_ep_plan."
            )
        hf_config = prepared
    return hf_config

Non-blocking: no registered spec sets prepare today, and the PR already defers the other prepare-ordering question to #2748 — this would fit naturally there alongside 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.

Not a defect at this head, so I'm postponing it to this PR's next code change rather than pushing for it alone.

No spec reachable from this PR sets prepare. The first ones are Kimi-VL and Kimi-K2.5, in #2748. Their hooks return the config they were given, with the plan attached to its text_config by a nested apply_distributed_support("deepseek_v3", ...), and _ep_config reads the plan there. So no registered spec reaches either end state you describe: no plan where the load reads one, or a None config.

The post-condition guards the next spec author. I'll add it, with a test, in this PR's next code change.

Comment on lines +57 to +74
_KNOWN_STYLES = frozenset(moe._EXPERTS_STYLES) | {"grouped_gemm"}


@pytest.mark.parametrize("model_type", EP_MODEL_TYPES)
def test_every_ep_plan_is_well_formed(model_type):
"""Globs map to style names; a malformed entry matches nothing and fails silently at load."""
plan = get_spec(model_type).ep_spec.base_model_ep_plan
if plan is None:
return
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}"
"""Every entry uses a style the expert-parallel load implements, and the plan names a
routed-experts container it can count and shard: a malformed plan fails silently at load."""
ep = get_spec(model_type).ep_spec
for plan in (ep.base_model_ep_plan, ep.prepared_ep_plan):
if plan is None:
continue
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 style in _KNOWN_STYLES, (
f"{model_type}: {glob!r} uses style {style!r}, which the expert-parallel load does "
f"not implement (known: {sorted(_KNOWN_STYLES)})"
)

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] _KNOWN_STYLES is narrower than the styles a base_model_ep_plan may legitimately carry, and the failure message states something that isn't true.

moe._EXPERTS_STYLES is documented as "the styles a base_model_ep_plan gives a layer's routed-experts container" — it is deliberately the container vocabulary, not the set of styles the load accepts. The plan itself is consumed by transformers' distributed loading, which honours its own styles (local_colwise, rowwise, gather, …). So a plan entry that styles something other than a container — a router/gate entry, which EPSpec.base_model_ep_plan's own docstring treats as a real option ("A router entry is not always wanted", with DeepSeek-V3 given as the reason it is omitted there) — would fail this assertion saying the style is one "the expert-parallel load does not implement", when in fact the load never sees it as a container style and transformers implements it fine.

That makes the next spec author's failure message actively misleading. Two ways out, either is fine:

  • keep the strictness but scope the claim to what it actually checks — assert the style set only over the entries _routed_expert_keys picks up, and word the message as "is not a routed-experts container style"; or
  • keep the whole-plan sweep and say instead that a style must be either a container style or one transformers implements, so a router entry passes.

Separately, "grouped_gemm" is spelled as a literal both here and in moe._routed_expert_keys; a shared module constant beside _EXPERTS_STYLES would keep the test and the implementation from drifting on 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.

Agreed: the message misleads for a router entry. The expert-parallel load keeps ep_router (it maps it to torch_a2a_router) and drops only the other styles outside the experts. A spec that added layers.*.mlp.gate: ep_router would load fine and still fail this test.

No registered spec has a router entry today; DeepSeek-V3 omits it on purpose. So every current plan passes, and nothing loads wrongly.

Postponed to this PR's next code change. I'll accept ep_router, reword the message to name a style the load does not keep, and spell grouped_gemm once, shared with _routed_expert_keys.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 7c0be5b

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

@coderabbitai Taken in 7c0be5b: the outside-diff item from your review on 41b8a6a, modelopt/torch/utils/moe.py (honor an existing root plan before requiring the nested config). apply_distributed_support now looks for a plan the config already carries before it requires the nested config the spec names.

  • A config with a plan of its own keeps it, at its root included, and the nested config is not required.
  • With no plan anywhere, a config without the nested config is still refused. The refusal now says it has neither.

The new test test_a_plan_at_the_root_needs_no_nested_config fails without the change, and the existing refusal test still holds.

@coderabbitai

coderabbitai Bot commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd '^AGENTS\.md$' --hidden --exclude .git --exec cat {} \;
ast-grep outline modelopt/torch/utils/moe.py --match 'apply_distributed_support|_ep_config'
ast-grep outline tests/unit/torch/models/test_ep_specs.py --match 'root|nested|missing'
rg -n -C 45 '^def apply_distributed_support\(' modelopt/torch/utils/moe.py
rg -n -C 24 'test_a_plan_at_the_root_needs_no_nested_config|neither|missing.*config|def test_.*nested' tests/unit/torch/models/test_ep_specs.py

Length of output: 24378


🏁 Script executed:

#!/bin/bash
sed -n '1,60p' README.md
sed -n '60,90p' modelopt/torch/utils/moe.py
sed -n '235,285p' modelopt/torch/utils/moe.py
rg -n -A 65 '^## .*Coding [Ss]tandards|^## Coding|^### .*Coding' CONTRIBUTING.md
base=$(git merge-base HEAD origin/main)
printf '\nReview baseline commit: %s\n' "$base"
git show "$base":.agents/review-guidelines/code-review.md

Length of output: 47567


@shengliangxu Confirmed in modelopt/torch/utils/moe.py: apply_distributed_support checks for an existing plan before it requires the nested config.

  • An existing root plan bypasses the nested-config requirement.
  • With no existing plan, a missing nested config still raises ValueError before prepare runs.
  • test_a_plan_at_the_root_needs_no_nested_config checks config identity and plan preservation. The existing refusal test covers both spec-supplied and prepare-supplied plans.

This addresses the reported concern. Following the repository’s review-feedback guidance, I checked the current source. I did not run the tests or verify the revert check. Per AGENTS.md, I leave thread resolution to the reviewer.

Review baseline: d464d30b120254151309c217a8410adde6ee1c45.


🧠 Learnings used
Learnt from: CR
Repo: NVIDIA/Model-Optimizer PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-10-20T14:44:36.749Z
Learning: Source excerpt:
# Agent Instructions for ModelOpt

## Pre-push review pass

- **Correctness.** Validate before mutating, so a refusal leaves state
  unchanged, and fail loudly rather than silently ignore an option or route.
  Cover the layouts the change can meet: dtypes, tied or shared weights,
  offloaded, FSDP2 and meta tensors, fused or grouped modules.
Learnt from: CR
Repo: NVIDIA/Model-Optimizer PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-10-20T14:44:36.749Z
Learning: Source excerpt:
# Agent Instructions for ModelOpt

## Responding to PR review feedback

- **Judge each comment on its merits before acting.** Check it against the
  current code — reviewers comment on stale diffs, and bot findings (CodeRabbit,
  Claude) are claims to verify, not instructions. Weight CODEOWNERS reviewers
  above bots; if a reviewer reaffirms after your pushback, that settles it.
Learnt from: CR
Repo: NVIDIA/Model-Optimizer PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-10-20T14:44:36.749Z
Learning: Source excerpt:
# Agent Instructions for ModelOpt

## Pre-push review pass

- **Evidence.** Each fix has a test that fails without it; check by reverting.
  A test for a hang fails fast instead of hanging CI. Run the paths CI skips
  (multi-GPU, GPU-only) when the change touches them, and state in the PR what
  you ran and what you did not.

You are interacting with an AI system.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 2b3dfc6

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-9a2-ep-spec-applier branch from 7c0be5b to 2b3dfc6 Compare October 31, 2026 04:27
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude review passed — the two items open since the last review are fixed, and I found nothing new.

Scope: full review (trigger carried no scoping). 2 changed files (+431/-16), both read in full: modelopt/torch/utils/moe.py (+145/-6), tests/unit/torch/models/test_ep_specs.py (+286/-10). Also read EPSpec and all eight registered EP sections, _EXPERTS_STYLES / _routed_expert_keys, and _ep_config's fall-through, from the checkout. Baseline: .agents/review-guidelines/code-review.md at base e352caca.

No action needed:

  • ✔️ Resolved since 38946893: the root-plan precedence gap (7c0be5b2) and the prepare replacement-config assertion (41b8a6a1).
  • I traced the existing hoist: with no plan anywhere the nested-target refusal is unchanged, and the attach is still guarded by not existing, so target is only used after resolution. test_a_plan_at_the_root_needs_no_nested_config does fail without it — the old next(...) over (text_config, llm_config) returned None for a root-only config.
  • test_every_ep_plan_is_well_formed is strictly strengthened (style whitelist replaces isinstance(str), plus a routed-container check, extended to prepared_ep_plan); nothing was dropped.
  • Still open as non-blocking suggestions from the last round: no runtime check that prepare's replacement config carries a load-readable plan (no spec sets prepare yet; author defers to #2748), and _KNOWN_STYLES is narrower than the plan vocabulary, so a future router style would fail the test — loudly, not silently.
  • Verification limits: I did not execute the tests (sandbox denied pytest) and did not check CI; both are the author's reported 5.18/5.5.4 runs.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 289b626

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-9a2-ep-spec-applier branch from 2b3dfc6 to 289b626 Compare November 1, 2026 07:25

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

Three follow-ups you replied to claude[bot] with "postponed to this PR's next code change" are not in this head (it is only a rebase). Please land them here, or say explicitly which PR they move to (#2746 or #2748).

Needs action:

  • apply_distributed_support: after prepare runs, check the returned config is not None and still carries a plan the load can read.
  • test_ep_specs.py _KNOWN_STYLES: accept ep_router and reword the failure message.
  • _spec_ep_plan docstring: cover the case where the plan ships with transformers (qwen3_moe, minimax_m3_vl).

target.base_model_ep_plan = dict(ep.base_model_ep_plan)

if ep.prepare is not None:
hf_config = ep.prepare(hf_config, ckpt_path=ckpt_path, trust_remote_code=trust_remote_code)

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.

Please check here that the config prepare returns is not None and still carries a plan the load can read; today a bad return only fails later and further from the cause.

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.

Done in b29d579. apply_distributed_support now checks the config prepare returns, right where it returns it. It refuses with a ValueError naming the model type when that config is None, or carries no base_model_ep_plan where the load reads one (on the config, or its text_config or llm_config).

test_a_prepare_returning_no_config_the_load_can_read_is_refused covers both cases, and both fail without the check.



# The styles the expert-parallel load implements for a plan's entries.
_KNOWN_STYLES = frozenset(moe._EXPERTS_STYLES) | {"grouped_gemm"}

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.

Please accept ep_router here and reword the failure message at line 71-73 to match.

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.

Done in 202a805. _KNOWN_STYLES now includes ep_router, so it lists what the expert-parallel load keeps: the routed experts' container styles, grouped_gemm for their weights, and the router's ep_router. The failure message now says the load drops the style, and lists the ones it keeps.

return hf_config


def _spec_ep_plan(model_type: str) -> dict[str, str]:

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.

Please extend this docstring to cover the case where the plan ships with transformers (qwen3_moe, minimax_m3_vl).

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.

Done in ccab53a. The docstring now says the result is empty where the plan ships with transformers rather than the spec (qwen3_moe, minimax_m3_vl): the config carries the plan, and apply_distributed_support keeps it as it is.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

All three follow-ups are now in this PR, at ccab53a. There is a reply with the commit in each thread.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test ccab53a

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

ep = _ep_spec(model_type)
if ep is None or ep.unsupported:
return {}
return dict(ep.base_model_ep_plan or ep.prepared_ep_plan or {})

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] base_model_ep_plan or prepared_ep_plan reports only one of the two, but nothing enforces that a spec sets only one.

apply_distributed_support attaches base_model_ep_plan and then runs prepare, which attaches prepared_ep_plan — so for a spec that declares both, the plan the load ends up with is the union, while _spec_ep_plan returns the base plan alone. The consumer in [3/3] uses this to decide whether an MoE held whole on every rank should be refused, via _routed_expert_keys; if the extra containers only prepared_ep_plan names are the routed ones, that decision reads an incomplete plan and can rule the model dense.

EPSpec.prepared_ep_plan's docstring already says it is "for a model whose plan only prepare supplies", so the invariant is intended — it is just unchecked. Cheapest fix is a line in the registry test next to test_a_spec_declares_a_plan_a_floor_a_prepare_or_a_refusal:

assert not (ep.base_model_ep_plan and ep.prepared_ep_plan), (
    f"{model_type} declares both plans; prepared_ep_plan is for a plan only prepare supplies"
)

Non-blocking: no registered spec sets prepare today, so nothing hits this yet.

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 f9c898a. test_a_spec_declares_a_plan_a_floor_a_prepare_or_a_refusal now also refuses a spec declaring both base_model_ep_plan and prepared_ep_plan. That is the invariant prepared_ep_plan's docstring states, so a load can't end up with the union while _spec_ep_plan reports only one. No registered spec, here or in the Kimi specs stacked above, declares both.

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

LGTM — the three suggestions from the previous Claude round are resolved in code, and the only thing left is human sign-off on the strengthened existing test.

Needs action:

  • Sign off on the edit to test_every_ep_plan_is_well_formed in tests/unit/torch/models/test_ep_specs.py — the diff justifies it (the loose isinstance(style, str) assertion became a style whitelist plus a routed-container check, extended to prepared_ep_plan); coverage only grows, but a bot cannot auto-approve a test edit.
  • Consider asserting base_model_ep_plan and prepared_ep_plan are mutually exclusive in the registry test — _spec_ep_plan reports only the first, while apply_distributed_support would attach both (inline on modelopt/torch/utils/moe.py:295, non-blocking).

No action needed:

  • ✔️ Resolved since the last review: the unguarded prepare return value (now refuses None and a config with no plan where _ep_config reads one, tested both ways), the style whitelist and its message, and the _spec_ep_plan docstring's transformers-ships-the-plan case. The new root-plan shortcut is correct: the plan_config_attr spelling check still runs first, so validate-before-mutate holds on all five refusal paths.
  • Baseline: .agents/review-guidelines/code-review.md at base cf38ec3. Reviewed head ccab53a, full scope, both changed files. I did not run tests or read CI; the test results in the description are unverified.

🤖 Generated with Claude Code

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test f9c898a

apply_distributed_support reads a model's EPSpec and prepares the config
it is given for the expert-parallel load. It checks the transformers
floor: the load's own 5.7, or the spec's later release, for a model with
no spec too. It attaches the spec's base_model_ep_plan to the config the
load reads it from, unless the model already carries a plan wherever the
load reads one, and refuses a placement the load would not read. Then it
runs the spec's prepare.

check_distributed_support is the floor and refusal check alone,
_spec_refuses_ep its predicate, and _spec_ep_plan the plan a spec would
supply, read without running prepare. The nested configs the load reads
a plan and an expert count from are one tuple, _SUB_CONFIG_ATTRS.

Nothing calls them yet; the expert-parallel decision and load do in a
later change.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The well-formedness test only checked for non-empty strings: it now checks
every entry's style against the ones the expert-parallel load implements,
that the plan names a routed-experts container, and does so for the plan a
spec's prepare attaches too. A new registry test checks that every spec
puts its plan on a config the load reads. check_distributed_support drops
its local import of parse, which the module imports at the top, and
_ep_spec's local import says why it is local.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The applier's tests skipped the whole module when transformers was
missing, which took the registry and plan checks, which need none of it,
out of the partial-install unit run. Skip only the tests that read the
installed transformers.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The way around expert parallelism offered FSDP2 for any unquantized
causal LM, but --use_fsdp2 refuses a Nemotron-H checkpoint carrying MTP
weights. Name that as well.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The test of the spec's prepare hook had it return a sentinel, so it held
only that apply_distributed_support returns what prepare returned. A
replacement config must also carry over the plan the spec attached, as
EPSpec.prepare's contract asks: the stand-in now returns one that does,
and the test checks the returned config is that one and holds the plan.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
apply_distributed_support required the nested config a spec names for
its plan before it looked for a plan the config already carries, so a
config with a plan of its own at its root, which wins and which the
expert-parallel load reads there, was refused for lacking a nested config
nothing needed. A plan the config carries is now found first; with none,
a config without the nested config is still refused, naming both.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The load builds from the config a spec's prepare returns. One returning
None, or a config carrying no base_model_ep_plan where the load reads
one, failed only later in the load, far from the spec at fault. It is now
refused as prepare returns, naming the model type.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The registered-plans check took only the routed experts' styles and
grouped_gemm, though the expert-parallel load also keeps ep_router, which
it swaps for its torch_a2a router. The check now accepts it, and its
failure says the load drops the style and which it keeps.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
_spec_ep_plan returns only the plan a spec supplies. For qwen3_moe and
minimax_m3_vl transformers ships the plan on the config, which
apply_distributed_support keeps, so their specs supply none.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
apply_distributed_support attaches base_model_ep_plan and then runs
prepare, which attaches prepared_ep_plan, so a spec declaring both would
load with their union while _spec_ep_plan reports one of them. The
registry test now refuses such a spec, as prepared_ep_plan's docstring
already intends it for a plan only prepare supplies.

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

Copy link
Copy Markdown
Collaborator Author

/ok to test 4cfbddf

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-9a2-ep-spec-applier branch from f9c898a to 4cfbddf Compare November 1, 2026 19:52
shengliangxu added a commit that referenced this pull request Nov 1, 2026
… refused

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

#3135 now refuses a spec prepare that returns no config, or one with no plan where the expert-parallel load reads one. Its refusal comes after the record of what the prepare patched for every later load: the patches stay in the process whatever it returned. A test has a Kimi-K3 prepare return None and checks that both types it spoils are recorded; it fails with the refusal above the record. The reload-refusal test's stand-in prepare now attaches the plan the spec states, as the real one does.

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