Skip to content

fix: release the allocator cache after every checkpoint load - #4053

Merged
mikasenghaas merged 2 commits into
mainfrom
fix/sft-resume-ckpt-load-memory
Oct 13, 2026
Merged

mikasenghaas merged 2 commits into
mainfrom
fix/sft-resume-ckpt-load-memory

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Release the CUDA allocator cache once after every checkpoint load: gc.collect() + torch.cuda.empty_cache() right after dcp_load returns in CheckpointManager.load_from_path.
  • Remove the cleanup from AppState.load_state_dict. It ran only with CPU offload, and the new cleanup now covers that path too.

Net diff: one ckpt.py change. The cleanup moves from the offload-only path to every load path (SFT and RL, with and without CPU offload).

Problem

Without CPU offload (model.offload = "none"), a resume peaks at much more reserved memory than an uninterrupted run at the same step. A 24-node SFT run (192 ranks, 512K sequence length, 267.7 GiB GPUs) showed:

Step 901, same batch Peak reserved
Fresh run, 900 steps in 216.8 GiB
Resumed from step 900 257.0 GiB (96%)

The resumed run logged expandable_segments: memory mapping failed with OOM during its first step.

Cause: DCP's _init_optim_state builds the optimizer state with a zero-grad optimizer.step(). This allocates a zeros_like gradient for every parameter, so allocated memory peaks about one fp32 parameter shard above the steady state during the load. When the load ends, those blocks stay in the allocator cache. The first step does not reuse them and reserves on top of them. Peak allocated memory does not change, so the extra memory is cache fragmentation, not live tensors. With CPU offload, the old cleanup in AppState.load_state_dict already released the cache, so only the non-offload path was affected.

Verification

Single-node runs: Qwen3-30B-A3B SFT on 1 node of H200s (6 trainer GPUs, 2 inference GPUs), online evals on, seq len 8192. A base run trains 1 step and saves a checkpoint. Each variant resumes from that checkpoint and trains step 2. Each change was toggled with an env var, so all variants share the same code and checkpoint. Numbers are rank 0, in GiB.

offload = "none". Ablation of each candidate change (the first two were in an earlier version of this PR):

Step 2 Reserved after load Peak allocated Peak reserved
Fresh run (no resume) — 83.7 93.6
Resume, main 80.0 83.5 114.9 (+21.3)
Resume, unconditional cleanup in AppState.load_state_dict 60.5 83.5 95.4 (+1.9)
Resume, empty_cache() before the SFT startup broadcast 80.0 83.5 93.5
Resume, both of the above 60.5 83.5 93.5
Resume, this PR 58.6 83.5 93.5
  • Peak allocated is the same in every row. Only the cached, unused memory changes.
  • On main, the load leaves 21.4 GiB cached (reserved 80.0 vs allocated 58.6). Step 2 reserves about 31 GiB on top of it.
  • With this PR, reserved memory after the load equals allocated memory, and step 2 matches the fresh run.
  • The cleanup inside AppState.load_state_dict leaves 1.9 GiB cached: some load buffers are freed only after dcp_load returns.
  • The empty_cache() before the startup broadcast also removes the gap, but only because it runs right after the load, and only when online evals are on. This PR does not need it.
  • Memory outside the PyTorch cache stays at about 2.4 GiB in every run. The cached blocks do not take memory away from NCCL or cuBLAS.
  • The offload = "none" variants ran twice, with identical numbers.

CPU offload. Removing the cleanup from AppState.load_state_dict changes nothing:

Resume Reserved after load Step-2 peak reserved
offload = "optimizer", main 19.5 82.0
offload = "optimizer", this PR 19.5 82.0
offload = "full", main 9.8 — (killed by an unrelated HF Hub 429 in inference)
offload = "full", both cleanups 9.8 35.1
offload = "full", this PR 9.8 34.9

Not tested: RL resumes (they use the same CheckpointManager.load_from_path), and the 24-node run above with this exact diff.

🤖 Generated with Claude Code


Note

