Data-Flow Analysis

Almost every optimisation a compiler performs rests on a question of the form "what do I know about the program's values at this point?" — is this variable definitely a constant here? is its current value ever read again? has this expression already been computed on every path leading here? Data-flow analysis is the single, unifying framework that answers all such questions. It is one of the most powerful ideas in compiling precisely because it is not an optimisation but the machinery underneath a whole family of them — reaching definitions, live variables, available expressions, constant propagation — all the same algorithm with the pieces swapped out.

The setting is the control-flow graph of a function, and the currency is facts that flow along its edges. Each block transforms the facts entering it into the facts leaving it; where edges merge, facts combine. Run that to a fixed point and you have proved something true on every possible execution path — without ever running the program.

The control-flow graph carries the facts

Recall the CFG: nodes are basic blocks (straight-line runs of three-address code), edges are the possible transfers of control. Data-flow analysis attaches to every block two sets of facts: \textit{IN}[B], true on entry to B, and \textit{OUT}[B], true on exit. A block relates them through its transfer function, and the CFG's edges relate one block's output to the next block's input. Solving the analysis means finding \textit{IN} and \textit{OUT} for every block that are mutually consistent.

gen and kill: what a block produces and destroys

A block's transfer function is built from two constant sets that summarise what happens inside it:

That single equation — generate, and pass through everything you didn't kill — is the transfer function for a forward "may" analysis like reaching definitions. Swap in different notions of \textit{gen}, \textit{kill}, and set operator and you get a different analysis, but the shape never changes.

Direction and the meet operator

Two knobs specialise the framework. The first is direction. A forward analysis pushes facts along the edges (a block's \textit{IN} comes from its predecessors' \textit{OUT}s) — this is how reaching definitions and available expressions work. A backward analysis pulls facts against the edges (a block's \textit{OUT} comes from its successors' \textit{IN}s) — this is how live-variable analysis works, because whether a value is needed depends on the future.

The second knob is the meet (or join) operator — how facts combine where control-flow paths merge. Union (\cup) gives a "may" analysis: a fact holds if it holds on some incoming path (a definition may reach here). Intersection (\cap) gives a "must" analysis: a fact holds only if it holds on every incoming path (an expression is available only if computed on all paths). The meet-over-all-paths solution is what we are really after; the iterative algorithm computes it.

AnalysisDirectionMeetFact trackedEnables
Reaching definitionsforward\cup (may)which defs reach a pointuse-def chains, const prop
Live variablesbackward\cup (may)values needed laterdead-code elim, allocation
Available expressionsforward\cap (must)expressions already computedcommon-subexpr elim
Very busy expressionsbackward\cap (must)exprs used on all future pathscode hoisting

The iterative worklist algorithm

How do we solve the mutually-recursive \textit{IN}/\textit{OUT} equations? By iterating to a fixed point. Initialise every set, then repeatedly recompute a block's facts from its neighbours; when a block's output changes, its dependents might change too, so put them back on a worklist. Keep going until nothing changes — the fixed point.

// Forward "may" analysis (reaching definitions). OUT[B] = {} for all B ; worklist = all blocks while worklist not empty: pick B from worklist IN[B] = union of OUT[P] for all predecessors P of B // meet newOut = gen[B] ∪ (IN[B] − kill[B]) // transfer if newOut ≠ OUT[B]: OUT[B] = newOut add all successors of B to the worklist // they may change

The worklist is just an efficiency trick — it avoids re-examining blocks whose inputs did not move. Because facts are only ever added (the sets grow monotonically) and there are finitely many possible facts, the loop must halt. Below, the same skeleton runs a real reaching-definitions analysis on a small CFG with a loop.

Reaching definitions, worked in code

