Checkpoint & Restart Under FailureβοΈ
How ezpz.examples.fsdp_tp behaves when training is interrupted: how it
saves distributed checkpoints, resumes automatically, and how much a
restart-from-checkpoint actually costs β with real measurements from
Sunspot (Intel PVC / XPU), not modeled estimates.
Key API
ezpz.examples._checkpointβ DCP save/load helpersfsdp_tpflags:--ckpt-dir,--save-interval,--train-iters,--no-resume,--async-ckpt,--ckpt-stage-dir,--meta-init- Metrics:
train/restart_seconds,train/ckpt_save_seconds,train/ckpt_stage_seconds,train/ckpt_drain_seconds
How it worksβοΈ
fsdp_tp uses PyTorch Distributed Checkpoint (DCP) β each rank writes
its own shard in parallel (no gather-to-rank-0 that would OOM at scale), so
it works identically under FSDP-only, HSDP, and 2D FSDP+TP.
- Save every
--save-intervalsteps into--ckpt-dir/step-<N>/. A.completemarker is written last, so a checkpoint interrupted mid-save (e.g. by the failure you're recovering from) is skipped. - Resume is automatic on startup: the newest complete checkpoint is
loaded and training continues from its step β no flag needed (pass
--no-resumeto force a fresh run).
Because resume is automatic, it composes directly with
ezpz launch --auto-retry: a relaunch simply
resumes.
ezpz launch --auto-retry --np <N> -- \
python3 -m ezpz.examples.fsdp_tp --model debug \
--ckpt-dir ./ckpts --save-interval 100 --train-iters 3000
Asynchronous checkpointingβοΈ
By default a save is synchronous β the training loop blocks while every
rank writes its shards to the durable --ckpt-dir. At large model sizes that
stall recurs every --save-interval steps. Pass --async-ckpt to overlap the
write with training:
python3 -m ezpz.examples.fsdp_tp ... \
--ckpt-dir /shared/ckpts --async-ckpt \
--ckpt-stage-dir /tmp/ezpz-ckpt --save-interval 100
--async-ckpt uses dcp.async_save: the state dict is staged to CPU memory
synchronously (so it's safe to keep training the instant the call
returns; tracked as train/ckpt_stage_seconds), then written to fast
node-local --ckpt-stage-dir (default /tmp/ezpz-ckpt-<jobid>) by a
background thread, and finally fanned out to the durable --ckpt-dir on
shared FS. The fan-out copy runs on a background worker (start_fanout), and
try_finalize_if_ready β called each step β stamps the durable .complete
marker (barrier, main thread) as soon as all ranks' copies finish, so the
expensive shared-FS write overlaps training rather than blocking it.
Watch stage + drain, not just the stage
The true per-save stall is ckpt_stage_seconds + ckpt_drain_seconds, not
the cheap stage alone β the first cut of the agpt-2b
measurements missed a 5 s
blocking fan-out by reading ckpt_stage_seconds in isolation. With the
fan-out backgrounded the drain residual is small (~0.7 s), but always
compare stage + drain against the sync save. One residual tradeoff: for
the ~1 copy-duration between a save and its marker, a crash falls back one
extra interval; shrink --save-interval to bound it.
Node-local staging is not durable
--ckpt-stage-dir (e.g. /tmp) is node-local and not resumable on its
own β its shards are scattered per node and it carries no completion
marker. Only the fanned-out --ckpt-dir copy on shared FS survives a node
failure, and resume always reads from there. That's why --async-ckpt
requires --ckpt-dir; /tmp is a staging tier, not the checkpoint.
The experimentβοΈ
To measure restart cost we ran two 3000-step jobs on 2 Sunspot nodes (24
XPU ranks, tp=2), checkpointing every 100 steps:
- Baseline β no failures.
- Checkpoint Restart β a background loop
SIGKILLs the training ranks across all nodes every ~90 s (a realpkill -9; PALS then tears down the trainingmpiexec, each attempt exiting rc=137). A relaunch loop restarts on the same nodes andfsdp_tpauto-resumes from the last checkpoint.

ResultsβοΈ
| Baseline | Checkpoint Restart | |
|---|---|---|
| Steps | 3000 | 3000 |
| Wall-clock | 5.81 min | 8.04 min |
| Failures | 0 | 4 (real SIGKILL) |
| Recovery overhead | β | +2.23 min (β38%) |
Per failure (kill β PALS teardown β relaunch β DCP resume):
| # | resume @ step | lost steps | restart_seconds |
|---|---|---|---|
| 1 | 801 | 71 | 10.40 |
| 2 | 1301 | 57 | 10.66 |
| 3 | 1801 | 57 | 10.79 |
| 4 | 2301 | 59 | 11.10 |
What the numbers mean:
- Restart cost β 10.4β11.1 s, strikingly consistent.
train/restart_secondsis timed from process entry (beforesetup_torch), so it captures the full cold path: process launch + distributed init + model build +dcp.load+ first productive step. - Lost steps β 57β71, bounded by the 100-step checkpoint interval β you
only ever redo work since the last checkpoint. Shrink
--save-intervalto reduce lost work (at the cost of more frequent save I/O). - β 33 s total per failure (~11 s restart + ~22 s recomputing lost steps), which is the +2.23 min overhead across 4 failures.
Async checkpointing under the same failuresβοΈ
Re-running the identical experiment with --async-ckpt (stage to /tmp, fan
out to shared FS) on the same 2 nodes / 24 XPU ranks:

| # | resume @ step | lost steps | restart_seconds |
|---|---|---|---|
| 1 | 801 | 74 | 8.94 |
| 2 | 1301 | 57 | 8.94 |
| 3 | 1801 | 61 | 9.31 |
| 4 | 2301 | 56 | 9.30 |
- Per-step stage stall:
train/ckpt_stage_secondsβ 28 ms (median) β the CPU stage barely touches the training thread. At this model's tiny checkpoint the drain (fan-out to shared FS) is also negligible, so async looks like a clean win here. But that's an artifact of scale β at 23 GB the drain becomes the dominant cost and flips the result (see the agpt-2b section). Don't generalize the debug-model stage number to real checkpoints. - Restart cost β 8.9β9.3 s β the resume path is the same as sync (init +
dcp.load), so restart time is comparable (here marginally lower, within run-to-run noise). - Recovery still works identically: every kill resumed from the last durable
(fanned-out) checkpoint, never from the node-local
/tmpstaging copy.
At realistic scale: agpt-2b, a 23 GB checkpointβοΈ
The debug-model numbers above make the mechanism clear, but the async win is
a rounding error there (28 ms stall). The whole point of async checkpointing is
large checkpoints, so we re-ran the experiment with agpt-2b (~2B params,
256K vocab) β a 23 GB sharded checkpoint β on 2 Sunspot nodes / 24 XPU
ranks, tp=2: baseline + sync restart + async restart, 2000 steps, save
every 100, 3 real SIGKILLs at steps 500/1000/1500.
Getting an honest async win took three iterations, and the two dead ends are instructive.
Dead end 1 β async was slower, and the obvious metric hid itβοΈ
The first cut looked like a landslide for async: the logged
ckpt_stage_seconds (0.30 s) was ~12Γ smaller than the sync
ckpt_save_seconds (3.5 s). But async's wall-clock was higher, even though
every logged metric favored it. The paradox was the tell β the cost was real
but untimed. An async save has two halves: the stage (copy state to host,
kick off the background write) is cheap, but the drain β the fan-out of the
full 23 GB from node-local /tmp to shared FS β was a blocking foreground
copy at the next step start, landing between train/dt windows, so no metric
captured it. Adding train/ckpt_drain_seconds exposed a 5.18 s blocking
drain: /tmp staging added I/O without moving the slow write off the critical
path, making async ~1.5Γ slower per save than plain sync. Lesson:
ckpt_stage_seconds alone is not the async cost; compare stage + drain.
Dead end 2 β backgrounding the copy, but finalizing too lateβοΈ
The drain copy is pure per-rank file I/O with no collectives, so it is safe
on a background thread; only the completion barrier + .complete marker must
stay on the main thread. So start_fanout() submits the copy to a background
worker and returns immediately. But the first version stamped the marker only at
the next save boundary β one full interval later. That fixed the stall (copy
now hidden) but broke durability: a checkpoint's shards landed on shared FS
~1 copy-time after the save, yet weren't marked resumable until +100 steps.
An arbitrarily-timed crash could then fall back ~2 intervals, and the
marker-gated failure injector (it waits for .complete before killing) fired
~1 interval late, so async recomputed extra steps and still trailed on
wall-clock β a real regression masquerading as the earlier artifact.
The fix β finalize as soon as the copy is doneβοΈ
try_finalize_if_ready() runs every step: a cheap MPI probe (on MPI's own
communicator, not the xccl training group β so it cannot cross-match the
gradient all-reduce and deadlock) checks whether all ranks' background
copies have finished, and stamps the durable marker the instant they have β
~1 copy-time after the save, not a full interval. Async durability now matches
sync except during the brief copy window.

