Skip to content

[OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank - #2742

Merged
shengliangxu merged 23 commits into
mainfrom
shengliangx/hfep-6b-ep-dense-sharding
Nov 3, 2026
Merged

shengliangxu merged 23 commits into
mainfrom
shengliangx/hfep-6b-ep-dense-sharding

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Note

Re-parented on 2026-10-30 onto #3136, #2741 [2/3] (the expert-parallel loader). #2741 was split into three PRs on review: #3131 [1/3], #3136 [2/3] and #2741 [3/3], its post-load refusals. This PR needs only the loader, so it sits on [2/3] and no longer waits for [3/3]. The series first became a tree on 2026-10-27 (this PR was [10/21]).

Base: main, where #3136 and #2780 have merged (2026-11-03). The diff below is this PR's alone. #2780 merged first, so this PR carries what the two need together: it lifts #2780's refusal of an expert-parallel plan with a sharded dense part, and the plan decision's give-way to whole-model FSDP2.

Re-parent: this PR's diff is unchanged, except that its dense-part load re-anchors around #3136's load guard. from_pretrained, with this PR's warmup and dense-meta contexts, runs inside the guard. The dense part's FSDP2 + DCP load, which runs collectives, comes after it. The PR's original 37 commits are condensed into one; the review-round commits are kept.

Series layout

What does this PR do?

Type of change: New feature

#2741 replicates the dense (non-expert) part of an expert-parallel model on every rank. That does not fit when the dense part alone is bigger than a GPU, which the plan from #2737 already recognizes (dense="fsdp").

  • The dense part is FSDP2-sharded over every rank when the plan says so (dense="fsdp", with the experts sharded over every rank too), and nothing beside the plan decides it:

    • it is left on meta during from_pretrained;
    • each decoder layer is then fully_shard-ed around its experts, which stay expert-parallel. The blocks come from Hugging Face's own _no_split_modules (_fsdp2_wrap_modules), looked up on the language backbone rather than on a VLM wrapper that would also list its vision blocks. A VLM whose language model shows no blocks (no _no_split_modules match, no detected decoder layers) is refused, naming MODELOPT_FSDP_SIZE=1, instead of wrapped by the VLM's own _no_split_modules, which can name vision blocks;
    • each rank loads only its own shards of the dense weights from the checkpoint through DCP ([OMNIML-6064] Load each rank's checkpoint shard straight into a meta-built model #2739).

    No rank ever holds the whole dense part. A dense param the loader already placed as a replicated DTensor is re-registered as a plain local parameter first, so FSDP2 shards it like any other.

  • A block-FP8 checkpoint meets the loader's format refusal here, whatever its dense layout. This load does not dequantize block FP8; [OMNIML-6062] Dequantize block-FP8 checkpoints as they load expert-parallel #2743 adds that, with the rule that keeps a block-FP8 dense part replicated.

  • A dense part FSDP2-sharded within several expert-data-parallel replicas is refused before loading. The no-gather export cannot write that layout, so load_and_prepare_ep refuses a plan with dense="fsdp" and edp > 1 before it looks at anything, rather than after the load and calibration it would otherwise run. The error says to set MODELOPT_EP_DEGREE to the world size, or MODELOPT_FSDP_SIZE=1. A replicated dense part at edp > 1 stays supported. The decision knows the layout too: left to decide, it replicates such a dense part and warns that it may run out of memory, naming the same way out; asked to shard it, it refuses with the same advice.

  • The load-time allocator warmup is sized to the real per-rank footprint. transformers' caching_allocator_warmup otherwise reserves about the whole device on every rank, and torch keeps that reservation, starving the all-to-all's NCCL buffers. Now:

    • expert params count their 1/ep share;
    • dense params left on meta are not counted at all.

    The replacement forwards whatever else transformers passes the hook, and a hook whose leading parameters are not (model, accelerator_device_map, hf_quantizer) keeps the stock sizing. Warmup sizes measured while building this (moved here from the source):

    Model, layout Sized before Sized with this PR
    Nemotron-3-Ultra-550B, EP32 ~275 GiB per rank (experts at full size) ~91 GiB per rank
    Kimi-K3, EP64, dense part sharded 185.8 GiB on a 184.31 GiB card (dense part on meta counted at full size) dense part on meta not counted

A dense parameter off the model's dtype (an fp32 router) stays replicated, as in fsdp2_wrap, and the dense meta load refuses a transformers without the get_device hook it patches.

  • A rank that fails before the DCP load fails every rank. _meta_dcp_load (main's, from [OMNIML-6064] Load each rank's checkpoint shard straight into a meta-built model #2739; this PR's dense load is its only caller) runs its per-rank preparation (the checkpoint's metadata read, the mapping checks, to_empty) through the failure-carrying all_reduce before dcp.load, so one rank failing there no longer leaves the others waiting in the load. Main's FSDP2 load (parallel_load_and_prepare_fsdp2) fills its shards through set_model_state_dict, not this function, so the fix reaches only this PR's dense load.
  • Every alias of a tied parameter is loaded. The dense load names both aliases of a tied pair, so a tied checkpoint that stores only lm_head.weight fills both, as from_pretrained does.

Why shard the dense part, and why separate helpers

A model that needs it. Kimi-K3 at EP64 on GB200s (184.31 GiB each). With the dense part replicated, a rank holds the dense part whole and its 1/64 share of the routed experts. transformers' load warmup sized that at 185.8 GiB, more than the card before any activation. With the dense part sharded, 79.2 GiB of weights stayed resident per rank. The NVFP4 run over 16 GB200 nodes finished in about 10 minutes at a peak of 155 GiB per GPU, on an earlier build of this series.

What it costs where it is not needed. Qwen3-30B-A3B at EP16 on 4 nodes × 4 GB200s (transformers 5.9.0, torch 2.11). Both arms ran hf_ptq.py --recipe general/ptq/nvfp4_default-kv_fp8 --batch_size 2 --calib_size 512 --calib_seq 2048 --skip_generate with MODELOPT_EP_DEGREE=16, back to back in one allocation:

Per rank (max over the 16 ranks) Replicated (MODELOPT_FSDP_SIZE=1) FSDP2-sharded (MODELOPT_FSDP_SIZE=16)
Peak allocated after load 6.35 GiB 4.64 GiB
Peak allocated after calibration 8.41 GiB 6.83 GiB
Peak reserved after calibration 17.36 GiB 16.90 GiB
Load time 6.2 s 5.8 s
Calibration time 20.7 s 21.2 s
Export time 4.2 s 4.5 s

Sharding saves 1.7 GiB of resident dense weights per rank and costs about 0.5 s (2%) of calibration in the dense all-gathers. This model does not need it: its dense part is about 1.8 GiB. With MODELOPT_FSDP_SIZE unset, auto chose replicate and reproduced the replicated arm to within 0.01 GiB and 0.1 s. The two layouts export the same checkpoint (see Testing).

The 60% boundary is a heuristic. auto compares what a rank would hold with the dense part replicated (the dense part whole, plus its 1/ep share of the routed experts) with 60% of its GPU's memory. The other 40% is left for calibration's activations and the communication buffers, the all-to-all's among them. The two measured models sit far on either side of the line: Kimi-K3 just over 100% of the card before any activation, and Qwen3-30B-A3B at a few percent. No sweep across models and GPU sizes has placed the line itself. Where it picks wrong, MODELOPT_FSDP_SIZE=1 replicates the dense part and MODELOPT_FSDP_SIZE=<world size> shards it.

Why separate helpers, rather than extending the existing paths:

  • parallel_load_and_prepare_fsdp2 builds the model from its config, FSDP2-shards every decoder layer with its experts, and has each layer's owner read the layer and broadcast it to every rank. Under expert parallelism the routed experts are not FSDP2-sharded. from_pretrained places each rank's share of them on the expert-parallel mesh through transformers' tensor-parallel plan, and the all-to-all forward routes tokens to them. The hybrid keeps from_pretrained for the experts and shards only the rest; reusing the FSDP2 loader would mean re-implementing that placement in it.

  • fsdp2_wrap wraps each detected decoder layer whole, and the root by default, and refuses a model its detection misses. The hybrid has different wrap units:

    • each block is wrapped around its experts, which stay out of the FSDP2 groups;
    • the root stays replicated;
    • blocks come from the language backbone's _no_split_modules, so a VLM's vision blocks stay replicated.

    Folding that into fsdp2_wrap would change the wrap units and the accepted models of the existing FSDP2 load, so its callers keep it as it is.

  • Accelerate offload cannot combine with this load. transformers refuses a device_map beside the tensor-parallel placement the expert-parallel load requests: "tp_plan and device_map are mutually exclusive" on 5.5.4, and the same refusal on 5.18. Offload would also keep a whole copy of the dense part per rank in host memory and move it to the GPU for every calibration forward. Sharding holds 1/world of it and all-gathers one block at a time.

Usage

plan = DistributionPlan(size(), ep_size=size(), dense="fsdp")
model = load_and_prepare_ep(ckpt_path, plan, hf_config=config)

Testing

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ Additive; the dense part stays replicated unless asked.
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: N/A. The feature's entry lands with [OMNIML-6085] [3/3] Run expert-parallel PTQ from hf_ptq.py #2754.
  • Did you get Claude approval on this PR?: Approved 2026-10-28 at 05e21c2ba84, and again on 2026-10-30 at 32d57f5c81c, after the review fixes in Testing, which change main's _meta_dcp_load.

Additional Information

Part of expert-parallel PTQ for Hugging Face MoE models.

Summary by CodeRabbit

  • New Features
    • Expert-parallel model loading supports dense weights that are replicated or FSDP-sharded, offering more flexible memory and distribution options.
    • FSDP2 wrapping follows model-defined layer boundaries, including language-model layers in vision-language models.
    • Block-FP8 checkpoints are refused by this PR's loader, whatever the dense layout; [OMNIML-6062] Dequantize block-FP8 checkpoints as they load expert-parallel #2743 adds their dequantization and keeps their dense part replicated.
  • Bug Fixes
    • Improved loading and training consistency for expert-parallel models, including quantized configurations.

@copy-pr-bot

copy-pr-bot Bot commented Oct 12, 2026

Copy link
Copy Markdown

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

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review.

Walkthrough

EP loading now supports FSDP2 sharding for dense parameters while expert parameters remain expert-parallel. Block-FP8 checkpoints reject explicit dense FSDP loading. Automatic planning can select replicated dense weights. Tests cover planning, loading, sharding, forward behavior, gradients, and quantization.

Changes: Expert-parallel dense loading

Layer / File(s) Summary
Distribution planning and warmup sizing
modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_resolve_distribution_plan.py, tests/unit/torch/utils/test_ep_load_warmup.py
Block-FP8 automatic EP plans use replicated dense weights, while explicit dense FSDP sharding raises NotImplementedError. EP-aware warmup sizing accounts for expert shards and can exclude dense parameters on meta.
Wrap boundaries and dense parameter selection
modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_meta_dcp_load.py, tests/unit/torch/utils/test_ep_dense_sharding.py
FSDP2 wrapping uses detected model boundaries, with decoder-layer fallback and language-backbone selection for VLMs. Dense sharding excludes EP expert parameters.
Dense parameter loading
modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_ep_dense_sharding.py, tests/unit/torch/utils/test_ep_loader.py
The loader leaves selected dense weights and biases on meta, then FSDP2-shards and DCP-loads them. EP plans with dense FSDP proceed through this loading path.
Distributed forward and training validation
tests/gpu/torch/utils/test_expert_parallel_load.py
GPU tests compare forward outputs, gradients, and quantizer scales against whole-model references for replicated and FSDP2-sharded dense parameters.

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

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant load_and_prepare_ep
  participant from_pretrained
  participant _load_ep_dense_distributed
  participant _meta_dcp_load
  load_and_prepare_ep->>from_pretrained: Load experts and leave selected dense weights on meta
  load_and_prepare_ep->>_load_ep_dense_distributed: Pass loaded model and dense FSDP mesh
  _load_ep_dense_distributed->>_meta_dcp_load: Load selected dense parameters from checkpoint shards
Loading
Merge Risk: Minimal · up to 5e3b7

The FSDP-sharded dense expert-parallel load path refuses unsupported layouts before it loads anything, and the GPU tests no longer exercise those layouts. No merge-blocking risk remains.

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 reviewed diff adds no torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), hardcoded trust_remote_code=True, external-input eval()/exec(), or # nosec comments…
Title check Passed The title "[OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank" names both main changes in the PR: optional FSDP2 sharding of the dense portion during expert-parallel load…
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Full details: Security Anti-Patterns

Explanation

PASS. The reviewed diff adds no torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), hardcoded trust_remote_code=True, external-input eval()/exec(), or # nosec comments in modelopt or examples. The diff also changes no pyproject.toml or requirements file. SECURITY.md excludes tests from these rules, and the remaining changes are tests.

Full details: Title check

Explanation

The title "[OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank" names both main changes in the PR: optional FSDP2 sharding of the dense portion during expert-parallel loading, and per-rank sizing of the Transformers allocator warmup. The changeset adds FSDP2 dense sharding (dense="fsdp", edp=1), block-FP8 handling, and EP-aware warmup sizing, so the title matches the main change. The ticket prefix is a tracker reference, not noise that hides meaning. The title is 80 characters and 14 words, which is longer than ideal but still readable and specific.

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

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.
ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: NVIDIA/Model-Optimizer/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Enterprise
  • Run ID: e0f3ea3d-383e-4fd6-b3c5-2ac1a0842a5a
📥 Commits

Reviewing files that changed from the base of the PR and between dacd135 and 5e3b73d.

📒 Files selected for processing: 1
  • tests/gpu/torch/utils/test_expert_parallel_load.py

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

@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 95.95960% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.32%. Comparing base (5870698) to head (c66e2ff).

Files with missing lines Patch % Lines
modelopt/torch/utils/plugins/model_load_utils.py 95.95% 8 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2742      +/-   ##
==========================================
+ Coverage   72.12%   80.32%   +8.19%     
==========================================
  Files         677      677              
  Lines       78261    78443     +182     
==========================================
+ Hits        56447    63010    +6563     
+ Misses      21814    15433    -6381     
Flag Coverage Δ
examples-diffusers 19.61% <0.00%> (-0.20%) ⬇️
examples-gpt-oss 13.30% <0.00%> (-0.17%) ⬇️
examples-hf_ptq 23.43% <15.15%> (-0.06%) ⬇️
examples-llm_distill 13.36% <0.00%> (-0.15%) ⬇️
examples-llm_eval 17.28% <15.15%> (-0.09%) ⬇️
examples-llm_qat 17.18% <0.00%> (-0.22%) ⬇️
examples-llm_sparsity 16.64% <15.15%> (-0.09%) ⬇️
examples-megatron_bridge 26.02% <0.00%> (-0.61%) ⬇️
examples-specdec_bench 13.08% <0.00%> (-0.15%) ⬇️
examples-speculative_decoding 17.52% <15.15%> (-0.04%) ⬇️
examples-torch_onnx 20.86% <0.00%> (-0.26%) ⬇️
examples-torch_trt 15.02% <0.00%> (-0.17%) ⬇️
examples-vllm_serve 17.39% <15.15%> (-0.10%) ⬇️
gpu 58.36% <15.15%> (+36.52%) ⬆️
regression 14.80% <0.00%> (-0.17%) ⬇️
unit 63.18% <95.95%> (+0.08%) ⬆️

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-6b-ep-dense-sharding branch from 6eab845 to d6cb219 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
Preview removed because the pull request was closed.
2026-11-03 18:32 UTC

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test d6cb219

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-6a-ep-loader branch from 5c154cc to e1ef1d1 Compare October 12, 2026 08:39
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-6b-ep-dense-sharding branch from d6cb219 to a5aecef Compare October 12, 2026 08:39
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test a5aecef

@shengliangxu shengliangxu changed the title [7/19] Load the dense part sharded, and size the load warmup to each rank [OMNIML-6061] [7/19] Load the dense part sharded, and size the load warmup to each rank Oct 12, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-6a-ep-loader branch from e1ef1d1 to fbdcd0c Compare October 13, 2026 00:53
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-6b-ep-dense-sharding branch 2 times, most recently from e8384c3 to ffbb850 Compare October 13, 2026 01:32
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test ffbb850

@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-6061] [7/19] Load the dense part sharded, and size the load warmup to each rank [OMNIML-6061] [7/20] Load the dense part sharded, and size the load warmup to each rank Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-6b-ep-dense-sharding branch from ffbb850 to 88ebf30 Compare October 13, 2026 19:38
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-6a-ep-loader branch from 288db20 to ac84840 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-6061] [7/20] Load the dense part sharded, and size the load warmup to each rank [OMNIML-6061] [8/21] Load the dense part sharded, and size the load warmup to each rank Oct 13, 2026
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 88ebf30

