Home › Hardware & OS › Processes & terminals
algorithm medium

Checkpoint and restart

Problem A job that runs for hours or weeks on machines that can fail, be preempted or be moved loses all its work at every interruption. Operators of training, simulation and batch systems need it to resume near where it stopped.

Periodically save enough state to resume a computation after a crash, preemption or move, with an interval that balances save cost against lost work.

O(state size) per checkpoint; optimal interval about sqrt(2 * checkpoint cost * MTBF) (Young), overhead about 2*sqrt(cost/(2*MTBF))
checkpointrestartfault-tolerancedurabilitycriuyoung-daly
Overview

Instead of starting over after every failure, the computation saves its state now and then and a restarted copy loads the last save. The state is what the program chooses to write (weights, step counter, RNG), or the memory image of a process (CRIU, DMTCP) or VM. The craft: the save must be a consistent cut, never replace a good checkpoint with a half-written one, and use an interval that balances save cost against lost work.

Key facts

Use when

  • Long jobs on failure-prone or preemptible machines: ML training on spot instances, HPC simulations, multi-day pipelines.
  • Moving or restoring running work: container migration, emulator save states, apps restored after the OS killed their process.

Avoid when

  • The job is short compared with its restart cost, or cheap to recompute.
  • State depends on things that cannot be saved (device contexts, live external sessions) and you cannot re-create them.

Deep dive

How it works

Parts:

  • Quiesce point — a consistent moment: step boundary, barrier, frozen process tree or coordinated cut.
  • Serializer — app-level (own format), user-space library (DMTCP), or kernel-assisted (CRIU reads /proc, injects helper code via ptrace).
  • Atomic writer — temp file, fsync, rename (atomic-file-replacement), checksum inside.
  • Generation store — newest N files; older ones deleted only after a newer one is complete.
  • Loader — newest generation that verifies; rebuilds state, resumes.
  • Interval policy — fixed, or from the Young/Daly formula.
  1. At the quiesce point capture state: arrays, counters, RNG and input-position state. CRIU stops the tree with PTRACE_SEIZE or the freezer cgroup, reads maps, fds and sockets (TCP via TCP_REPAIR), and dumps pages from inside the target.
  2. Optionally resume at once and write in the background: copy-on-write fork (copy-on-write-fork) or a staged copy plus async upload.
  3. Write tmp, fsync, rename, fsync the directory (fsync-and-direct-io); delete generations beyond N.
  4. After a crash take the newest file whose magic, length and checksum verify.
  5. Restore state, re-create what is not serialisable (connections, device contexts), continue from the saved step.
  6. Distributed: all ranks save the same logical step, or use markers (Chandy-Lamport snapshots); uncoordinated saves can force a rollback chain (domino effect).
 compute ─► step k ─► [quiesce] ─► serialize ─► tmp ─fsync─► rename ─► ckpt-k
    ▲                                                                  │ keep N
    └──────── crash ─► load newest valid (checksum) ◄──────────────────┘
 failure at step k+j  ⇒  lost work = j steps (at most one interval)

Code

Std-only Rust: xorshift64 RNG state, CRC-32 trailer, temp+fsync+rename, 2 generations. main crashes at step 137, resumes, asserts a bit-identical result, and asserts that a truncated and a bit-flipped newest file are rejected for the previous generation. Run: cargo run --release -- /tmp/dir.

use std::{fs::{self, File}, io::Write, path::Path, time::Instant};

const MAGIC: &[u8; 8] = b"CKPT0001";
const fn crc_table() -> [u32; 256] {
    let (mut t, mut i) = ([0u32; 256], 0);
    while i < 256 {
        let (mut c, mut k) = (i as u32, 0);
        while k < 8 { c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; k += 1; }
        t[i] = c; i += 1;
    }
    t
}
static TABLE: [u32; 256] = crc_table();
fn crc32(b: &[u8]) -> u32 {
    !b.iter().fold(!0u32, |c, &x| TABLE[((c ^ x as u32) & 0xFF) as usize] ^ (c >> 8))
}

