feat: overlap CPU optimizer with backward - #3234
Draft
samsja wants to merge 54 commits into
Draft
Conversation
…load The CPUOffloadOptimizer now performs the optimizer.step() per-transformer-layer instead of all-at-once. Each layer's optimizer states are moved to GPU, the step runs for that layer only, and states are moved back to CPU before the next layer. This bounds peak GPU optimizer-state memory to ~one layer's worth (plus one prefetched layer for overlap) instead of the full model's, preventing OOM when weight + grad + all_opt_states exceeds available VRAM. H2D and D2H transfers are pipelined on dedicated CUDA streams so the next layer's states are prefetched while the current layer computes, and the previous layer's states are evicted while the next layer computes. Pinned-memory D2H is fixed to pre-allocate the pinned destination and async-copy into it, avoiding a race where pin_memory() reads a tensor whose async copy hasn't completed. Muon's per-group step counter is synced after all chunks complete, since the param_groups are temporarily swapped during chunked stepping.
Add and config options to independently toggle per-layer chunking and stream-overlapped H2D/D2H. When stream is disabled, the chunked step uses a simple sequential move→step→move loop with no CUDA stream logic, making the code path clearer and easier to debug.
1. Async D2H checkpoint corruption (HIGH): Add torch.cuda.synchronize()
after _move_states('cpu') in state_dict() and in ckpt.py's
AppState.state_dict() to ensure async pinned D2H copies complete
before CPU tensors are read.
2. Incomplete stream dependency chain (MEDIUM): Replace wait_stream-only
finish in _step_chunked_streamed with torch.cuda.synchronize() to
block CPU. Add h2d_stream.wait_stream(d2h_stream) before prefetching
chunk i+1 to prevent racing with the previous chunk's D2H into the
same pinned CPU buffers.
3. First step OOM (HIGH): The init step now uses the same chunked path
instead of a full optimizer.step() that materializes all states on
GPU at once. States are created per-chunk and immediately evicted.
4. Defaults disagree (MEDIUM): Align setup_optimizer defaults for
cpu_offload_chunked and cpu_offload_stream with config defaults
(both False).
Also add torch.cuda.synchronize() at the end of _step_chunked (no-stream
path) to ensure async D2H completes before returning.
Remove the 'if step in group' guard so _sync_step_counters always writes group['step'] = orig_step + 1. This handles optimizers that lazily create the step key on per-chunk copies (e.g. Muon) where the original groups might not have had the key set yet.
Pass closure only to the first chunk's optimizer.step() and None to the rest, so loss/grad recomputation happens once per training step rather than once per layer.
- Remove redundant section separators and verbose docstrings - Inline _build_chunk_param_groups into _step_chunk - Inline _chunk_param_ids into _move_chunk_states - Simplify _move_states to iterate values() directly - Shorten comments to be targeted, not narrative
…ream default 1. HIGH - Streamed D2H memory race: call record_stream on GPU tensors before replacing them during async D2H copy, so the caching allocator doesn't reuse their storage while the copy is still in flight on d2h_stream. Guard with is_cuda since some optimizer state tensors (e.g. AdamW step counter) live on CPU. 2. LOW - Stream default disagrees: change CPUOffloadOptimizer.__init__ stream default from True to False to match config and setup_optimizer defaults.
Update docs, config docstring, and class docstring to say 'about one layer's worth (two with stream overlap)' instead of 'a single layer's worth', since stream overlap keeps up to ~3 layers resident on GPU during the prefetch/D2H/compute overlap window.
… errors If optimizer.step() raises inside _step_chunk, the try/finally ensures param_groups are restored to the original full set, preventing subsequent steps from only updating a single chunk's parameters.
Let errors propagate — if optimizer.step() raises, training crashes anyway and there's no point restoring param_groups.
The stream path already caps GPU residency at 2 layers (prefetch waits for eviction via h2d_stream.wait_stream(d2h_stream)), so the non-stream sequential path and the stream config toggle are unnecessary. Removes optim_cpu_offload_stream config field, the _step_chunked method, and the stream parameter from CPUOffloadOptimizer and setup_optimizer.
Removes optim_cpu_offload_chunked config field entirely. When optim_cpu_offload is enabled, the optimizer step is always performed per-transformer-layer with stream-overlapped H2D/D2H transfers (max 2 layers on GPU). This eliminates the non-chunked code path, the separate _step_chunked_streamed method, and one config flag. For large models (where offloading matters) chunked+stream is faster than all-at-once because transfers overlap with compute. For small models the absolute overhead is negligible.
Stream is always passed now — removes the None fallback, default_stream() context manager, and all stream is not None guards.
Tests that per-layer chunked+stream stepping produces identical parameters and optimizer states as all-at-once stepping, plus a state_dict round-trip test.
Adds optim_cpu_offload_chunked config flag (default False). When disabled, CPUOffloadOptimizer uses the simple all-at-once path (move all states to GPU, step, move back). When enabled, uses per-layer chunked stepping with stream-overlapped H2D/D2H (max 2 layers on GPU). Also adds torch.cuda.synchronize() to load_state_dict for async D2H safety.
Creating new torch.cuda.Stream() on every step() caused the caching allocator to hold onto memory via record_stream references on dead streams. Streams are now created once in __init__ and reused.
The fused LM head wrapper forwards seq_lens/seq_lens_are_pre_shard to the inner model, but only GlmMoeDsaForCausalLM accepted them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With 4 train nodes the resident FP32 master shards are ~87 GiB/rank and the per-layer broadcast (full expert gather + fp8 conversion) peaked ~49 GiB above that, OOMing the post-step broadcast once training transients filled the cache. - empty_cache() before broadcast so cached training pools are reusable - drop consumed expert tensors eagerly and quantize into preallocated stacks instead of list+torch.stack copies (~49 -> ~31 GiB transient per layer) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The post-accumulate offload hook runs inside FSDP foreach_reduce on the autograd thread; lazily cudaHostAlloc'ing the staging buffer there stalls against the device while peer ranks' NCCL kernels spin waiting for this rank's next collective — a distributed deadlock (faulthandler-confirmed: autograd thread at optim.py empty_like(pin_memory=True) under _fsdp_collectives foreach_reduce; deterministic at 78 layers, independent of seq len, act-offload, hook design, or staging pageability). Pinning is only safe before any collective exists, so allocate accumulator + staging for every param at manager init. Cost: the staging set is now always allocated (~equal to the accumulator set) even for single-micro-batch steps; a config gate can refine this later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
init_process_group applies the configured timeout only to the default PG; device-mesh sub-groups fall back to torch's 10-minute module default, so dist_timeout_seconds never reached the PGs doing the real work. Long single-rank host phases (e.g. init-time pinned-buffer preallocation skew across nodes) then trip peer watchdogs at 600s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
~800 per-param cudaHostAllocs (~1.4 TiB/node) took 10-20 minutes at init with large node-to-node skew; one pinned slab per dtype for accumulators and one for staging pins the same bytes in a handful of driver calls. Per-param buffers become 256-byte-aligned views into the slabs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sibility Config pack for the handover baseline matrix: Qwen3-30B-A3B four-variant offload comparison and the GLM-5 one-node proxy, all validated with --dry-run. Meta-device parameter counts show the 22-layer proxy (190.8B params, 2.78 TiB CPU state) exceeds a 2 TiB node, and the four-node full-model job needs 2.71 TiB/node; handover.md updated with the measured feasibility numbers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SFT entrypoint defaults deployment.num_gpus to 1, and the launcher defaults OMP_NUM_THREADS to 1, which serializes the CPU optimizer (measured 15.7 s/step vs 6.5 s at 28 threads/rank on 8xH200). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-step wall-time counters for the bounded pipeline (D2H event wait, ring slot waits, BF16-to-FP32 materialization, native Adam, post-backward drain), logged at debug level. Profiling on 8xH200 showed the pipeline floor is host-DRAM-bandwidth-bound: the Adam kernel takes ~3.4s/step against a ~0.4s compute estimate and does not scale with OMP threads. optim_cpu_offload.numa_bind pins each rank's CPUs to its GPU's NUMA node before state allocation so first-touch keeps slabs and OMP threads local (measured 6.39s -> 6.13s per step at seq 8K on Qwen3-30B-A3B). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four-variant matrix and 2K-64K sweep for Qwen3-30B-A3B with bit-identical losses throughout. The short-sequence pipeline floor is DRAM-bandwidth-bound; with numa_bind, native full offload beats no-offload from 16K per-GPU sequence upward while roughly halving peak HBM. Benchmark configs enable numa_bind. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pre_download_model fetched the full weight snapshot even when debug.random_init never reads it — for GLM-5 that is ~1.4 TB pulled into the shared HF cache. Random-init runs now fetch only config and tokenizer files. GLM-5 proxy config drops to 20 layers to fit measured H200 node RAM. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
16K/32K/64K native full offload with numa_bind: ~18s/step CPU Adam for 21.4B params/rank, exposed drain shrinking from 17.4s at 16K to 6.9s at 64K. 20 layers is the RAM/HBM ceiling for a 2.95 TiB H200 node. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
10 layers OOMs an H200 with resident optimizer state at 16K; at 8 layers full offload trails at 16K (38% TPS retained), wins outright at 32K (117%), and is the only variant that runs at 64K. Halves peak HBM throughout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…versubscring intra-op parallelsism threads
Gradients reduce in FP32 but FSDP2 materializes them in the sharded parameter's dtype — BF16 for the full-offload compute model — so full offload rounds each reduced gradient to BF16 once before the FP32 CPU update. An FP32-transport option is structurally blocked (FSDP2 has no grad-dtype override), so document the semantics in the config, the training skill, and the handover, and point to state-only offload as the gradient-bit-faithful mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 'no offload' rows measured the default state-offload path; add the true GPU-resident baseline table (requires --model.optim-cpu-offload None) and relabel accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
optim_cpu_offload previously defaulted to state-only offload and carried three modes (state-only, gradients, full) with separate streaming paths. State-only streamed ~60 GiB/rank of optimizer state over PCIe every step and cost ~45% of throughput at seq 8K against a GPU-resident baseline. Now the option means one thing: full offload (FP32 masters, moments, and gradients on CPU, optimizer overlapped with backward), enabled by setting model.optim_cpu_offload, disabled by default so the baseline keeps optimizer state on GPU. numa_bind defaults to true. Removes the GPU- chunked step, state-move machinery, and per-mode branching; example configs that enabled the old default drop the line. Validated: config suite, dry-runs, full-native smoke with DCP checkpoint at step 2 and resume (exact loss parity), torch-backend smoke, no-offload default smoke. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> # Conflicts: # skills/training/start-run/SKILL.md
…thread budget Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
samsja
changed the base branch from
feat/fsdp-optimizer-offload-policy
to
main
August 12, 2026 23:54
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> # Conflicts: # docs/scaling.md # src/prime_rl/trainer/models/glm_moe_dsa/modeling_glm_moe_dsa.py # src/prime_rl/trainer/optim.py # src/prime_rl/trainer/rl/train.py
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.
Summary
TL;DR — the trade-off: full optimizer offload cuts peak GPU memory to a third–half (Qwen3-30B at 64K: 90 → 45 GiB) and is what makes models trainable when optimizer state exceeds HBM (GLM-5-class: 257 GiB/GPU of state). The price is throughput at short sequences: vs a true GPU-resident baseline it keeps 44% of TPS at 8K per-GPU tokens, 90% at 16K, and is parity within run noise from ~32K up. At production sequence lengths the offload is effectively free; below ~16K you're trading real speed for memory.
Supersedes #3212 (closed; this PR targets
mainand carries the combined diff). Turns CPU optimizer offload into an optimizer-in-backward wavefront: each CPU optimizer chunk runs as soon as its last gradient reaches host memory, and the refreshed BF16 weights stream back while backward is still executing. Gradients and weight refreshes cross PCIe in BF16; masters, moments, and all optimizer arithmetic stay FP32. A native multi-tensor CPU AdamW kernel (AVX-512/AVX2/ATen dispatch) replaces fusedtorch.optim.AdamWby default (cpu_optimizer_backend = "torch"retained for parity checks). Pinned memory is bounded by size-classed transfer rings instead of model-sized slabs.Breaking simplification:
optim_cpu_offloadnow means exactly one thing — full offload — and is disabled by default (the previous default silently ran state-only offload, costing ~45% throughput at seq 8K vs a GPU-resident baseline; that mode andgradientsmode are removed along with their streaming machinery, ~250 lines). Enable withmodel.optim_cpu_offload = true.numa_binddefaults on. Validated: config suite, DCP checkpoint save + resume with exact loss parity, native and torch backends, and the no-offload default.Also in this PR: per-step pipeline timing diagnostics (debug level), an automatic per-rank CPU thread budget at startup (#3248, merged — the launcher default
OMP_NUM_THREADS=1would serialize the CPU optimizer), and random-init debug runs no longer pre-download weights. Launch configs inbenchmarks/offload/; scaling docs updated indocs/scaling.md.Gradient numerics
Full offload is not gradient-bit-identical to no-offload, by construction: gradients reduce across ranks in FP32, but FSDP2 materializes
.gradin the sharded parameter's dtype — BF16 for the full-offload compute model — so each reduced gradient is rounded to BF16 once before the FP32 CPU update (masters, moments, accumulation, and Adam arithmetic stay FP32; the BF16 weight refresh is exact). An FP32-transport toggle is structurally blocked (FSDP2 has no grad-dtype override on BF16 sharded params); optimizer-state-only offload is the gradient-bit-faithful mode. Documented in the config, the training skill, andhandover.md. Paired runs matched losses to display precision.Results (one 8xH200 node, fake data, random init, losses identical in every comparison)
Qwen3-30B-A3B, paired same-session runs, baseline = true no-offload (optimizer states resident on GPU,
--model.optim-cpu-offload None; note the repo default silently enables state-only offload, a mode this PR has since removed):¹ Single unprofiled runs; run-to-run variance on this node is ±15–20% at these lengths, and paired profiler traces at 64K show identical backward GPU work and sub-second tails for both modes — treat 32K–64K as parity, not a win for either side.
Against the real baseline, full offload costs throughput below ~16K per-GPU sequence and reaches parity from roughly 16–32K upward — at a third to half of the resident-state HBM footprint throughout, and it remains the only mode that runs when optimizer state cannot fit on the GPU.
GLM-5 truncated to 8 layers (53.2B) — the largest size where resident optimizer state fits an H200 at all (pre-#3248/#3249 numbers; both arms carry the respective overheads):
GLM-5 20-layer proxy (171B, CP2/EP8, no-offload impossible at 257 GiB/GPU of state; 16K remeasured with #3248):
The durable wins: roughly half the peak HBM at every point, and training models whose optimizer state cannot fit on GPU at all. For a 3.8B-param rank shard the GPU optimizer step itself measures ~32 ms; the no-offload post-backward tail is bookkeeping (~1.3-2.1 s in these runs, of which the metric removed in #3249 accounts for ~0.3 s at 8K). Where full offload wins (64K), the gain is split between a faster backward — gradients leave the GPU as BF16 instead of accumulating as FP32 shards, and the allocator runs at 45 instead of 59 GiB — and a smaller tail.
Validation
ruffclean; native-kernel unit test passes on AVX-512 hosts; five-step native-vs-torch parity, BF16-transport bitwise equality, DCP save/resume, accumulation-8 stress, and synchronous-validation smokes all pass (details in prior revisions andhandover.md).🤖 Generated with Claude Code