@shengliangxu
shengliangxu removed this pull request from stack #2781 October 14, 2026 00:13
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-6a-ep-loader branch from ac84840 to 95781ac Compare October 14, 2026 00:14
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-6b-ep-dense-sharding branch from 88ebf30 to 426a9d1 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-6061] [8/21] Load the dense part sharded, and size the load warmup to each rank [OMNIML-6061] [9/22] Load the dense part sharded, and size the load warmup to each rank Oct 14, 2026
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 426a9d1

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test ab58a1a

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

@kevalmorabia97 Fixed in ab58a1a. test_a_tied_head_the_checkpoint_stores_alone_reaches_both_aliases now writes copies of the tensors it read ({k: v.clone() for k, v in load_file(weights).items()}), so save_file no longer overwrites the file they map. With safetensors 0.8.0-rc.0 on transformers 5.9.0, the test fails before the change and passes after it. No other test in #2742 or #2743 rewrites a file it read with load_file.

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

LGTM. The tied-head test now clones the tensors before rewriting the file; no other changes since my last review. Still open for the author: the 2-GPU dense backward/fp8 QAT cases fail on Ada with transformers 5.9 (layer-1 grads off 3-13%, replicated layout too) - please confirm whether that is Ada numerics or a transformers-5.9 issue.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 9768e7a

