Repository navigation
fix: release the allocator cache after every checkpoint load - #4053
Conversation
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>
garrett361
left a comment
There was a problem hiding this comment.
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
| # 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() |
There was a problem hiding this comment.
@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.
| # 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. |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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>
Summary
gc.collect()+torch.cuda.empty_cache()right afterdcp_loadreturns inCheckpointManager.load_from_path.AppState.load_state_dict. It ran only with CPU offload, and the new cleanup now covers that path too.Net diff: one
ckpt.pychange. 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:The resumed run logged
expandable_segments: memory mapping failed with OOMduring its first step.Cause: DCP's
_init_optim_statebuilds the optimizer state with a zero-gradoptimizer.step(). This allocates azeros_likegradient 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 inAppState.load_state_dictalready 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):mainAppState.load_state_dictempty_cache()before the SFT startup broadcastmain, the load leaves 21.4 GiB cached (reserved 80.0 vs allocated 58.6). Step 2 reserves about 31 GiB on top of it.AppState.load_state_dictleaves 1.9 GiB cached: some load buffers are freed only afterdcp_loadreturns.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.offload = "none"variants ran twice, with identical numbers.CPU offload. Removing the cleanup from
AppState.load_state_dictchanges nothing:offload = "optimizer",mainoffload = "optimizer", this PRoffload = "full",mainoffload = "full", both cleanupsoffload = "full", this PRNot 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_cacheruns.Overview
Moves GPU cache cleanup to run on every checkpoint resume, not only when optimizer CPU offload is enabled.
Post-load
gc.collect()andtorch.cuda.empty_cache()are removed fromAppState.load_state_dict(previously gated onhas_cpu_offload, with optionalstate_dict.clear()). The same cleanup now runs inCheckpointManager.load_from_pathimmediately afterdcp_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.