Live-Variable Analysis

A variable is live at a program point if the value it currently holds might be used later — that is, if some path forward from here reads the variable before overwriting it. If no such path exists, the variable is dead, and whatever we just stored in it was a waste. Liveness is the mirror image of reaching definitions: where reaching definitions look back to ask "where did this value come from?", liveness looks forward to ask "will this value ever be needed?". It is the second canonical instance of the dataflow framework, and its answers drive two of the most important back-end tasks: dead-code elimination and register allocation.

Because the question is about the future, information flows backward through the flow graph, against the direction of the edges. And because "used on some path" is enough to keep a variable alive, the meet is union — liveness is a "may" analysis.

def and use, per block

Each block is summarised by two sets of variables (not definitions this time):

The dataflow equations are the backward mirror of reaching definitions — IN/OUT and pred/succ swap places:

\text{IN}[B] \;=\; \text{use}_B \,\cup\, \big(\text{OUT}[B] - \text{def}_B\big), \qquad \text{OUT}[B] \;=\; \bigcup_{S \,\in\, \text{succ}(B)} \text{IN}[S].

The boundary condition sits at the exit: \text{OUT}[\text{EXIT}] = \varnothing — nothing is live after the program ends (barring globals or return values, which are modelled as an artificial use at EXIT). Every interior set starts at \varnothing and grows to a fixed point.

A worked flow graph, read backward

Take a small loop over variables a, b, c. Blocks and their def/use sets: B_1 a=0, B_2 b=a+1, B_3 c=c+b, B_4 a=b*2; if a<9, B_5 return c, with a back-edge B_4 \to B_2. Reveal the live-OUT set that each block finally carries — reading from the exit upward:

Follow the logic at B_4. Its successors are B_5 (which needs c) and, round the back-edge, B_2 (which needs a — the loop counter — and c). So the value of a that B_4 computes is live out of B_4 only because of the back-edge. On the first backward pass we do not yet know that a is live at B_2's entry, so a is missing from \text{OUT}[B_4]; a second pass adds it. That is the loop forcing iteration, exactly as before.

Iterating to a fixed point

Visiting blocks in reverse order B_5, B_4, B_3, B_2, B_1, here are the live-OUT sets as they settle:

Block (use / def)OUT after pass 1OUT after pass 2Final
B_1 — def \{a\}\{a,c\}\{a,c\}\{a,c\}
B_2 — use \{a\}, def \{b\}\{b,c\}\{b,c\}\{b,c\}
B_3 — use \{b,c\}, def \{c\}\{b,c\}\{b,c\}\{b,c\}
B_4 — use \{b\}, def \{a\}\{c\}\{a,c\}\{a,c\}
B_5 — use \{c\}\varnothing\varnothing\varnothing

Only \text{OUT}[B_4] changes between the passes — gaining a once the back-edge reveals that a is live at B_2's entry. A third pass changes nothing, so we have converged.

A backward worklist solver

Liveness is another bit-vector problem, so variables become bits and the set algebra becomes bitwise operations — but now the iteration runs backward: OUT is the union of successors' IN.

// Variables a, b, c map to bits 0, 1, 2. const A = 1, B = 2, C = 4; const fmt = (m: number): string => { const out: string[] = []; if (m & A) out.push("a"); if (m & B) out.push("b"); if (m & C) out.push("c"); return "{" + out.join(",") + "}"; }; interface Block { succ: number[]; use: number; def: number; } // B1 a=0; B2 b=a+1; B3 c=c+b; B4 a=b*2, if a<9 -> B2; B5 return c. const blocks: Block[] = [ { succ: [1], use: 0, def: A }, // B1 { succ: [2], use: A, def: B }, // B2 { succ: [3], use: B | C, def: C }, // B3 { succ: [1, 4], use: B, def: A }, // B4 (back-edge to B2) { succ: [], use: C, def: 0 }, // B5 ]; const IN = blocks.map(() => 0); const OUT = blocks.map(() => 0); const order = blocks.map((_, i) => i).reverse(); // backward: visit exits first let passes = 0, changed = true; while (changed) { changed = false; passes++; for (const i of order) { let outB = 0; for (const s of blocks[i].succ) outB |= IN[s]; // OUT = union of successors' IN OUT[i] = outB; const inB = blocks[i].use | (outB & ~blocks[i].def); // IN = use ∪ (OUT − def) if (inB !== IN[i]) { IN[i] = inB; changed = true; } } } console.log(`fixed point after ${passes} passes`); blocks.forEach((_, i) => console.log(`B${i + 1}: IN=${fmt(IN[i])} OUT=${fmt(OUT[i])}`));

The output matches the table: a appears in OUT[B4] only after the loop is taken into account, and c is live almost everywhere because it is read at the very end and threaded through the loop. Flip OUT = ∪ succ IN back to IN = ∪ pred OUT and you would be computing reaching definitions — the two analyses are the same engine run in opposite directions.

What liveness is for

Model each variable as a node. Draw an edge between two variables whenever they are simultaneously live at some point — this is the interference graph, and liveness analysis is exactly what tells you where two variables overlap. Now assign each variable a physical register such that no two adjacent nodes get the same one: that is a graph colouring with k colours, where k is the number of available registers. If the graph is k-colourable, everything fits in registers; if not, some variable must be spilled to memory. Chaitin's classic allocator does exactly this, using the heuristic that any node with fewer than k neighbours can always be coloured, so it is safe to remove and colour last. Without precise liveness the interference graph would be far too dense, and you would spill variables that never actually conflicted.

Two traps sink students here. First, direction. Liveness flows backward: you begin at EXIT with \text{OUT}[\text{EXIT}] = \varnothing and propagate upstream, with \text{OUT}[B] built from successors' IN. Run it forward — or set the boundary at ENTRY — and every result is wrong. Second, "may", not "must". A variable is live if it is used on some future path, so the meet is union, not intersection. If x is read on the then branch of an if but not the else, it is still live before the branch — it might be needed. Using intersection would declare it dead on the merged path and let dead-code elimination delete a store the program actually depends on. Liveness deliberately over-approximates: keeping a variable that turns out unused merely wastes a register, whereas dropping a live one is a miscompile.