Skip to content

feat: chunked per-layer optimizer step with stream-overlapped CPU offload#3109

Open
samsja wants to merge 6 commits into
mainfrom
feat/chunked-cpu-optim-offload
Open

feat: chunked per-layer optimizer step with stream-overlapped CPU offload#3109
samsja wants to merge 6 commits into
mainfrom
feat/chunked-cpu-optim-offload

Conversation

@samsja

@samsja samsja commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

The CPUOffloadOptimizer now optionally performs optimizer.step() per-transformer-layer ("chunked") instead of all-at-once, with optional stream-overlapped H2D/D2H transfers. Each layer's optimizer states are moved to GPU, the step runs for that layer only, and states move back to CPU before the next layer.

Problem

With the old all-at-once offload, the entire model's optimizer states land on GPU simultaneously during step(). When weight + grad + all_opt_states > VRAM — even without activations — this causes OOM on large models.

Config

Two new config options (both default false — opt-in):

[trainer.model]
optim_cpu_offload_chunked = true   # per-layer step vs all-at-once
optim_cpu_offload_stream = true    # stream overlap vs sequential move->step->move
  • optim_cpu_offload_chunked = false: all-at-once offloading (loads full model states to GPU in one shot)
  • optim_cpu_offload_stream = false: chunked step without CUDA stream overlap — simple sequential loop, no stream objects in the code path

Implementation

  • Per-layer chunking: Parameters grouped by transformer layer (regex layers.{N}.). Non-layer params (embeddings, lm_head, norm) go into a trailing "misc" chunk.
  • Stream-overlapped (_step_chunked_streamed): H2D/D2H on dedicated CUDA streams; next layer prefetched while current computes; previous layer evicted while next computes. Ends with torch.cuda.synchronize() to block CPU.
  • No-stream (_step_chunked): Simple sequential move_to_gpu -> step -> move_to_cpu per chunk. No CUDA stream objects.
  • First-step chunked: The init step also uses the chunked path, avoiding materializing all optimizer states on GPU at once during initialization.
  • Pinned memory: D2H pre-allocates pinned CPU destination and async-copies into it. state_dict() and ckpt paths sync after D2H to prevent reading incomplete copies.
  • Muon step counter sync: Per-group step integer synced after all chunks since param_groups are temporarily swapped.

VRAM impact (synthetic benchmark, float32, AdamW)

Model (layers, hidden) Opt states All-at-once overhead Chunked overhead Savings
Small (14, 768) 1,383 MiB 2,076 MiB (3.4 GiB) 564 MiB (1.9 GiB) 1.5 GiB
Medium (28, 1024) 4,084 MiB 6,132 MiB (10.2 GiB) 756 MiB (4.8 GiB) 5.4 GiB
Large (40, 2560) 33,250 MiB 50,350 MiB (83.9 GiB) 2,462 MiB (36.0 GiB) 47.9 GiB

Validation

  • Full reverse-text RL training (20 steps, Qwen3-0.6B, 2 GPUs) completed successfully
  • Unit tests for chunked/stream, chunked/no-stream, non-chunked, first-step chunked, state_dict sync, checkpoint compatibility all pass
  • base_optimizer, state, param_groups, zero_grad, state_dict, load_state_dict APIs unchanged

Bugbot issues addressed

  1. Async D2H checkpoint corruption (HIGH): Added torch.cuda.synchronize() after _move_states("cpu") in state_dict() and ckpt.py
  2. Incomplete stream dependency (MEDIUM): Added torch.cuda.synchronize() at end of streamed step; h2d_stream.wait_stream(d2h_stream) before prefetch to prevent pin buffer races
  3. First step OOM (HIGH): Init step now uses chunked path instead of full optimizer.step()
  4. Defaults disagree (MEDIUM): Aligned setup_optimizer defaults with config (false)

samsja added 2 commits July 22, 2026 18:55
…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.
@samsja
samsja marked this pull request as ready for review July 22, 2026 20:07
Comment thread src/prime_rl/trainer/optim.py
Comment thread src/prime_rl/trainer/optim.py Outdated
Comment thread src/prime_rl/trainer/optim.py Outdated
Comment thread packages/prime-rl-configs/src/prime_rl/configs/trainer.py
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.
Comment thread src/prime_rl/trainer/optim.py Outdated
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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 053beda. Configure here.

Comment thread src/prime_rl/trainer/optim.py
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.
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