The expert-parallel load replicates the dense part on every rank, which
does not fit when the dense part alone is bigger than a GPU. With
dense="fsdp" in the plan, the dense part is now left on meta through
from_pretrained, each decoder block is FSDP2-sharded around its experts
over the whole world, and each rank DCP-loads only its shard. A dense part
sharded within several expert-data-parallel replicas, which the export
cannot write, is refused before loading, and the decision replicates it
with a warning instead. The load-time allocator warmup is sized to each
rank's share: expert params count 1/ep_degree, a dense part on meta
nothing.

This is the PR's original 37 commits in one, re-parented onto the
expert-parallel loader's [2/3]: from_pretrained, with its warmup and
dense-meta contexts, runs inside the loader's load guard, and the dense
part's FSDP2 + DCP load runs after it, before the parallel states.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
fully_shard, DTensor and defaultdict were imported again inside the
helpers that use them, though the module imports them already; Replicate,
sys, transformers, transformers.modeling_utils and transformers'
replace_layer_number_by_wildcard were imported inside functions with no
reason to be (every supported transformers, 5.5 through 5.18, has them).
They are all module-level imports now. The warmup sizing's guard for a
transformers without modeling_utils goes with its local import: no 5.x
release lacks it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The helpers' docstrings and their call sites retold the same load
mechanics at length, and the warmup sizing carried model-specific memory
numbers. Each now states its contract and the one reason it is built
that way: blocks are wrapped whole because fully_shard swaps a module's
class, the experts and off-dtype params are left out, the root stays
replicated, and the dense part stays on meta until it is sharded. The
numbers move to the PR description.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Each rank reads the checkpoint's metadata, checks it against the model and
materializes its params before DCP's collective load. A rank failing there
alone (a metadata read on a node that cannot see the checkpoint, to_empty out
of memory) raised while its peers went on into dcp.load and waited for it.
The preparation now runs through the failure-carrying all_reduce, so a failure
on any rank raises on every rank before any enters the load. Both the
expert-parallel dense load and the FSDP2 load go through _meta_dcp_load.

