Skip to content

feat(trainer): async checkpointing with pinned staging (supersedes #4331) - #4428

Open
faresobeid wants to merge 4 commits into
mainfrom
feat/async-checkpoint-v2
Open

faresobeid wants to merge 4 commits into
mainfrom
feat/async-checkpoint-v2

Conversation

@faresobeid

@faresobeid faresobeid commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Async trainer checkpoint saves for every model.offload mode. This supersedes #4331 and builds on it.

What it does

ckpt.async_save = true (--ckpt.async-save for sft; --ckpt.async-save or --trainer.ckpt.async-save for rl; default off). A save copies each rank's model and optimizer state into pinned host buffers and returns. A background thread writes the copy with DCP while training continues.

Design

  • Staging (PinnedStager). Each tensor in the state dict is copied into a pinned host buffer. Buffers are keyed by the tensor's path and reused across saves, so pinning happens on the first save only. Later staging calls take about 130 ms. DTensors are staged through their local shard and rewrapped with their spec. DCP's DefaultStager cannot stage this trainer's DTensors ("invalid python storage", also hit in feat(trainer): async DCP checkpoint saves #3874). Non-tensor values are deep-copied.
  • Without offload (model.offload = "none"). The GPU→host copy runs on a side stream, overlapping the next step's forward and backward. The source tensors are kept alive until it finishes.
    • Fence: ckpt_manager.wait_for_staging() right before optimizer.step() in the RL and SFT loops. In every run here it waited 0 ms.
  • With offload (optimizer / full). The state dict reads the CPU optimizer state where it lives. Unlike a synchronous save, it skips prepare_checkpoint_save's CPU→GPU→CPU round trip; this is feat(trainer): async checkpoint saves with CPU optimizer offload #4331's _build_state_dict path, including its GPU detour for fused entries split along their sharded dim. The CPU state is then copied into the pinned buffers before save returns.
    • It must not be passed by reference: offloaded state changes outside the optimizer step (full updates it during backward), and the copy means a write longer than one step never stalls the next step.
  • Writer. A single-thread executor runs dcp.save on a dedicated gloo group, created on the first async save. Each write is submitted with its staging object as an argument. get_dcp_group() is cached and also serves the main thread's barriers and loads.
    • The thread is bound to the rank's GPU. A thread's current device starts at 0, so a writer thread created a CUDA context on GPU 0 for every rank: +3.7 GiB of non-PyTorch memory on GPU 0. feat(trainer): async checkpoint saves with CPU optimizer offload #4331's DCP thread writer has the same effect.
    • I chose a thread over DCP's writer process because the process path on torch 2.13/2.14 needs two workarounds: pickling a DTensor to the writer process segfaults, and staging needs /dev/shm the size of the shard.
    • The writer thread does not slow down steps without a save: 24.90 ± 0.11 s vs 24.88 ± 0.06 s without checkpointing.
  • One save at a time. save first waits for the previous write. keep_last/keep_interval count a step only once its write has finished (credit feat(trainer): async checkpoint saves with CPU optimizer offload #4331), and at startup count only complete steps (see Completeness), so cleanup never deletes the newest finished checkpoint for a write in flight or for a step cut off mid-write before a resume.
  • Errors surface at the fence, the next save, or the final wait(). The final save waits for its write before exit. The control API's checkpoint action now waits before mark_saved, so a committed checkpoint is on storage.
  • Completeness (also fixes a bug in sync saves). DCP writes trainer/.metadata after every rank's shards. resolve_latest_ckpt_step now skips a step whose trainer checkpoint has none. Before this, a crash mid-write made --resume pick the partial step and fail in dcp_load (reproduced on feat(trainer): async checkpoint saves with CPU optimizer offload #4331 below). The trainer, the orchestrator and the launchers all resolve through this function, so they agree on the step.

How it differs from #4331

#4331 passes the CPU optimizer state to the writer by reference and holds the next optimizer update until the write finishes. When a write takes longer than a step, the step after each save stalls for the rest of the write. With the settings below, that is ~24 s per save, half of a sync save. It also only supports CPU optimizer offload, and it has no resume-side completeness check.

This PR keeps #4331's fence placement and keep_last handling, and its offloaded-state read path. It adds:

  • pinned staging of the CPU state, so writes no longer stall training;
  • offload = "none" support, with the overlapped copy and fence;
  • the completeness check;
  • a dedicated writer group;
  • the control-action wait.

Cost

  • Host: pinned buffers ≈ the rank's checkpoint shard (model + optimizer), kept for the run. With optimizer offload this comes on top of the offloaded state.
  • GPU: with the device-bound writer, the peak is the same as sync: 21.4 GiB vs 21.4 GiB (offload none). PyTorch's allocated and reserved memory are unchanged.

Results

The equality, resume, kill and timing runs below used the first commit, where DCP's thread writer did the writes. After switching to the device-bound writer thread, I re-ran MoE equality (both offload modes, byte-identical), memory, and offload-none timing (save step 26.35 ± 1.91 s, step after save 25.1 s, peak GPU 21.4 GiB).

1 node, 8×H200, SFT trainer with fake data, checkpoints on node-local disk (~0.4 GB/s, so a 20 GB write takes ~48 s).

Equality. A synchronous reference copy of the same state was saved right before the async save, then compared chunk by chunk:

model offload steps compared result
Qwen3-1.7B (FSDP 8) none 2, 4 (the copy overlapped steps 3 and 5) 6018/6018 chunks byte-identical
Qwen3-1.7B (FSDP 8) optimizer 2, 4 (writes overlapped training) 6018/6018 byte-identical
samsja/mini-glm-moe (EP 8) none 2 6598/6598 byte-identical
samsja/mini-glm-moe (EP 8) optimizer 2 6598/6598 byte-identical

Resume. Resuming from the async step-4 checkpoint and from the sync copy gives losses identical to the uninterrupted run at steps 5–8:

  • offload none: 8.9624 / 7.6331 / 7.9907 / 9.3335
  • offload optimizer: 8.9665 / 7.6377 / 7.9933 / 9.3261

Kill mid-write. Async saves every step. Every trainer process was SIGKILLed while step_4/trainer had no .metadata.

Step time. Qwen3-1.7B, batch 256 (~25 s steps), ckpt.interval = 5, 16 steps, mean over saves at steps 5/10/15:

offload arm save step step after save ~cost per save
optimizer no checkpoint 25.10 s 25.1 s –
optimizer sync 75.33 ± 1.15 s 25.3 s 50 s
optimizer #4331 27.53 ± 1.25 s 49.5 / 48.3 s 26 s
optimizer this PR 27.13 ± 1.50 s 28.4 / 25.2 s 2–5 s
none no checkpoint 24.83 s 24.9 s –
none sync 74.67 ± 0.58 s 24.7 s 50 s
none this PR 26.67 ± 1.46 s 27.9 / 25.1 s 2–5 s

The first step after the first save is slower (28 s) while the pinned buffers are allocated and registered.

ruff check / ruff format are clean. tests/unit/utils/test_pathing.py passes, including the new test. tests/unit/test_configs.py has the same failures as on main (taskset packages missing in my environment). tests/unit/train/test_state_offload.py and tests/unit/train/test_ckpt.py pass on GPU (7 passed). Two of those tests fail on the code before the fix: test_offloaded_async_save_writes_after_it_returned (an offloaded async write that starts after save returned) and test_keep_last_ignores_a_step_cut_off_mid_write.

🤖 Generated with Claude Code


Note

Medium Risk
Changes checkpoint I/O and training-step synchronization on a critical resume path; mitigated by completeness checks, explicit fences, and targeted tests, but adds pinned RAM and background-thread failure modes.

Overview
Adds optional asynchronous trainer checkpoint saves via ckpt.async_save (--ckpt.async-save on SFT/RL). Instead of blocking on DCP writes, saves copy model and optimizer state into reused pinned host buffers on a side CUDA stream, then a background thread persists the staged state while training continues.

CheckpointManager gains PinnedStager (DTensor-aware), single in-flight save semantics, wait_for_staging() before the optimizer step, and wait() for durability. Offloaded optimizer paths stage CPU state without the sync save’s GPU round trip; with model.offload = "none", copy overlaps the next forward/backward until the staging fence. Resume and retention now treat checkpoints as complete only when trainer/.metadata exists (is_ckpt_step_complete), so mid-write crashes no longer win --resume or get pruned by keep_last.

RL and SFT loops call the new fences; control-API checkpoint actions wait for storage before mark_saved. Config propagation and docs document behavior, host-memory cost, and failure handling. Unit/GPU tests cover incomplete-step cleanup, latest-step resolution, and offloaded async writes.

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

faresobeid and others added 3 commits November 7, 2026 23:04
ckpt.async_save copies each rank's state into reused pinned host buffers and writes them from a background
thread on a dedicated gloo group. Without optimizer offload the copy overlaps the next step and is fenced
before its optimizer step; with optimizer offload the CPU state is copied in place (no GPU round trip) before
the save returns. Resume skips trainer checkpoints without DCP .metadata, keep_last only counts finished
writes, and the final save and control-API checkpoint actions wait for the write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run the DCP write in our own single-thread executor bound to the rank's GPU. A thread's current device
starts at 0, so DCP's thread writer created a CUDA context on GPU 0 for every rank (+3.7 GiB there).
The staging copy is now tracked by a CUDA event instead of a waiter thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@faresobeid
faresobeid marked this pull request as ready for review November 8, 2026 16:14

@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 2 potential issues.

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 aa5de9e. Configure here.

Comment thread src/prime_rl/trainer/ckpt.py Resolved
Comment thread src/prime_rl/trainer/ckpt.py Resolved
…e steps for cleanup

The background write closed over the `staging` name, which the offload path sets to None
once staging is done: a write that started after that read None. It now gets the staging
as an argument.

CheckpointManager seeded keep_last from every step dir, including a trainer checkpoint cut
off mid-write before a resume, which could displace the complete step the run resumed from.
It now seeds only complete steps (is_ckpt_step_complete, as resume does).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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