Skip to content

[Umbrella] Enable FSDP2 + expert parallelism via a 2-D (fsdp, tp) device mesh - #48204

Draft
qgallouedec wants to merge 18 commits into
mainfrom
ep-fsdp-2d-mesh
Draft

[Umbrella] Enable FSDP2 + expert parallelism via a 2-D (fsdp, tp) device mesh#48204
qgallouedec wants to merge 18 commits into
mainfrom
ep-fsdp-2d-mesh

Conversation

@qgallouedec

@qgallouedec qgallouedec commented Aug 22, 2026

Copy link
Copy Markdown
Member

CPU CI GPU run-slow

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.

  • Today: tp_size=N, fsdp_size=M with both > 1 raises FSDP+TP is not supported yet.
  • But EP shards only the experts — everything else (and its optimizer state) replicates on every EP rank. Big MoEs need both.
  • This draft: 2-D (fsdp, tp) mesh, EP plan on tp, fully_shard on fsdp, trains end-to-end with Trainer.

Try it

pip install git+https://github.com/huggingface/transformers@ep-fsdp-2d-mesh
pip install git+https://github.com/huggingface/accelerate@fsdp2-ep-integration
pip install git+https://github.com/huggingface/peft@fsdp2-dtensor-fixes

Validated on 64 H100 with TRL's SFTTrainer:

How

  • Build the 2-D mesh, record _tp_size/_fsdp_size so Trainer hands accelerate a ParallelismConfig (otherwise accelerate wraps the DTensor model in DDP, which raises).
  • Trainer fallbacks: all-reduce expert gradients over fsdp (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:

  • uninitialized torch._grouped_mm sentinel rows → NaN on step 2
  • router hook drops non-local score gradients → wrong gradients for every non-expert parameter

fp32 gradient certification vs a single-GPU reference, after the fixes:

  • EP: 179/179 params within float epsilon (max 2.7e-5; before: 3/179)
  • 2-D mesh: 179/179 (5.6e-5) · real Qwen3-30B weights: 47/47 (2.7e-6)
Loss parity, throughput and memory tables image

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):

config tok/s/GPU peak mem/GPU
EP only, ep=8 3,089 40.9 GB
FSDP2 × EP, ep=4 × dp=2 2,379 34.2 GB

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)
# torchrun --nproc_per_node 4 min_fsdp_ep.py
import torch
from torch.utils.data import Dataset

from transformers import AutoModelForCausalLM, Trainer, TrainingArguments
from transformers.distributed import DistributedConfig


class RandomTokens(Dataset):
    def __len__(self):
        return 512

    def __getitem__(self, i):
        g = torch.Generator().manual_seed(i)
        ids = torch.randint(0, 50000, (512,), generator=g)
        return {"input_ids": ids, "labels": ids.clone()}


model = AutoModelForCausalLM.from_pretrained(
    "allenai/OLMoE-1B-7B-0924",
    dtype=torch.bfloat16,
    distributed_config=DistributedConfig(tp_size=2, fsdp_size=2, enable_expert_parallel=True),
)

trainer = Trainer(
    model=model,
    args=TrainingArguments(
        output_dir="/tmp/fsdp-ep",
        per_device_train_batch_size=1,
        max_steps=8,
        logging_steps=1,
        report_to=[],
        save_strategy="no",
    ),
    train_dataset=RandomTokens(),
)
trainer.train()

Known limitations

  1. tp_size doubles as the EP size (a dedicated ep name would be clearer).
  2. LoRA needs two PEFT fixes: Fix LoRA on FSDP2-sharded models: shape inference and module hooks peft#3578.
  3. Pure FSDP2 from 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).
  4. Checkpoint saving works (every rank gathers, main writes, HF format). Resume does not: the Trainer's resume path raw-copies plain tensors into DTensor params and doesn't convert per-expert → fused; it needs to reuse the from_pretrained sharded-load path.
  5. The proper design likely puts expert DTensors on the full 2-D mesh so FSDP2 handles reduction itself (torchtitan-style).

What this branch carries

What Standalone
NaN + missing gradients #48205
EP reachable through Trainer #48208
load-scaled pg timeout (rank skew vs 10-min NCCL watchdog) #48228
don't device-move sharded-at-load models in Trainer this branch
HF_SHARD_PREFETCH: 4–6× cold multi-node loads #48227
token-dispatch + trunk-data-parallel EP (env-gated, fp32-certified, 31–59× samples/s at 64 GPUs) follow-up PR
HF_DEBUG_INIT_SWEEP logging this branch
fix empty FSDP shards in sharded loading (crash + silent group hang; froze 357B runs at ep=8 × fsdp=8) #48237

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

Copy link
Copy Markdown

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.

qgallouedec and others added 2 commits August 21, 2026 22:12
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.
…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.
@qgallouedec qgallouedec changed the title Enable FSDP2 + expert parallelism via a 2-D (fsdp, tp) device mesh [Umbrealla] Enable FSDP2 + expert parallelism via a 2-D (fsdp, tp) device mesh Aug 23, 2026
@qgallouedec qgallouedec changed the title [Umbrealla] Enable FSDP2 + expert parallelism via a 2-D (fsdp, tp) device mesh [Umbrella] Enable FSDP2 + expert parallelism via a 2-D (fsdp, tp) device mesh Aug 23, 2026
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.
# Conflicts:
#	src/transformers/distributed/configuration_utils.py
#	src/transformers/distributed/mixin.py
@github-actions

Copy link
Copy Markdown
Contributor

CI recap

Dashboard: View test results in Grafana
Latest run: 32661785504:1
Result: failure | Jobs: 2 | Tests: 23 | Failures: 1 | Duration: 1m 55s

Code quality check failed: test jobs were skipped. Fix the code quality issues and push again to run tests.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants