Repository navigation
[OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank - #2742
shengliangxu wants to merge 21 commits into
Conversation
|
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. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## shengliangx/hfep-6a-ep-loader #2742 +/- ##
================================================================
Coverage ? 61.83%
================================================================
Files ? 649
Lines ? 72969
Branches ? 0
================================================================
Hits ? 45117
Misses ? 27852
Partials ? 0
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
6eab845 to
d6cb219
Compare
|
|
/ok to test d6cb219 |
5c154cc to
e1ef1d1
Compare
d6cb219 to
a5aecef
Compare
|
/ok to test a5aecef |
e1ef1d1 to
fbdcd0c
Compare
e8384c3 to
ffbb850
Compare
|
/ok to test ffbb850 |
ffbb850 to
88ebf30
Compare
288db20 to
ac84840
Compare
|
/ok to test 88ebf30 |
ac84840 to
95781ac
Compare
88ebf30 to
426a9d1
Compare
|
/ok to test 426a9d1 |
|
/ok to test 591b986 |
cjluo-nv
left a comment
There was a problem hiding this comment.
Bot review (gpt-6.1-sol) — DM the bot to share feedback.
Comment: the prior fixes are verified, but bound-device handling and unguarded dense wrapping can still fail valid distributed loads.
Needs action:
- Guard rank-local dense wrapping before the next collective in
_load_ep_dense_distributed; add a bounded two-rank wrapping-failure regression (inline). - Use the caller-bound CUDA device for dense materialization and cover
LOCAL_RANK != current_device()(inline).
No action needed:
- ✔️ Resolved since the last review: module-based test naming. Earlier alias, dense-meta, and DCP-preparation fixes remain intact.
- Existing refusal, environment-knob, and GPU-layout test edits are justified by the supported/refused layouts.
| """ | ||
| cuda = fsdp_mesh.device_type == "cuda" | ||
| device = torch.device(fsdp_mesh.device_type, dist.local_rank() if cuda else None) | ||
| n_dense = _fsdp2_shard_dense(model, fsdp_mesh, expert_modules) |
There was a problem hiding this comment.
Bot comment.
[P1] Agree on success of the local wrapping stage before entering _meta_dcp_load. _fsdp2_shard_dense runs outside both _refused_together and the new preparation all_reduce. A rank-local exception here—for example an OOM while fully_shard allocates local storage for already-materialized non-.weight/.bias dense parameters—therefore leaves successful peers waiting in _meta_dcp_load's preparation gather. Structural wrap decisions being identical does not make allocation failures identical. Could the non-collective wrapping/name-collection stage use the existing failure-carrying guard too, keeping DCP itself outside it? Extend the bounded two-rank coverage to fail wrapping on one rank and assert every rank raises the originating error before DCP, rather than a collective timeout.
There was a problem hiding this comment.
Fixed in 0143941. The FSDP2 wrapping, and the dense names it collects, now run inside the load's failure-carrying guard, with DCP left outside it. A rank that fails to wrap (out of memory, say) makes every rank raise that failure before DCP's collectives. The new bounded two-rank test fails the wrapping on rank 1, stands DCP in by a bounded barrier, and asserts both ranks raise the originating error. It fails without the guard.
| already loaded and EP-sharded, are left as they are. Returns the number of layers sharded. | ||
| """ | ||
| cuda = fsdp_mesh.device_type == "cuda" | ||
| device = torch.device(fsdp_mesh.device_type, dist.local_rank() if cuda else None) |
There was a problem hiding this comment.
Bot comment.
[P2] Use the bound CUDA device, not dist.local_rank(), for materialization. load_and_prepare_ep's GPU preflight and _experts_sharded_over explicitly honor torch.cuda.current_device(), including a caller-owned group where each process sees one distinct physical GPU as cuda:0 while LOCAL_RANK is 0/1. That layout passes preflight and loads experts on cuda:0, but rank 1 now passes cuda:1 to _meta_dcp_prepare/to_empty, which fails with an invalid device ordinal. With multiple visible GPUs and a remapped binding, it instead materializes dense weights on a different GPU from the experts/FSDP compute device. Keep the CPU branch, but select CUDA through current_device() (as _move_to_fsdp_device does). Extend the device assertion to cover a local rank different from the bound device; the current CPU-only assertion and rank-bound GPU workers miss this.
There was a problem hiding this comment.
Fixed in f0c2a2b. The dense part now materializes on the GPU the rank is bound to: _own_gpu_index(), which the GPU check and the load routing share, keeping the CPU branch. The new test runs local rank 1 bound to cuda:0, as a launch showing each process one GPU has it, and asserts DCP gets cuda:0. It fails without the change, which passed cuda:1.
There was a problem hiding this comment.
Checked on GPUs since: 2 GPUs, one visible per process, nccl, LOCAL_RANK 0/1, with a dense-sharded expert-parallel plan. At this fix both ranks load, rank 1 on its bound cuda:0. Before it, rank 1 failed with "CUDA error: invalid device ordinal" on cuda:1, and rank 0 refused, naming rank 1's failure.
|
/ok to test 0143941 |
|
/claude review |
| orig = getattr(hf_modeling_utils, "get_total_byte_count", None) | ||
| if orig is None: | ||
| yield | ||
| return |
There was a problem hiding this comment.
[SUGGESTION] This is now the only one of the three fallbacks that reverts to transformers' full-device sizing without saying so. The signature-mismatch branch below (:1845-1850) and the except Exception branch (:1908-1913) both warn precisely because that sizing "may reserve most of the device" and a later NCCL/OOM failure would then have no stated cause — but a transformers release that removes get_total_byte_count (renames it, moves it out of modeling_utils) lands here and is indistinguishable from a healthy load.
That is also the more likely of the three to happen: the function is private to modeling_utils, and _ep_dense_meta_load already had to grow a two-module lookup for get_device for exactly this reason.
Worth the same warn_rank_0 the siblings get, naming the version:
orig = getattr(hf_modeling_utils, "get_total_byte_count", None)
if orig is None:
warn_rank_0(
f"transformers {transformers.__version__} has no get_total_byte_count in "
"modeling_utils to size the load warmup by; using its own sizing, which may reserve "
"most of the device."
)
yield
returntest_a_transformers_without_the_sizing_hook_is_left_alone would then gain a pytest.warns, matching the two sibling tests. Non-blocking.
There was a problem hiding this comment.
Taken in 2860c0c, with your wording. A release without get_total_byte_count now warns, naming the transformers version, as the other two fallbacks do. test_a_transformers_without_the_sizing_hook_is_left_alone expects the warning, and fails without it.
|
/ok to test 00a592d |
|
/ok to test 2860c0c |
|
/claude review |
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>
|
/ok to test 7ea4ac7 |
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: #3136. The diff below is this PR's alone, and it merges after #3136.
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
main([OMNIML-6073] [2/3] Quantize fused experts under grouped_mm; leave transposed layouts unquantized #2745, [OMNIML-6064] Load each rank's checkpoint shard straight into a meta-built model #2739, [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736, [OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737, [OMNIML-6082] [1/3] Let a reverse conversion record a merge rule no key matched #3100, [OMNIML-6082] [2/3] Move each tensor of a sharded model whole to one owner rank #3101, [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099, [OMNIML-6073] [1/2] Calibrate and fold expert-parallel fused experts on each rank's own experts #3122, [OMNIML-6060] [1/3] Add require_transformers, and build a VLM without its LM head #3131 [1/3] and [OMNIML-6082] [3/3] Write a sharded model's HF checkpoint shards from each tensor's owner rank #2751 have merged)shengliangx/hfep-base-6a-ep-loader= [OMNIML-6054] [2/2] Route load_pretrained by the distribution plan #2779 + [OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067 + [OMNIML-6071] Calibrate a distributed model on per-rank data #2740What 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:from_pretrained;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_modulesmatch, no detected decoder layers) is refused, namingMODELOPT_FSDP_SIZE=1, instead of wrapped by the VLM's own_no_split_modules, which can name vision blocks;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_eprefuses a plan withdense="fsdp"andedp > 1before it looks at anything, rather than after the load and calibration it would otherwise run. The error says to setMODELOPT_EP_DEGREEto the world size, orMODELOPT_FSDP_SIZE=1. A replicated dense part atedp > 1stays 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_warmupotherwise reserves about the whole device on every rank, and torch keeps that reservation, starving the all-to-all's NCCL buffers. Now: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):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 theget_devicehook it patches._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-carryingall_reducebeforedcp.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 throughset_model_state_dict, not this function, so the fix reaches only this PR's dense load.lm_head.weightfills both, asfrom_pretraineddoes.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_generatewithMODELOPT_EP_DEGREE=16, back to back in one allocation:MODELOPT_FSDP_SIZE=1)MODELOPT_FSDP_SIZE=16)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_SIZEunset,autochosereplicateand 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.
autocompares 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=1replicates the dense part andMODELOPT_FSDP_SIZE=<world size>shards it.Why separate helpers, rather than extending the existing paths:
parallel_load_and_prepare_fsdp2builds 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_pretrainedplaces 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 keepsfrom_pretrainedfor the experts and shards only the rest; reusing the FSDP2 loader would mean re-implementing that placement in it.fsdp2_wrapwraps each detected decoder layer whole, and the root by default, and refuses a model its detection misses. The hybrid has different wrap units:_no_split_modules, so a VLM's vision blocks stay replicated.Folding that into
fsdp2_wrapwould 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_mapbeside the tensor-parallel placement the expert-parallel load requests: "tp_plananddevice_mapare 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
Testing
In
tests/unit/torch/utils/test_model_load_utils.py(first written astest_ep_dense_sharding.py), on CPU under gloo:test_ep_loader.py);_no_split_moduleson the language backbone, falling back to decoder-layer detection, and refused for a model without blocks and a VLM whose language model shows none (test_meta_dcp_load.py);In
tests/unit/torch/utils/test_model_load_utils.py(first written astest_ep_load_warmup.py):Mutation-checked: each test fails on at least one deliberate break of the code.
The expert-matcher cases are one table of 7 rows, and other duplicates become cases of the tests that cover them. Each drop is checked by mutation.
The decision replicating, or refusing, a dense part within several expert-parallel groups; an fp32 router left replicated, with every layer still unsharding; the missing-
get_devicerefusal.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/uniton 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.GPU results at a named head are under "GPU runs, 2026-10-30" at the end of this section; the other GPU results here were measured on earlier revisions of this branch.
tests/gpu/torch/utils/test_expert_parallel_load.pynow also runs with the dense part sharded, at EP over every GPU (sharded within several EP groups, the loader refuses it): each rank's experts and the logits match the whole model. At 8192a75 the whole file passed: 12 on 4 GPUs; 8 on 2 GPUs, where the 4 cases with EP over half the GPUs skip.Also in
tests/gpu/torch/utils/test_expert_parallel_load.py, QAT under expert parallelism: an FP8 or NVFP4 model calibrated expert-parallel must reach the whole model's scales, with its experts over every rank or half of them and the dense part replicated or FSDP2-sharded, and its gradients through the fake quantizers must match. These tests came with the fused-experts quantization ([OMNIML-6073] [2/3] Quantize fused experts under grouped_mm; leave transposed layouts unquantized #2745) before the series was reordered; they land here, the first PR with an FSDP2-sharded dense part. At 8192a75, where the whole file passed:End to end with the export fixes in [OMNIML-6081] [2/2] Fold fused experts' quantizers into their weights shard-locally #2750, the sharded and replicated dense layouts export the same checkpoint:
Both were measured before [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074's expert weight-scale fix, when both layouts inflated expert scales the same way: the agreement shows the two layouts match, not that those scales were right.
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: a dense sharding that finds blocks but shards none is refused, pointing at
MODELOPT_FSDP_SIZE=1; a TP-planned param keeps its share before the warmup's meta skip, and the TP-plan lookup triestransformers.distributedfirst. 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.Review fixes, 2026-10-27, round 2: the warmup replacement forwards whatever transformers passes the hook and leaves a hook of another signature alone; replicating a dense part within several expert-parallel groups warns that it may run out of memory, with the way out; the dense-sharding comments and progress line say where it wraps and that it shards over every rank. 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).
Rebased over [OMNIML-6060] [3/3] Refuse an expert-parallel load that would calibrate the experts wrong #2741's last round, which prints the loader's notes through
print_rank_0and no longer importsis_master: this PR's dense-sharding notes import it themselves.Review suggestion, 2026-10-28: two of the dense-sharding notes print through
print_rank_0;is_masterstays where it keeps the meta-parameter listing off the other ranks.Review suggestion, 2026-10-28, second round: the EP-aware warmup-sizing note prints through
print_rank_0too; this PR's MoE-helper import joins [OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737's module-level one.Review suggestion, 2026-10-28, third round: the warmup sizing credits a TP-planned param
1/ep_degree, its share of the ep submeshfrom_pretrainedplaces it over, rather than1/world_size. The test's world now holds two ep submeshes and fails with the old divisor.Declined review suggestion, 2026-10-28, third round: alias-safe re-registration of replicated parameters tied inside one decoder block, which no model this load supports has; the embedding-to-
lm_headtie sits at the root, which is not wrapped.Review suggestion, 2026-10-28, fourth round: the warmup's docstring says only that no expert param is in
_tp_planunder the EP load, which is what the sizing relies on.Review suggestion, 2026-10-28, fifth round: the expert-param matcher is the dot-padded containment test alone, which the exact and suffix matches were special cases of.
Review suggestions, 2026-10-28, sixth round: the expert matcher's docstring states its one containment test, and the meta-placement context and the sharded dense load each have their own summary.
Re-cut on 2026-10-29 (see the note at the top): rebased onto the re-cut [OMNIML-6060] [3/3] Refuse an expert-parallel load that would calibrate the experts wrong #2741 with no source conflict. The edits of the deleted
test_resolve_distribution_plan.py(the env-knob table, and the dense part the expert-parallel load cannot shard) moved totest_model_load_utils.py. Pre-commit, mypy included, passes at this head over the whole change against main. On CPU at this head (torch 2.14), this PR's tests withtest_ep_loader.py,test_meta_dcp_load.pyandtest_model_load_utils.pypass on transformers 5.18 and 5.5.4.Review fixes, 2026-10-29 (cjluo-nv's review bot):
_meta_dcp_load's preparation now fails every rank beforedcp.load. A bounded two-rank test fails rank 1 either in its metadata read or withto_emptyout of memory; with the fix reverted, rank 0 enters the load. (Corrected 2026-10-30: main's FSDP2 load does not call_meta_dcp_load, so the fix reaches only this PR's dense load.)lm_head.weightfailed through this caller on the CPU before the fix, and now loads both aliases. The load also takes its device from the mesh.tests/unit/torch/utilspasses on the CPU at this head on transformers 5.18 and 5.5.4. GPU runs of that head did not happen; see 8192a75 below.Re-parented 2026-10-30 onto [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 ([OMNIML-6060] [3/3] Refuse an expert-parallel load that would calibrate the experts wrong #2741 [2/3]):
from_pretrainedre-anchored inside the load guard; the dense part's load after it._meta_dcp_loadhands its prepared state over as a named tuple;replace_layer_number_by_wildcardis imported from either transformers location.tests/unit/torch/utilspasses on CPU at this head, on transformers 5.18 and 5.5.4.Review fixes, 2026-10-30 (Claude's re-review): with the dense part sharded, a load in which no dense param came back on meta is refused, since every rank would then have loaded the whole dense part (
test_a_dense_part_from_pretrained_did_not_leave_on_meta_is_refusedfails without the check); the EP-aware warmup sizing warns when it falls back to transformers' own sizing (the fallback test asserts the warning and fails without it). Rebased with [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 onto main 99c3834; the rest of this PR's diff is unchanged. Pre-commit, mypy included, passes against main, andtests/unit/torch/utilspasses on CPU at this head, on transformers 5.18 and 5.5.4.Review fixes, 2026-10-30 (cjluo-nv's review bot):
_fsdp2_wrap_modules' two imports stay in the function, asfsdp2_wrap's does, with a comment on why (6388dc4; superseded below, they are now at module scope): the export and quantization packages sit above this module, and the export package imports it. Moving them would not cycle today: with both at module scope, every entry order imports on transformers 5.18 and 5.5.4 and on torch 2.8, andimport modelopt.torchalready loads both modules. So the reason is layering, not cost._meta_dcp_load, and block-FP8 handling lands in [OMNIML-6062] Dequantize block-FP8 checkpoints as they load expert-parallel #2743.tests/unit/torch/utilspasses on CPU at this head, on transformers 5.18 and 5.5.4.grouped_gemm. The dense-meta refusal test's unplaced-keys stand-in returns a list, as the function does.Review fixes, 2026-10-30, round 2 (cjluo-nv's review bot):
_fsdp2_wrap_modules' two imports, andfsdp2_wrap's, are at module scope (c2f1bf9). Each ofmodel_load_utils,modelopt.torch.exportandmodelopt.torch.quantization, imported first in a fresh interpreter, loads cleanly on transformers 5.18, 5.9.0 and 5.5.4.MODELOPT_EP_DEGREE=1, which keeps the dense part sharded by dropping expert parallelism (201e2a5).tests/unit/torch/utilspasses on CPU at this head, on transformers 5.18, 5.9.0 and 5.5.4.GPU runs, 2026-10-30, at 8192a75: NVIDIA GB200, torch 2.11.0a0 (nv26.02), transformers 5.9.0, pytest with
-p no:rerunfailures.tests/gpu/torch/utils/test_expert_parallel_load.py(EP logits, backward, FP8/NVFP4)tests/gpu/torch/utils/test_model_load_utils.py(main's FSDP2 load)tests/gpu/torch/export/test_fsdp2_export.py(main's FSDP2 load)These results were taken at 8192a75. Up to aa85e91 this PR's diff since then was the module-scope imports, the hook-signature warning, the
MODELOPT_EP_DEGREE=1remedy text and two docstrings; the 2026-10-30 rebase below then changed the base under it, and the file was re-run on the rebased stack (the last entry here).Review suggestions, 2026-10-30 (Claude's review at 201e2a5, approved): two docstrings no longer over-claim.
_fsdp2_wrap_modulesleaves vision blocks unwrapped only for a VLMget_language_model_from_vlrecognizes, and the dense meta load leaves buffers alone unless they are named.weight/.bias(aa85e91). Pre-commit, mypy included, passes against main, andtests/unit/torch/utilspasses on CPU at this head, on transformers 5.18, 5.9.0 and 5.5.4.Review fix, 2026-10-30 (cjluo-nv's review bot):
test_ep_dense_sharding.pyandtest_ep_load_warmup.py, which coveredmodel_load_utilsalone, moved intotest_model_load_utils.pywith their fixtures and parametrization, as CONTRIBUTING.md's naming rule asks (004de2a). No test was added or dropped:tests/unit/torch/utilsgives the same counts on CPU on transformers 5.18, 5.9.0 and 5.5.4, and pre-commit, mypy included, passes against main.Rebased 2026-10-30 with [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 onto the rebuilt
shengliangx/hfep-base-6a-ep-loader, which after [OMNIML-6060] [1/3] Add require_transformers, and build a VLM without its LM head #3131 merged holds [OMNIML-6054] [2/2] Route load_pretrained by the distribution plan #2779 (on [OMNIML-6054] [1/2] Move fsdp2_wrap out of distributed.py into fsdp2_utils #3140, which movesfsdp2_wrapintomodelopt/torch/utils/fsdp2_utils.py), [OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067 and [OMNIML-6071] Calibrate a distributed model on per-rank data #2740 on main 62a5c48. Withfsdp2_wrapin its own module, this PR's paragraph in its docstring moved tofsdp2_utils.py,fsdp2_wrapkeeps its local quantization import there (fsdp2_utilsimports no Hugging Face or quantization package), andmodel_load_utilsimports_off_dtype_paramsandfully_shardfor the dense sharding itself. The rest of this PR's diff is unchanged. Pre-commit, mypy included, passes against main, andtests/unit/torch/utilspasses on CPU at this head, on transformers 5.18, 5.9.0 and 5.5.4.GPU run, 2026-10-30, at [OMNIML-6062] Dequantize block-FP8 checkpoints as they load expert-parallel #2743's head 9c917ae, which contains this PR's head f991fa5:
tests/gpu/torch/utils/test_expert_parallel_load.pyon transformers 5.18.0, the test file unchanged since the run at 8192a75.On 1 GPU every case skips, so it is no evidence. On 2 GPUs the four cases with expert parallelism over half the GPUs skip; on 4 GPUs all 12 run: each rank's experts and the logits against the whole model, and FP8 and NVFP4 quantized training (scales and gradients), with the experts over every GPU or half of them and the dense part replicated or FSDP2-sharded.
Rebased 2026-10-30 onto [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 e251f6c (meenchen's review round), whose module-scope imports replace the load's local import block: the comment on the load's guard keeps [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136's note on what it rests on, and this PR's own diff is otherwise unchanged. Pre-commit, mypy included, passes against main, and
tests/unit/torch/utilspasses on CPU at this head, on transformers 5.18, 5.9.0 and 5.5.4.Rebased 2026-10-30 onto [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 1491b66 (its MXFP4 refusal and review fixes); this PR's own diff is unchanged. Pre-commit, mypy included, passes against main, and
tests/unit/torch/utilspasses on CPU at this head, on transformers 5.18, 5.9.0 and 5.5.4.GPU run, 2026-10-30, at [OMNIML-6062] Dequantize block-FP8 checkpoints as they load expert-parallel #2743's head 7192e05, which contains this PR at a4e2e1d:
tests/gpu/torch/utils/test_expert_parallel_load.pyon transformers 5.18.0.On 4 GPUs every case runs, the four with expert parallelism over half the GPUs included, as at the run before. 1 GPU skips everything, so it is no evidence.
GPU run, 2026-10-30, at [OMNIML-6062] Dequantize block-FP8 checkpoints as they load expert-parallel #2743's head 7c83230, which contains this PR's head 4abc058:
tests/gpu/torch/utils/test_expert_parallel_load.pyon transformers 5.18.0.The same result as the run before, with the same test file, so it speaks for the code underneath. The file loads an unquantized checkpoint on transformers 5.18, so it cannot show the MXFP4 refusal or the block-FP8 floor of 5.8 firing: [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136's config-refusal table covers the MXFP4 refusal, and [OMNIML-6062] Dequantize block-FP8 checkpoints as they load expert-parallel #2743's 5.7.0 test the block-FP8 floor, on CPU.
Rebased 2026-10-30 onto [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 ad9559d (meenchen's review, round 3, on the base rebuilt with [OMNIML-6054] [2/2] Route load_pretrained by the distribution plan #2779 47ca6d1); this PR's own diff is unchanged. Pre-commit, mypy included, passes against main, and
tests/unit/torch/utilspasses on CPU at this head, on transformers 5.18, 5.9.0 and 5.5.4. The GPU tests have not run on this head.Rebased 2026-10-30 onto main 059588b with [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 eb6c382, on
shengliangx/hfep-base-6a-ep-loaderrebuilt there from [OMNIML-6054] [2/2] Route load_pretrained by the distribution plan #2779 3b814a6, [OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067 fbff3f3 and [OMNIML-6071] Calibrate a distributed model on per-rank data #2740 186efae. [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 now also refuses a rank not yet bound to its GPU. This PR's own diff is unchanged. Pre-commit, mypy included, passes against main, andtests/unit/torch/utilspasses on CPU at this head, on transformers 5.18, 5.9.0 and 5.5.4. The GPU tests have not run on this head.Rebased 2026-10-30 onto [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 56353ef, whose new commits change only tests and one comment; this PR's own diff is unchanged. Pre-commit, mypy included, passes against main, and
tests/unit/torch/utilspasses on CPU at this head, on transformers 5.18, 5.9.0 and 5.5.4.GPU run, 2026-10-30, at [OMNIML-6062] Dequantize block-FP8 checkpoints as they load expert-parallel #2743's head 7540a2e, which contains this PR at 6415c94:
tests/gpu/torch/utils/test_expert_parallel_load.pyon transformers 5.18.0.The same result as the runs before. On 4 GPUs every case runs, the four with expert parallelism over half the GPUs included; 1 GPU skips everything, so it is no evidence. Since that head, [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 changed only tests and one comment, so the result speaks for this head's code.
Rebased 2026-10-30 onto [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 75bc2d6, which trims the docstring and comments this PR edits. This PR's own diff is unchanged but for its docstring paragraph on the sharded dense part, now one sentence on the trimmed docstring. Pre-commit, mypy included, passes against main, and
tests/unit/torch/utilspasses on CPU at this head, on transformers 5.18, 5.9.0 and 5.5.4. The GPU tests have not run on this head.Rebased 2026-10-30 with [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 ab06860 onto main d464d30, on
shengliangx/hfep-base-6a-ep-loaderrebuilt there from [OMNIML-6054] [2/2] Route load_pretrained by the distribution plan #2779 8f9d877, [OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067 d452768 and [OMNIML-6071] Calibrate a distributed model on per-rank data #2740 9a87d15; this PR's own diff is unchanged. Pre-commit, mypy included, passes against main, andtests/unit/torch/utilspasses on CPU at this head, on transformers 5.18, 5.9.0 and 5.5.4.GPU runs, 2026-10-30, at an earlier head with the same own diff ([OMNIML-6062] Dequantize block-FP8 checkpoints as they load expert-parallel #2743's 6f5391d, which contains this PR at 72f1e49), on transformers 5.18.0:
tests/gpu/torch/utils/test_expert_parallel_load.py:On 4 GPUs every case runs, the four with expert parallelism over half the GPUs included; 1 GPU skips everything, so it is no evidence.
tests/unit/torch/utils/test_ep_loader.pyon a host with one visible GPU: 59 of 59 passed.Review fixes, 2026-10-31 (cjluo-nv's review bot): the sharded dense part materializes on the GPU each rank is bound to, not the one its local rank numbers (f0c2a2b; the new test, local rank 1 bound to cuda:0, fails without it), and the FSDP2 wrapping runs in the load's failure-carrying guard, so a rank failing to wrap fails every rank before DCP's collectives (0143941; the new bounded two-rank test fails without it). Pre-commit, mypy included, passes against main, and
tests/unit/torch/utilspasses on CPU at this head, on transformers 5.18, 5.9.0 and 5.5.4. The GPU tests have not run on this head.Rebased 2026-10-31 onto [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 7747c5d, which moves
test_ep_loader.py's cases intotest_model_load_utils.pyand theexpert_parallel_meshones intotest_moe_a2a.py. This PR's test edits moved with those cases, and its source diff is unchanged. Its dense-sharding tests' experts class is now_LayerExperts, because [OMNIML-6060] [3/3] Refuse an expert-parallel load that would calibrate the experts wrong #2741's tests define an_Expertsin the same file. Pre-commit, mypy included, passes against main, andtests/unit/torch/utilscollects and passes the same cases as before on CPU, on transformers 5.18, 5.9.0 and 5.5.4.GPU run, 2026-10-31, at 0143941, which has this head's source:
tests/gpu/torch/utils/test_expert_parallel_load.pyon transformers 5.18.0.On 4 GPUs every case runs, the dense-sharded arms included. The file has no dense-sharded arm with experts over half the GPUs, by design: this PR refuses a dense part FSDP2-sharded within several expert-data-parallel replicas (
edp > 1), a layout the export cannot write yet.Review suggestion, 2026-10-31 (Claude): a transformers without
get_total_byte_countnow warns that the load warmup keeps its own sizing, as the other two fallbacks do (2860c0c); the test of that release expects the warning and fails without it. Pre-commit, mypy included, passes against main, andtests/unit/torch/utilspasses on CPU at this head, on transformers 5.18, 5.9.0 and 5.5.4.GPU check of the bound-device fix (f0c2a2b), 2026-10-31: 2 GPUs, one visible per process, nccl, LOCAL_RANK 0/1,
DistributionPlan(2, ep_size=2, dense="fsdp"). The dense-sharded path ran on both ranks in both runs.Rebased 2026-10-31 with [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 930bb0f onto main e352cac, on
shengliangx/hfep-base-6a-ep-loaderrebuilt there from [OMNIML-6054] [2/2] Route load_pretrained by the distribution plan #2779 c78d2e5, [OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067 adb9757 and [OMNIML-6071] Calibrate a distributed model on per-rank data #2740 dea9b79; this PR's own diff is unchanged. Pre-commit, mypy included, passes against main, andtests/unit/torch/utilspasses on CPU at this head, on transformers 5.18, 5.9.0 and 5.5.4.GPU run, 2026-10-31, at [OMNIML-6062] Dequantize block-FP8 checkpoints as they load expert-parallel #2743's head 939bf6e on main e352cac, which contains this PR's head 7ea4ac7:
tests/gpu/torch/utils/test_expert_parallel_load.pyon transformers 5.18.0.The same 12 cases as before, now over the rebuilt base, which includes main's fused-MoE GPTQ change. On 4 GPUs every case runs, the dense-sharded ones included: each rank's experts and the logits against the whole model, and both quantized formats' training. 1 GPU skips everything, so it is no evidence.
Before your PR is "Ready for review"
CONTRIBUTING.md: N/A05e21c2ba84, and again on 2026-10-30 at32d57f5c81c, after the review fixes in Testing, which change main's_meta_dcp_load.Additional Information
Part 10 of 21 of expert-parallel PTQ for Hugging Face MoE models; merges after #2741.
Summary by CodeRabbit