Available Expressions

Programs recompute things. A loop body squares an index, an address calculation re-adds the same base and stride, a bounds check re-evaluates n-1 for the third time this block. Some of that repetition is genuinely necessary; much of it is not. Available-expressions analysis is the compiler's tool for telling the two apart across the whole procedure, and it is the engine behind global common-subexpression elimination (global CSE): if the value of a+b is already sitting in a register when control reaches you, do not compute a+b again — reuse it.

Locally, within one basic block, spotting a redundant a+b is easy — you just read the block top to bottom (that is what the value-numbering DAG does). The hard, interesting question is global: at a point p buried deep in the control-flow graph, with many paths flowing into it, is a+b guaranteed to have been computed already, no matter which path execution actually took? That is a job for the dataflow framework.

What "available" means — precisely

The word doing all the work is every. One path that fails to compute a+b, or that clobbers a after computing it, is enough to make the expression unavailable. This is a "must" property — it must hold on all incoming paths — and that single fact dictates the entire shape of the analysis: the paths are combined with intersection, and the iteration starts optimistic, assuming everything is available until a path proves otherwise. It is a forward analysis, because availability accumulates as control flows forward from the entry.

Local effect: e\text{-}gen and e\text{-}kill

Before we can talk about whole paths, we summarise each basic block B by two sets over the fixed universe U of all candidate expressions in the procedure:

SetMeaning
e\text{-}gen[B] expressions computed in B whose operands are not subsequently redefined within B — the block generates them and they survive to its exit
e\text{-}kill[B] expressions in U that use a variable B redefines — assigning to a kills every expression that mentions a

With those, the transfer function of a block — how it turns the set available on entry into the set available on exit — is the familiar dataflow shape "gen what you make, keep what survives":

\mathrm{OUT}[B] \;=\; e\text{-}gen[B] \;\cup\; \big(\mathrm{IN}[B] \setminus e\text{-}kill[B]\big).

And because availability must hold on all incoming edges, the value on entry is the intersection (the meet, "\wedge") over predecessors:

\mathrm{IN}[B] \;=\; \bigcap_{P \,\in\, \mathrm{pred}(B)} \mathrm{OUT}[P], \qquad \mathrm{IN}[\text{entry}] = \varnothing.

The initialisation that trips everyone up

You solve those equations by iteration. But what do you start from? For a "must" analysis the answer is counter-intuitive and it is the single most common bug: initialise

\mathrm{OUT}[B] = U \ \text{(the FULL universe)} \quad\text{for every block } B \neq \text{entry},\qquad \mathrm{OUT}[\text{entry}] = e\text{-}gen[\text{entry}].

Start by optimistically assuming every expression is available everywhere, then let the intersections chip availability away as the iteration discovers paths that fail. This is exactly the opposite of a "may" analysis such as reaching definitions, which unions and starts from \varnothing. Intersecting into an \varnothing-initialised set would wrongly annihilate everything on the first pass; you must start from the top element of the lattice, and for a must/intersection analysis the top element is the full set U.

A worked example

Track three expressions, U = \{\,a+b,\ a\cdot c,\ b+c\,\}, over this small control-flow graph. Block B_1 computes a+b and a\cdot c; the graph then forks, and on the left arm B_3 redefines c (killing a\cdot c and b+c), while on the right arm B_4 computes b+c. Both arms merge at B_5, which uses a+b and a\cdot c.

The per-block summaries are:

Blockcodee\text{-}gene\text{-}kill
B_1t1 = a+b; t2 = a·c{a+b, a·c}{ }
B_2(branch){ }{ }
B_3c = …{ }{a·c, b+c}
B_4t3 = b+c{b+c}{ }
B_5use a+b, a·c{ }{ }

Now iterate. Every non-entry \mathrm{OUT} begins at the full set U=\{a{+}b, a{\cdot}c, b{+}c\}; one forward sweep already reaches the fixed point:

BlockInit OUTPass 1 · INPass 1 · OUT
B_1{a+b, a·c}{ } (entry){a+b, a·c}
B_2U{a+b, a·c}{a+b, a·c}
B_3U{a+b, a·c}{a+b}
B_4U{a+b, a·c}{a+b, a·c, b+c}
B_5U{a+b}{a+b}

