Skip to content

fix(muon): avoid EP/FSDP AGRS OOM - #2069

Open
jayhenry wants to merge 15 commits into
InternLM:mainfrom
jayhenry:fix_muon_oom
Open

jayhenry wants to merge 15 commits into
InternLM:mainfrom
jayhenry:fix_muon_oom

Conversation

@jayhenry

@jayhenry jayhenry commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes an EP+FSDP AGRS out-of-memory in the Muon optimizer's remainder-batch path, then replaces the
implicit remainder handling with an explicit, benchmarked remainder_strategy choice
(agrs / pad_all2all / ragged_all_to_all) and a companion sub-group all-gather fix.

Root Cause

For GLM-5.2 with 512 ranks and EP8, the expert FSDP group has 64 ranks. The 76 same-shaped
fused_w2 parameters form a remainder batch of 12. AGRS used the full DTensor dimension
(256 * 6144) instead of the EP-local, FSDP-visible dimension (256 * 6144) / 8, producing a
72 GiB communication buffer and a second 72 GiB flatten() copy.

AGRS itself also all-gathers every matrix in a batch onto every rank, so its time and peak memory
scale linearly with batch size — worst case is exactly a large remainder batch, which is where
GLM-5.2 hit this OOM.

Fix

  • FSDP-visible dimension: compute the EP-adjusted global shard dimension once per
    shape-homogeneous batch instead of once per mesh group (the previous scope let a later
    differently-shaped batch reuse an earlier batch's dimension). Muon._create_muon_tasks was
    split into _resolve_mesh_comm_plan (mesh-topology / communication-strategy, resolved once per
    (device_mesh, placements) group) and _build_muon_task (per-batch, shape-dependent metadata),
    so the split makes this scoping explicit instead of implicit in one long function.
  • Sub-group all-gather guard: the sub-group path (each MoE expert spans a small rank
    sub-group) was gated on len(params) < fsdp_size, so a model whose expert params happen to
    fill a batch (GLM-5.2: 76 fused expert weights over 64 ranks) fell back to full-group
    all-to-all / AGRS reconstructing the whole multi-expert matrix on every rank. The guard is
    dropped so any group whose experts span a sub-group always takes the cheaper path.
  • remainder_strategy option, benchmarked at world size 8 on 96 MB (8192x6144, bf16)
    matrices:
    • "agrs" — all-gather + reduce-scatter; time/memory grow linearly with remainder size
      (14.9 ms / 624 MB at R=1 → 20.1 ms / 2016 MB at R=7).
    • "pad_all2all" (default) — zero-pads the remainder to world size and uses a uniform
      all-to-all; flat ~15.2 ms regardless of R, but every rank (including idle ones) allocates a
      full matrix.
    • "ragged_all_to_all" — sends only the real matrices via all-to-all split sizes, so idle
      ranks allocate nothing (648 MB busy / 48 MB idle at R=2, vs. 804 MB / 804 MB for padded
      all-to-all). The same split-size path also covers uneven shards from DTensor's
      ceil(size / world_size) chunking, so it needs no separate padding branch.
    • enable_all2all=False now coerces the strategy to "agrs" instead of rejecting the
      combination, since AGRS is the only remainder path that doesn't use all-to-all.
  • Removed the scratch OOM-investigation notes and repro script now that the fix has a permanent
    regression test.

Test Plan

  • PYTHONPATH=. python -m pytest tests/optim/test_muon.py -q — 20 passed (run under the local
    GPU lock), covering:
    • FSDP parity for all three remainder strategies against a single-process reference.
    • EP+FSDP batch-specific FSDP-visible-dimension regression (fails on the pre-fix revision,
      where Newton-Schulz sees the un-divided global shape instead of the EP-local one).
    • Sub-group all-gather is taken even when expert params fill a full batch.
    • remainder_strategy validation and default-value behavior, including the
      enable_all2all=False coercion.
  • ruff check / ruff format --check on the changed files.

@jayhenry
jayhenry requested a review from nil0x9 September 14, 2026 09:46
Comment thread xtuner/v1/config/optim.py Outdated
remainder_strategy: Annotated[
Literal["agrs", "pad_all2all"],
Parameter(help="Communication strategy for Muon parameter batches smaller than the FSDP group size"),
] = "agrs"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. It's good to have another option for flexibility.

Comment thread xtuner/v1/optim/muon.py Outdated
if sharded_tensor_dim is not None and non_fsdp_shard_factor > 1:
full_shard_dim_size = params[0].size(sharded_tensor_dim)
assert full_shard_dim_size % non_fsdp_shard_factor == 0
global_shard_dim_size = full_shard_dim_size // non_fsdp_shard_factor

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch

Comment thread xtuner_muon_oom.md Outdated
jayhenry and others added 14 commits September 16, 2026 11:00
_create_muon_tasks mixed group-, mesh-, and batch-level logic in one
generator body. Extract _resolve_mesh_comm_plan (shape-independent
sharding topology and communication strategy, resolved once per
device_mesh/placements group) and _build_muon_task (shape-dependent
per-batch metadata and AsyncTask construction), leaving
_create_muon_tasks as a plain linear loop over the three levels.

Also replace the EP-mesh-dim loop with a direct lookup by elimination,
since the multi-shard case is validated to be exactly FSDP + EP.
- muon_update_batch_async's agrs/all_to_all branches recomputed
  global_shard_dim_size from X[0].size(shard_dim) whenever the caller
  passed None. The optimizer's only caller, _build_muon_task, is the
  sole owner of this FSDP-visible size and always derives it the same
  way, so the fallback was dead defensive code; replace it with an
  assert that the caller supplied it, matching the existing shard_dim/
  process_group assertions for these strategies.
- _build_muon_task correspondingly always computes
  global_shard_dim_size when sharded_tensor_dim is set (dividing by a
  non_fsdp_shard_factor of 1 is a no-op), instead of special-casing
  factor > 1 and leaving it None otherwise.
- Replace the EP-mesh-dim loop in _resolve_mesh_comm_plan with a
  direct lookup by elimination, since exactly 2 shard placements are
  already validated and one is known to be the FSDP dim.
- Add step-by-step comments to _resolve_mesh_comm_plan and
  _build_muon_task describing each stage of the mesh-plan and
  batch-task resolution.
Handle the unsharded mesh case with an early local plan so sharded_mesh_dim
and sharded_tensor_dim stay non-optional, and replace the flag-then-dispatch
strategy selection with one early return per communication strategy.
The sub-group path was gated on `len(params) < fsdp_size`, so a deep MoE model
whose expert params fill a batch (GLM-5.2: 76 fused expert weights over a 64-rank
FSDP mesh) fell back to the full-group all-to-all and its AGRS remainder, which
reconstruct the whole multi-expert matrix on every rank and blow up memory.

Drop the guard so any group whose experts span a sub-group uses the sub-group
all-gather, and record the measured per-strategy memory cost next to the choice.
AGRS all-gathers every matrix of the batch onto every rank while each rank
orthogonalizes only R/W of them, so both its time and its peak memory grow
linearly with the batch size, while padded all-to-all is flat in R.

Measured at world size 8 on 96MB (8192x6144, bf16) matrices, one remainder
batch, mean of 10 steps:

    R   agrs            pad_all2all
    1   14.90ms  624MB  15.15ms  876MB
    3   16.68ms 1104MB  15.38ms  804MB
    5   18.34ms 1488MB  15.29ms  732MB
    7   20.07ms 2016MB  15.21ms  660MB

The gap widens with the FSDP group size: GLM-5.2 on a 64-rank mesh leaves a
12-param remainder per shape group, where AGRS moves ~12x the bytes.

Without all-to-all AGRS is the only remainder path, so enable_all2all=False now
coerces the strategy instead of rejecting the default combination.
…o-all

A remainder batch of R matrices used to be padded up to the FSDP group size, so
every rank received a full matrix and W-R of them orthogonalized zeros. Sending
the batch with all_to_all_single split sizes instead keeps the assembling ranks'
work identical and leaves the rest idle: no zero shards on the wire, no zero
Newton-Schulz, and no full-matrix buffers on ranks without a matrix.

Measured at world size 8 on 96MB (8192x6144, bf16) matrices, R=2, mean of 10
steps, peak allocation during the step:

    strategy      ms/step   busy rank   idle rank
    agrs            16.03     912 MB      576 MB
    padded a2a      14.86     804 MB      804 MB
    ragged a2a      14.82     648 MB       48 MB

Step time is unchanged at this scale — the collective is ~0.3ms against ~14ms of
Newton-Schulz, and a task's latency is gated by the ranks that do assemble a
matrix. The gain is the freed memory plus the freed GPU time on idle ranks, which
AsyncRuntime can spend on the other batches in flight; both grow with the group
size, and GLM-5.2 on a 64-rank mesh leaves 52 of 64 ranks idle per remainder.

Uneven shards still pad, since split sizes require equal-sized shards.
The ragged remainder path was gated on the sharded dimension dividing the FSDP
group size, which nothing guarantees: DTensor chunks a dimension into ceil(size /
world_size) pieces, so trailing ranks can hold a short shard or none, and both
AGRS and the padded all-to-all carry explicit uneven branches for it.

Split sizes are per rank, so they express an uneven shard directly. Sending the
sharded dimension first (a no-op view under FSDP, which shards dim 0) lets one
code path cover both cases and drops the gate, so every remainder batch now takes
the ragged exchange. The DTensor split rule moves into _shard_sizes, shared with
the uneven all-to-all branch.
…tegy

The ragged exchange was applied to every remainder batch, which left no way to
ask for the padded one and made remainder_strategy='pad_all2all' a no-op. Add
'ragged_all_to_all' as a third value so the knob names the batch strategy
directly, and keep 'pad_all2all' as the default.

Tested across all three values: the FSDP parity test gains a ragged case, and the
EP test asserts that padding hands every rank a matrix while AGRS and the ragged
exchange leave the batch to the rank that assembles it.
These were scratch artifacts from diagnosing the AGRS EP/FSDP OOM and are not
needed now that the fix and its regression test are in place.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

2 participants