| per-save training-thread stall | sync | async (backgrounded) |
|---|---|---|
| stage | β | ckpt_stage_seconds 0.31 s |
| drain residual (barrier + marker) | β | ckpt_drain_seconds 0.73 s |
| blocking write | ckpt_save_seconds 3.75 s |
β |
| true total | β3.75 s | β1.05 s |
| Baseline | Sync restart | Async restart | |
|---|---|---|---|
| Steps | 2000 | 2000 | 2000 |
| Wall-clock | 16.94 min | 20.94 min | 20.75 min |
| Lost steps / kill | β | 2β4 | 6β11 |
- Async per-save stall is ~3.6Γ less than sync (1.05 s vs 3.75 s); the 23 GB copy is fully hidden and only the cross-rank barrier + marker remain. Validated on 24 ranks with no cross-thread collective deadlock β the barriers stay on the main thread in lockstep.
- Async now finishes first on wall-clock (20.75 vs 20.94 min). Over ~19 failure-free saves per phase it sheds ~2.7 s of stall each (~50 s total), which more than covers the handful of extra steps it recomputes. This is the regime async is for: many saves between rare failures.
- Lost steps are now matched (sync 2β4, async 6β11) β both resume from the identical durable checkpoints (501/1001/1501). Async's small residual is the genuine ~1-copy-window durability cost, not the earlier marker-lag artifact.
- Restart cost β 40β44 s (both), dominated by the 23 GB
dcp.load+ init.
Residual durability tradeoff (bounded)
Async is still not free on durability: for the ~1 copy-duration between a
save and its marker, the newest resumable checkpoint is the previous one, so
a crash in that narrow window falls back one extra interval. This is
inherent to any overlapped write and far smaller than the earlier
full-interval lag. Recovery is never broken β the previous complete
checkpoint is always durable β and shrinking --save-interval bounds the
worst case.
Scaling up: agpt-20b, a 232 GB checkpoint (needs --meta-init)βοΈ
agpt-20b (~20B params) initially OOM'd at model build: the example moved the
full dense model onto one GPU before FSDP sharded it, capping model size at what
fits whole on a single device (~2β8B) regardless of node count. --meta-init
(default auto, on for models β³6B) fixes this β the model is built on the
meta device, sharded, then only each rank's shard is materialized
(torchtitan's pattern). Peak memory drops from OOM (>64 GB/tile) to ~14 GB/
tile, and the same checkpoint-restart experiment then runs unchanged:

| per-save training-thread stall | sync | async (backgrounded) |
|---|---|---|
| stage | β | ckpt_stage_seconds 1.73 s |
| drain residual | β | ckpt_drain_seconds 3.69 s |
| blocking write | ckpt_save_seconds 23.57 s |
β |
| true total | β23.6 s | β5.4 s |
- The async win scales with checkpoint size. At 232 GB a synchronous save freezes the training loop for ~24 s every checkpoint; backgrounded async cuts that to ~5.4 s β 4.4Γ less, an ~18 s/save saving (vs ~2.7 s at 2b). The bigger the checkpoint, the more the fan-out is worth hiding.
- Restart cost β 55β63 s (both), dominated by the 232 GB
dcp.load. - Meta-init composes with everything: TP + FSDP2 sharding, DCP save/resume
(verified restoring from a 232 GB checkpoint), and the backgrounded fan-out β
all at 20B on 4 Sunspot nodes with no OOM. Small models (agpt-2b and below)
stay on the exact dense-init path (
autokeeps them bit-for-bit).
Measuring it yourselfβοΈ
fsdp_tp logs a RESUMED from step=N line on resume and a
train/restart_seconds metric on the first post-resume step (into the
metrics JSONL and W&B). The driver script + a reusable plotter live in the
repo under experiments/checkpoint-restart/:
qsub experiments/checkpoint-restart/restart_experiment.pbs # 2 nodes
python3 experiments/checkpoint-restart/plot_restart.py \
expt_<jobid>/baseline/*/metrics-0.jsonl \
expt_<jobid>/restart/*/*/metrics-0.jsonl \
--out restart_plot.png --report restart_report.md
The agpt-2b sync-vs-async comparison above is the same driver run at scale with a step-driven kill injector; its combined plotter takes all three phases:
python3 experiments/checkpoint-restart/plot_2b_comparison.py \
--baseline expt_<jobid>/baseline/*/*/metrics-0.jsonl \
--sync expt_<jobid>/sync/*/*/metrics-0.jsonl \
--async expt_<jobid>/async/*/*/metrics-0.jsonl \
--out agpt2b_restart.png --report agpt2b_restart_report.md
ScopeβοΈ
This shows the two behaviors ezpz provides natively: a baseline and checkpoint restart (fail β lose steps to the last checkpoint β resume). Frameworks that recover without losing steps or without a process restart β e.g. pause/resume or in-place elastic recovery (TorchFT-style) β are separate systems not integrated into ezpz and are out of scope here.
Numbers above are from Sunspot: debug-model runs (job 12471687, 2 nodes); the
agpt-2b iterations β blocking drain (12471769), backgrounded but finalized
late (12471771), and the final fair finalize-when-ready run (12471773, 2000
steps / save every 100, 2 nodes) the 2b plot reflects; and the agpt-20b run
(job 12471783, 4 nodes, --meta-init) the 232 GB plot reflects. Absolute
restart_seconds grows with model size and node count (larger dcp.load,
longer init) β the debugβagpt-2bβagpt-20b jump (β10 s β β40 s β β60 s) shows
exactly that; the mechanism is parallelism-agnostic since DCP is sharded.