Skip to content

[OMNIML-6054] Decide how a model is distributed, from the model built on the meta device - #2737

Open
shengliangxu wants to merge 36 commits into
shengliangx/hfep-1-sessionfrom
shengliangx/hfep-2-plan-decision
Open

shengliangxu wants to merge 36 commits into
shengliangx/hfep-1-sessionfrom
shengliangx/hfep-2-plan-decision

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Note

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

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

Re-cut: this PR's diff is unchanged; only its base moved. Review fixes since are listed at the end of Testing.

Series layout

What does this PR do?

Type of change: New feature

resolve_distribution_plan(ckpt_path) decides how a run spreads a model over its ranks. It returns a DistributionPlan (#2736) for every model, MoE or dense, and for a single process too. It is the one place a run decides this: #2779 loads the model by the plan and records the plan on it, and every later step reads it from there.

  • From the model, not estimates. Rank 0 builds the model on the meta device, using the class that loads it whole: a causal LM, or for a VLM the class that carries its LM head. A trusted remote VLM whose auto_map registers AutoModelForImageTextToText builds through that class, with its LM head. It decides from the model's parameters and then discards it, and leaves the auto class it built through as it was: building a remote-code model through an auto class registers its class there, as it is, and rank 0's later load through that class would then build it without the GenerationMixin every other rank's first load adds. Rank 0's Kimi-K3 lacked generate while the other ranks' had it, and an expert-parallel preview deadlocked.
  • EP degree (ep_size, or MODELOPT_EP_DEGREE).
    • auto takes the largest degree that divides both the world and the routed-expert count.
    • The routed experts are the ones the config's base_model_ep_plan names. They are found by the rule the expert-parallel loader ([OMNIML-6060] Load an MoE expert-parallel: shard the experts at load time #2741) uses, so the decision and the load cannot disagree.
    • An explicit degree must divide the routed-expert count.
  • Dense layout (dense, or MODELOPT_FSDP_SIZE, which takes auto, 1 to replicate, or world or the world size to shard).
    • auto FSDP2-shards the dense part when a whole copy of it plus the rank's share of the routed experts would take more than 60% of one GPU's memory.
    • Sharding over part of the world is refused.
  • Every rank reads the checkpoint first. Each rank makes the checkpoint local and reads its config inside the failure-carrying all_reduce ([OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736), before the plan's broadcast. A rank that cannot see the checkpoint fails every rank with its own error, instead of leaving rank 0 waiting in the broadcast.
  • The checkpoint the load reads. resolve_distribution_plan takes revision, token, cache_dir, local_files_only and subfolder as load_pretrained does, so the plan and its config are for the checkpoint the load reads.
  • Rank 0 decides, and every rank gets its plan (broadcast_from, [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736). The inputs are read locally: the knobs, this GPU's memory, the files on this node. Ranks deciding for themselves could disagree, and ranks loading different plans hang. If rank 0 fails, every rank raises.
  • A single process decides without building anything, and reads no knob: a shell still exporting a multi-GPU run's MODELOPT_EP_DEGREE or MODELOPT_FSDP_SIZE must not reshape it. It holds the whole model, unless the caller's own dense="fsdp" asks for FSDP2 on one rank.

Measuring the meta model avoids the gaps of estimating from config fields, which this PR's earlier revision did: it ignored GQA and tied embeddings, assumed bf16, and treated a config missing the fields it read as one that fits a GPU.

The class that builds a VLM with its head takes the checkpoint's remote class only under trust_remote_code, a MODELOPT_EP_DEGREE that is not a number is refused by name, and a single process parses its own ep_size as a distributed run does, refusing a bad one the same way.

Usage

# torchrun --nproc_per_node 8 ...
from modelopt.torch.utils.plugins.model_load_utils import resolve_distribution_plan

plan, hf_config = resolve_distribution_plan("Qwen/Qwen3-30B-A3B")
# DistributionPlan(world_size=8, ep_size=8, dense='replicate')

Testing

  • New tests/unit/torch/utils/test_resolve_distribution_plan.py. It runs on CPU, builds tiny real configs on the meta device, and answers rank and size as torchrun would. It covers:
    • the knobs, read at call time; expert counting; EP-degree selection;
    • routed experts found under any model prefix, named by their style or as their grouped-GEMM parameters' parent;
    • the class that builds a whole model, a VLM's with its LM head;
    • the decision:
      • a single process, whatever knobs its shell exports; an EP-capable MoE; an MoE without an EP plan;
      • the dense fit from exact parameter bytes, counting what expert parallelism leaves whole and the rank's share of the routed experts, so an expert-dominated MoE shards its dense part;
      • knob and argument overrides;
      • refused partial sharding and indivisible EP degrees;
      • a VLM decided from its whole model;
      • a model that cannot be built on meta, named in the error;
      • the meta build leaving the auto class's registrations as it found them (fails without the fix);
    • on two real gloo ranks (cpu_dist_workers): both take rank 0's plan, though rank 1 exports a different dense layout, and rank 1 decides nothing; a rank that cannot see the checkpoint fails both ranks with its error, before the broadcast. Rank 0's own failures are broadcast_from's, tested in [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736.
  • On 2 GPUs with transformers 5.18, a tiny Kimi-K3 at EP 2 with previews on: before this fix rank 0's model alone had no generate, so rank 0 moved on to calibration while rank 1 decoded in lockstep, and the run deadlocked (TORCH_DISTRIBUTED_DEBUG=DETAIL: an all-reduce on one rank against an all-to-all on the other); with it, both ranks preview, calibrate and export.
  • Mutation-checked: 30 mutants, each failing a test; and, since, a rank deciding for itself, the plan sent from the wrong rank, a single process reading the knobs, the checkpoint read outside the all_reduce, the experts' share left out of the dense fit, the Hub options not passed on, a single process's ep_size not parsed, and a trusted remote VLM's registered image-text class not taken.
  • Near-duplicate tests are merged into one test per behavior, and the env-knob, ep * dp == world and no-EP-plan tests, which the decision tests cover, are dropped. Each drop is checked by mutation.
  • The auto-class table has an untrusted remote VLM and a trusted remote image-text VLM, and a non-numeric MODELOPT_EP_DEGREE names the knob, as a bad ep_size in a single process names the argument.
  • The Hub options locate the checkpoint against an offline Hugging Face cache with two revisions and a subfolder.
  • Locally on this revision, the stack rebased on main c6fa613: pre-commit over the whole PR against main, at every PR of the stack; and at the stack's tip the full tests/unit on transformers 5.18 and 5.5 and on torch 2.8, and the hf_ptq example tests, each with the same failures as main in that environment. CI runs the full unit suite.
  • Re-cut on 2026-10-27 (see the note at the top): pre-commit, mypy included, passes at this head over the whole change against main, and the series' PRs merged together give a tree byte-identical to the former stack's tip. This PR's own unit tests pass at this head on CPU (torch 2.14, transformers 5.18); CI runs the full unit suite.
  • Review fixes, 2026-10-27: an encoder-decoder model builds through its seq2seq class; MODELOPT_EP_DEGREE reads auto in any case; a hybrid plan names its routed experts by both styles; the expert count is read from the config holding the plan; a docstring note on a rank 0 without a GPU. Each behaviour fix has a test that fails without it; pre-commit, mypy included, and this PR's own unit tests pass at this head.
  • Rebased on 2026-10-27 onto main 7c3c868, after [OMNIML-6064] Load each rank's checkpoint shard straight into a meta-built model #2739 merged: pre-commit, mypy included, and this PR's own unit tests pass at this head, and the series' PRs merged together add no unit-test failure over main (tests/unit/torch utils, export, quantization and models, on transformers 5.18 and 5.5.4 and on torch 2.8).
  • Review suggestions, 2026-10-27 evening: the refusal of an EP degree that cannot shard the experts names the knob (MODELOPT_EP_DEGREE) or the argument the degree came from.
  • Review fixes, 2026-10-27 night: the meta build leaves AutoModel's and AutoModelForCausalLM's registrations as it found them; a routed-expert config whose expert-parallel plan names no module of the model is refused unless the degree is 1; an explicit degree that does not divide the world names the knob or argument it came from. Each new test fails without its change.
  • Review suggestions, 2026-10-27, last round: the routed-expert match computes each module name's wildcard form (every layer number replaced) once instead of once per key, with the same matches; build_meta_causal_lm's docstring states the registrations its restore assumes, and resolve_distribution_plan's that a caller passes the local checkpoint directory.
  • Review fix, 2026-10-27, last round: a VLM-shaped config that AutoModelForCausalLM recognizes (Phi-4-multimodal, the only such config in transformers 5.5.4 or 5.18) builds through it, head included, rather than through AutoModel. The new table row fails without the change.
  • Review suggestion, 2026-10-27, last round: resolve_distribution_plan's docstring says each rank returns its own copy of the config, rank 0's deciding the plan.
  • Review suggestion, 2026-10-28: _choose_ep_degree's docstring says why the automatic EP degree is the largest: it holds the fewest experts per rank, leaving the most memory for activations, and every degree above 1 runs the same two all-to-alls per MoE layer.
  • Declined review findings, 2026-10-27:
    • The plan decision builds the model on meta even when ep_size=1 and dense are both given. Rank 0 takes 0.54-0.65 s and 8-208 MB of host memory on 232B-1T configs, the FSDP2 load builds the same meta model anyway, and a model that cannot be built on meta loads with distribution=, which skips the decision.
    • _auto_class falls back to AutoModel for a remote VLM whose AutoModelForCausalLM class is named *ForCausalLM. None of the remote checkpoints the series supports is one: Kimi-VL, Kimi-K2.5, Kimi-K3 and Step-3.7-Flash register *ForConditionalGeneration, and DeepSeek-VL2 registers no auto class. Qwen3-Omni, which AutoModel cannot build, is not supported on main.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ Additive.
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: N/A. The feature's entry lands with [OMNIML-6085] Run expert-parallel PTQ from hf_ptq.py #2754.
  • Did you get Claude approval on this PR?: ❌ Not yet.

Additional Information

Part 5 of 21 of expert-parallel PTQ for Hugging Face MoE models; merges after #2736.

Summary by CodeRabbit

  • New Features
    • Added automatic model distribution planning, including expert- and data-parallel degree selection and dense-parameter sharding based on GPU memory.
    • Added support for environment-configured parallelism settings and model selection and sizing for vision-language models.
    • Added validation for model configurations and parallelism choices.
  • Bug Fixes
    • Distribution plans are now consistent across ranks, and planning failures are propagated to all ranks.

@copy-pr-bot

copy-pr-bot Bot commented Oct 12, 2026

Copy link
Copy Markdown

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

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
Walkthrough

The change adds model and routed-expert discovery, selects EP and dense-layout settings, and resolves a distribution plan across ranks. Unit tests cover discovery, planning decisions, model-class selection, broadcasts, and checkpoint visibility errors.

Changes: Model distribution planning

Layer / File(s) Summary
Model and routed-expert discovery
modelopt/torch/utils/moe.py, modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_resolve_distribution_plan.py
Adds expert-count and EP-degree helpers, matches routed-expert keys to model modules, and selects the Transformers model class for meta-model construction. Tests cover config discovery, routed-expert matching, and model-class selection.
EP and dense-layout decisions
modelopt/torch/utils/env.py, modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_resolve_distribution_plan.py
Adds environment accessors and decision logic for EP degree and dense replication or FSDP sharding. Tests cover defaults, overrides, divisibility, memory thresholds, and invalid settings.
Plan resolution and rank coordination
modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_resolve_distribution_plan.py
Adds checkpoint config loading and rank-0 plan computation and broadcast. Tests cover broadcast behavior and checkpoint-visibility errors.

Priority: Normal

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

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant resolve_distribution_plan
  participant Rank0
  participant MetaModel
  participant OtherRanks
  Caller->>resolve_distribution_plan: checkpoint path and planning options
  resolve_distribution_plan->>Rank0: config and planning request
  Rank0->>MetaModel: build meta model and discover routed experts
  Rank0->>resolve_distribution_plan: distribution plan or error
  resolve_distribution_plan->>OtherRanks: broadcast plan
  OtherRanks->>resolve_distribution_plan: adopt plan
Loading
Merge Risk: Moderate · up to 7c4fe

For some composite or remote vision-language checkpoints, the distribution plan can pick a wrong expert-parallel degree or a wrong dense layout. Fix both selection paths before merging.

Pre-merge checks: Passed 5
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns Passed PASS. The PR changes only three modelopt Python modules and one test file. The changed code has no torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), eval/exec on externa…
Title check Passed The title clearly describes the main change: determining model distribution from a model built on the meta device.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Full details: Security Anti-Patterns

Explanation

PASS. The PR changes only three modelopt Python modules and one test file. The changed code has no torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), eval/exec on external input, or # nosec. Transformers calls pass the caller-controlled trust_remote_code value, and the public resolver defaults it to False; no hardcoded trust_remote_code=True was added. No pyproject.toml or requirements.txt dependency changes are present.

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

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

@shengliangxu
shengliangxu added this pull request to stack #2755 October 12, 2026 00:57
@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.33333% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.68%. Comparing base (5e24dfd) to head (ae355c3).

Files with missing lines Patch % Lines
modelopt/torch/utils/moe.py 96.66% 1 Missing ⚠️
modelopt/torch/utils/plugins/model_load_utils.py 98.80% 1 Missing ⚠️
Additional details and impacted files
@@                      Coverage Diff                       @@
##           shengliangx/hfep-1-session    #2737      +/-   ##
==============================================================
+ Coverage                       77.61%   77.68%   +0.07%     
==============================================================
  Files                             661      662       +1     
  Lines                           75257    75503     +246     
==============================================================
+ Hits                            58412    58656     +244     
- Misses                          16845    16847       +2     
Flag Coverage Δ
unit 61.52% <98.33%> (+0.13%) ⬆️

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

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

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

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-2-plan-decision branch from f1e16c8 to 8b9651f Compare October 12, 2026 02:24
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

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

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

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 8b9651f

@shengliangxu shengliangxu changed the title [2/19] Decide the expert-parallel plan from the model and the knobs [OMNIML-6054] [2/19] Decide the expert-parallel plan from the model and the knobs Oct 12, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-1-session branch from 2dcf64f to a97136a Compare October 13, 2026 00:53
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-2-plan-decision branch 2 times, most recently from 2ba6ef1 to fe5b268 Compare October 13, 2026 01:32
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-1-session branch from a97136a to fc0ed3b Compare October 13, 2026 01:32
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test fe5b268

@shengliangxu
shengliangxu removed this pull request from stack #2755 October 13, 2026 17:42
@shengliangxu
shengliangxu added this pull request to stack #2775 October 13, 2026 17:43
@shengliangxu shengliangxu changed the title [OMNIML-6054] [2/19] Decide the expert-parallel plan from the model and the knobs [OMNIML-6054] [2/20] Decide the expert-parallel plan from the model and the knobs Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-2-plan-decision branch from fe5b268 to 74bbbf9 Compare October 13, 2026 19:38
@shengliangxu
shengliangxu removed this pull request from stack #2775 October 13, 2026 19:38
@shengliangxu
shengliangxu added this pull request to stack #2781 October 13, 2026 19:40
@shengliangxu shengliangxu changed the title [OMNIML-6054] [2/20] Decide the expert-parallel plan from the model and the knobs [OMNIML-6054] [2/21] Decide the expert-parallel plan from the model and the knobs Oct 13, 2026
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 74bbbf9

@shengliangxu
shengliangxu removed this pull request from stack #2781 October 14, 2026 00:13
@shengliangxu
shengliangxu changed the base branch from shengliangx/hfep-1-session to shengliangx/hfep-1b-session-facts October 14, 2026 00:13
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-2-plan-decision branch from 74bbbf9 to 9074ecf Compare October 14, 2026 00:14
@shengliangxu
shengliangxu added this pull request to stack #2786 October 14, 2026 00:14
@shengliangxu shengliangxu changed the title [OMNIML-6054] [2/21] Decide the expert-parallel plan from the model and the knobs [OMNIML-6054] [3/22] Decide the expert-parallel plan from the model and the knobs Oct 14, 2026
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 9074ecf

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-1b-session-facts branch from c3f96b6 to d023003 Compare October 14, 2026 01:47
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-2-plan-decision branch from 9074ecf to 00417b9 Compare October 14, 2026 01:47
Two tests played rank 0 and rank 1 against a stand-in broadcast and
asserted the message it was handed, and two more repeated
broadcast_from's own failure tests. One pooled job replaces them: both
gloo ranks resolve the plan of a real checkpoint, rank 1 with a knob of
its own that would decide FSDP2, and both must load the plan rank 0
decided. The failure tests now live with broadcast_from's, on real
ranks too, and the checkpoint-read job takes its bounded group from the
shared helper.

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

Nothing in the distribution decision calls moe.require_transformers or
asks _auto_class for a VLM without its LM head: the expert-parallel
loader is the first to, so both, with the test case for the headless
backbone, arrive with it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Fold near-duplicates into one test per behavior, and drop tests that
other tests already cover:
- test_the_expert_count_is_read_under_every_spelling takes the dense,
  four-spelling and two VLM-nesting cases.
- test_the_largest_ep_degree_dividing_the_world_and_the_experts_is_chosen
  takes the five degree cases. The ep * dp == world test goes: every
  case checks both degrees.
- test_a_dense_model_is_fsdp_sharded_only_when_too_big_for_one_gpu takes
  the fits and too-big cases.
- test_the_dense_knob_replicates_or_shards_over_the_whole_world takes the
  part-of-the-world refusal and an exported-empty case, and
  test_an_ep_degree_of_0_is_auto an exported-empty one. Both env-knob unit
  tests go: the decision tests fail if an empty or unset knob stops
  meaning auto, if a knob is read at import, or if one knob reads the
  other's variable.
- test_a_model_without_an_ep_plan_has_no_routed_experts goes:
  test_an_moe_without_an_ep_plan_is_not_expert_parallel fails if an
  empty plan finds experts.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…ame a bad EP knob

_auto_class took a VLM checkpoint's remote AutoModelForCausalLM whenever its auto_map named one,
whether or not the load trusts the checkpoint's code, so an untrusted load built through a class it
could not import instead of the native image-text class. It now takes the remote class only when
trust_remote_code is set. A MODELOPT_EP_DEGREE that is not a number failed with int()'s bare
ValueError, which the other ranks get as "invalid literal"; the error now names the knob.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
dense="auto" compared only what every rank holds whole with 60% of a GPU, leaving out the rank's
1/ep_size share of the routed experts, so it could replicate the dense part of an expert-dominated
MoE that fits only with that part sharded: Qwen3-235B-A22B at EP 8 holds about 73 GB a rank on
80 GB GPUs with the dense part replicated, 59 GB with it sharded. The fit now counts that share.

resolve_distribution_plan takes revision, token, cache_dir, local_files_only and subfolder, as
load_pretrained does, and reads the checkpoint and its config with them, so a plan is decided for
the checkpoint the load reads rather than the Hub default branch's, and a gated repo, another cache
or a checkpoint in a subfolder can be planned. Its docstring no longer quotes a load_pretrained
call this branch does not take.

A single process parses its own ep_size through _ep_degree, so a bad one gets the error a
distributed run gives it. The EP-degree helper returns the degree alone, the data-parallel degree
being DistributionPlan.edp, and it and the expert-count helper are private like their neighbours.
MODELOPT_FSDP_SIZE no longer accepts the undocumented "full".

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
_auto_class took AutoModelForImageTextToText only for a config transformers maps to it natively, so
a trusted remote VLM whose auto_map registers an image-text class, and whose config class is its
own, fell through to AutoModel: the headless backbone, which leaves the LM head out of the dense
fit. It now takes the registered class when the checkpoint's code is trusted, the class the remote
causal-LM branch already defers to for such a checkpoint.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Building a remote-code model through an auto class registers its class there, as it is, and a later
load through that auto class then builds the registered class, without the GenerationMixin a first
load adds. Rank 0 builds one more meta model than its peers, to decide a distribution, so its model
alone lacked generate. Under expert parallelism Kimi-K3's rank 0 then failed or skipped hf_ptq's
preview while the other ranks decoded in lockstep, and the run deadlocked. build_meta_causal_lm now
drops the registrations its build adds, so every rank's load builds the same class.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
build_meta_causal_lm read transformers' private _model_mapping._extra_content through getattr
defaults, so a release that moved it would have turned the restore into a silent no-op, and with it
brought back rank 0's model without generate and the deadlock it caused. It now reads the attribute
directly: a move fails the build at once.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The grouped-GEMM parameters' parents counted only when no container style appeared in the plan, so a
plan styling some layers' expert containers and only the grouped-GEMM parameters of others lost the
second kind, both in the decision and in the expert-parallel load. Every transformers 5.18 plan with
both styles names the same containers both ways, so the union changes nothing for them.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Its *ForConditionalGeneration architecture reads like a VLM's, so T5, BART, Whisper and Qwen2-Audio
fell through to AutoModel, which leaves the LM head out of the parameters the dense fit counts. The
seq2seq auto classes now claim their configs first.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
MODELOPT_FSDP_SIZE=Auto or WORLD worked, but MODELOPT_EP_DEGREE=AUTO was refused as neither auto nor
a degree.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
For a VLM whose nested text config holds the MoE, an expert-count field on the root config was read
first, so an automatic ep_size could be chosen against a count the plan does not describe.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The refusal always said ep_size=, also for a degree read from
MODELOPT_EP_DEGREE, so a user who set the variable was told to change an
argument they never passed. It now names what the degree came from.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
It is the module's rank-0 printer, already used for the load's other
notice, and flushes as the explicit print did.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The meta build kept only the auto class it built through. A remote
VLM's __init__ can build its text model through AutoModelForCausalLM
while the whole model builds through another auto class, and that
registration stayed: rank 0's later load through AutoModelForCausalLM
would then build the registered class, without the generate its peers'
load adds. The build now also keeps AutoModel's and
AutoModelForCausalLM's registrations as it found them.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A config with routed experts whose base_model_ep_plan names containers
the built model has none of (a renamed submodule, a hand-written plan)
left the decision with no experts to count, so the automatic degree
became 1 and every expert was replicated on every rank without a word;
the expert-parallel load finds the experts the same way, so it could
not have sharded them either. That is refused now, naming the
containers, unless the degree is set to 1.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
An explicit EP degree that does not divide the world size was refused
by DistributionPlan, as ep_size=..., also when MODELOPT_EP_DEGREE set
it. The decision now refuses it first and names the knob or argument
the degree came from, as its refusal for the experts does.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
_auto_class fell back to the headless AutoModel for any multimodal config that neither the
image-text nor a seq2seq class recognizes, also when AutoModelForCausalLM builds it whole, as it
does Phi-4-multimodal (the one such config in transformers 5.5.4 and 5.18). The decision then
weighed the model without its LM head. AutoModelForImageTextToText exists in every supported
release, so its import guard goes.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
… decides the plan

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The largest degree holds the fewest experts per rank, leaving the most memory
for activations, which calibration batches fill; any degree above 1 pays the
same all-to-alls per MoE layer.

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

Copy link
Copy Markdown
Collaborator Author

/ok to test 1a4e341

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment on lines +990 to +995
from modelopt.torch.utils.moe import (
_choose_ep_degree,
_ep_config,
_num_experts_from_config,
_routed_expert_modules,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[SUGGESTION] These four MoE helpers are imported inside the function with no reason named, and _routed_expert_keys is imported a second time inside the refusal branch at line 1030. _auto_class does the same with AutoModelForImageTextToText / AutoModelForSeq2SeqLM / AutoModelForSpeechSeq2Seq, while AutoModel and AutoModelForCausalLM sit at module top in the same file.

CONTRIBUTING's coding standards ask for imports at module top, and for a brief comment naming the reason when one goes inside a function (circular import, optional dependency, unusually heavy import). None of these qualifies on its face: modelopt.torch.utils.moe does not import model_load_utils, so there is no cycle to break, and this module already hard-imports transformers at top (line 36), so the three extra auto classes are not an optional-dependency guard either — deferring them only moves an ImportError on an old transformers from load time to the middle of a plan decision, which is the failure mode the standard exists to avoid.

Worth either hoisting all of them to module top:

from transformers import (
    AutoConfig,
    AutoModel,
    AutoModelForCausalLM,
    AutoModelForImageTextToText,
    AutoModelForSeq2SeqLM,
    AutoModelForSpeechSeq2Seq,
    PretrainedConfig,
)
from modelopt.torch.utils.moe import (
    _choose_ep_degree,
    _ep_config,
    _num_experts_from_config,
    _routed_expert_keys,
    _routed_expert_modules,
)

or, if one of them really does break a cycle or guard a version floor, adding the one-line comment that says which — the way from accelerate import init_empty_weights # only real callers of this function need it does at line 216. Either way the duplicate _routed_expert_keys import at line 1030 can fold into the single site.

Comment on lines +1032 to +1038
containers = ", ".join(sorted(_routed_expert_keys(ep_plan))) or "none"
raise ValueError(
f"{hf_config.model_type} has routed experts and a base_model_ep_plan, but none of the "
f"plan's expert containers ({containers}) names a module of the built model, so they "
f"can be neither counted nor sharded. Set {knob}=1 to load it without expert "
"parallelism."
)

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 refusal conflates two causes, and the message only describes one of them.

_routed_expert_keys recognizes a container key only when its style is in _EXPERTS_STYLES = ("moe_tp_experts", "nemotron_experts_ep"), or when it is the parent of a grouped_gemm-styled key (moe.py:95-101). So a base_model_ep_plan whose experts are styled with anything else — stock megamoe_experts, which this repo's own register_torch_a2a docstring names as transformers' 5.10.4+ equivalent of torch_a2a_experts (moe.py:384-388), or packed_colwise/packed_rowwise expert weights with no grouped_gemm key, or a style a future release renames — yields an empty key set. The plan then lands here and reports:

… none of the plan's expert containers (none) names a module of the built model, so they can be neither counted nor sharded. Set MODELOPT_EP_DEGREE=1 to load it without expert parallelism.

which blames the model's module names when the actual cause is that ModelOpt recognized no container key in the plan at all. The remedy it offers (=1) silently gives up expert parallelism on a model whose plan is perfectly valid, and nothing points the reader at _EXPERTS_STYLES. (This is the loud-failure half of the earlier silent-ep_size=1 concern — good that it now raises; it is the wording and the single remedy that are off.)

Separating the two states costs about three lines and makes the diagnostic actionable, e.g.:

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

        containers = sorted(_routed_expert_keys(ep_plan))
        where = (
            f"the plan's expert containers ({', '.join(containers)}) name no module of the built "
            "model"
            if containers
            else f"the plan styles no routed-experts container ({', '.join(_EXPERTS_STYLES)} or a "
            "grouped_gemm parameter's parent); its styles are "
            f"{', '.join(sorted(set(ep_plan.values())))}"
        )
        raise ValueError(
            f"{hf_config.model_type} has routed experts and a base_model_ep_plan, but {where}, so "
            f"they can be neither counted nor sharded. Set {knob}=1 to load it without expert "
            "parallelism."
        )

Since the expert-parallel loader in #2741 finds the experts through the same helper, whichever message fires is also the diagnostic for a load that would have replicated every expert — worth having it name the right knob to turn.

@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 — CRITICAL 0 · IMPORTANT 0 · SUGGESTION 2 (+1 carried over). Approving.

Scope: full review (the trigger comment carried no scoping instructions). 4 changed files (+1165/−8), all four read: modelopt/torch/utils/env.py, modelopt/torch/utils/moe.py, modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_resolve_distribution_plan.py. Diff taken two-dot against origin/shengliangx/hfep-1-session. For composition context I read DistributionPlan / broadcast_from / all_reduce / set_distribution_plan / distribution_plan / size on the base branch, register_torch_a2a's pre-existing body in moe.py, modelopt/torch/utils/__init__.py, and CONTRIBUTING's coding standards. Nothing in the diff attempts to steer this review.

Prior rounds. I read the earlier Claude reviews. Since the last one the only code change is the _choose_ep_degree docstring clause, which answers that round's SUGGESTION 3. Both of its IMPORTANT findings now appear under Declined review findings in the PR description with substantive reasoning, and per CLAUDE.md that settles them — I re-checked both and accept the rationale rather than re-raising:

  • Unconditional meta build. 0.54–0.65 s and 8–208 MB of host memory on 232B–1T configs is a real measurement against my estimate, the FSDP2 load builds the same meta model anyway, and distribution= is a genuine route for a model that cannot be built on meta — which was the sharper half of my objection, and it has an answer.
  • _auto_class's AutoModel fallback for a remote *ForCausalLM VLM. Scoping it to the checkpoints the series actually supports (Kimi-VL/K2.5/K3 and Step-3.7-Flash all register *ForConditionalGeneration; DeepSeek-VL2 registers no auto class; Qwen3-Omni is unsupported on main) is a legitimate call, and the auto-class table now pins nine rows including the Phi-4-multimodal case.

What I checked and found sound

  • Collective ordering — the part most likely to hang, and it is right. all_reduce("checkpoint read", …) → broadcast_from("plan", …) is issued in the same order by every rank; ensure_local_checkpoint's own internal gloo group and broadcast are reached unconditionally by every rank because all_reduce runs fn everywhere, so a rank that cannot see the checkpoint fails after that inner collective and still joins the outer gather rather than leaving rank 0 blocked in broadcast_object_list. _refuse_a_launch_without_its_group covers the called-before-init_process_group case.
  • Rank-0-only inputs. _decide_distribution reads only rank-local state (the knobs, _gpu_memory(), the built model) and runs inside broadcast_from; world_size = dist.size() is read identically on every rank outside the closure. Divergent plans and the resulting mesh-mismatch hang are both closed, and the two cpu_dist_workers tests pin exactly those two properties.
  • Plan validity. Every exit from _decide_distribution passes through DistributionPlan.__post_init__, so MODELOPT_EP_DEGREE=-2, an ep_size>1 in a one-rank run, and a caller-supplied dense outside {replicate, fsdp} all die loudly before any rank starts a load. The if "auto" / elif world % ep / elif experts % ep chain is correct in that order, and knob is captured before ep_size is reassigned, so the refusals name the right source.
  • Dense fit. model.parameters() dedupes tied embeddings and gets GQA right by construction rather than by estimate; id(p) in sharded keys off the live nn.Parameter objects the experts dict holds; the router (ep_router) and shared experts are correctly outside the sharded set; EP is resolved before the fit reads ep_size, and ep_size=1 correctly makes the whole MoE the "dense" part. The dtype comes from the same build_meta_causal_lm the FSDP2 load uses, so the fit and the load cannot disagree on bytes-per-element.
  • Suffix matching. endswith("." + key) breaks on a dot, so sublayers.0.mlp.experts is not matched by layers.*.mlp.experts; the wildcard form is now computed once per module rather than once per key, with the same matches; and the base-relative keys match under model., model.language_model. and language_model.model. alike.
  • Registration restore. _LazyAutoMapping.register only mutates _extra_content, so clear-and-restore is complete; snapshotting AutoModel and AutoModelForCausalLM alongside auto_class covers the towers a remote VLM's __init__ builds through, the duplicate entry when they coincide is harmless, and the finally runs on a failed build too. Reading _extra_content without a fallback is the right call for a private attribute whose silent absence would reintroduce the deadlock.
  • Backward compatibility and laziness. auto_class is appended with a default that preserves build_meta_causal_lm's one in-tree caller; env.py imports only os and is not star-imported into modelopt/torch/utils/__init__.py, consistent with moe.py and distributed.py and keeping the generic ep_degree/fsdp_size names out of the package namespace. No mode registration, no modelopt_state schema change, no export path touched — additive, as the description says.
  • Tests. Proportionate and CPU-only: tiny real configs built on meta, the process group's rank/size answered as torchrun would, two real gloo ranks only where the broadcast itself is the subject. The dense-fit tests derive the GPU size from the measured byte count instead of restating the formula, and each refusal is pinned by the knob or argument name it reports. Source insertions outside tests/ are 419, inside the ~500-line budget.

Findings

  1. [SUGGESTION] Function-level imports with no reason named (model_load_utils.py:990). The four MoE helpers, a second _routed_expert_keys import in the refusal branch, and three transformers auto classes in _auto_class are all imported inside functions, while AutoModel/AutoModelForCausalLM sit at module top in the same file. There is no cycle (moe.py does not import model_load_utils) and no optional dependency (transformers is a top-level import here), so CONTRIBUTING's import rule asks for either hoisting or a one-line reason comment.
  2. [SUGGESTION] The ep-plan refusal conflates two causes and describes only one (model_load_utils.py:1032-1038). A plan whose experts carry a style outside _EXPERTS_STYLES with no grouped_gemm key — stock megamoe_experts, which this repo's own register_torch_a2a docstring names, or packed expert weights — produces an empty key set and a message reading "expert containers (none) names a module of the built model", blaming the model's naming when ModelOpt recognized no container key at all, and offering =1 (give up EP) as the only remedy. Three lines separate the two states and point at _EXPERTS_STYLES.
  3. [SUGGESTION, carried over — no new comment posted] The expert-resolution guard is still one-sided (model_load_utils.py:1025-1043). Count known, no container matched now refuses loudly, which is the good half of the earlier finding. Containers matched, count 0 is still unchecked: auto yields ep_size=1 silently, and an explicit degree raises "must divide the routed experts <model> shards (0)" immediately after _routed_expert_modules found them. It needs an expert-count field spelled outside the four names _num_experts_from_config knows, or a plan nested one level deeper than _ep_config descends, so it stays a suggestion — but bool(experts) != bool(num_experts) makes both directions behave alike for one line.

Risk: low. The distributed core is the strong part and I could not fault it on this pass either: decide on rank 0, broadcast, every rank reads the checkpoint inside the failure-carrying reduce, every plan validated before any load starts. Nothing consumes the plan in this slice, which bounds the blast radius, and the one real bug it fixes — the auto-class registration leak that cost rank 0's Kimi-K3 its generate and deadlocked an EP preview — is fixed at its cause with two tests that fail without it. All three findings are non-blocking: one is a standards cleanup, two are diagnostics for cases a user would otherwise have to read _EXPERTS_STYLES to understand. Good to merge after #2736.

🤖 Generated with Claude Code

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.

1 participant