Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 55 additions & 15 deletions src/prime_rl/trainer/optim/state_offload.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ class CPUOffloadOptimizer(OffloadOptimizer):
at the same time: peak memory becomes max(activations, opt_states) instead of sum.
"""

def __init__(self, optimizer: Optimizer, pin_memory: bool = True):
def __init__(self, optimizer: Optimizer, pin_memory: bool = True, step_chunk_bytes: int | None = 4 << 30):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can this default to None, with the size coming from a config field that setup_optimizer passes through? 4 GiB is also hard-coded in optim/__init__.py:98, and with this default any caller that omits the argument gets chunking, Muon included.

self.optimizer = optimizer
self.pin_memory = pin_memory
# Upper bound on the optimizer state (estimated at two fp32 moments per element) brought
# to GPU per step pass. Moving all states at once needs the full sharded state on GPU next
# to weights and gradients, which can OOM for very large models. None steps everything in
# one pass.
Comment on lines +22 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this isn't strictly an upper bound. Chunks are built from whole parameters, so a parameter whose local shard alone exceeds step_chunk_bytes becomes its own chunk, above the limit.

self.step_chunk_bytes = step_chunk_bytes
self._initialized = False
self._cpu_buffers: dict[tuple[torch.Tensor, str], torch.Tensor] = {}

Expand All @@ -32,9 +37,12 @@ def _offload_to_cpu(self, param: torch.Tensor, key: str, src: torch.Tensor) -> t
buffer.copy_(src, non_blocking=True)
return buffer

def _move_states(self, device: str):
"""Move optimizer states to CPU or back to GPU (matching each parameter's device)."""
for param in self.optimizer.state:
def _move_states(self, device: str, params=None):
"""Move optimizer states to CPU or back to GPU (matching each parameter's device), for
`params` or every parameter with state."""
for param in self.optimizer.state if params is None else params:
if param not in self.optimizer.state:
continue
state = self.optimizer.state[param]
for key, value in state.items():
if isinstance(value, DTensor):
Expand All @@ -57,20 +65,52 @@ def _move_states(self, device: str):
def step(self, closure=None):
# First step initializes states on GPU - offload after
if not self._initialized:
result = self.optimizer.step(closure)
self._move_states("cpu")
if self.step_chunk_bytes is not None:
result = self._chunked_step(closure)
else:
result = self.optimizer.step(closure)
self._move_states("cpu")
self._initialized = True
return result

# Move states to GPU
self._move_states("cuda")

# Run optimizer step
result = self.optimizer.step(closure)

# Move states back to CPU
self._move_states("cpu")

if self.step_chunk_bytes is None:
self._move_states("cuda")
result = self.optimizer.step(closure)
self._move_states("cpu")
return result
return self._chunked_step(closure)

def _step_chunks(self, params: list[torch.Tensor]) -> list[list[torch.Tensor]]:
chunks: list[list[torch.Tensor]] = [[]]
chunk_bytes = 0
for param in params:
local = param._local_tensor if isinstance(param, DTensor) else param
state_bytes = 8 * local.numel()
if chunks[-1] and chunk_bytes + state_bytes > self.step_chunk_bytes:
chunks.append([])
chunk_bytes = 0
chunks[-1].append(param)
chunk_bytes += state_bytes
return chunks
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

def _chunked_step(self, closure=None):
"""Step the parameters chunk by chunk, with only one chunk's states on GPU. Each pass sees
only its chunk's gradients; the optimizer skips parameters without one, so every parameter
is updated exactly once with the same element-wise math as a single step."""
params = [p for group in self.optimizer.param_groups for p in group["params"] if p.grad is not None]
grads = {id(p): p.grad for p in params}
result = None
try:
for chunk in self._step_chunks(params):
ids = {id(p) for p in chunk}
for param in params:
param.grad = grads[id(param)] if id(param) in ids else None
self._move_states("cuda", chunk)
result = self.optimizer.step(closure)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: nothing passes a closure today, but if something did, it would run once per chunk (loss and backward recomputed each time). Suggest assert closure is None on the chunked path.

self._move_states("cpu", chunk)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

_move_states("cpu", ...) ends with torch.cuda.synchronize(), so this is now one host sync per chunk instead of one per step. One sync after the last chunk should be enough, since the caching allocator is stream-ordered.

finally:
for param in params:
param.grad = grads[id(param)]
return result

def zero_grad(self, set_to_none: bool = True):
Expand Down
17 changes: 17 additions & 0 deletions tests/unit/train/test_state_offload.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,20 @@ def test_offload_reuses_pinned_buffers_across_steps() -> None:
assert pointers_before.keys() == pointers_after.keys()
for key, pointer in pointers_before.items():
assert pointers_after[key] == pointer, f"state buffer reallocated for {key}"


@pytest.mark.parametrize("step_chunk_bytes", [1, 64 * 128 * 8, None])
def test_chunked_steps_match_plain_adamw(step_chunk_bytes: int | None) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These tests need CUDA, and the PR says they weren't run. Please run them and confirm they pass.

reference_model = make_model(0)
reference_optimizer = torch.optim.AdamW(reference_model.parameters(), lr=1e-2)
run_steps(reference_model, reference_optimizer, num_steps=3, seed=1)

chunked_model = make_model(0)
chunked_optimizer = CPUOffloadOptimizer(
torch.optim.AdamW(chunked_model.parameters(), lr=1e-2), step_chunk_bytes=step_chunk_bytes
)
run_steps(chunked_model, chunked_optimizer, num_steps=3, seed=1)

assert_params_equal(reference_model, chunked_model)
assert_states_offloaded(chunked_optimizer)
assert all(param.grad is None for param in chunked_model.parameters())
Loading