Record & Replay in Practice

The previous lesson promised a debugger with a reverse gear on ordinary, information-destroying hardware — and tools like rr, UDB and gdb's built-in reverse execution deliver it, on programs of millions of lines, at overheads small enough to leave on all day. Which should strike you as impossible. A big program overwrites gigabytes of state per second; surely "remember everything you destroy" means drowning in a log?

The escape is one sharp observation: almost everything a program does is deterministic. An add instruction, a memory read, a whole compiler pass — given the same starting state, they produce the same result, every time. Deterministic work doesn't need to be remembered, because it can always be recomputed. The only things that must be recorded are the events the program could not have computed for itself: the nondeterministic inputs. Everything else is regenerated on demand by simply running the program again. This is deterministic record & replay, and it is how reverse execution actually ships.

What goes in the log

Perhaps the shortest important list in systems programming — record only:

Not recorded: results of arithmetic, values of local variables, the contents of memory — all of it deterministic, all of it recomputable. Replaying the log through a fresh execution of the same binary reproduces the original run bit for bit. Try the whole architecture in miniature:

// A program whose ONLY nondeterminism is readInput(). Everything else recomputes. function runProgram(readInput: () => number, log?: number[]): number { let total = 0; for (let i = 0; i < 4; i++) { const r = readInput(); // nondeterministic! if (log) log.push(r); // RECORD mode: journal the INPUT only total = total * 2 + r; // deterministic - recomputed for free } return total; } // RECORD: run with "real" (pseudo-random) inputs, logging them. let seed = 42; function noisyInput(): number { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % 10; } const log: number[] = []; const recorded = runProgram(noisyInput, log); console.log("record run: result = " + recorded + " log = " + JSON.stringify(log)); // REPLAY: no randomness at all - just feed the log back in. let cursor = 0; function replayInput(): number { return log[cursor++]; } const replayed = runProgram(replayInput); console.log("replay run: result = " + replayed + " (identical, forever, on demand)");

Four log entries reproduce the entire run — however much deterministic computation happened between them. That ratio is why rr's log stays small and its recording overhead sits around 1.2–2× on a single core: the tracee mostly just runs, with the recorder intervening only at syscalls and signals.

Snapshots make it fast; the log makes it exact

Replay alone gives you time travel of a clumsy sort: to see step 89 of a billion-step run, replay from the very beginning. So the tools add the second ingredient: periodic checkpoints — full snapshots of the process, taken cheaply on Linux with fork(), where the operating system's copy-on-write pages mean a "copy" of gigabytes costs almost nothing until pages actually change. Now reverse-step from step n means: restore the nearest checkpoint before n-1, then replay forward to n-1.

Read that again: the debugger never actually runs backwards. Every "reverse" command is restore-plus-replay-forward, with the checkpoint spacing setting the price. Dense checkpoints: fast reverse-steps, lots of memory. Sparse checkpoints: cheap recording, slow hops back. If that trade sounds familiar, it should — it is exactly the checkpoint tradeoff from the pebble game, running in production on your laptop. Bennett's space–time bargain, with fork() as the pebble:

// Reverse-step cost model: restore nearest checkpoint, replay forward. function reverseStepCost(target: number, spacing: number): number { const checkpoint = Math.floor(target / spacing) * spacing; return target - checkpoint; // steps to replay forward } const RUN = 100000; // total steps in the recording for (const spacing of [10, 100, 1000, 10000]) { const snapshots = Math.floor(RUN / spacing) + 1; console.log("spacing " + spacing + ": snapshots kept = " + snapshots + ", worst reverse-step replays " + reverseStepCost(spacing - 1, spacing) + " steps, e.g. to step 8674: " + reverseStepCost(8674, spacing)); } console.log("dense checkpoints: fast but memory-hungry; sparse: cheap but slow - the pebble tradeoff.");

rr was built at Mozilla in the early 2010s, born of Firefox-sized desperation: a browser is millions of lines of C++ chewing on the wild-west input of the entire web, and its worst bugs were intermittent — crashes that reproduced once per thousand test runs, evaporating the moment a developer attached a debugger. Robert O'Callahan and colleagues reasoned that if a failing run could be recorded cheaply enough to leave recording on in automated testing, then every one-in-a-thousand failure would become permanently, deterministically replayable — and debuggable backwards at leisure. The trick that made it practical was ruthless minimalism: no instruction emulation, no code rewriting — run the real binary at nearly full speed on one core, log syscalls and signals via ptrace, count hardware performance events to replay signal delivery at the exact right instruction. rr escaped Mozilla to become a beloved everyday tool — and its authors' rule of thumb became folklore: an intermittent bug you can record is just a deterministic bug you haven't walked backwards through yet.

Chaos, and the race that breaks the tape

One core, one interleaving — rr's single-core scheduling makes multithreaded runs recordable, but it also makes them gentler than reality: many race conditions only bite under true parallelism. rr's answer is chaos mode, which deliberately randomises scheduling — starving threads, preempting at cruel moments — to coax rare interleavings into showing up while the tape is rolling. Once recorded, even a one-in-a-million race replays forever.

The same coin has a dark side: unrecorded nondeterminism breaks replay. If two threads race on shared memory across truly parallel cores, the winner of the race is decided by hardware timing that appears in no syscall and no log — a naive replayer cannot reproduce the outcome, and replay diverges from the recording. That is precisely why rr accepts the single-core cost, and why recording true parallelism faithfully (as some research systems attempt) requires logging memory ordering itself, at far greater expense. The log must contain every source of nondeterminism, or the whole edifice quietly collapses.

The classic misconception about record & replay — assumed by almost everyone meeting it for the first time — is that the tool saves the program's state after every step, like frames of a film. It does not, and could not: at gigabytes of overwritten state per second, the "film" would dwarf every disk you own. What is stored is inputs plus occasional checkpoints: kilobytes of syscall results and signal timings, plus a handful of copy-on-write snapshots. Every intermediate state you inspect in the debugger is recomputed on demand by deterministic replay from the nearest checkpoint. When the debugger shows you "the state at step 89", you are not reading a recording — you are watching a fresh execution that has been steered, by the log, into being indistinguishable from the original. Storage is traded for recomputation: the space–time tradeoff is not just an implementation detail of these tools; it is the whole design.