Low Risk
Behavior change is limited to memory reclamation after resume; no changes to model, optimizer, or checkpoint I/O logic beyond when empty_cache runs.

Overview
Moves GPU cache cleanup to run on every checkpoint resume, not only when optimizer CPU offload is enabled.

Post-load gc.collect() and torch.cuda.empty_cache() are removed from AppState.load_state_dict (previously gated on has_cpu_offload, with optional state_dict.clear()). The same cleanup now runs in CheckpointManager.load_from_path immediately after dcp_load, with a note that DCP’s optimizer materialization can leave gradient allocations in the CUDA cache and inflate reserved memory on the first training step after resume.

This targets inflated peak reserved memory (and near-OOM on large SFT resumes with model.offload = "none") without changing checkpoint contents or load semantics.

Reviewed by Cursor Bugbot for commit 9193c73. Bugbot is set up for automated code reviews on this repo. Configure here.

AppState.load_state_dict cleared the loaded state dict and drained the
allocator only when an optimizer was an OffloadOptimizer. With
model.offload = "none" that branch is dead, so a resume carried the whole
checkpoint-load pool into the first training step.

The non-offload path is the one that needs it: its optimizer state is
resident on the GPU, and the trainer runs with expandable_segments, where
a cached block stays physically mapped and so is unavailable to NCCL and
cuBLAS workspaces until empty_cache hands it back.

Measured on a 24-node SFT run, same step number and batch:

  fresh  step 901  216.81 GiB reserved   (flat since step 306)
  resume step 901  257.04 GiB reserved   -> mapping OOM with 1.3 MB free

The resume path also runs an eager optimizer step via DCP's
_init_optim_state and duplicates every state tensor through
set_state_dict, none of which a fresh start does.

Also drain the cache before the SFT startup broadcast, as the RL trainer
already does: the per-layer gather peaks well above the resident weights
and on a resume lands on the pool the load just left behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@samsja
samsja marked this pull request as ready for review October 13, 2026 03:32

@garrett361 garrett361 left a comment

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.

Great if this change is helping with peak memory, but none of the added comment strings align with my understanding of what these. Also, claude is harsh on this point

The ckpt.py change is worth keeping, but almost every explanation the PR gives for it is wrong

lol

Comment thread src/prime_rl/trainer/sft/train.py Outdated
Comment on lines +392 to +397
# The per-layer gather peaks well above the resident weights, and on a resume
# it lands on the pool the checkpoint load just left behind. Release cached
# blocks first, draining pending work so empty_cache cannot free a block out
# from under a running kernel. Mirrors the RL trainer's broadcast.
torch.cuda.synchronize()
torch.cuda.empty_cache()

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.

@mikasenghaas did you test without this addition? Just with the ckpt.py edit? I don't believe this is necessary.

Also don't believe calling empty_cache should be able to free any block that's currently used by a kernel.

Comment thread src/prime_rl/trainer/ckpt.py Outdated
Comment on lines +157 to +160
# allocator can release whatever blocks it cached during the read. The
# non-offload path needs this most: its optimizer state is resident on the
# GPU, and under expandable_segments a cached block stays mapped and so is
# unavailable to NCCL and cuBLAS workspaces until empty_cache returns it.

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.

I also think this is all more closely related to memory fragmentation. Unconditionally doing clear/collect/empty_cache is more aggressively dropping references and letting the allocator reset its cache before doing further allocations, which may let the newer allocations be less fragmented and reserve less memory.

Do you have the before/after peak allocated memory numbers, too? Would probably be informative.

garrett361
garrett361 previously approved these changes Oct 13, 2026

@garrett361 garrett361 left a comment

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.

Approving this as I don't think any of this is harmful, but I don't think either comment string is accurate and only one of the two file changes is actually addressing what is described in the PR.

## Summary

Stacked on #4053. Ablations on that PR showed that one cleanup point is
enough, so this PR replaces both of its changes:

- `CheckpointManager.load_from_path`: run `gc.collect()` +
`torch.cuda.empty_cache()` right after `dcp_load` returns. This covers
every load path: SFT and RL, with and without CPU offload.
- `AppState.load_state_dict`: remove the cleanup. The new cleanup after
`dcp_load` makes it redundant on every path, including the offload-only
cleanup that `main` already had.
- `sft/train.py`: remove the `synchronize()` + `empty_cache()` before
the startup broadcast. It is not needed, and the sync added no safety.

Net diff against `main`: the cleanup moves from
`AppState.load_state_dict` (offload paths only) to `load_from_path` (all
paths).

## Why

During a non-offload resume, peak allocated memory rises about 21 GiB
above the steady state. That is about the size of the sharded fp32
parameters: DCP's `_init_optim_state` builds the optimizer state with a
zero-grad step, which allocates `zeros_like` gradients for every
parameter. When the load ends, those blocks stay in the allocator cache.
The first step does not reuse them and reserves on top of them. Peak
allocated does not change, so the extra memory is cache fragmentation,
not live tensors.

## Verification

Same setup as the ablation in #4053: Qwen3-30B-A3B SFT on 1 node of
H200s, with 6 trainer GPUs and 2 inference GPUs, online evals on, seq
len 8192. Each resume loads a step-1 checkpoint and trains step 2.
Numbers are rank 0, in GiB. Each configuration was tested with env-var
toggles on top of #4053; those debug toggles are not part of this PR.

`offload = "none"`:

| Step 2 | Reserved after load | Peak allocated | Peak reserved |
|---|---|---|---|
| Fresh run (no resume) | — | 83.7 | 93.6 |
| Resume on `main` | 80.0 | 83.5 | 114.9 |
| Resume on #4053 | 60.5 | 83.5 | 93.5 |
| Resume, this PR | **58.6** | 83.5 | **93.5** |

With this PR, reserved memory after the load equals allocated memory
(58.6), and step 2 matches the fresh run. The `ckpt.py` change from
#4053 on its own left 1.9 GiB (step-2 peak reserved 95.4).

CPU offload, to check that removing the `AppState` cleanup is safe:

| Resume | Reserved after load | Step-2 peak reserved |
|---|---|---|
| `offload = "optimizer"`, `main` | 19.5 | 82.0 |
| `offload = "optimizer"`, this PR | 19.5 | 82.0 |
| `offload = "full"`, `main` | 9.8 | — (job killed by an unrelated HF
Hub 429 in inference) |
| `offload = "full"`, both cleanups | 9.8 | 35.1 |
| `offload = "full"`, this PR | 9.8 | 34.9 |

RL was not tested. It uses the same `CheckpointManager.load_from_path`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Timing-only memory hygiene around checkpoint resume; no change to what
is loaded or how training steps run.
> 
> **Overview**
> Consolidates post-checkpoint-load CUDA memory cleanup into **one
place**: `CheckpointManager.load_from_path` now runs `gc.collect()` and
`torch.cuda.empty_cache()` immediately after `dcp_load`, so SFT/RL
resume and all offload modes share the same fix for allocator-cache
fragmentation from DCP’s optimizer materialization (zero-grad
gradients).
> 
> **Removes redundant cleanup elsewhere:** `AppState.load_state_dict` no
longer clears the load `state_dict` and empties the cache (that path was
offload-specific and duplicated the new hook), and SFT startup no longer
calls `synchronize()` + `empty_cache()` before the policy weight
broadcast—resume loads are already cleaned up centrally.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
a37c28d. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@mikasenghaas mikasenghaas changed the title fix(trainer): release the checkpoint-load pool on the non-offload resume path fix: release the allocator cache after every checkpoint load Oct 13, 2026
@mikasenghaas
mikasenghaas requested a review from samsja October 13, 2026 20:13
@mikasenghaas
mikasenghaas merged commit bbbe9cb into main Oct 13, 2026
20 checks passed
@mikasenghaas
mikasenghaas deleted the fix/sft-resume-ckpt-load-memory branch October 13, 2026 22:49
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.

3 participants