_meta_dcp_load keeps its signature: it runs _meta_dcp_prepare (the checks and
the materialization, unchanged) through all_reduce and hands its state to
_meta_dcp_fill (the load and the re-tie, unchanged). A single process runs it
as before.

A two-rank test fails rank 1's preparation, at the metadata read or at
to_empty, and checks that both ranks raise rank 1's error without entering the
load. Without the change rank 0 enters it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
_load_ep_dense_distributed materialized the dense part on
cuda:<local_rank> whatever mesh it was given, through a function-local
import of local_rank. It now takes the device from the FSDP mesh, this
rank's GPU on a CUDA mesh as before, so a CPU mesh loads on the CPU and
the helper runs in a unit test, and it reads the rank through the
module's own dist import. Its docstring and comments say what a
maintainer needs: the meta dense part is FSDP2-sharded over the mesh,
each rank DCP-loads its shard, the experts are left as loaded.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The dense load named the params to fill through named_parameters(),
which lists a tied embedding and LM head once, as the embedding. A tied
checkpoint can store the pair as lm_head.weight alone, as from_pretrained
accepts; the shared meta+DCP load then dropped that tensor as outside the
names it was given, and refused the load with both aliases left
uninitialized. The names now count every alias, as the shared load does
for its own checks, so the stored head fills the pair.

