system_ml / 26 · Checkpointing & fault tolerance lesson 26 / 26

Checkpointing & fault tolerance — surviving the 1000-GPU run

At one GPU the worst case is you lose an afternoon. At ten thousand GPUs for three weeks, hardware failure isn't an edge case — it's the expected hourly event. The last system you have to design isn't a parallelism strategy; it's the one that lets the job die and come back having barely noticed.

Why failure is the common case, not the rare one

A single GPU is reliable — call its mean time between failures M_1, maybe years. But failures are roughly independent across devices, so a job spanning N GPUs fails when any of them does, and the rates add:

M_job ≈ M_1 / N

Plug in numbers. If one accelerator (plus its host, NIC, optics, PSU) faults on average every ~50,000 GPU-hours, then a 10,000-GPU job has an effective MTBF of ~5 hours. A three-week run will be interrupted dozens of times. This is not pessimism — published large-model training logs report failures every few hours at scale, dominated by GPU faults, ECC errors, NIC/optics flaps, and the occasional host kernel panic. The synchronous nature of the collectives in lesson 02 makes it worse: one dead rank stalls an AllReduce, and the entire job hangs, not just the failed node.

The brutal arithmetic of no checkpointing
Without checkpoints, every failure throws away all progress since the start. If your run is longer than M_job, it may never finish — it crashes before it completes, restarts from zero, and crashes again. Checkpointing isn't an optimization here; it's the thing that makes long runs possible at all.

What is actually in a checkpoint

"Save the model" is necessary and badly insufficient. To resume bitwise-equivalently — as if the failure never happened — you must restore every piece of mutable training state:

ComponentSize (70B, mixed precision)Why it must be saved
Model parameters (bf16)~140 GB (2 B/param)obviously
Optimizer state (fp32 master + Adam m, v)~840 GB (12 B/param)dominates — master + both moments = 6× the bf16 weights (the moments alone are 4×)
LR scheduler / step countbytesresume at the right point on the LR curve
RNG state (per rank)KBreproducible dropout, data shuffling
Data-loader positionbytesresume mid-epoch without re-seeing data (lesson 25)

The headline: a 70B checkpoint is ~980 GB (params + optimizer state — gradients are transient and not saved), and the optimizer state, not the weights, is most of it. Saving only the weights (the common "I'll just save the model" mistake) makes the checkpoint look 7× smaller and makes resumption wrong: you'd restart Adam's moments from zero, spiking the loss every time you resume.

Failures over a run — feel the rate

Set the cluster size and the per-GPU MTBF and watch the expected failure count over a multi-week run. The point of this widget is visceral: at small scale the timeline is empty; drag the GPU count up and it fills with interruptions.

Job MTBF & the failure timeline
Each red tick is an expected failure over the run. M_job = M_1 / N. The point isn't the exact placement — failures are random — it's the count: how many times this job will hit the floor.
job MTBF
expected failures
mean uptime between

The central tradeoff — how often to checkpoint

Checkpoint too rarely and each failure costs a lot of lost recompute. Checkpoint too often and you spend the whole run writing checkpoints. There is a clean optimum, and it's one of the most useful formulas in the whole series.

Let C = the cost (time) of writing one checkpoint, M = the job MTBF, and τ = the interval (compute time) between checkpoints. Two sources of waste, as fractions of useful time:

waste(τ) = C/τ + τ/(2M) ⇒ minimised at τ* = √(2 C M)

This is the Young–Daly formula (1974/2006), and the derivation is two lines of calculus. The τ/(2M) term carries one assumption worth stating: failures arrive uniformly in time (a Poisson process, independent of where you are in the interval), so a failure lands on average halfway through the current interval — you lose, in expectation, half an interval of work, τ/2, once every M of uptime, a fraction τ/(2M). Differentiate the total and set it to zero:

d/dτ [ C/τ + τ/(2M) ] = −C/τ² + 1/(2M) = 0 ⇒ τ² = 2CM ⇒ τ* = √(2CM)