/// Everything the computation needs to continue: counter, RNG state, accumulator, bulk data.
#[derive(Clone, PartialEq, Debug)]
struct State { step: u64, rng: u64, acc: u64, data: Vec<u64> }

impl State {
    fn advance(&mut self) { // deterministic given the state: xorshift64 drives all choices
        for _ in 0..1_000_000 {
            self.rng ^= self.rng << 13; self.rng ^= self.rng >> 7; self.rng ^= self.rng << 17;
            let i = (self.rng % self.data.len() as u64) as usize;
            self.data[i] = self.data[i].wrapping_mul(31).wrapping_add(self.rng);
        }
        self.acc = self.acc.rotate_left(5) ^ self.rng;
        self.step += 1;
    }
    fn encode(&self) -> Vec<u8> {
        let mut b = MAGIC.to_vec();
        for v in [self.step, self.rng, self.acc, self.data.len() as u64] { b.extend(v.to_le_bytes()); }
        for v in &self.data { b.extend(v.to_le_bytes()); }
        let c = crc32(&b);
        b.extend(c.to_le_bytes()); // trailer: CRC over header + payload
        b
    }
    fn decode(b: &[u8]) -> Option<State> {
        if b.len() < 44 || &b[..8] != MAGIC { return None; }
        let (body, tail) = b.split_at(b.len() - 4);
        if crc32(body) != u32::from_le_bytes(tail.try_into().ok()?) { return None; }
        let w = |i: usize| u64::from_le_bytes(body[8 + 8 * i..16 + 8 * i].try_into().unwrap());
        let n = w(3) as usize;
        if body.len() != 40 + 8 * n { return None; } // header length must match payload
        Some(State { step: w(0), rng: w(1), acc: w(2), data: (0..n).map(|i| w(4 + i)).collect() })
    }
}

fn save(dir: &Path, s: &State, keep: usize) -> std::io::Result<()> {
    let tmp = dir.join("ckpt.tmp"); // same directory, same file system: rename is atomic
    let mut f = File::create(&tmp)?;
    f.write_all(&s.encode())?;
    f.sync_all()?; // data durable BEFORE the name appears
    fs::rename(&tmp, dir.join(format!("ckpt-{:010}.bin", s.step)))?;
    File::open(dir)?.sync_all()?; // make the rename itself durable
    let mut names = list(dir);
    while names.len() > keep { fs::remove_file(dir.join(names.remove(0)))?; } // keep N generations
    Ok(())
}
fn list(dir: &Path) -> Vec<String> {
    let mut v: Vec<String> = fs::read_dir(dir).unwrap().filter_map(|e| e.ok())
        .map(|e| e.file_name().to_string_lossy().into_owned())
        .filter(|n| n.starts_with("ckpt-") && n.ends_with(".bin")).collect();
    v.sort(); // zero-padded step numbers sort by age
    v
}
fn load_latest(dir: &Path) -> Option<State> {
    list(dir).iter().rev().find_map(|n| State::decode(&fs::read(dir.join(n)).ok()?))
}
fn run(dir: &Path, s: &mut State, until: u64, every: u64) {
    while s.step < until {
        s.advance();
        if every > 0 && s.step % every == 0 { save(dir, s, 2).unwrap(); }
    }
}