We solve reaching definitions for a four-block CFG — an entry, a loop head, a loop body (which redefines a variable, killing the entry's definition), and an exit — and print the fixpoint \textit{IN} sets.

type Block = { name: string; gen: string[]; kill: string[]; preds: string[] }; // d1: x defined in entry; d2: x defined in the loop body (d2 kills d1 and vice versa). const cfg: Record<string, Block> = { entry: { name: "entry", gen: ["d1"], kill: ["d2"], preds: [] }, head: { name: "head", gen: [], kill: [], preds: ["entry", "body"] }, body: { name: "body", gen: ["d2"], kill: ["d1"], preds: ["head"] }, exit: { name: "exit", gen: [], kill: [], preds: ["head"] }, }; const order = ["entry", "head", "body", "exit"]; const IN: Record<string, Set<string>> = {}; const OUT: Record<string, Set<string>> = {}; for (const b of order) { IN[b] = new Set(); OUT[b] = new Set(); } const eq = (a: Set<string>, b: Set<string>) => a.size === b.size && [...a].every((x) => b.has(x)); let changed = true, passes = 0; while (changed) { changed = false; passes++; for (const b of order) { const blk = cfg[b]; const inSet = new Set<string>(); // IN = union of preds' OUT (may / forward) for (const p of blk.preds) for (const d of OUT[p]) inSet.add(d); const outSet = new Set<string>(blk.gen); // OUT = gen ∪ (IN − kill) for (const d of inSet) if (!blk.kill.includes(d)) outSet.add(d); IN[b] = inSet; if (!eq(outSet, OUT[b])) { OUT[b] = outSet; changed = true; } } } console.log(`fixed point reached in ${passes} passes`); for (const b of order) console.log(`IN[${b}] = {${[...IN[b]].sort().join(",")}}`);

The interesting block is head: it is reached both from entry (carrying d1) and from body (carrying d2), so at the fixed point \textit{IN}[\texttt{head}] = \{d1, d2\}both definitions of x may reach the loop head. The analysis discovered that fact by chasing the back-edge around until the sets stopped growing.

Why it always terminates: lattices and monotonicity

The framework's guarantees come from order theory. The possible values of each \textit{IN}/\textit{OUT} set form a lattice — a partially ordered set (here, sets of facts ordered by \subseteq) with a meet operation combining any two elements. The transfer functions are monotonic: give them a bigger input and they produce a no-smaller output; they never undo progress. A finite lattice plus monotone transfer functions is exactly the condition of the Kleene fixed-point theorem: iterate from the bottom element and you climb the lattice, strictly, until you reach the least fixed point — and because the lattice has finite height, you get there in finitely many steps.

That is the deep reason data-flow analysis is trustworthy: it is not a heuristic that "usually works," it is a computation with a proof of termination and a proof that its answer is a sound over-approximation of every real execution. The full theory of lattices and fixed points makes this precise.

Because the approximation always errs in the safe direction — and which direction is safe flips with the analysis. Live-variable analysis may report a variable live when it is actually dead; the cost is a missed dead-code elimination, never a wrong deletion. Available-expressions analysis will only claim an expression is available if it truly is on every path; if unsure, it says "not available" and you miss a common-subexpression elimination, but you never reuse a stale value. This is the meaning of a conservative (sound) analysis: it may leave optimisations on the table, but it never performs an incorrect one. "May" analyses over-approximate; "must" analyses under-approximate; both stay on the safe side of correctness. Optimisation is the art of being conservative without being timid.

A frequent muddle is to think you can pick forward/backward or union/intersection to taste. You cannot — the question dictates both. "Which definitions reach here?" is inherently about the past, so it is forward; and a definition reaching along any path counts, so it is union. "Is this value used later?" is inherently about the future, so it is backward; used on any future path counts, so union again. "Is this expression already computed on the way here?" needs it computed on all paths to be safe, so it is intersection. Get the meet wrong — use union where you needed intersection — and you will "prove" an expression is available when it was computed on only one branch, and cheerfully reuse a value that was never computed on the other. The direction and meet are part of the analysis's specification, not tuning parameters.