A test loads a tied tiny Qwen3-MoE whose checkpoint stores lm_head.weight
alone through the dense load, as the expert-parallel load leaves it; it
failed on "2 param(s) ... would stay uninitialized" before the change.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The tied-head test imported it inside the function; the repo keeps
test imports at module top too.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The gradient comparison read a sharded gradient with full_tensor(), a
collective, inside the loop that also skipped a parameter without a
gradient. A rank missing one gradient the others had would skip that
all-gather while they entered it: the test hung or crashed in mismatched
collectives instead of reporting the missing gradient. Every rank now
checks for missing gradients first and fails together before any
comparison runs.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
_meta_dcp_prepare returned a bare 7-element tuple that _meta_dcp_fill took
as eight positional parameters, with nothing checking the two orders
agree: swapping two of its sets, both set[str], would still run and
silently change which params the unfilled-param guard covers. The state is
a typed NamedTuple now, built and read by field name.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
replace_layer_number_by_wildcard, moved to module scope with the other
imports, came only from transformers.integrations.tensor_parallel. On
transformers 5.18 that module is a partial shim of
transformers.distributed.tensor_parallel (it already lacks
_get_parameter_tp_plan, which the warmup sizing looks up in both places),
so a release dropping the name from it would make the whole module
unimportable, not just the expert-parallel load. It is now imported from
the new location first, as the warmup's lookup does, and from integrations
on older releases (5.5.4 has only that one).

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The dense part is sharded and loaded per rank only if from_pretrained
leaves it on meta, which it does by asking get_device for each dense
weight. A transformers that patched get_device but placed the dense part
another way would load it whole on every rank -- the memory this layout
exists to save -- and then shard and refill it without a word, the user
seeing at most an out-of-memory error. The load now refuses that, naming
MODELOPT_FSDP_SIZE=1, and the diagnostic it printed only on rank 0 goes
through print_rank_0.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The fallback keeps the load going, but transformers' full-model sizing
can reserve most of the device and starve the all-to-all's buffers, so a
bug in the EP-aware sizing showed up only as a later NCCL or memory
failure. It now warns with the error it fell back from. fsdp2_wrap's
docstring points at _fsdp2_wrap_modules, which picks the blocks for the
expert-parallel dense load and keeps a VLM's vision tower replicated.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Both imports come from packages that sit above this module, and the
export package imports it, so they stay in the function, as fsdp2_wrap's
does; the export one moves up beside the other.

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

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
LayerActivationCollector and get_language_model_from_vl were imported in
_fsdp2_wrap_modules, which uses them. Imported first in a fresh
interpreter, model_load_utils, modelopt.torch.export and
modelopt.torch.quantization each load cleanly with both at module scope,
on transformers 5.5.4, 5.9.0 and 5.18, so nothing calls for a local
import there. fsdp2_wrap keeps its own, in fsdp2_utils, which imports no
Hugging Face or quantization package.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…GREE=1