fn main() {
    let base = Path::new(&std::env::args().nth(1).unwrap()).to_path_buf();
    let fresh = || State { step: 0, rng: 0x9E37_79B9_7F4A_7C15, acc: 0, data: vec![1; 1 << 20] }; // 8 MiB
    let (total, every) = (300, 25);
    let mut reference = fresh();
    run(&base, &mut reference, total, 0); // uninterrupted run, no checkpoints

    let dir = base.join("a"); fs::create_dir_all(&dir).unwrap();
    let mut victim = fresh();
    run(&dir, &mut victim, 137, every); // "crash" at step 137: in-memory state is lost
    drop(victim);
    let mut s = load_latest(&dir).expect("checkpoint");
    assert_eq!(s.step, 125); // resumed from the last checkpoint, not step 137
    run(&dir, &mut s, total, every);
    assert_eq!(s, reference); // bit-identical incl. RNG and accumulator

    // torn newest generation: truncate it; flip a byte in another copy
    let newest = dir.join(list(&dir).pop().unwrap());
    let good = fs::read(&newest).unwrap();
    fs::write(&newest, &good[..good.len() / 2]).unwrap();
    assert_eq!(load_latest(&dir).unwrap().step, 275); // previous generation used
    let mut bad = good.clone(); bad[1000] ^= 1;
    fs::write(&newest, &bad).unwrap();
    assert_eq!(load_latest(&dir).unwrap().step, 275);
    let mut s = load_latest(&dir).unwrap();
    run(&dir, &mut s, total, every);
    assert_eq!(s, reference);

    for every in [0, 100, 10] { // overhead
        let d = base.join(format!("t{every}")); fs::create_dir_all(&d).unwrap();
        let (mut s, t0) = (fresh(), Instant::now());
        run(&d, &mut s, 300, every);
        println!("every={every:3} total={:?}", t0.elapsed());
    }
    println!("ok");
}

Complexity and constants

  • Checkpoint cost grows with state size S: S / bandwidth plus one durable flush. Expected lost work per failure is about T/2.
  • Overhead per unit time ≈ C/T + T/(2M) (first-order model), minimised at T = sqrt(2*C*M), where overhead = sqrt(2C/M). Example (arithmetic, not measured): C = 60 s, M = 24 h gives T ≈ 3,220 s, overhead ≈ 3.7 %.
  • Young: T ≈ sqrt(2 * Ts * Tf) (save time, mean time to failure). Daly's first-order form T = sqrt(2*C*(M+R)) - C (R = restart time) is reported valid while (T+C)/M < 1/2; his 2006 paper adds a perturbation series (see the paper).
  • Measured on this Mac (Apple M4 Pro, macOS 27.0.1): an 8 MiB state saves in ≈ 30 ms; ≈ 16 ms of that is the byte-at-a-time table CRC-32, the rest encode, write, F_FULLFSYNC (sync_all), rename, directory sync. One compute step ≈ 1.7 ms.

Efficiency

  • Cost: one 8 MiB application checkpoint takes ≈ 30 ms (measured on this Mac (Apple M4 Pro): 20 saves, two runs, 29.8 and 30.5 ms). 300 steps with no checkpoint: 0.50-0.52 s; with a checkpoint every 100 steps: 0.59-0.64 s (+14 % to +24 %); every 10 steps: 1.39-1.55 s (2.7-3x) (5 runs, measured on this Mac (Apple M4 Pro)). Bound: storage durability latency and bandwidth, plus checksum and copy time.
  • Levers:
    • hardware or sliced CRC (CRC32C) — the checksum was over half of the measured save — large states
    • fork-based snapshot — parent only forks, never does disk I/O (Redis docs) — big in-memory state
    • incremental dirty-page checkpoints — write only changed pages (CRIU --track-mem, dirty-page tracking) — slowly changing state
    • async staging and upload — compute resumes after the local copy (PyTorch DCP async_save) — slow or remote storage
    • Young/Daly interval — balances save cost against lost work — failures roughly Poisson
  • Naive → standard → state of the art:
    • stop everything, write one file in place · lost on torn write; stall = full write time
    • temp+fsync+rename, checksum, N generations, interval from Young/Daly · stall = serialize + flush
    • fork/staged async write, incremental or sharded parallel save with resharding on load · stall = snapshot cost only