(The second derivative 2C/τ³ > 0 confirms it's a minimum, not a maximum.) The minimum waste is √(2C/M) — it gets worse as checkpoints get expensive (C↑) or failures get frequent (M↓). The whole game of the rest of this lesson is driving C down, because a smaller C lets you checkpoint more often and waste less time doing it.

Young–Daly · the optimal checkpoint interval
Blue = checkpoint overhead C/τ (falling). Orange = expected lost work τ/2M (rising). Black = their sum. The green line is the optimum τ* = √(2CM); drag your interval and watch total waste bottom out there.
optimal τ*
waste at τ*
waste at your τ
verdict

Driving down C — three mechanisms

A naïve checkpoint is a synchronous, single-writer dump: training halts while one rank streams ~1 TB to storage. At even a generous 2 GB/s that's ~8 minutes of dead time per checkpoint. With the Young–Daly lens, a huge C forces huge intervals and large lost-work. Three ideas, stacked, take C from minutes to seconds.

1 · Sharded / distributed checkpoint

Under FSDP/ZeRO (lesson 05) each rank already holds only 1/N of the state. So let each rank write its own shard in parallel: aggregate write bandwidth scales with the number of writers, and wall-clock write time drops by . This is what torch.distributed.checkpoint (DCP) does. It also stores state logically (by parameter name + offset, not by physical rank layout), which enables the next trick.

Resharding — load into a different topology
Because DCP saves logical ranges, a checkpoint written by a TP=8, FSDP=64 job can be loaded by a TP=4, FSDP=128 job. The loader maps logical parameter ranges onto the new sharding. Without this, a checkpoint is welded to the exact GPU count and parallelism layout that wrote it — and you could never resume a failed run on a differently-shaped pool of healthy nodes (or scale the run up after warm-up).

2 · Asynchronous checkpoint

Split the write into a fast copy and a slow flush. Snapshot the state from HBM into pinned host memory — bounded by PCIe at ~25 GB/s, a few seconds — then resume training immediately while a background thread drains host memory to storage. The training stall shrinks from "time to write to disk" down to "time to copy to RAM." The disk write still happens; it just no longer blocks the GPUs.

3 · In-memory / redundant checkpoint

For the fastest recovery, keep a copy of each rank's shard in a peer GPU/host's memory (erasure-coded or replicated, à la Gemini / CheckFreq). When a node dies, its neighbour already holds its last shard — recovery is a memory copy over NVLink/IB, not a multi-minute read from a cold object store. Disk checkpoints remain as the durable fallback for whole-cluster outages.

Checkpoint stall · sync vs sharded vs async
The same 70B-class checkpoint, three write strategies. The bar is the time training is stalled (not the total write time — async keeps writing in the background while the GPUs run).
checkpoint size
sync stall
sharded stall
async stall

Recovery — detection, restart, and elasticity

A fast checkpoint is half the story; you also have to come back. The recovery loop:

  1. Detect. A health monitor (or a collective timeout) notices a rank is gone. Because collectives hang on a missing peer, you need an explicit watchdog — otherwise the job silently stalls forever instead of failing.
  2. Reschedule. Either swap in a hot spare node, or — with elastic training (torchrun/TorchElastic) — re-form the process group with the surviving nodes at a smaller world size. Elasticity trades a little throughput for not needing the exact node count to be available.
  3. Restore. Every rank loads the latest checkpoint (resharded to the new topology if the world size changed) and resumes from the saved step, LR position, RNG, and data offset.
Elasticity changes your effective batch — re-read lesson 11
Restarting on fewer nodes shrinks N, which shrinks B_eff = b·N·G unless you bump accumulation G to compensate. Silently continuing at a smaller effective batch (and now-mismatched LR) is a real way an "automatic recovery" quietly degrades a run. Robust setups pin B_eff and re-solve G on every reshape.

Detecting the failure — the recovery loop starts here

All the write machinery above is dead weight if you can't tell something broke — and detection at thousand-GPU scale is the part everyone underestimates. The naïve mental model is "a process crashes, the OS notices, you restart." Reality is uglier: the common failure mode is not a clean crash but a hang. One rank dies or slows, and every other rank blocks indefinitely inside a collective. This is a direct consequence of lesson 02: a collective only returns when all ranks have called it. If rank 743's GPU faults mid-step, ranks 0–742 and 744–8191 all enter the next AllReduce, wait for a peer that will never arrive, and sit there — burning power, holding the allocation, making zero progress, and emitting no error. A run can be "alive" by every process-liveness check while being thoroughly, silently dead.

So you need explicit mechanisms that turn a silent hang into a loud, localized fault:

The reason for all three is that you must distinguish three distinct fault classes, because they route to different responses:

ClassSymptomRoot causeResponse
Dead rankprocess gone / GPU fell off the bus; peers hang in the next collectivehardware: GPU fault, ECC, NIC/optics flap, host panicevict the node, restore from checkpoint, reschedule (spare or elastic)
Desyncranks issued different collectives (mismatched op/shape) → deadlock or shape errorsoftware/data: a control-flow branch that diverges across ranks, a ragged batchdo not just restart — it'll re-deadlock; this is a code/data bug to fix
Stragglerstep time spikes on one rank; everyone else waits at the barrier; no timeout firesa slow-but-alive GPU: thermal throttle, a degraded NVLink, a noisy neighbourdetect via per-rank step-time outlier, then evict the slow node proactively
SDC (silent data corruption)no crash, no NaN, no hang — a wrong number that survives ECC and quietly biases gradientsa marginal SM / bad lane: a bit-flip in compute logic ECC doesn't cover, a faulty multipliercross-replica gradient compare or periodic recompute + checksum; evict the suspect node
SDC — the fault with no symptom
ECC protects memory, but a bit-flip in the compute path — a flaky multiplier on one SM — produces a wrong result that crashes nothing and isn't a NaN, so every watchdog above stays silent. It just quietly biases the gradients on one rank, which the next AllReduce spreads to everyone. Detection is genuinely hard: the practical hooks are (a) cross-replica comparison — two data-parallel replicas on the same batch must produce bit-identical gradients, so a mismatch localizes the bad node, and (b) periodic recompute + checksum — re-run a step and compare. The checkpoint-checksum machinery below is the natural place to hang this: you're already hashing shards for durability, so extend it to detect the corruption, not just the torn write.
A straggler is the failure that never trips your alarm
A dead rank eventually trips the collective timeout. A straggler never does — it does call every collective, just late, so the whole job runs at the speed of its slowest member with no error anywhere. One GPU throttling to 60% drags an 8000-GPU step to 60% goodput indefinitely. You only catch it by watching the distribution of per-rank step times and flagging the outlier, not by waiting for something to break.

Checkpoint durability — the checkpoint itself can be poison

Here is the failure mode that turns your safety net into the thing that kills the run: a crash during a checkpoint write. Failures are routine (M_job = M_1/N), and a checkpoint write takes seconds-to-minutes, so eventually a node will die while a checkpoint is being written. The result is a torn checkpoint — a file (or a shard) that is half-old, half-new, or truncated. If the canonical "latest" name now points at that partial blob, the next restart loads garbage, and either crashes immediately (best case) or silently corrupts the run by restoring inconsistent state across shards (worst case). The thing you built to survive failures has become the failure.

The defenses are cheap and non-negotiable:

The single-checkpoint trap
Overwriting one latest.pt in place, with no temp-rename and no history, is the most common way a "checkpointed" run still loses everything: one crash during that write and the only copy is corrupt. Atomicity and a small ring of history are what make the checkpoint trustworthy — not just present.

When the math dies — NaN as a fault

Close the loop with lesson 24: a loss spike or a NaN is a failure too — and the powerful realization is that it reuses the exact same machinery as a dead GPU. There are two ways a training step can stop being useful: "a GPU died" (hardware) and "the math died" (a NaN/Inf propagated into the weights). Both route into one path: detect, roll back to the last good checkpoint, resume. You do not need a separate recovery system for numerical failure; you need to feed it into the one you already built.

"A GPU died" vs "the math died" → one restore path
Hardware fault and numerical fault are detected differently (watchdog/timeout vs. all-reduced finite-check) but recover identically: reload the last good checkpoint and resume from the saved step, RNG, and data offset. Treating NaN as just another fault — rather than a special case that aborts the run for a human to babysit — is what keeps an unattended three-week run unattended.

Goodput — optimize the whole loop, not just the write

Young–Daly's cost term C is seductively narrow: it's easy to read it as "checkpoint write time" and declare victory once async flush has driven the stall to a few seconds. But a real failure costs far more than the write. The end-to-end recovery cost is:

C_recover = detect + reschedule + reload + recompute_since_last_checkpoint

Each term is real and independently large. Detect is the timeout/heartbeat latency from the first section — if your collective timeout is 20 minutes, every failure burns 20 minutes of the whole cluster before recovery even begins. Reschedule is getting a replacement node — instant if you have a warm hot spare, minutes-to-tens-of-minutes if the scheduler has to find and boot one. Reload is reading the (now cold) checkpoint back. Recompute is the Young–Daly lost work, ~τ/2. The write being cheap does nothing for the first three.

The metric that captures all of it is ML goodput:

goodput = useful_training_wall_clock / total_wall_clock

Useful wall-clock is time spent doing forward/backward passes that survive to the final model; everything else — checkpoint stalls, detection latency, rescheduling, reloads, and recomputed-then-discarded work — is in the denominator but not the numerator. The trap: you can have a near-zero checkpoint write cost and still bleed goodput, because frequent small failures with slow detection dominate. At a 5-hour job MTBF with a 20-minute detect-and-reschedule per failure, a three-week run eating ~100 failures loses ~33 hours to detection alone — independent of how fast the write is. Optimizing C's write term while ignoring detect/reschedule is optimizing the wrong term.

This reframes the engineering targets:

Goodput is the real objective
Young–Daly minimizes the steady-state write+lost-work waste; goodput is the honest, whole-loop version that also charges you for detection, rescheduling, and reload. The same M_1/N arithmetic from lesson 01 that says failures are routine also says the recovery loop — not the write alone — is what determines whether a 10,000-GPU run spends its weeks training or thrashing.

The whole picture

  ───●────────────────●────────────────✗  failure (rank dies)
     │ checkpoint      │ checkpoint      │
     └── τ work ───────┴── τ work ───────┘  lost: ~τ/2 of recompute
                                          │
                          detect (watchdog) → reschedule (spare / elastic)
                                          │       → restore latest checkpoint
                                          ▼          (reshard if world size changed)
  ───────────────────────────────────────●──────▶  resume at saved step

   τ* = √(2·C·M)        drive C down with: sharded write · async flush · in-mem redundancy

Synthesis — MFU × goodput, the two halves of efficiency

Part VI closes by putting two numbers in one equation that the series has so far kept apart. MFU (lesson 23) measures compute efficiency within useful wall-clock: of the seconds you spend doing forward/backward that survives to the final model, what fraction of the FLOP-budget went to useful math. Goodput (this lesson) measures what fraction of total wall-clock was useful at all — the rest lost to crashes, restarts, detection latency, reloads, and checkpoint stalls. They are the same efficiency family, decomposed multiplicatively: one is efficiency given the clock is useful, the other is the chance the clock is useful in the first place.

effective_tokens_per_GPU_per_dollar ∝ MFU × goodput

The product is what you actually pay for. A run at a stellar 55% MFU but 60% goodput — great kernels, but it crashes every few hours and each recovery bleeds detection-and-reload time — delivers the same useful throughput as a mediocre 38% MFU run at 87% goodput, and far less than a 50% × 95% run. A high-MFU run with poor goodput still wastes the cluster: the tensor cores are magnificently efficient during the shrinking slices of wall-clock that survive, and idle or recomputing-then-discarding during the rest. You cannot optimize one and ignore the other — MFU × goodput is the single quantity that says whether the weeks of GPU-time you rented turned into a model. That product, tying the compute story of Part V and the operational story of Part VI together, is the real scoreboard for a large-scale run.

Takeaway
At scale, M_job = M_1/N makes failure routine, so the run must checkpoint all mutable state (optimizer state dominates — ~6× the weights) and recover fast. Young–Daly sets the cadence: τ* = √(2CM), with minimum waste √(2C/M) — so the entire engineering effort goes into shrinking the checkpoint cost C via sharded parallel writes, asynchronous background flush, and in-memory redundancy. Resharding-capable checkpoints plus elastic restart turn a dead node from a fatal event into a few-minute hiccup. And the whole track reduces to one product: MFU × goodput — compute efficiency times the fraction of wall-clock that survives — is what turns rented GPU-weeks into a finished model.
End of Part VI — and the track
You now have the full stack: the constraints (I), the six ways to shard (II), the training-memory & effective-batch math (III), how to compose them and serve (IV), the single-GPU machinery underneath (V), and the operational glue that keeps a real run alive for weeks (VI). The CUDA primitives that sit below all of it — and the serving kernels that build on them — continue in GPU Kernels for ML Engineers.