Repository navigation
[OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time - #3136
shengliangxu wants to merge 23 commits into
Conversation
|
/ok to test 84fe2d9 |
|
/claude review |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review: 3
Please check the settings in the CodeRabbit UI or the
|
| # Keep expert-related entries (grouped_gemm expert params + the router/experts styles); DROP the TP | ||
| # styles (a2a builds no TP group for them -> would shard attention across the wrong mesh). | ||
| swapped_plan = { | ||
| k: swap.get(v, v) for k, v in ep_plan.items() if v in swap or v == "grouped_gemm" | ||
| } | ||
| # Tag every routed-experts CONTAINER torch_a2a_experts (injects the EP process_group + marks the | ||
| # routed expert), found as the distribution plan's decision finds them: some plans tag only the | ||
| # expert PARAMS grouped_gemm, and name the container through their parents. | ||
| swapped_plan.update(dict.fromkeys(_routed_expert_keys(ep_plan), "torch_a2a_experts")) |
There was a problem hiding this comment.
[IMPORTANT Algorithm] The plan filter recognizes only grouped_gemm as an expert-weight shard style, so a plan that shards the experts another way loads unsharded — silently, with wrong results.
swapped_plan keeps an entry only when v in swap or v == "grouped_gemm", and then tags every routed-experts container torch_a2a_experts, whose shard_tensor is deliberately a no-op (moe.py: return param[...], commented "Expert weights shard via packed_colwise/rowwise + grouped_gemm, so shard_tensor is a no-op here").
So the expert weights are sharded only by the surviving grouped_gemm param entries. Two plan shapes break:
- a plan that styles only the container (
{"layers.*.mlp.experts": "moe_tp_experts", "layers.*.mlp.gate": "ep_router"}— exactly the_EP_PLANthe unit tests use, and which the pre-load checks accept): stockmoe_tp_expertsis what would have sharded, andtorch_a2a_expertsreplaces it with a no-op; - a plan that shards the expert params with
packed_colwise/packed_rowwisebeside a container style: the filter drops those entries.
In both cases every rank ends up holding all experts, while torch_a2a_experts_forward routes each token by expert_id // (num_experts // ep_size). Nothing refuses it: the pre-load checks only verify that _routed_expert_keys is non-empty and that ep_size divides the expert count. The result is a calibration that quantizes the wrong experts with the wrong token pools — the worst failure mode, since it produces a checkpoint rather than an error.
Suggested fix: after the load, assert this rank's expert dim is num_experts // ep_size (the docstring already promises "A count the config names none of the ways this reads is checked once the experts load, from their weights" — this is the same check, and it is the one that makes the shard style assumption safe). If that verification is what [3/3] lands, please add the refusal here instead, or at minimum refuse up front a plan whose routed-expert containers have no grouped_gemm param entry under them, so this PR cannot mis-load on its own.
There was a problem hiding this comment.
Agreed: this slice must not mis-load on its own. Fixed in 4b6470c with the up-front refusal you suggested.
With the other plan checks, before anything loads, the loader now refuses a plan in which some routed-expert container has no grouped_gemm entry under it. That covers both shapes you named:
- a container-only plan;
- one that shards the expert weights by
packed_colwise/packed_rowwise.
Every base_model_ep_plan that transformers 5.18 ships (52 models) and every plan ModelOpt's model specs supply put grouped_gemm under each container, so no real plan is refused.
The test's stand-in plan gains the grouped_gemm entries the real plans have. Two rows of the config-refusal table pin the new check: a container-only plan and a packed_colwise one. Both fail with the check removed.
The post-load check of each rank's actual expert share (#2741 [3/3], on top of this PR) still catches what a plan cannot show, such as experts sharded over another mesh. #2780, the PR that routes users to this loader, merges only after it.
| # The checkpoint formats load_and_prepare_ep dequantizes as it loads, beside unquantized ones (None). | ||
| # mxfp4 is for experts stored in the standard layout: GPT-OSS's are transposed, which the load refuses. | ||
| _EP_SOURCE_FORMATS = frozenset({None, "mxfp4"}) |
There was a problem hiding this comment.
[SUGGESTION] This comment and the mxfp4 branch at L1858-1870 contradict each other: here GPT-OSS is "which the load refuses", there the log line reads native mxfp4 checkpoint (e.g. gpt-oss) -> Mxfp4Config(dequantize=True). There is no transposed-layout refusal anywhere at this head (grep transpose in this file finds only this comment), so a GPT-OSS checkpoint is accepted today.
A wrong comment is worse than none here, because it is the only place a reader would look for that constraint. If the refusal lands with [3/3], say so ("refused once the experts load, in a follow-up"); otherwise drop the clause and keep the layout caveat factual.
There was a problem hiding this comment.
|
|
||
| def _ep_mesh(*args, **kwargs): | ||
| index = torch.cuda.current_device() if ep_mesh.device_type == "cuda" else None | ||
| return torch.device(ep_mesh.device_type, index), ep_mesh | ||
|
|
There was a problem hiding this comment.
[SUGGESTION] The load device comes from torch.cuda.current_device(), not from the mesh, so the "device-bound process group" the docstring requires is assumed but never checked. A run that called init_process_group("nccl") without torch.cuda.set_device(local_rank) leaves current_device() == 0 on every local rank, and on 5.18+ releases (the only ones that reach this stub) every rank would place its shard on cuda:0 — an OOM or a silently co-resident load rather than a clear error.
The loader already front-loads the plan / group / transformers / config checks precisely so a bad setup fails before anything loads; this is the same kind of precondition. Consider adding it to the _refused_together("the expert-parallel load's checks") block, e.g. refuse when torch.cuda.current_device() != torch.distributed.get_rank() % torch.cuda.device_count(), or derive the index from the mesh's local coordinate instead of the ambient current device.
There was a problem hiding this comment.
Not changed: an unbound device does not lead to the silent co-resident load described here.
- Supported entry point: the loader documents that the caller binds the device.
distributed_run, whichhf_ptqand the GPU tests go through, binds it withtorch.cuda.set_device(local_rank())before it creates the group. - Modelopt's group without a binding: the group is
cpu:gloo,cuda:nccl, so the guards' object collectives run on the CPU and leave CUDA uninitialized. Torch'sinit_device_meshthen binds an uninitialized process byLOCAL_RANK, or byrank % devices_per_host, beforefrom_pretrainedreadstorch.cuda.current_device(). - A caller's NCCL-only group without a binding: every rank's first object collective (guard 1's) lands on
cuda:0. NCCL then fails loudly at the load's first collective.
A check of our own would have to guess the launcher's binding rule. Comparing against LOCAL_RANK refuses one-GPU-per-task setups, where every process is cuda:0. Comparing against rank % device_count refuses heterogeneous nodes. Torch's mesh already applies the launcher's own LOCAL_RANK.
| ) | ||
| if hf_config is None: | ||
| config_kwargs: dict[str, Any] = {"trust_remote_code": trust_remote_code} | ||
| if attn_implementation is not None: | ||
| config_kwargs["attn_implementation"] = attn_implementation | ||
| hf_config = AutoConfig.from_pretrained(ckpt_path, **config_kwargs) |
There was a problem hiding this comment.
[SUGGESTION] When the caller passes no hf_config, every rank calls AutoConfig.from_pretrained(ckpt_path) on what may still be a Hub ID — the checks run before ensure_local_checkpoint (L1810), which exists so that only rank 0 talks to the Hub. At a few hundred ranks that is a few hundred concurrent config.json fetches; a 429 or a transient network error on a subset then refuses the whole run (cleanly, thanks to _refused_together, but needlessly).
The ordering itself is well motivated ("a refused load downloads nothing") and the resulting failure is safe, so this is non-blocking. Worth a note in the docstring that callers at scale should hand in hf_config (as hf_ptq does), or read the config once on rank 0 and broadcast it alongside the checkpoint resolution.
There was a problem hiding this comment.
Taken as the docstring note you suggested, in 4b6470c. Without hf_config, every rank reads the checkpoint's config itself, from the Hub for a Hub ID, before rank 0 makes the checkpoint local, so callers on many ranks should pass it, as hf_ptq does.
The ordering stays, so that a refused load downloads nothing. Broadcasting rank 0's config would need a config object that loads with weights_only, which PretrainedConfig is not.
| __all__ = [ | ||
| "build_meta_causal_lm", | ||
| "fsdp2_wrap", | ||
| "load_and_prepare_ep", | ||
| "load_pretrained", | ||
| "parallel_load_and_prepare_fsdp2", | ||
| "record_unplaced_source_keys", | ||
| "resolve_distribution_plan", | ||
| "unplaced_source_keys", | ||
| ] |
There was a problem hiding this comment.
[SUGGESTION] The new __all__ omits is_fsdp2_text_only_vlm (L92), which examples/hf_ptq/example_utils.py:57 imports from this module — so it is part of the module's effective public surface. Nothing breaks today (nothing star-imports model_load_utils, and from ... import is_fsdp2_text_only_vlm is unaffected by __all__), but per CONTRIBUTING's "__all__ defines the public API" convention the list should match what callers outside the module use, otherwise the next from .model_load_utils import * in plugins/__init__.py silently drops it and autodoc stops listing it.
There was a problem hiding this comment.
Done in 4b6470c. __all__ now lists is_fsdp2_text_only_vlm. It was the only name that code outside the module imports from it and the list lacked; the others are load_pretrained, parallel_load_and_prepare_fsdp2, fsdp2_wrap and record_unplaced_source_keys.
There was a problem hiding this comment.
Claude review — [2/3] expert-parallel loader
Scope: full review (trigger was a bare /claude review). 4 changed files (+1219/-9); reviewed all of them — modelopt/torch/utils/plugins/model_load_utils.py and modelopt/torch/utils/moe.py in full for the changed hunks plus surrounding context, and both test files. Traced the new public symbols (load_and_prepare_ep, expert_parallel_mesh) and the base-branch helpers they lean on (_refused_together, ensure_local_checkpoint, _routed_expert_keys/_routed_expert_modules, ParallelState, distributed_forward_loop, register_torch_a2a, the amax-sync path in model_calib.py).
Findings
CRITICAL: 0 · IMPORTANT: 1 · SUGGESTION: 4
IMPORTANT — the expert-weight shard style is assumed, not verified (inline, model_load_utils.py:1884-1892). swapped_plan keeps only grouped_gemm as the expert-param style, and torch_a2a_experts.shard_tensor is a deliberate no-op, so the experts are sharded only by the surviving grouped_gemm entries. A plan that styles just the container ({"...experts": "moe_tp_experts", "...gate": "ep_router"} — the shape _EP_PLAN in the new unit tests uses), or that shards the expert params with packed_colwise/packed_rowwise, passes every pre-load check and then loads with every rank holding all experts, while torch_a2a_experts_forward routes by expert_id // (num_experts // ep_size). That calibrates the wrong experts on the wrong token pools and still produces a checkpoint — no error. The docstring already promises a post-load check against the loaded weights; landing it here (or an up-front refusal of a plan with no recognized expert-param shard style) is what makes this slice safe on its own, per the "every sub-PR stands on its own" rule.
SUGGESTIONs: contradictory GPT-OSS/mxfp4 comments with no refusal behind them (1626); the required device binding is assumed but unchecked while the load device comes from the ambient torch.cuda.current_device() (1667); every rank hits the Hub for config.json ahead of ensure_local_checkpoint (1771); __all__ omits is_fsdp2_text_only_vlm, which the examples import (78).
What I checked and found correct
- Collective lockstep. Each guarded step's body is collective-safe: the checks are rank-local before
dist.all_reduce;ensure_local_checkpoint'snew_groupis the block's first statement, and it already broadcasts rank 0's error rather than stranding peers; both meshes are built after the checkpoint block has reduced, so every rank reachesinit_device_meshtogether. The_sub.size() <= 1skips are plan-derived and therefore identical on every rank, and the warmup's(dp, ep, edp)order is fixed — no rank-dependent collective anywhere. - Mesh factorization.
DistributionPlan.__post_init__already enforcesworld_size % ep_size == 0, so(edp, ep)is always a valid shape, and row-major ordering makesep_rank == rank % ep_size— which matches both the GPU test's expected shard slice and the transport'sexpert_id // per_rankowner math. - Parallel-state seeding.
_seed_parallel_stateswritingmodule._parallel_statepre-conversion is the right hook:QuantModule.convertonly calls_initialize_parallel_state()whenparallel_state is None. Dense → world DP, routed experts →edpDP +epexpert group composes correctly withmodel_calib.py: expert weight amax is excluded from the EP sync by_should_sync_amax_across_ep, and the input-amax share is gated on_share_input_amax_across_ep(set on the HF experts container), so no per-rank shard gets reduced against a peer's. - Config round-trip.
declared_planis read offvars(ep_cfg)before the swap and restored after (popping to fall back to the config class's default), so an exportedconfig.jsonnames no ModelOpt-only style;_experts_implementationlives in_experts_implementation_internaland stays out ofto_dict(). Both are asserted against a realQwen3MoeConfig. - The 5.18+ mesh swap.
_experts_sharded_overpatchingtransformers.distributed.mixin.initialize_distributed_meshis verified to actually take effect by driving the realPreTrainedModel.prepare_distribute_model, and the restore is checked on the raising path too — not just asserted in a comment. - No circular import from
moe.py's new top-levelfrom .distributed import distribution_plan(distributed.pyreferences nothing inmoe), anddistributed_forward_loopreally does take theep_meshthe new docstrings point at. - Tests. Genuinely good coverage for a loader this version-sensitive: the
DistributedConfigfield-layout matrix across 5.6-5.19 via synthesized dataclasses, and the six-row two-rank gloo test where one rank fails alone at each guarded step. Both sit intests/unitlegitimately — the multi-rank one uses the session-scopedcpu_dist_workersthat CLAUDE.md exempts.
Risk
Moderate. The plumbing is careful and the rank-local-failure work (the new part relative to #2741's approval) is correct and well tested. The single blocking concern is that the load trusts the checkpoint's plan to name grouped_gemm and never confirms the experts actually sharded — on the tested path (Qwen3-MoE) they do, but the failure mode on an untested model family is a wrong checkpoint rather than a crash, which is worth closing inside this slice.
🤖 Generated with Claude Code
|
/ok to test 4b6470c |
#3136 refuses a plan whose routed-expert container has no grouped_gemm entry under it before anything loads, so this test's container-only plan no longer reached the refusal it checks. Its plan now shards the container's gate_up_proj through grouped_gemm, as a real plan does. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
e1ab284 to
d9406fe
Compare
|
/ok to test ab06860 |
meenchen
left a comment
There was a problem hiding this comment.
Bot review (gpt-6.1-sol) — DM the bot to share feedback.
Nudge: the latest blockers are resolved, but test placement and possible prompt-injection markup in an older review comment need human attention.
Needs action:
- Inspect the embedded
</body><parameter name="confirmed">truemarkup in the supplied Claude comment atmodel_load_utils.py:1922; confirm it is harmless serialization residue. - Move loader unit cases into
tests/unit/torch/utils/test_model_load_utils.pyand MoE-only cases into module-matching tests, followingCONTRIBUTING.md’s test-filename rule.
No action needed:
- ✔️ Resolved since the last review: host-independent single-process CUDA fixtures and shortened loader documentation. Earlier bound-device and plan-validation fixes remain present.
|
/ok to test 7747c5d |
|
Re the review above (test placement and the markup):
|
load_and_prepare_ep(ckpt_path, plan) loads a model by an expert-parallel DistributionPlan, with every rank both data-parallel and expert-parallel: transformers' own expert-parallel load builds only each rank's experts, over the (edp, ep) mesh's ep submesh, with the base_model_ep_plan swapped to the torch_a2a styles for the load and put back on the config it keeps. The dense part is replicated. Every module gets the ParallelState its amax syncs over, and the meshes' communicators are created in step. The plan, the process group, the transformers floor (5.7), the checkpoint's format and the plan's routed experts are checked before anything loads; a native MXFP4 checkpoint is dequantized as it loads; on transformers 5.18+, whose from_pretrained builds a world-sized mesh of its own, the experts are still sharded over the EP group. The model keeps the mesh for calibration (moe.expert_parallel_mesh), and the checkpoint tensors it has no parameter for are recorded for the export. This is the loader #2741 introduced, without the post-load refusals, which stay in #2741 on top of it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Each rank checks its own transformers, reads or is handed its own config, sees the checkpoint where rank 0 made it local, and loads its own share, so a rank could fail alone and leave the others waiting for it in the meshes, the load's collectives or the warmup's: a node with an older transformers or without the checkpoint's storage, one rank handed another config, a rank out of memory in the load or unable to read a weight file. Those steps now run inside _refused_together blocks, whose failure-carrying all_reduce raises a failure on every rank before the next collective: the checks before anything loads, the checkpoint, now made local through ensure_local_checkpoint (rank 0 resolves it for every rank, on a group whose timeout outlasts a download), and the load through the routed experts' lookup. A test runs the load on two real gloo ranks, one failing alone at each of those points, with the meshes' collective stood in by a bounded barrier: every rank raises that rank's failure. Without the blocks every case fails, the other rank waiting at the barrier or the warmup. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The load shards an expert's weights only through the plan's grouped_gemm entries under its container: the torch_a2a styles the containers take for the load shard nothing themselves, and packed_* entries are dropped. A plan that styles only the container, or shards the expert weights another way, passed every check, and every rank loaded every expert while the all-to-all transport routed tokens as if each held its share. It is now refused with the other plan checks, before anything loads. Every plan transformers 5.18 ships, and every one ModelOpt's model specs supply, shards its experts through grouped_gemm. The tests' stand-in plan gains the grouped_gemm entries the real plans have, and two rows pin the refusal: a container-only plan and one sharding the experts by packed_colwise. Both fail without the check. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A plan whose routed-expert containers no module of the loaded model matches left every module the dense state, so calibration reduced each expert's weight amax over ranks holding different experts: the load now refuses it, inside its guard. The refusal moves here from the loader's [3/3], word for word, with its test. A plan that shards one expert weight by grouped_gemm and styles another by a style this load drops passed the grouped_gemm check: the other weight loaded whole beside its sharded sibling, and the grouped forward paired slices of different experts. Such a plan is refused before anything loads. Every base_model_ep_plan transformers 5.18 ships and every ModelOpt spec plan passes the check. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
record_unplaced_source_keys returns a sorted list, which a later check iterates; the new test's stand-in returned None. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The load read the expert count from the root config first, while the distribution plan's decision reads the config holding the plan: a VLM whose root config carries an expert-ish field of its own beside a nested MoE was planned by one count and refused by the other. The load now counts them where the decision does. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…ir own Each rank builds the load's device meshes on its own GPU, and a live process group (gloo, say) does not give every rank one: a rank without it failed building them while the others waited in their subgroups' creation. Every rank now learns whether any rank has its GPU first, and a rank without one, where some other has its own, refuses the load on every rank, naming its device count and local rank. A run in which none has one still fails building the meshes on every rank alike. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The load imported its distributed and MoE helpers, the device-mesh builder and transformers' DistributedConfig inside the function, so its tests' stand-ins took effect. They are module-scope imports now, and the load reaches what the tests stand in for through its module (dist.is_initialized, dist.size, moe.require_transformers, device_mesh.init_device_mesh, hf_distributed.DistributedConfig). The loader tests' local transformers imports move to the top of the file, as every supported transformers has them. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A rank failing inside from_pretrained fails every rank only because the other ranks finish the load and meet it in the refusal's gather, which holds because transformers' expert-parallel load runs no collective: before 5.16 it slices each rank's experts into plain tensors, from 5.16 it builds DTensors locally, and the one mesh it would build (5.18) is replaced by the load's own. The docstring and the guard's comment now say so. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
transformers dequantizes MXFP4 experts into GPT-OSS's transposed (num_experts, in, out) layout whatever the model's own: a standard-layout MoE loaded its experts transposed, without an error, and failed on its first forward (or, with square shapes, computed garbage). GPT-OSS, the model whose layout it is, the load does not quantize either. So the load now refuses an MXFP4 checkpoint as it refuses other quantized formats, pointing to an unquantized copy of the model. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…ed on The docstring stated its premise, that from_pretrained runs no collective, for transformers in general; it was checked on 5.7 through 5.18, and the load accepts later releases too. It now says so, and how each release range shards the experts without one. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Every rank already receives every rank's answer from the gather, so the mixed case is decided on every rank alike without a second, guarded round, and the refusal names the ranks without a GPU of their own. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The TP-linear converter warns when it finds a transformers mesh on the model but no TP-sharded Linear under it. An expert-parallel load keeps its experts' mesh there (calibration reads it back), and shards no Linear by design, so every expert-parallel calibration warned. The warning now skips a model whose recorded distribution plan is expert- parallel. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The plan checks refused an expert weight's style unless it was grouped_gemm, ep_router or an experts style, and the swapped plan kept an entry if its style was a swap key or grouped_gemm: two lists of the same styles. The swap is now defined once, before the checks, and both read the styles it keeps from it, so a style added to or dropped from the swap changes what the checks accept and what the load keeps alike. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The tests kept their own copy of tests/_test_utils/torch/moe_models.py's model_with_decoder_at, differing only in the experts' quantizer child. They now use the shared model, and the parallel-state test, the one that needs a child under the experts, adds it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The load's GPU check read a rank's GPU as the one its local rank numbers, so a launch that shows each process one GPU, each bound to cuda:0, refused every rank but the first. The predicate it shares, fixed in the load routing below, now takes the device the rank is bound to. Two ranks, each bound to its one visible GPU under local ranks 0 and 1, now both pass the check and go on to build the meshes; the test fails with the old predicate. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The GPU check reads a rank's GPU as the device it is bound to once CUDA is initialized, and before that as the one its local rank numbers. A launch that shows each process one GPU but has not bound them yet (a gloo group, say) so read every rank but the first as having none, and was refused with "Run one process per GPU", which it does. Building the meshes would have failed there anyway: DeviceMesh binds such a rank by its local rank, a GPU rank 1 does not see. The check now also gathers which ranks see CUDA but are bound to no GPU yet, and refuses such a run on every rank, telling the caller to bind each rank to its GPU first, as distributed_run() does. The one-GPU-per-process test covers that launch bound and not yet bound; the not-yet-bound case fails without the check. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The one-rank-failure rows left the GPU check to read the host's own CUDA, and the gloo workers bind no GPU. On a host with a visible GPU every rank so read as not yet bound, and the load refused before the step four rows exercise (no-gpu, unseen-checkpoint, load, record); CPU-only CI hid it. A helper now pins what the check reads of torch.cuda (availability, initialization, current device, device count): no GPU for the rows the GPU plays no part in, and in the no-gpu row a bound GPU on every rank but the failing one, which the real predicate then reads instead of a patched _has_own_gpu. The one-GPU-per-process test uses the same helper. On a host faked to show GPUs that no rank has bound, the four rows failed before and pass now. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The comment over the grouped_gemm check said packed_* entries are dropped, but the check below it refuses a plan that styles a weight under a routed-expert container by any style other than grouped_gemm, so such an entry never reaches the swapped plan. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Importing modelopt initializes CUDA wherever torchao and triton are installed (torchao's mx_formats asks has_triton(), which reads the current device), so in a launch that binds no rank every rank is on cuda:0 by the time the load runs. DeviceMesh binds a rank only while CUDA is uninitialized, so it leaves them there: on a multi-GPU node they share GPU 0, and NCCL fails at the first collective on the ranks' shared device. The GPU check now gathers the UUID of the GPU each initialized rank is on and refuses, on every rank, a run in which two ranks are on the same one, telling the caller to bind each rank to its own. Claude's review: the refusal of a rank not yet bound was broader than the launch it is for. Where CUDA is still uninitialized, DeviceMesh binds such a rank by its local rank, which is right when that names a GPU the rank sees. The refusal is now kept for the rank whose local rank names none, as a launch showing each process one GPU gives every rank but the first. The GPU-layout test covers four launches: one GPU each, bound or not yet; every GPU, not yet bound; and every rank on cuda:0. The narrowing fails the not-yet-bound one-GPU and every-GPU cases without it, and the shared-GPU case fails without the new refusal. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The two-rank tests pin the CUDA state the GPU check reads, but the single-process ones, through gloo_world_of_one, still read the host's. The module now overrides that fixture to pin no GPU, so those tests, the Hub-resolution and nested-expert-count ones included, reach the outcomes they check whatever GPU the host has. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
meenchen's review: the docstring ran to some 50 lines, and the comments after it carried launch history and version-by-version root causes. The docstring now states what a caller must know (a group set up and bound to each rank's GPU, the same plan on every rank, data of each rank's own, the formats and transformers releases it loads, the collective-free load its failure guarantee rests on), and the comments say in a line or two why the code is as it is. The rationale they carried moves to the PR description. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
meenchen's review: a test file is named after the module it covers. The expert-parallel load's tests move from test_ep_loader.py into test_model_load_utils.py, as a section of their own at its end, and the two expert_parallel_mesh cases into test_moe_a2a.py, beside moe.py's other tests. The tests themselves are unchanged, and the CPU suites collect and pass the same cases as before. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
7747c5d to
930bb0f
Compare
b8c8df5 to
304c94f
Compare
|
/ok to test 930bb0f |
Note
[2/3] of #2741, split on review: #2741 grew past the 500-line budget, so it is now three PRs, merged in order: #3131 [1/3] (the helpers, on
main), this PR (the expert-parallel loader), and #2741 [3/3] (its post-load refusals).Base:
shengliangx/hfep-base-6a-ep-loader, a branch (not a PR) that merges #2779, #3067 and #2740 onmain, the PRs this one builds on (#3131 [1/3] has merged). The diff below is this PR's alone, and it merges after all of them.Series layout
main([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
load_and_prepare_ep(ckpt_path, plan)loads a model by an expert-parallelDistributionPlan, with every rank both data-parallel and expert-parallel.DistributedConfig(enable_expert_parallel=True)) builds only each rank's experts, over theepsubmesh of an(edp, ep)mesh.base_model_ep_planis swapped to thetorch_a2astyles for the load, so tokens travel to the rank that owns their expert.config.jsonnames no ModelOpt-only style.from_pretrained; the loader hands them the EP mesh for the load. EachDistributedConfigargument goes where the release has a field for it._parallel_state: dense modules reduce over the whole world, and routed-expert containers over their expert-data-parallel replicas. The routed experts are found from the samebase_model_ep_planthat sharded them.require_transformers, [OMNIML-6060] [1/3] Add require_transformers, and build a VLM without its LM head #3131);ep_sizedivides the expert count, read from the config that holds the plan, as the distribution plan's decision reads it (review fix).moe.expert_parallel_mesh), and records the checkpoint tensors it has no parameter for, for the export._refused_togetherblock ([OMNIML-6054] [2/2] Route load_pretrained by the distribution plan #2779), whose failure-carryingall_reduceraises the failure on every rank before the next collective:ensure_local_checkpoint;from_pretrainedrunning no collective, as transformers' expert-parallel load does not on 5.7 through 5.18: before 5.16 it slices each rank's experts into plain tensors, and from 5.16 it builds DTensors locally (run_check=False,src_data_rank=None). The other ranks finish the load and meet a rank that failed in it in the gather.Why a loader of its own.
ep_router,moe_tp_experts) keep every rank on the same tokens. Each rank runs all of them through its own experts, and an all-reduce over the expert-parallel group sums the results. Calibration needs each rank on its own data, so this load keeps transformers' expert sharding (grouped_gemm) and swaps the routing for thetorch_a2astyles. Those send each token to the rank that owns its expert by all-to-all, around the grouped GEMM that ModelOpt quantizes.parallel_load_and_prepare_fsdp2shards whole decoder layers, experts included, and gathers each layer's weights on every rank in every forward. It cannot keep a rank's experts resident and route tokens to them. Expert sharding also cannot start from a meta-initialized model: transformers'GroupedGemmParallel.shard_tensorslices materialized tensors. So this load reads real weights throughfrom_pretrainedover the EP submesh.from_pretraineddrops itsdevice_meshargument oncedistributed_configis given, and builds a world-sized mesh from the config's sizes. Whenever the EP degree is below the world size, that mesh shards the experts over every rank instead of the rank's EP group._experts_sharded_overhands transformers' builder the EP submesh this load built, for the duration of the call only. Older releases honordevice_meshand need no override.Usage
Design notes
Moved here from the code comments (review).
Transport. The load swaps the model's
base_model_ep_planto thetorch_a2astyles. The router emits global expert ids unmasked. The experts forward sends each token to the rank that owns its expert by an NCCL all-to-all, runs the quantizable grouped GEMM, and sends the result back by a second all-to-all, instead of every rank computing every token and all-reducing. Every rank sees different tokens, hence per-rank calibration data. Nemotron-H's logits-only gate has noep_routerentry, so itsnemotron_experts_epalso maps totorch_a2a_experts.Meshes. Two factorizations of the same world:
(edp, ep)for the experts: sharded overep, replicated overedp. Theepsubmesh also drivesfrom_pretrained's expert sharding and the transport.(dp,)for the dense part, replicated over the whole world.The all-to-all case is
edp == 1, and the dense amax always reduces over the whole world.edp > 1adds data parallelism across expert-parallel groups, at the cost of per-rank memory, with no concurrentedpandepcollectives.Real weights. Expert parallelism cannot be applied to a meta-initialized model, because transformers'
GroupedGemmParallel.shard_tensorslices materialized tensors. So the load reads real weights throughfrom_pretrainedover theepsubmesh.Release floor. transformers 5.6 has the MoE expert-parallel framework, but builds a model only with the experts implementations it ships, never
torch_a2a. A model whose expert-parallel support landed later states its release in its spec.No tensor parallelism. transformers applies expert parallelism through its tensor-parallel path: with
enable_expert_parallel=True,model.tp_planreturns the expert plan.tp_plan="auto"is only the switch that makestp_size > 1and so reachesapply_tensor_parallelism. The swapped plan keeps expert entries only, so attention and embeddings stay replicated.Where
tp_plangoes depends on the release: 5.17 and later refuse it beside adistributed_config, andDistributedConfighas notp_planfield before 5.10, nor in 5.11 and 5.12. Passed alone on 5.17 and later, it rebuilds the config withoutenable_expert_parallel. So the dataclass fields decide: each argument goes onDistributedConfigwhere it has a field for it, else tofrom_pretrained.Collective-free load. transformers' expert-parallel
from_pretrainedruns no collective on 5.7 through 5.18. Before 5.16 it slices each rank's experts into plain tensors, and from 5.16 it builds DTensors locally (run_check=False,src_data_rank=None). So the other ranks finish the load and meet a rank that failed in it in the guard's gather.Experts implementation.
from_pretrained'sexperts_implementationargument resolves non-FP8 implementations back togrouped_mm, but the experts forward readsconfig._experts_implementationwhen it runs, so the load sets it on every (sub)config. Without that, thetorch_a2a_expertsstyle would pass a fifth argument, the process group, to the grouped-mm forward. transformers keeps it as_experts_implementation_internal, which a serialized config leaves out.Warmup. The load creates each amax collective's NCCL communicator in the same order on every rank, with a barrier around each, rather than lazily mid-calibration. Lazy creation deadlocks when subgroups reach their first collective in different phases at DP>1. There is no forward warmup, since a variable per-group time would put the ranks out of step first.
GPU binding. Importing modelopt initializes CUDA wherever torchao and triton are installed: torchao's mx_formats kernels call
has_triton(), which readstorch.cuda.current_device(). So in a launch that binds no rank, every rank is already on cuda:0 when the load runs, andDeviceMesh, which binds a rank only while CUDA is uninitialized, leaves it there. On a gloo group, the load refuses such a run on every rank: ranks on the same GPU, compared by UUID. An NCCL launch in that state fails earlier, ininit_process_group, where NCCL refuses the duplicate GPU. Where CUDA is still uninitialized, the load refuses ranks whose local rank names no GPU they see.Testing
tests/unit/torch/utils/test_ep_loader.py(CPU), covering:DistributedConfigargument across the 5.6–5.19 field layouts;expert_parallel_meshand the quantization-method read.New:
test_a_load_failing_on_one_rank_fails_every_rankruns the load on two real gloo ranks, with the meshes' collective stood in by a bounded barrier. One rank fails alone at each guarded step:hf_config;Every rank raises that failure. With the guards made rank-local, all six rows fail, the other rank waiting at the barrier (about 8 s each, bounded).
tests/gpu/torch/utils/test_expert_parallel_load.py: loads a tiny Qwen3-MoE expert-parallel with EP over every GPU and over half of them, and checks each rank's share of the experts and the logits against the whole model. Unchanged from [OMNIML-6060] [3/3] Refuse an expert-parallel load that would calibrate the experts wrong #2741, where it passed on 2 and 4 GPUs. On this split it ran at 96be72d (below).2026-10-30: pre-commit, mypy included, passes against main.
tests/unit/torch/utilsand the fused-experts tests pass on CPU at this head, on transformers 5.18 and 5.5.4.Review fixes, 2026-10-30 (Claude's review): a plan in which some routed-expert container has no
grouped_gemmentry under it is refused before anything loads, sincegrouped_gemmis the only expert-weight style this load shards by (two new table rows fail without the check; every plan transformers 5.18 ships and every ModelOpt spec's plan passes it); the mxfp4 comments no longer name GPT-OSS; the docstring says to passhf_configon many ranks;__all__listsis_fsdp2_text_only_vlm. Pre-commit, mypy included, passes against main; the loader, model-loading and MoE unit tests pass on CPU on transformers 5.18 and 5.5.4.Rebased 2026-10-30 onto main 99c3834, with [OMNIML-6060] [1/3] Add require_transformers, and build a VLM without its LM head #3131 rebased there too; 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 and 5.5.4.Review fixes, 2026-10-30, round 2 (Claude's review): the load refuses a plan whose routed-expert containers match no module of the loaded model, inside its guard, so calibration cannot reduce the experts' amax over ranks holding different experts; this refusal and its test move here from [OMNIML-6060] [3/3] Refuse an expert-parallel load that would calibrate the experts wrong #2741. It also refuses, before anything loads, a plan that styles a weight under a routed-expert container by a style the load drops; every
base_model_ep_plantransformers 5.18 ships and every ModelOpt spec plan passes the check. Both new tests fail without their checks. Pre-commit, mypy included, passes against main, andtests/unit/torch/utilspasses on CPU at this head, on transformers 5.18 and 5.5.4.2026-10-30: the no-module plan test's unplaced-keys stand-in returns a list, as the function does (test only). The loader tests pass on CPU at this head, on transformers 5.18 and 5.5.4.
GPU run, 2026-10-30, at 96be72d (the guards around
from_pretrainedon NCCL, until then checked on CPU gloo only):tests/gpu/torch/utils/test_expert_parallel_load.pyon NVIDIA GB200, torch 2.11.0a0 (nv26.02), transformers 5.9.0, pytest with-p no:rerunfailures.Rebased 2026-10-30 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. 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 run above was taken at 96be72d; the next entry is one on the rebased stack.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 b726fb3 ([OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank #2742 adds the dense-sharded and quantized-training cases to the file):
tests/gpu/torch/utils/test_expert_parallel_load.pyon transformers 5.18.0.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.
Review fixes, 2026-10-30 (meenchen's review bot):
no-gpurow fails without the check.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.Review fixes, 2026-10-30, round 2 (meenchen's and Claude's reviews):
gate_up_proj(4, 64, 192)instead of(4, 192, 64), with no error. The config-refusal table's newmxfp4row fails without the change.tests/unit/torch/utilspasses on CPU at this head, on transformers 5.18, 5.9.0 and 5.5.4. So do the HF quantization plugin's tests.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 e251f6c with the mixed-GPU gather:
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 1491b66:
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: the config-refusal table's
mxfp4row covers the MXFP4 refusal, on CPU.Review fixes, 2026-10-30, round 3 (meenchen's review bot):
DeviceMeshdoes. The base is rebuilt on [OMNIML-6054] [2/2] Route load_pretrained by the distribution plan #2779 47ca6d1 for it. A new bounded two-rank test (ad9559d) shows each process one GPU, bound tocuda:0under local ranks 0 and 1: both ranks pass the check and go on to the meshes. It fails with the old predicate. Theno-gpurow is unchanged.model_with_decoder_atfromtests/_test_utils/torch/moe_models.py(9a0f5e0).tests/unit/torch/utilsand the HF plugin tests pass 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, 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; this PR's own commits are unchanged.Review fix, 2026-10-30 (Claude's re-review): a run in which some rank sees CUDA but is bound to no GPU yet is refused on every rank, telling the caller to bind each rank to its GPU first, as
distributed_run()does (eb6c382). Before, such a rank was read as having no GPU and the run was refused with "Run one process per GPU"; building the meshes would have failed there anyway, asDeviceMeshbinds an unbound rank by its local rank. The one-GPU-per-process test runs that launch bound and not yet bound; the not-yet-bound case fails without the check. Pre-commit, mypy included, passes against main, andtests/unit/torch/utilsand the HF plugin tests pass 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.Review fixes, 2026-10-30 (Claude's review at eb6c382):
no-gpurow; on a host faked to show GPUs no rank has bound, four rows failed. All pass now.packed_*entries are dropped: any other style under a routed-expert container is refused (56353ef).tests/unit/torch/utilsand the HF plugin tests pass 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 eb6c382:
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.
Review fixes, 2026-10-30 (Claude's and meenchen's reviews at 56353ef):
init_process_group. Importing modelopt initializes CUDA wherever torchao and triton are installed, so a launch that binds no rank has every rank on cuda:0 at the load, andDeviceMeshleaves it there. The not-yet-bound refusal is narrowed to a rank whose local rank names no GPU it sees.gloo_world_of_oneoverride (b658f37).tests/unit/torch/utilsand the HF plugin tests pass 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 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 commits are unchanged. Pre-commit, mypy included, passes against main, andtests/unit/torch/utilsand the HF plugin tests pass 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 75bc2d6), 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.The shared-GPU check, on one node with two processes seeing the same two GPUs and neither bound, so that importing modelopt left both on cuda:0:
init_process_groupitself fails on both ranks ("Failed to initialize NCCL communicator: invalid usage"), before the load.So the check catches gloo groups; NCCL launches fail in
init_process_group.Tests moved 2026-10-31 (meenchen's review): the expert-parallel load's cases from
test_ep_loader.pyto a section at the end oftest_model_load_utils.py, and the twoexpert_parallel_meshcases totest_moe_a2a.py, besidemoe.py's other tests (7747c5d). The tests themselves are unchanged. 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.Rebased 2026-10-31 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/utilsand the HF plugin tests pass 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 930bb0f:
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/AAdditional Information
Part of expert-parallel PTQ for Hugging Face MoE models. The loader's review history is on #2741.