Implementation notes

  • Temp file in the same directory (rename is atomic only within one file system); fsync the file before, the directory after.
  • Header with magic, version, length; trailer checksum over everything: catches truncation and bit rot.
  • Save RNG, step, input offset, optimizer state and schedules, not only weights.
  • Zero-padded step in the name makes lexical order age order.
  • PyTorch DCP: every rank writes its shards in parallel (at least one file per rank); load-time resharding allows a different cluster topology.
  • CRIU needs privileges and refuses some resources (devices, tasks with a debugger attached; see its "What cannot be checkpointed" page).

Pitfalls

  • Torn checkpoint: in-place write or rename before fsync; only checksum plus generations survive it.
  • Missing RNG, data position or optimizer state: the run resumes but diverges (the code asserts equality).
  • Saving mid-step captures an inconsistent cut.
  • Uncoordinated checkpoints of communicating processes can roll back repeatedly (Elnozahy et al. survey).
  • Non-serialisable state: GPU contexts, open sockets, wall-clock time, PIDs. Re-create on restore.
  • Interval too short saturates storage; too long loses hours. Re-estimate M from real failures.
  • Deleting the old generation before the new one is synced.

Variants and alternatives

CRIU, DMTCPno code changes; larger images, fragile with devices and external connections.
VM snapshot / live migrationwhole machine (vm-snapshot-restore, memory-live-migration).
Checkpoint plus log replaywhen work between checkpoints cannot be redone (write-ahead-log, point-in-time-recovery, batch-checkpoint-restart).
Replicationrun copies instead; costs resources, no rollback.
A supervisor restarts the process; the checkpoint gives it something to resume (process-supervision).

In production

  • Redis RDB: a forked child writes a temporary file and replaces the old one.
  • CRIU in Docker, Podman, LXC/Incus and Kubernetes; DMTCP in HPC (MPI, SLURM).
  • PyTorch Distributed Checkpoint and Orbax for training state.
  • Android: the OS may destroy a backgrounded app process, so apps save UI state for relaunch (mobile-background-execution).
  • Emulator and game save states, database snapshots, long scientific pipelines.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
CRIUCcheckpoint-restore/criuapt install criu————Linux process/container checkpoint; used by Docker, Podman, Kubernetes
DMTCPC++dmtcp/dmtcpapt install dmtcp————user-space, transparent; MPI/HPC support
PyTorch DCPPythonpytorch/pytorchpip install torch————torch.distributed.checkpoint; per-rank shards, async save, resharding on load
OrbaxPythongoogle/orbaxpip install orbax-checkpoint————JAX checkpointing library

Sources

Builds on

Used by

Systems (9):

Alternatives and related

vm snapshot restore · plannedSnapshot an initialised VM/isolate heap and restore it for fast cold starts
atomic file replacement · plannedWrite temp file, fsync, rename over target, fsync directory: crash-safe whole-file updates
fsync, O_DSYNC and O_DIRECTHow do you make sure data written by a program actually survives a crash or power loss? This guide answers which file-write calls really guarantee durability on Linux and macOS, and in what sequence to use them.CRC32C checksumsStorage engines, logs and network protocols must detect accidental corruption such as bit flips and torn writes in blocks, records and frames, using a check that is fast enough not to slow the data path.
memory live migration · plannedPre-copy dirty-page iteration and post-copy faulting to move running VMs
Process supervisionA long-running service crashes, hangs, or leaves child processes behind, and nobody is awake to notice. Operators of servers, devices and pipelines need it noticed, started again at a sane pace, and stopped cleanly.
chandy lamport snapshots · plannedConsistent distributed snapshots via markers/barriers (Flink-style checkpoints)
batch checkpoint restart · plannedLarge batch jobs (end-of-day, settlement, payroll runs) that checkpoint and restart idempotently
Write-ahead logDatabases, queues and metadata services must not lose or corrupt committed data when the process crashes or power fails halfway through updating files.Point-in-time recoveryAfter a bad deploy, an exploit or an accidental delete, an operator must bring a database or service back to its exact state of a moment before the damage, without restoring only to the last nightly copy.
mobile background execution · plannedIOS and Android background task, fetch and push-wake limits for syncing apps