Repository navigation
perf(optim): chunked optimizer-state offload for Muon via a subset-step interface (builds on #4427) - #4429
Draft
faresobeid wants to merge 26 commits into
Draft
perf(optim): chunked optimizer-state offload for Muon via a subset-step interface (builds on #4427)#4429faresobeid wants to merge 26 commits into
faresobeid wants to merge 26 commits into
Conversation
…of the chunked-step gate
# Conflicts: # tests/unit/train/test_state_offload.py
The chunked CPUOffloadOptimizer step returns before its chunks run. They update on a side stream while the next forward runs, enqueued as the forward reaches them (8 up front, then 4 ahead of the forward), so the CPU never fills the CUDA launch queue. Each FSDP parameter group waits for the chunk holding its parameters before its all-gather. The step's hyperparameters and gradients are captured at step time, so the math is unchanged. model.offload_overlap_forward (default true) turns it on for AdamW. Anything else that reads the parameters calls wait_for_optimizer_step first: weight broadcasts in the SFT and RL loops; checkpoint saves already wait through prepare_checkpoint_save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tep interface CPUOffloadOptimizer now chunks the step of any optimizer that can update a subset of its parameters at a time with the math of a full step (subset_step.SupportsSubsetStep). torch AdamW and dion's Muon get adapters; Muon's units are dion's own batches, so its all-to-alls and compiled updates are unchanged, and its param-group step counters advance once per step. Forward overlap applies to every such optimizer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…load dion's Muon step syncs the host and launches hundreds of kernels per chunk, so running it alongside the forward measured slower than the synchronous chunked step (40.1k vs 41.2k tok/s on Qwen3-30B-A3B). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A prime-rl Muon in trainer/optim/muon.py, numerically dion's Muon (with dion's Newton-Schulz plugged in, every parameter is bitwise equal to dion over 20 steps on 8 GPUs), without dion: - Expert stacks sharded only on the expert dim orthogonalize in place, batched. - FSDP-sharded matrices gather onto owner ranks with uneven all-to-alls (no padding), owners balanced by Newton-Schulz FLOPs, in layer-ordered buckets; bucket k+1 gathers while bucket k orthogonalizes, on Muon's own NCCL communicators (4 CTAs). - Newton-Schulz in bf16 on QuACK's symmetric GEMM with fused epilogues, batched per shape; tall matrices iterate on XᵀX without transposing; schedules are data (default, deepseek_v4, polar_express, step3p5). - Element-wise updates are compiled once per unit; no host syncs in the step. - Implements the subset-step interface for chunked optimizer-state offload. - Same state keys and param groups as dion's Muon: its checkpoints resume as they are. Adds optim.update_rms and optim.newton_schulz. Removes the dion dependency. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o compiled graphs stay few Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le limit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eplaces them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
model.offload_overlap_forward now defaults to false. The overlapped step and the pre-broadcast wait have not been exercised in an RL run yet, nor with sparse weight deltas; enable it explicitly until they have. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Native Muon replaces dion and implements SupportsSubsetStep itself, so the dion adapter (DionMuonSubsetStep, create_param_batches) goes; subset_step_for() finds Muon by duck typing. test_state_offload's Muon tests run against the native Muon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erlap Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se the DTensor local helpers in _move_states Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # tests/unit/train/test_state_offload.py # uv.lock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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.
Builds on #4427 (and so #4008) and on #4430 (native Muon, which removes dion), and lands after both: this branch is #4427's head and #4430's head merged, plus the commits here. With
model.offload = "optimizer"andoptim.type = "muon", the offloaded step now streams the Muon states through the GPU in chunks, the way #4008 does for AdamW. Before this, Muon took the serial path: all states to the GPU,step(), all states back.To do that, the chunked path now runs on a small optimizer-agnostic interface. An optimizer opts in by updating a subset of its parameters at a time with the same math as a full step. torch AdamW gets an adapter. #4430's native Muon implements the interface itself, and
subset_step_for()checks the optimizer for it before it looks for an adapter. There is no dion adapter, since #4430 removes dion.Measured with dion's Muon. The profile and numbers below come from this PR's first version, before it was merged with #4430: dion's Muon behind a dion adapter. They have not been re-measured with the native Muon. The chunked step is bound by copy bandwidth, so the copy figures should carry over. The host-side costs in the forward-overlap section were dion's.
Why the Muon step was slow
Qwen3-30B-A3B on 8xH200 (FSDP 8 / EP 8) has 14.5 GiB of Muon state per rank: fp32 momentum, plus AdamW moments for the embeddings, LM head and norms. Main moves all of it to the GPU, steps, and moves it back, one phase after the other. Rank 0 trace, steps 5-7:
Each step moves 14.5 GiB per rank in each direction, 29 GiB in total. At #4426's measured per-GPU bandwidth, copying one direction at a time takes at least 0.56 s. Copying both directions at once (38.8 GiB/s combined) puts the floor at 0.75 s. The chunked step takes 0.85 s untraced. That is within about 0.1 s of the floor, the cost of loading the first chunk and writing back the last one.
Numbers
Qwen3-30B-A3B, 1 node 8xH200, FSDP 8 / EP 8, 16k seq, micro batch 1, global batch 8, full AC, compile, fake data,
optim.type = "muon"(dion's Muon), no activation offloading. tok/s is the mean of the last 10 of 20 steps. Step time and optimizer step time are means/medians over steps 6-20 (synchronized at both ends). "NUMA" means bound to the GPU's socket, as in #4426.offload = "optimizer"The chunked step removes 0.53 s of the 1.12 s that offload added to each step: +17% tok/s over main + #4426, +20% over main. The no-offload arm's step shows the remaining cost of the copies (0.64 s per step).
AdamW is unchanged by the refactor. With forward overlap (
offload_overlap_forward = true, opt-in since #4427) and NUMA, this PR runs at 37.3k tok/s vs 37.5k for #4427 (3.52 s vs 3.49 s per step), within noise.Forward overlap (#4427-style) for Muon: measured with dion, and left off
I ran the chunked dion Muon step alongside the next forward through the same interface, and it is bitwise correct. It is slower than the synchronous chunked step, so
setup_optimizerkeepsoffload_overlap_forwardAdamW-only. #4430's Muon has no host syncs in its step and batches its Newton–Schulz launches, but overlap with it has not been measured, so it stays off. From the dion trace:row_lr.unsqueeze(-1).to(X[0].device)is a pageable H2D copy, so the host waits until the chunk's load lands. That happened 14 times per forward, about 116 ms in total.Change
optim/subset_step.pyadds the interface and the AdamW adapter (spec below).CPUOffloadOptimizerchunks the step for any optimizer for whichsubset_step_forfinds one, not justisinstance(optimizer, AdamW). The chunk planner packs whole units instead of whole parameters, and streams every state tensor withndim >= 1.setup_optimizer. It is opt-in (model.offload_overlap_forward, defaultfalse, from perf(optim): overlap the offloaded AdamW step with the next forward (builds on #4008) #4427).Design note
The interface
begin_subset_step()replacesstep()for one step. It captures what the step reads that the trainer may change before the last chunk runs (group hyperparameters, gradients) and advances step counters.unitspartitions the parameters the step updates into the smallest sets that must update together. If the step communicates,unitsmust be identical on every rank, decided from global shapes.step_params(params)updates exactly a union of whole units, bitwise as the full step would. It reads state fromoptimizer.stateat call time (the wrapper has swapped it for GPU staging views), enqueues on the current stream without host syncs, and never creates state.Implementations
step()(GroupSubsetStep, perf(optim): overlap optimizer state offload copies with the AdamW step #4008's mechanism, moved here).SupportsSubsetStepitself, found by duck typing.begin_subset_stepadvances the groups' step counters and captures the hyperparameters and gradients. Its units are its buckets of FSDP-sharded matrices (one all-to-all each, the same on every rank), its batches of local matrices, and each AdamW group as a whole (one compiled update).step()isbegin_subset_step()followed bystep_params(all), so a chunked step runs the same code as a plain one. The step has no host syncs and uses its own NCCL communicators.What stays outside the optimizer:
FSDPParamGroup.unshard/wait_for_unshard) belongs to the offload wrapper's forward overlap and does not depend on the optimizer.Validation
test_chunked_muon_matches_plain_muon(single GPU, native Muon, 1 GiB and 4 KiB chunks, step counters). On the current head,tests/unit/train/test_state_offload.pypasses (8 tests, H200), with chunked native Muon bitwise equal to plain native Muon, and feat(optim): native Muon, replacing dion #4430'stests/unit/train/test_muon.pypasses too (9 tests).ruff checkandruff format --checkpass on the changed files.🤖 Generated with Claude Code