The Dataflow Analysis Framework

Global optimizations need facts — "is this definition still in force here?", "has this expression already been computed on every path to here?", "will this variable ever be read again?". Every one of these questions has the same shape: attach a piece of information to each point in the flow graph, say how each basic block transforms that information, say how information from several incoming paths combines, and then solve the whole tangle of mutually-dependent equations by iterating to a fixed point. That single recipe is the dataflow analysis framework, and once you see it, a dozen separate-looking analyses become one algorithm with four knobs.

The framework is due to Kildall, and its monotone/lattice-theoretic foundations to Kam and Ullman. Its beauty is that you never argue about the solver again: you specify the four knobs, and the same round-robin loop computes the answer for reaching definitions, live variables, available expressions, constant propagation and more.

The four knobs

A dataflow problem is completely specified by four choices:

The dataflow equations

With the knobs chosen, the analysis is two equations per block, coupled across the graph. For a forward problem:

\text{IN}[B] \;=\; \bigwedge_{P \,\in\, \text{pred}(B)} \text{OUT}[P], \qquad \text{OUT}[B] \;=\; f_B\big(\text{IN}[B]\big).

For a backward problem, the roles of IN/OUT and predecessor/successor swap:

\text{OUT}[B] \;=\; \bigwedge_{S \,\in\, \text{succ}(B)} \text{IN}[S], \qquad \text{IN}[B] \;=\; f_B\big(\text{OUT}[B]\big).

With the gen/kill transfer expanded, the forward pair reads:

\text{OUT}[B] \;=\; \text{gen}_B \,\cup\, \big(\text{IN}[B] - \text{kill}_B\big).

There is one special block. In a forward problem the graph's ENTRY node has no predecessors, so its IN is set to a fixed boundary value (often \varnothing). In a backward problem the boundary is EXIT's OUT. Get that boundary right and the rest follows.

The lattice, and why iteration terminates

The domain is a lattice: the meet of any two facts is well-defined, and there is a top element \top. Picture the powerset of two definitions \{d_1, d_2\} as a Hasse diagram — every set sits above the sets it contains:

Iteration works because the transfer and meet functions are monotone (they never undo progress) and the lattice has finite height. Each block's estimate can only move in one direction along the lattice as we iterate; since there are finitely many steps to the top or bottom, the estimates must stop changing — the fixed point. The round-robin solver below is just "recompute every block's equation until nothing changes".

The four canonical analyses

Almost every classical bit-vector analysis is one cell of a 2×2 grid — direction crossed with meet:

Meet = union (∪) — "may"Meet = intersection (∩) — "must"
Forward Reaching definitions — which defs may reach here Available expressions — which exprs are computed on all paths
Backward Live variables — which vars may be used later Very busy (anticipated) expressions — which exprs are used on all later paths

The two union ("may") analyses ask "is there some path…?" and start each interior point at \varnothing. The two intersection ("must") analyses ask "on every path…?" and start each interior point at the full universal set U (the top of the meet). This single grid organises most of Chapter 9.

A generic round-robin solver

Here is the whole framework as one function. Facts are sets of numbers; you pass in the CFG, the gen/kill sets, the meet, the direction and the boundary — and it iterates every block's equation until a whole pass changes nothing. Below we instantiate it as a forward-union analysis over a tiny three-block loop.

type Set = number[]; // a fact = a sorted list of ids const union = (a: Set, b: Set): Set => [...new Set([...a, ...b])].sort((x, y) => x - y); const inter = (a: Set, b: Set): Set => a.filter((x) => b.includes(x)); const minus = (a: Set, k: Set): Set => a.filter((x) => !k.includes(x)); const eq = (a: Set, b: Set): boolean => a.length === b.length && a.every((x, i) => x === b[i]); interface Block { succ: number[]; pred: number[]; gen: Set; kill: Set; } // Generic solver. forward=true uses pred+IN/OUT; meet combines neighbours. function solve(blocks: Block[], forward: boolean, meet: (a: Set, b: Set) => Set, boundary: Set) { const IN: Set[] = blocks.map(() => []); const OUT: Set[] = blocks.map(() => []); const order = forward ? blocks.map((_, i) => i) : blocks.map((_, i) => i).reverse(); let round = 0, changed = true; while (changed) { changed = false; round++; for (const i of order) { const B = blocks[i]; if (forward) { // Meet over predecessors; ENTRY (no preds) takes the boundary value. // [] is the identity for union; for an intersection problem start from the universal set instead. let inB: Set = B.pred.length ? [] : boundary.slice(); for (const p of B.pred) inB = meet(inB, OUT[p]); IN[i] = inB; const newOut = union(B.gen, minus(inB, B.kill)); // gen ∪ (IN − kill) if (!eq(newOut, OUT[i])) { OUT[i] = newOut; changed = true; } } } } return { IN, OUT, round }; } // Three blocks: B0 -> B1 -> B2 -> B1 (a loop). Each defines one definition id. const blocks: Block[] = [ { succ: [1], pred: [], gen: [1], kill: [] }, // B0: d1 { succ: [2], pred: [0, 2], gen: [2], kill: [] }, // B1: d2 { succ: [1], pred: [1], gen: [3], kill: [1] }, // B2: d3, kills d1 ]; const { IN, OUT, round } = solve(blocks, true, union, []); console.log(`converged in ${round} passes`); blocks.forEach((_, i) => console.log(`B${i}: IN={${IN[i].join(",")}} OUT={${OUT[i].join(",")}}`));

Swap union for inter and set the boundary to the universal set and the very same loop computes an available-expressions-style "must" analysis instead. That interchangeability is the whole point of a framework.

The equations can have many solutions — any consistent assignment of IN/OUT sets that satisfies them is technically a fixed point. We want the most precise safe one. Starting every interior point at the top element \top and iterating downward with monotone functions lands on the maximum fixed point: the tightest set of facts that is still consistent with every path. Start too low (an unsafe guess) and you might converge to something that claims more than the program guarantees — a wrong answer that an optimizer would act on. The lattice direction and the initial value are what pin you to the safe, maximally-precise solution rather than merely a solution.

The framework is only as correct as its four knobs. Three classic mistakes: (1) wrong initial value. Initialise a "must" (intersection) analysis to \varnothing instead of the universal set U and the very first meet wipes everything out — you converge instantly to a useless empty answer. The interior initial value must be the meet's identity (\top): U for \cap, \varnothing for \cup. (2) wrong boundary. ENTRY (forward) or EXIT (backward) carries the problem's boundary condition, not the interior default. (3) mismatched direction and meet. A backward problem solved with forward equations gives nonsense. Live variables is backward-union; run it forward, or with intersection, and every downstream optimization built on it is unsound. Fix the four knobs first; only then trust the solver.