[Umbrella] Enable FSDP2 + expert parallelism via a 2-D (fsdp, tp) device mesh - #48204
Draft
qgallouedec wants to merge 18 commits into
Draft
[Umbrella] Enable FSDP2 + expert parallelism via a 2-D (fsdp, tp) device mesh#48204qgallouedec wants to merge 18 commits into
qgallouedec wants to merge 18 commits into
Conversation
Removes the DistributedConfig(fsdp_size=N, tp_size=M) guard and makes the combination train: - initialize_fully_sharded_data_parallelism builds a 2-D (fsdp, tp) mesh when both sizes are > 1; fsdp is the outer dimension so tp ranks stay contiguous. - DistributedMixin applies BOTH parallelisms (experts on the tp submesh, FSDP on the fsdp submesh) instead of if/elif, and records _tp_size/_fsdp_size so the Trainer can mirror them into accelerate's ParallelismConfig (otherwise accelerate wraps the DTensor model in DDP, which raises). - Trainer: gradients of parameters that no parallelism shards across the dp dimension (the EP-sharded experts) are explicitly averaged over it; the gradient norm and clipping handle parameters living on different meshes, discounting replicated mesh dimensions. - Optimizer: fall back to per-parameter (non-fused, non-foreach) AdamW when the parameter set spans more than one mesh. Validated on OLMoE-1B-7B and Qwen3-30B-A3B (see PR description for losses, grad norms and throughput).
…uped_mm rows Under EP, sentinel token-expert slots sit beyond offsets[-1] and torch._grouped_mm leaves those output rows (fwd output and bwd d_input) uninitialized. The forward relied on a single post-mask plus a single pre-mask, letting NaN/Inf from uninitialized memory transit the activation and down-projection backward. The gate product's backward (act_fn(gate) * up) turns 0 x Inf into NaN (torch.autograd anomaly mode names this exact Mul), and it escapes into finite gradients: full fine-tuning of any EP-sharded MoE produced nan grad_norm on the second step (the first step survives only because freshly-allocated CUDA memory happens to be zeroed) and the loss collapsed to 0. Mask the sentinel-tail rows after each grouped GEMM instead. Full fine-tuning of OLMoE-1B-7B under ep=4 now matches the single-GPU loss trajectory.
…training Under EP the router hook zeroes the routing scores of non-local experts, so in backward each rank's score gradient covers only the slots of its local experts, and nothing sums the per-rank partial gradients: the gate weights and, through the gate's input, every parameter upstream of each MoE block receive gradients missing the contributions that flow through remote experts. The existing _AllReduceBackward on the experts' hidden input covers the dispatch branch, and the top_k_weights branch is explicitly skipped when is_expert_parallel -- but under EP it is exactly as partial as under TP-MoE. Measured against a single-GPU reference (OLMoE-1B-7B, one batch, fp32 so rounding noise vanishes): before the fix, 3/179 parameters agree (relative max-abs errors 0.3-2.5 on attention, norms, embeddings and router gates, 10-100x above the run-to-run noise floor; only the last layer's experts and the final norm -- the parameters backward reaches before crossing an expert block -- are correct). After the fix: 179/179 agree, max relative error 2.7e-5. Fix: allreduce-sum the score gradient in the EP router hook, before the non-local mask (each slot has exactly one owning rank, so the sum is exact).
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
Trainer.save_model only called save_pretrained on the main process, but for a model sharded by from_pretrained (DistributedConfig) the state-dict gather inside save_pretrained is collective: rank 0 issued gathers alone while the other ranks were already in the next step's collectives, and NCCL timed out after 10 minutes (this is how every other sharded backend in save_model — accelerate-FSDP, DeepSpeed — is already handled: gather on all ranks, write on one). Gather the DTensor state dict on every rank via gather_state_dict_for_save, write on the main process only, and have the non-writer ranks call the end-of-save barrier that save_pretrained runs on the writer, so no rank enters the next step while the checkpoint is being written. PEFT models keep the adapter-only save. Tested (OLMoE-1B-7B, fsdp=2 x tp=2, save_steps=2): training runs through the save (losses 12.28, 12.27, 11.39), and the checkpoint holds the full model in HF format (3219 tensors, per-expert weights restored, values finite).
Sharded loading of a TB-scale checkpoint takes tens of minutes and ranks finish far apart (measured: >10 minutes spread on GLM-4.6, 714 GB over 16 ranks), so the fastest ranks' first collective dies on the default 10-minute NCCL watchdog. Use a 2-hour timeout when transformers initializes the group itself.
A model sharded by from_pretrained (DistributedConfig) manages its own placement, and .to() on FSDP2-managed (possibly CPU-offloaded) parameters raises RuntimeError: _apply(): Couldn't swap FSDPLinear.weight. Skip the Trainer-side device move like the other sharded backends. (accelerate's prepare_model does its own move; fixed separately.)
Instead of gathering the full batch on every EP rank and masking non-local experts (allreduce EP), each rank keeps 1/ep of the tokens, all-to-alls them to their expert owners, runs the local grouped GEMM, and all-to-alls the results back before the weighted combine. Collectives are explicit autograd Functions; gradients crossing the dispatch boundary (hidden_states and top_k_weights) are summed across the EP group in backward. Gradient-certified in fp32 against a single-GPU reference on a tiny GLM-4 MoE (4 ranks): 36/36 params within 2e-2, max rel err 7.2e-7 - the same noise floor as the allreduce EP baseline (6.6e-7); forward loss bitwise identical.
This was referenced Aug 22, 2026
…P=1) Each EP rank carries its own microbatch: the dense trunk becomes data- parallel and the dispatch simplifies to route/all-to-all/compute/return - no slicing, no boundary gradient sums, no trailing all-gather. The caller averages ep-replicated (dense) parameter gradients across the EP group and scales expert gradients by 1/ep after backward, mirroring data parallelism. fp32-certified against a single-GPU batch-of-4 reference (4 EP ranks, one sample each): 36/36 params within 2e-2, max rel err 2.4e-6.
…TCH) The loader's per-tensor read pattern pulls a network filesystem at well under 1 GiB/s while large sequential reads sustain many times that (measured: 0.26-0.7 GiB/s vs 8.5 GiB/s on Lustre). With HF_SHARD_PREFETCH=<threads>, the local ranks split the shard list and stream it into the page cache before loading; the load then runs at memory speed. Measured on GLM-4.5-Air (206 GiB, cold, 8 GPUs): 60 s baseline vs 23 s prefetch + 10 s load.
The post-load initialize_weights pass can silently grind for hours at the 100B+ scale (all ranks CPU-bound in nn.init with no output, easily mistaken for a trainer hang). The env-gated log reports how many parameters lost their _is_hf_initialized mark and how long the sweep took.
Uneven FSDP sharding can assign a rank an EMPTY local shard (e.g. 20 local experts chunked over fsdp=8 leaves the last rank zero rows). Two things then go wrong while loading: 1. MergeModulelist receives zero pieces for the stacked parameter and torch.stack([]) raises 'stack expects a non-empty TensorList' - fatal at the end of loading on those ranks. Fixed by skipping the merge: the pre-sharded empty local tensor installed at init is already correct. 2. Those params are never marked _is_hf_initialized, so _initialize_missing_keys runs _init_weights on them - whose first DTensor RNG op is a mesh-wide collective the fully-loaded ranks never join: mismatched collectives, and the group hangs silently (0% GPU, all ranks in R state). Fixed by marking empty-local DTensors before the sweep - an empty shard has nothing to initialize. Root cause of GLM-4.6 (160 experts) freezing at ep=8 x fsdp=8 while every other configuration worked. Verified live at 64 ranks: ranks 56-63 (fsdp index 7) held empty expert shards; with TORCH_DISTRIBUTED_DEBUG=DETAIL the hang becomes a monitoredBarrier error naming exactly those ranks.
The MergeModulelist-specific skip missed Concatenate (torch.cat of zero pieces), which Qwen-family gate/up fusion hits first. Skip at the collection point instead: when every piece of a parameter was dropped by the sharding operation, this rank owns none of it and the mapping has nothing to do. Validated with a 0.2M-param 4-GPU repro (2 experts, fsdp_size=4): crash+hang before, clean load after.
This was referenced Aug 25, 2026
# Conflicts: # src/transformers/distributed/configuration_utils.py # src/transformers/distributed/mixin.py
Contributor
CI recapDashboard: View test results in Grafana
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Important
Draft to unblock experimentation (fine-tuning large MoEs in TRL without replicating dense weights on every EP rank). The Trainer-side fallbacks are deliberately blunt; we'll migrate to whatever native design transformers lands.
tp_size=N, fsdp_size=Mwith both > 1 raisesFSDP+TP is not supported yet.(fsdp, tp)mesh, EP plan ontp,fully_shardonfsdp, trains end-to-end withTrainer.Try it
Validated on 64 H100 with TRL's
SFTTrainer:How
_tp_size/_fsdp_sizesoTrainerhands accelerate aParallelismConfig(otherwise accelerate wraps the DTensor model in DDP, which raises).Trainerfallbacks: all-reduce expert gradients overfsdp(FSDP2 only reduces what it shards), a grad-norm/clip that spans parameters on different meshes, per-parameter AdamW when fused/foreach can't span the set.Correctness
Validation surfaced two pre-existing EP bugs, split out as #48205:
torch._grouped_mmsentinel rows → NaN on step 2fp32 gradient certification vs a single-GPU reference, after the fixes:
Loss parity, throughput and memory tables
OLMoE-1B-7B FFT: EP-only tracks the single-GPU control step by step; the 2-D run has 2× the batch and agrees in trend.
Qwen3-30B-A3B full fine-tuning, 8×H100, seq 2048 (both converge, grad norms finite):
OLMoE-1B-7B full FT, 8×H100: ep=8 → 10,052 tok/s at 10.4 GB; ep=4×dp=2 → 7,143 at 8.9 GB; ep=2×dp=4 → 7,023 at 8.7 GB. LoRA at small scale fits everywhere and EP-only is faster; the 2-D mesh is for full FT and beyond.
Minimal repro (
min_fsdp_ep.py)Known limitations
tp_sizedoubles as the EP size (a dedicatedepname would be clearer).from_pretrained(tp_size=1) still fails in accelerate (pre-existing, tracked in Should Trainer support models FSDP2-sharded at load time (DistributedConfig(fsdp_size=N))? #48210).from_pretrainedsharded-load path.What this branch carries
TrainerTrainerHF_SHARD_PREFETCH: 4–6× cold multi-node loadsHF_DEBUG_INIT_SWEEPlogging