Skip to content

[Feature] Decouple expert parallelism from FSDP sharding (dp2ep layout) - #2093

Open
silencelamb wants to merge 10 commits into
InternLM:mainfrom
silencelamb:pr/decouple-ep-fsdp
Open

silencelamb wants to merge 10 commits into
InternLM:mainfrom
silencelamb:pr/decouple-ep-fsdp

Conversation

@silencelamb

Copy link
Copy Markdown

Summary

This PR decouples MoE expert parallelism from FSDP sharding (the "dp2ep" layout of torchtitan): EP becomes a sub-dimension of the FSDP shard dimension instead of an orthogonal mesh axis. Dense parameters are sharded over the full data-parallel shard group and are no longer replicated across EP ranks; routed experts keep their EP split and are FSDP-sharded only over the remaining efsdp = dp_shard / ep ranks. The switch is FSDPConfig.decouple_ep_fsdp (default False; the legacy layout is untouched). The design note is docs/design/decouple_ep_fsdp.md.

flowchart LR
    R["root mesh (replicate, efsdp, ep)"] --> EP["ep_mesh = root[ep]<br/>token dispatch / expert ownership"]
    R --> DS["fsdp_mesh = flatten(efsdp, ep) = dp_shard<br/>FSDP group of dense params"]
    R --> EF["expert_fsdp_mesh = root[efsdp]<br/>FSDP group of routed experts on top of EP"]
    R -. "replicate > 1 (HSDP)" .-> HS["hsdp_mesh = root[replicate, dp_shard]<br/>expert_fsdp_mesh = root[replicate, efsdp]"]
Loading

With the legacy (fsdp, ep) mesh, fsdp = world / ep, so EP=8 on 8 GPUs keeps a full copy of every dense parameter and its fp32 optimizer state on every rank. For GLM-5.2-30B that pins the allocator at its ceiling (2–3 allocation retries per step) and makes EP8 about 7× slower than EP4. With the decoupled layout EP4 goes from 99.5 to 83.5 GiB at equal step time, and EP8 from 120.0 GiB / 11.8 s to 76.3 GiB / 1.64 s per step (8×H200, production recipe).

Changes

  • FSDPConfig.decouple_ep_fsdp, validated by a pydantic model_validator (ep | dp_shard, positive sizes); the runtime mesh checks raise ValueError.
  • MoE._init_decoupled_device_mesh: one root mesh (replicate, efsdp, ep); fsdp_mesh = root[efsdp, ep]._flatten("dp_shard") keeps its meaning for existing consumers, expert_fsdp_mesh is new, and HSDP + EP coexist on this path.
  • Two-level fully_shard: every MoEBlock is wrapped on expert_fsdp_mesh first, then each decoder / MTP layer on the dense mesh; expert all-gathers are prefetched together with the next layer.
  • _scale_and_reduce_grad_decoupled: routed experts get grad.div_(ep) on top of FSDP's reduce-scatter, FSDP-ignored fully replicated fp32 parameters get one coalesced all-reduce, and the legacy manual cross-EP all-reduce of dense gradients is gone.
  • Float8: per-class padding chunk counts and tile-wise reduce meshes (dense: dp_shard chunks, rank stride 1; experts: efsdp chunks, rank stride ep).
  • HF / DCP checkpoints need no adapter changes: LoadSpec derives the shard history from the new DTensor placements.
  • RL weight sync: BaseModel._fsdp_gather_group picks the gather group per LoadSpec, and WeightIterator._param_owner gathers compose-model parameters with the submodule that owns them, so efsdp / HSDP shards of a language tower are gathered instead of being mistaken for EP-local shards.
  • examples/v1/config/sft_glm5p2.py: DECOUPLE_EP_FSDP and HSDP_SHARDING_SIZE environment switches.
  • Tests: fake-process-group L0 placement tests for the legacy and decoupled layouts (8 / 16 / 64 ranks), 8-GPU L1–L3 gates, RL weight-iterator tests; the manual experiment scripts under tests/model/ share their helpers with the gates.

Verification

  • pre-commit hooks on the changed xtuner/v1 files (codespell, docformatter, pyupgrade, ruff, ruff-format, pydantic-extra-check) passed; mypy 1.16.1 reports no errors in the lines this PR touches.
  • tests/model/test_decoupled_ep_fsdp_mesh.py: 31 passed (fake process groups, CUDA context only).
  • tests/rl/test_weight_iterator.py: 13 passed.
  • tests/engine/test_decoupled_ep_fsdp_train_engine.py on 8×H200 (torch 2.9.1, tiny random Qwen3-MoE): 6 passed. L1 (ep=1, ep=8 legacy, ep=8 decoupled) and L2 (ep=4 legacy vs. efsdp=2, HSDP + EP with efsdp=1 and efsdp=2): loss curves within 1e-3 relative (observed ~2e-5), total and step-0 per-parameter gradient norms within 1e-2, per-rank parameter memory matching the layout. L3: bit-exact HF export right after from_hf, DCP resume, HF export after resume within bf16 ulps, and cross-layout DCP resharding.
  • GLM-5.2-30B on 8×H200, production recipe: EP4 1.688 s / 99.5 GiB → 1.658 s / 83.5 GiB; EP8 11.8 s / 120.0 GiB → 1.64 s / 76.3 GiB. Tile-wise FP8 runs on a 3.4B Qwen3-MoE and on GLM-5.2 stay within run-to-run noise of the legacy layout.