The punchline is \mathrm{IN}[B_5]: the meet \mathrm{OUT}[B_3] \cap \mathrm{OUT}[B_4] = \{a{+}b\} \cap \{a{+}b, a{\cdot}c, b{+}c\} = \{a{+}b\}. So a+b is available at B_5 — its two uses are redundant and CSE can replace them with the saved value. But a\cdot c is not available, precisely because the left arm B_3 redefined c; the intersection dropped it, exactly as the "must on every path" rule demands.

Solving it in code

Represent each set as a bitset — one bit per expression in U — so meet is bitwise AND, union is OR, and difference is AND-NOT. The universal set is the all-ones mask. The solver below runs the exact example above and prints the availability at each block's entry.

// Available expressions via bitsets. Bit i = expression i is available. // Universe U = [a+b, a*c, b+c] -> bits 0,1,2. const NAMES = ["a+b", "a*c", "b+c"]; const U = (1 << NAMES.length) - 1; // all-ones universal set = 0b111 interface Block { name: string; preds: number[]; eGen: number; // bitset eKill: number; // bitset } const b = (...bits: number[]) => bits.reduce((m, i) => m | (1 << i), 0); // B1 entry(0) -> B2(1) -> {B3(2), B4(3)} -> B5(4). const blocks: Block[] = [ { name: "B1", preds: [], eGen: b(0, 1), eKill: b() }, // t1=a+b; t2=a*c { name: "B2", preds: [0], eGen: b(), eKill: b() }, // branch { name: "B3", preds: [1], eGen: b(), eKill: b(1, 2) }, // c = ... kills a*c, b+c { name: "B4", preds: [1], eGen: b(2), eKill: b() }, // t3 = b+c { name: "B5", preds: [2, 3], eGen: b(), eKill: b() }, // uses a+b, a*c ]; const IN = new Array(blocks.length).fill(U); const OUT = blocks.map((_, i) => (i === 0 ? 0 : U)); // MUST-analysis: init to FULL set OUT[0] = blocks[0].eGen; // ...except entry's OUT let changed = true, rounds = 0; while (changed) { changed = false; rounds++; for (let i = 0; i < blocks.length; i++) { const B = blocks[i]; // meet = INTERSECTION over predecessors (empty preds -> entry gets empty set) let inSet = B.preds.length ? U : 0; for (const p of B.preds) inSet &= OUT[p]; IN[i] = inSet; const out = B.eGen | (inSet & ~B.eKill & U); // gen ∪ (IN \ kill) if (out !== OUT[i]) { OUT[i] = out; changed = true; } } } const show = (m: number) => "{" + NAMES.filter((_, i) => m & (1 << i)).join(", ") + "}"; console.log(`converged in ${rounds} passes`); for (let i = 0; i < blocks.length; i++) { console.log(`${blocks[i].name}: available on entry = ${show(IN[i])}`); }

The output confirms the hand-computation: only {a+b} is available entering B5. Change B3's kill to b() and rerun — now a*c survives the intersection and becomes redundant too. That one-line edit is the difference between "the left path clobbers c" and "it doesn't".

The four textbook bit-vector analyses are the same algorithm with two independent switches: direction (forward vs. backward) and meet (union "may" vs. intersection "must"). Available expressions is forward + intersection. Reaching definitions is forward + union. Live variables is backward + union. Very-busy (anticipable) expressions is backward + intersection. Learn the two switches and you have not learned four analyses — you have learned one framework with four settings, which is precisely the point of casting them as a lattice problem. The meet direction alone fixes the initial value: union analyses start empty, intersection analyses start full.

Two mistakes recur, and both come from muscle memory built on union analyses. First, the initialisation: for available expressions you must set every non-entry \mathrm{OUT}[B] to the full universe U, not to \varnothing. Seed with \varnothing and the first intersection wipes out every expression and stays there — you will "prove" nothing is ever available. Second, the meet is intersection (bitwise AND), never union: an expression is available at a merge only if it is available on every incoming edge. And keep the third rule in the front of your mind: a single redefinition of any operand kills the expression — assigning to a removes a+b, a\cdot c, and every other expression that mentions a, no matter how the value of a was computed.