The EP-aware warmup sizing falls back to transformers' own when the
hook's leading parameters differ, which is how a transformers upgrade
would reach it; that sizing may reserve most of the device, so it now
warns, as the error fallback does. The dense part's sharding refusal
also names MODELOPT_EP_DEGREE=1, which keeps the dense part sharded by
dropping expert parallelism.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…load as usual

_fsdp2_wrap_modules searches a VLM from its language backbone only when
get_language_model_from_vl recognizes it; any other model is searched
whole, an unrecognized VLM's listed vision blocks included, as a test
already pins. The dense meta load sends a buffer to meta too if it is
named .weight or .bias, which no supported MoE has. The two docstrings
said both unconditionally.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The two test files cover model_load_utils alone, so their tests join
that module's test file, as CONTRIBUTING.md asks: test_ep_dense_sharding.py
and test_ep_load_warmup.py become two sections at its end, with their
fixtures and parametrization. The warmup file's module-level skip on a
transformers without get_total_byte_count becomes a mark on its tests,
and the plan constant both files defined is defined once.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The dense load put each rank's shard on the GPU its local rank numbers.
The experts and FSDP2's compute are on the GPU the rank is bound to, and
a launch that shows each process one GPU binds every rank to cuda:0 while
the local ranks run 0, 1, ...: rank 1 asked for cuda:1, which it does not
see. The dense load now takes the bound GPU, as the load routing places a
whole or sharded load. The new test, local rank 1 bound to cuda:0, fails
without the change.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Wrapping the dense part in FSDP2 allocates each rank's shards on its own,
so it can fail on one rank alone (out of memory, say), and the ranks that
wrapped went on into DCP's collectives to wait for it there. The wrapping,
and the dense names it collects, now run in the load's failure-carrying
guard: every rank raises that failure before DCP. The bounded two-rank
test fails the wrapping on one rank and fails without the guard.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Claude's review: of the three ways the load warmup's EP-aware sizing falls
back to transformers' own, only a release without get_total_byte_count
did so silently. That sizing may reserve most of the device, and a later
NCCL or out-of-memory failure would then have no stated cause, so it now
warns, naming the transformers version, as its siblings do. The test of
that release now expects the warning, and fails without it.

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