Result

decouple_ep_fsdp defaults to False and the execution logic of the legacy path is unchanged; the legacy placements are pinned by the L0 tests. Known limits are listed in §6 of the design note: ExpertTP and decoupling are mutually exclusive, fp32_keys_pattern matching a routed expert is unsupported on both paths, HSDP + EP is validated on single-node scaled-down topologies only, and the two-mesh fully_shard + DeviceMesh._flatten combination is validated on torch 2.8 / 2.9.

…layout

Single-process fake ProcessGroup tests asserting the mesh ranks and DTensor
placements that MoE.fully_shard produces today for 8/16/64 ranks, plus the
Phase-0 baseline snapshot and the decision log for the EP/FSDP decoupling work.
Add FSDPConfig.decouple_ep_fsdp. When enabled, MoE builds a single
(replicate, efsdp, ep) root mesh with efsdp = dp_shard / ep, shards routed
experts with their own FSDP group on efsdp and every other parameter over the
full dp_shard mesh (no more ep-fold replication of dense params), and scales
expert gradients by 1/ep instead of all-reducing dense gradients over EP.
The legacy layout is untouched when the flag is off.

Includes fake-PG L0 placement tests for 8/16/64 ranks, the 8-GPU numerics
script used for the L1 report, and reports/L1.md.
… EP/FSDP layout

- Float8Handler: per-parameter-class fp8 padding chunk counts and tile-wise
  reduce meshes (routed experts follow the efsdp shard stride, dense params
  the flattened dp_shard); legacy single-mesh path unchanged.
- BaseModel._fsdp_foreach_allgather picks the FSDP gather group per LoadSpec
  so EP-local expert slices are still reconstructed for RL weight sync.
- examples/v1/config/sft_glm5p2.py: DECOUPLE_EP_FSDP / HSDP_SHARDING_SIZE
  env switches.
- L0 fake-PG tests for the fp8 meshes, the L3 DCP/HF checkpoint script and
  reports/L3.md (bit-exact HF export, DCP resume, cross-layout DCP reshard,
  fp8 numerics within the fp8 run-to-run noise floor).
Root cause: `WeightIterator.iter_layer_batches` (IPC + Turbomind) took the
parameters and `LoadSpec`s of a compose model's language tower but ran the
FSDP-only all-gather through the outer compose model. The compose model is
wrapped on the world mesh, has no `expert_fsdp_mesh`, and never builds a
`load_spec_mapping` of its own, so on the decoupled EP/FSDP layout the language
tower's `efsdp` expert shards (and, with HSDP, its `dp_shard` dense shards)
were treated as shards to preserve and never gathered: Turbomind received
rank-local fragments. The vision / projector / non-layer language parameters
additionally hit the empty `load_spec_mapping` of the compose model.

Fix: resolve the owning submodule (language_model / vision_tower /
multi_modal_projector, or the model itself) for every parameter and run
`_fsdp_foreach_allgather`, `_to_float8` and the `load_spec_mapping` lookup on
that owner. Plain models are unaffected.

Test plan: `tests/rl/test_weight_iterator.py::TestLayerBatchesGatherWithParamOwner`
builds a compose model whose language tower is EP-sharded and FSDP-sharded on
`efsdp` (with and without an HSDP replicate dim) on fake process groups and
asserts every streamed tensor is complete (EP slice kept; `efsdp`, `dp_shard`
and world shards gathered). It fails before the fix and passes after; the
existing weight-iterator, load-spec and L0 mesh tests still pass.
- FSDPConfig: replace the `assert`s in `model_post_init` with a
  `@model_validator(mode="after")` that raises `ValueError` (surfaced as a
  pydantic `ValidationError`), so the checks survive `python -O`; also reject
  non-positive `ep_size` / `hsdp_sharding_size`.
- MoE._init_decoupled_device_mesh: raise `ValueError` instead of asserting
  `world_size % dp_shard == 0` and `dp_shard % ep_size == 0`.
- MoE._scale_and_reduce_grad_decoupled: narrow `mesh_dim_names` before
  indexing so the decoupled path type-checks.
- L0 regression tests for the config validator and the runtime check.
- xtuner/_testing/decoupled_ep_fsdp.py: the tiny Qwen3-MoE checkpoint, token
  stream, per-layout training run and HF-checkpoint comparison shared by the
  gates and the manual experiment scripts.
- tests/engine/test_decoupled_ep_fsdp_train_engine.py: 8-GPU
  `DeterministicDDPTestCase` gates. L1 / L2 compare the decoupled layouts
  (efsdp == 1, efsdp > 1, HSDP + EP) with the legacy ones on loss curves,
  total grad norms, per-parameter grad norms at step 0 and per-rank parameter
  memory; L3 checks bit-exact HF export after `from_hf`, DCP resume, the HF
  export after resume and cross-layout DCP resharding. CPU tests pin the
  checkpoint comparison itself.
- run_decoupled_ep_fsdp_numerics.py / run_decoupled_ep_fsdp_ckpt.py now import
  the shared helpers; their CLI and JSON output are unchanged.
…upled EP/FSDP design note

- §3.1 shows the `model_validator`; §3.6 and the §5 evidence table link the
  8-GPU gates next to the experiment scripts; the two resolved items leave
  §6.3.
- reports/decoupled_ep_fsdp_review_zh.md: status note for N1 and W5.
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.

1 participant