Skip to content

feat: overlap CPU optimizer with backward - #3234

Draft
samsja wants to merge 54 commits into
mainfrom
feat/optimizer-in-backward
Draft

feat: overlap CPU optimizer with backward#3234
samsja wants to merge 54 commits into
mainfrom
feat/optimizer-in-backward

Conversation

@samsja

@samsja samsja commented Aug 11, 2026

Copy link
Copy Markdown
Member

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 main and 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 fused torch.optim.AdamW by 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_offload now 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 and gradients mode are removed along with their streaming machinery, ~250 lines). Enable with model.optim_cpu_offload = true. numa_bind defaults 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=1 would serialize the CPU optimizer), and random-init debug runs no longer pre-download weights. Launch configs in benchmarks/offload/; scaling docs updated in docs/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 .grad in 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, and handover.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):

Seq No offload (GPU-resident) Full offload (native) TPS retained Peak HBM
8K 1.75 s 3.96 s 44% 63.1 → 17.5 GiB
16K 5.17 s 5.73 s 90% 67.0 → 21.5 GiB
32K 16–21 s (noisy) 15.4–16.7 s ~100% 73.7 → 29.0 GiB
64K (ac) 38.7 s 34.1 s ~parity¹ 90.3 → 44.9 GiB

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

Seq No offload Full native TPS retained Peak HBM
16K 2.1 s / 28.0k TPS 6.0 s / 10.7k TPS 38% 122.9 → 53.9 GiB
32K 7.7 s / 15.9k TPS 7.1 s / 18.6k TPS 117% 134.1 → 67.0 GiB
64K OOM 10.7 s / 24.5k TPS 91.6 GiB

GLM-5 20-layer proxy (171B, CP2/EP8, no-offload impossible at 257 GiB/GPU of state; 16K remeasured with #3248):

Seq Step med TPS Peak HBM Exposed drain
16K 19.4 s 3.4k 102 GiB 12.6 s
32K 23.1 s 5.8k 115 GiB 11.8 s
64K 37.2 s 7.3k 134 GiB 6.9 s

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

ruff clean; 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 and handover.md).

🤖 Generated with Claude Code

samsja and others added 30 commits August 4, 2026 14:51
…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>
samsja and others added 23 commits August 7, 2026 21:04
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>
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
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
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