test_a_tied_head_the_checkpoint_stores_alone_reaches_both_aliases read
model.safetensors with load_file and wrote it back with save_file while the
loaded tensors were still alive. Tensors that map the file then see it
overwritten: on safetensors 0.8.0-rc.0 every one of them reads as zeros and
the test fails. It now writes copies.

With safetensors 0.8.0-rc.0 on transformers 5.9.0, the test fails before
the change and passes after it. Test only.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
#2780 merged first, while the expert-parallel load still replicated the
dense part, so load_pretrained refused an expert-parallel plan that
FSDP2-shards it, and the plan decision gave up expert parallelism for
whole-model FSDP2 when the dense part would not fit one GPU. This PR
loads that plan, so it takes over the junction the two had on the
hf_ptq helper branch:
- _route sends every expert-parallel plan to load_and_prepare_ep;
- load_pretrained's docstring and the distributed-loading guide say the
  dense part is replicated or FSDP2-sharded as the plan says, and the
  guide drops the decision's give-way;
- the plan tests expect an MoE left to decide to keep its EP degree and
  FSDP2-shard its dense part, and an expert-parallel plan with
  dense="fsdp" to load through load_and_prepare_ep;
- the dense-sharding tests' config doubles name a model type, which
  main's load reads for the model spec.

