Constant Propagation and Folding

Programmers write constants without noticing. A configuration flag set to 1, a loop that starts at 0, an array whose width is #define'd to 8 — the source is full of values that never actually change at run time. Constant propagation is the analysis that discovers, for each variable at each program point, whether it is guaranteed to hold a single known constant; constant folding is the transformation that then evaluates any now-fully-constant expression at compile time. Together they turn x = 3; y = x * 8 + 1; into y = 25; before the program ever runs — and, crucially, they expose more constants downstream, so the two feed each other in a cascade.

The analysis rides on the def–use structure exposed by reaching definitions: to know the value of x where it is used, you follow the definitions that reach that use. If exactly one definition reaches and it assigns a constant, the value is that constant. If two definitions reach with different constants, the value is unknowable — and capturing that "unknowable" precisely is what makes constant propagation a beautiful little exercise in lattice theory.

The constant-propagation lattice

Each variable, at each point, is assigned one of three kinds of value, drawn from a small lattice with three levels:

ElementReads asMeaning
\top (top)"undefined" no definition has been seen yet — optimistically, it could still be any single constant
c (a constant)"the value c" every definition reaching here assigns the same constant c (e.g. 7, -3)
\bot (bottom)"NAC" — Not A Constant the variable can hold different values on different paths; give up on it

The order is \top \sqsupseteq c \sqsupseteq \bot: top is the most optimistic ("might be constant"), bottom the most conservative ("definitely varies"), and the individual constants sit in an antichain in the middle — no constant is above or below another. Notice the picture: there is one \top, then an infinite fan of constants \dots, -1, 0, 1, 2, \dots, then one \bot. The lattice is technically infinitely wide, but its height is only 3 per variable: any single variable can drop at most twice — \top \to c \to \bot — and that finite height is exactly what guarantees the iterative analysis terminates.

The meet rule — where two paths collide

At a control-flow merge, a variable's value is the meet (\wedge) of its values on the incoming edges. Three rules, applied component-wise per variable, define it completely:

\top \wedge v = v, \qquad \bot \wedge v = \bot, \qquad c_1 \wedge c_2 = \begin{cases} c_1 & \text{if } c_1 = c_2,\\[2pt] \bot & \text{if } c_1 \neq c_2. \end{cases}

Read them in plain words. Meeting with \top ("no information yet") leaves the other value untouched — top is the identity. Meeting with \bot ("already varies") stays \bot — bottom is absorbing. And meeting two constants agrees only if they are literally the same constant; two different constants meet to \bot (NAC), because a variable that is 2 on one path and 3 on another is, at their join, simply not a constant.

The transfer function inside a block then just evaluates expressions in this three-valued arithmetic: c_1 + c_2 folds to the constant sum; anything combined with \bot yields \bot; anything with a \top operand stays \top (with the usual short-circuit exceptions, e.g. 0 \times \bot = 0).

Folding, in code

Once the lattice value of every variable is known, folding is a straight walk over the statements, replacing any expression whose operands are all constant with its computed value. The engine below propagates a three-valued environment through a straight-line block and folds as it goes.

// Constant-propagation lattice value: TOP (undefined), a number, or NAC (bottom). type CP = "TOP" | "NAC" | number; // meet of two lattice values (used at merges). function meet(a: CP, b: CP): CP { if (a === "TOP") return b; if (b === "TOP") return a; if (a === "NAC" || b === "NAC") return "NAC"; return a === b ? a : "NAC"; // two DIFFERENT constants -> NAC } // evaluate a op b in three-valued arithmetic. function apply(op: string, a: CP, b: CP): CP { if (a === "NAC" || b === "NAC") return "NAC"; if (a === "TOP" || b === "TOP") return "TOP"; switch (op) { case "+": return a + b; case "-": return a - b; case "*": return a * b; default: return "NAC"; } } type Stmt = { dst: string; a: string | number; op?: string; b?: string | number }; const env = new Map<string, CP>(); const val = (t: string | number): CP => typeof t === "number" ? t : (env.get(t) ?? "TOP"); // A straight-line block. x is a genuine constant; input n is NAC (unknown). env.set("n", "NAC"); const block: Stmt[] = [ { dst: "x", a: 3 }, // x = 3 { dst: "y", a: "x", op: "*", b: 8 }, // y = x * 8 -> 24 { dst: "z", a: "y", op: "+", b: 1 }, // z = y + 1 -> 25 { dst: "w", a: "z", op: "+", b: "n" }, // w = z + n -> NAC (n unknown) ]; for (const s of block) { const result: CP = s.op ? apply(s.op, val(s.a), val(s.b!)) : val(s.a); env.set(s.dst, result); const rhs = s.op ? `${s.a} ${s.op} ${s.b}` : `${s.a}`; const folded = typeof result === "number" ? `${s.dst} = ${result} (folded)` : `${s.dst} = ${rhs} [${result}]`; console.log(folded); }

x, y and z fold to 3, 24, 25; w stays symbolic because it depends on the unknown input n. The cascade is the whole point: folding x is what let y fold, which is what let z fold.

Sparse conditional constant propagation

The classic algorithm has a blind spot: it evaluates every branch, even ones that a constant condition proves can never run. Sparse Conditional Constant Propagation (SCCP, Wegman & Zadeck) fixes this by tracking two things at once — the lattice value of each variable and the reachability of each CFG edge — and letting them reinforce each other:

Because SCCP starts every variable at \top and every edge as unreachable (optimistic on both axes), it is strictly more powerful than doing constant propagation and dead-code elimination in separate passes: if (false) x = ...; never even lowers x off \top, so a constant that a pessimistic pass would have surrendered survives.

The subtlety is loops and merges. Consider i = 0; before a loop whose body does i = i + 1;. At the loop header, i's value is the meet of the entry (0) and the back-edge (i+1). On the first optimistic pass the back-edge carries \top, so the header holds 0; the body then computes 1, feeds it back, and 0 \wedge 1 = \bot. The header correctly settles on \boti is not a compile-time constant, exactly as intuition demands. The lattice's finite height is what stops this bouncing: each variable can only fall \top \to c \to \bot, so the loop's iteration must stop.

First trap: at a merge, 2 \wedge 3 is not some average or the latest value — it is \bot (NAC). A variable that is a different constant on two paths is simply not a constant at their join. Second, subtler trap: constant propagation is monotone but not distributive, which means the iterative solver can be strictly more conservative than the ideal meet-over-all-paths answer. The textbook witness: path A does a=2; b=3;, path B does a=3; b=2;, then both merge and compute c = a + b;. On each individual path c = 5, so the meet-over-all-paths (MOP) solution is c = 5, a constant. But the iterative analysis meets firsta \to \bot, b \to \bot — and only then computes c = \bot + \bot = \bot. So the iterative (MFP) result says NAC where MOP found 5. The analysis is safe (never claims a false constant) but imprecise: \mathrm{MFP} \sqsubseteq \mathrm{MOP}, with the gap opening precisely because + does not distribute over \wedge in this lattice.