The decision's give-way itself was dropped in this PR's first commit,
where it conflicted with the dense-part rule.

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

Copy link
Copy Markdown
Collaborator Author

/ok to test c66e2ff

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

@kevalmorabia97 Thanks for this. We reproduced it on 2 RTX 6000 Ada GPUs, and it comes from routing, not from a wrong gradient: one token in layer 1 goes to a different pair of experts in the sharded model than in the whole model.

We ran tests/gpu/torch/utils/test_expert_parallel_load.py -k dense on 2 GPUs. The test uses a tiny Qwen3-MoE (8 experts, 2 layers), with the experts sharded over both GPUs and the dense part replicated or FSDP2-sharded. On Ada we used torch 2.11.0 (CUDA 13.0, NCCL 2.28.9); there, transformers runs the experts' grouped GEMM through torch.nn.functional.grouped_mm.

GPU Code transformers Backward cases Largest gradient error Tokens routed differently (layer 0 / layer 1)
RTX 6000 Ada this PR 5.9.0 2 failed 0.156 0 / 1 of 64
RTX 6000 Ada this PR 5.18.0 2 failed 0.156 0 / 1 of 64
RTX 6000 Ada main with this PR's test file 5.9.0 and 5.18.0 replicated failed 0.156 0 / 1 of 64
GB200 this PR 5.9.0 and 5.18.0 2 passed 0.003 0 / 0
GB200 main with this PR's test file 5.9.0 replicated passed 0.003 0 / 0
a second GPU type this PR, and main with its test file 5.18.0 passed 0.003 0 / 0

Everything else passed wherever it could run: the forward cases, and the fp8 and nvfp4 QAT cases. The ep-half cases need 4 GPUs and skipped. On main, the dense-sharded cases fail by design, since main's loader refuses a sharded dense part.

So on Ada:

  • The failure doesn't depend on transformers: 5.9.0 and 5.18.0 give the same numbers to four digits. Nor does it depend on this PR: main's replicated path fails the same way.
  • Layer 0 routes identically, and its gradients stay within 0.009. In layer 1, one of 64 tokens takes a different pair of experts, and only layer 1's gradients fail: the experts by 9-14%, the router gate by 3-4% and the post-attention norm by 12-16%.

That supports the first explanation. Ada's bf16 GEMMs put layer 0's output a little further from the whole model's (0.009 against 0.003), which is enough to flip a near-tied route in layer 1. A token that takes other experts sends its gradient to other weights, and no tolerance should absorb that.

So I'd change neither the tolerance nor the transformers floor. Fixed the test in #3198: the tiny model gets a router that decides clearly, so bf16 noise cannot flip a route, and the test checks that both models route every token alike before it compares gradients, so a flip is reported as one.

On the same 2 RTX 6000 Ada GPUs, the fixed file passes on transformers 5.9.0 and 5.18.0 (10 passed, 4 skipped), with the largest gradient error at 0.011.

Your fp8 cases failed too. Ours passed on Ada, but only without ModelOpt's FP8 CUDA extension, which our CUDA 13.3 toolchain could not build against torch 2.11. If your run had the extension, its FP8 rounding could flip a route the same way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants