Constant Folding and Constant Propagation

Consider a compiler translating a routine that computes `int x = 3 + 4; int y = x * 2; return y + 1;`. A naive compiler emits instructions to add 3 and 4 at runtime. It also multiplies by 2 and adds 1 at runtime. This wastes cycles on arithmetic whose answer was known before the program ever ran. Every production compiler instead recognizes that `3 + 4` is always `7`. It also recognizes that `x` is always `7` wherever it flows unmodified. The compiler collapses the entire computation into `return 15;`. This is the joint work of two optimizations that are almost always described together: constant folding, which evaluates constant expressions at compile time, and constant propagation, which tracks where variables are guaranteed to hold a single known value and substitutes that value in. These optimizations matter far beyond toy examples. Real programs are full of symbolic constants, configuration flags, inlined function bodies, and template instantiations. These generate large amounts of arithmetic on values fixed at compile time. Constant propagation is also a gateway optimization. Once a branch condition like `if (DEBUG)` folds to `if (false)`, an entire block of dead code becomes unreachable. That dead code can then be deleted by a companion pass. Compilers such as GCC, LLVM, and javac run these passes repeatedly. They interleave them with dead-code elimination and inlining, because each pass can expose new opportunities for the others. The technique sits squarely inside the general Data-Flow Analysis Framework. Constant propagation is phrased as a forward data-flow problem over an abstract domain. That domain tracks "unknown," "a specific constant," or "definitely not a single constant" for each variable at each program point. This framing is what makes the analysis both mechanical to implement and provable to terminate and be safe. It is not an ad hoc pattern-matching hack. This article gives the precise definitions of folding and propagation. It works through the classic sparse conditional constant propagation (SCCP) algorithm step by step. It proves why the algorithm terminates and why it never miscompiles a program. It traces a full worked example by hand. Finally, it surveys the pitfalls that make this optimization surprisingly easy to get subtly wrong — especially around floating point, overflow, and unreachable code.

Key takeaways
  • Constant folding replaces compile-time-computable expressions (like `3 + 4`) with their evaluated result.
  • Constant propagation determines which variables always hold one fixed value at a program point, then substitutes it.
  • Both are instances of a forward Data-Flow Analysis Framework over a lattice of `⊤` (unknown), constants, and `⊥` (not-constant).
  • The sparse conditional constant propagation (SCCP) algorithm combines constant propagation with reachability analysis, folding branches and pruning dead code simultaneously.
  • Correctness rests on monotonicity of the lattice transfer functions, guaranteeing termination and a safe (conservative) fixed point.
  • The optimizations feed directly into Static Single Assignment (SSA) Form and enable later Loop Optimizations and Loop-Invariant Code Motion.
The core insight

Constant folding evaluates known arithmetic at compile time; constant propagation finds which values are known, and together they form a single data-flow fixed-point computation that also prunes unreachable branches.

The idea

Constant folding is a purely local, syntactic rewrite: whenever a compiler sees an operator applied to operands that are themselves compile-time constants, it replaces the whole expression with the single constant that results from evaluating it. `2 + 3` folds to `5`; `"ab" ++ "cd"` folds to `"abcd"`; `true && false` folds to `false`. Folding requires no analysis of program flow — it is a bottom-up evaluation of an expression tree (or its equivalent in an intermediate representation).

Constant propagation is the harder, non-local problem: determining, for a variable `v` at a program point `p`, whether every execution path reaching `p` assigns `v` the same value, and if so, what that value is. This requires reasoning about control flow, because a variable might be constant along one path and not another, or might become constant only after other variables have been shown constant (which is why the two optimizations are run as a single interleaved fixed-point rather than folding once and propagating once). Formally, propagation is stated over an abstract value lattice per variable: ``` ⊤ (undefined / not yet seen — "could become anything") / | \ .. c .. (a specific known constant c) \ | / ⊥ (definitely not a single constant — "overdefined") ``` `⊤` means "no information reaching this point yet" (optimistic starting assumption), a middle element `c` means "this variable holds exactly the constant `c` here," and `⊥` means "this variable is proven to take more than one possible value, or its value depends on runtime input." The meet operator `∧` combines information arriving from multiple predecessors: ``` ⊤ ∧ x = x (no info yet, so take the other side) c ∧ c = c (same constant from two paths agrees) c₁ ∧ c₂ = ⊥ if c₁ ≠ c₂ (different constants — no longer constant) x ∧ ⊥ = ⊥ (⊥ is absorbing — "poisons" the meet) ``` This lattice has height 3 (three "levels": `⊤`, the constants, `⊥`), which is the single most important fact used later to bound the running time of the algorithm.

How it works

The classical algorithm, due to Wegman and Zadeck (1991), is sparse conditional constant propagation (SCCP). It is usually presented over a program already in Static Single Assignment (SSA) Form, but the ideas apply, less cleanly, to non-SSA code too; we present it over SSA because that is how it is implemented in practice, and foreshadow that SSA itself is covered separately. SCCP maintains two data structures simultaneously, updated to a joint fixed point: 1. `Value[v]` — the current lattice value (`⊤`, a constant, or `⊥`) for each SSA variable `v`. 2. `ExecutableEdge[(b1, b2)]` — a boolean flag for each control-flow edge, marking whether that edge has been proven reachable. The key insight that makes SCCP more powerful than propagation alone: reachability and constancy are mutually dependent. A branch's condition can only be folded once we know the branch is reachable and its operands are constant; conversely, a block is only reachable if some edge into it has been marked executable. By computing both simultaneously, SCCP can prove branches unreachable (e.g. `if (false)`) and then ignore the values flowing along the dead branch when computing meets, which is strictly more precise than plain propagation run after a separate reachability pass. Two worklists drive the fixed-point iteration: a CFG worklist of edges to (re)examine for reachability, and an SSA worklist of variables whose value may have just changed and whose uses must be revisited. ``` PSEUDOCODE — Sparse Conditional Constant Propagation Input: control-flow graph G in SSA form, entry block B0 Output: Value[v] for every SSA variable v, and the set of executable edges for every variable v: Value[v] := TOP for every edge e in G: ExecutableEdge[e] := false CFGWorklist := { edges out of B0 } SSAWorklist := {} mark B0 as reachable while CFGWorklist is not empty or SSAWorklist is not empty: // ---- process a newly-reachable control-flow edge ---- if CFGWorklist is not empty: e = (pred, block) := CFGWorklist.remove() if not ExecutableEdge[e]: ExecutableEdge[e] := true // visit every phi-function in `block` for each phi node "v := phi(v1 from p1, v2 from p2, ...)" in block: VisitPhi(v, phi-args) // if this is the first time `block` becomes reachable, // process every non-phi statement in it once if block was not previously reachable: mark block reachable for each statement s in block (in order): VisitStatement(s) // ---- process a variable whose value changed ---- elif SSAWorklist is not empty: v := SSAWorklist.remove() for each statement s that uses v: if s is a phi node: VisitPhi(s) else if block containing s is reachable: VisitStatement(s) return Value, ExecutableEdge

function VisitStatement(s): if s is an assignment "v := op(a1, ..., ak)": newVal := Evaluate(op, Value[a1], ..., Value[ak]) // constant folding happens HERE if newVal != Value[v]: Value[v] := newVal SSAWorklist.add(v) else if s is a conditional branch "if c goto Lt else Lf": if Value[c] == TOP: // don't yet know — propagate no edges skip else if Value[c] is a constant TRUE: CFGWorklist.add(edge to Lt) // fold: only the true edge is live else if Value[c] is a constant FALSE: CFGWorklist.add(edge to Lf) // fold: only the false edge is live else // BOTTOM — condition truly unknown CFGWorklist.add(edge to Lt) CFGWorklist.add(edge to Lf) else if s is an unconditional jump "goto L": CFGWorklist.add(edge to L)

function VisitPhi(v, args): // args: list of (value, incoming-edge) newVal := TOP for (ai, edge_i) in args: if ExecutableEdge[edge_i]: newVal := Meet(newVal, Value[ai]) // ignore inputs from dead edges! if newVal != Value[v]: Value[v] := newVal SSAWorklist.add(v)

function Evaluate(op, v1, ..., vk): if any vi == BOTTOM: return BOTTOM if any vi == TOP: return TOP // all operands are known constants — this is constant FOLDING return foldConstant(op, v1, ..., vk)

function Meet(x, y): if x == TOP: return y if y == TOP: return x if x == y: return x // same constant return BOTTOM ``` The subtlety that gives SCCP its extra power over "propagation then folding" is the guarded phi rule: `VisitPhi` only takes the meet over incoming edges already proven executable. A phi with an unreachable predecessor never gets "poisoned" to `⊥` by a value coming down a dead path, which is exactly how `if (false) { x = readInput(); } else { x = 5; }` still lets `x` be folded to `5` after the branch merges.

Figure
SCCP lattice, control-flow graph, and worklist state
Lattice (per variable) ⊤ 3 7 c ⊥ height = 3 → each Value[v] rises at most twice CFG being analyzed B0: x := 5 B1: if x>0 B2: y:=x+1 (live) B3: y:=read() (dead) B4: φ(y) ; return y Solid green = ExecutableEdge=true. Dashed red = edge never marked (B3 unreachable). φ(y) meets only over the green edge ⇒ y = 6. Worklists (converged) Value[x] = 5 Value[cond] = TRUE (5>0) Value[y] = 6 (folded x+1) ExecutableEdge(B1→B2)=true ExecutableEdge(B1→B3)=false

Complexity & correctness

Time complexity. Let `V` be the number of SSA variables, `E` the number of edges in the SSA def-use graph (i.e., total operand occurrences), and `C` the number of control-flow edges. Each `Value[v]` can only move downward through the lattice: `⊤ → constant → ⊥`, a chain of length at most 2 transitions, because the lattice has height 3. Every time `Value[v]` changes, `v` is pushed onto `SSAWorklist` at most once per change, and each pop triggers revisiting every use of `v`. So the total number of statement-visits attributable to value changes is bounded by `O(V)` changes `× O(deg(v))` uses each, summing to `O(E)` overall. Separately, each control-flow edge is added to `CFGWorklist` and processed to completion at most once (the `ExecutableEdge` flag is monotonic, set once from false to true and never reset), contributing `O(C)`. Each visit does `O(k)` work for a `k`-ary operation, and in typical IRs `k` is a small constant (2 for binary ops, or bounded by phi in-degree). Overall: ``` Time = O(E + C) (linear in program size / def-use edges) Space = O(V + C) (one lattice cell per variable, one flag per edge) ``` This linearity is the crucial payoff of formulating the analysis over a finite-height lattice with monotonic transfer functions — a direct instance of the general Data-Flow Analysis Framework fixed-point theorem, which guarantees termination in time proportional to (number of flow facts) × (lattice height) × (edges), and here the height is a constant (3), collapsing the bound to linear.

Correctness (safety). The central invariant maintained throughout the algorithm is: > At every point in the iteration, `Value[v]` is either `⊤`/`⊥`, or a constant `c` such that on every control-flow path from the entry to the current definition point that the algorithm has proven executable, `v` provably evaluates to `c`. This is an optimistic analysis: it starts by assuming every variable is `⊤` ("as constant as possible — no evidence against it yet") and only weakens toward `⊥` when concrete contradicting evidence (two different constants, or a genuinely input-dependent operand) arrives. Optimism is essential for soundness in the presence of loops: a phi node inside a loop header initially sees `⊤` from the not-yet-analyzed back edge, allowing the loop-invariant part of the meet to determine an initial constant guess, which is only retracted if a later iteration proves it wrong. A pessimistic analysis (starting everything at `⊥`) would never discover loop-carried constants; an unsound analysis (assuming constants without justification) could miscompile the program. The correctness proof is essentially: 1. Monotonicity: `Evaluate` and `Meet` never move a value up the lattice (`⊥` is absorbing, matching constants stay fixed, mismatches only push down to `⊥`, never back up to `⊤`). This guarantees the iteration is monotone decreasing, hence terminates (finite lattice, finite variable count) and converges to the unique least fixed point reachable from the all-`⊤` start, by the Knaster–Tarski argument underlying the general data-flow framework. 2. Reachability soundness: an edge is marked executable only after its guarding condition has been shown (by folding) to definitely take that branch, or shown unresolved (`⊥`), in which case both successor edges are conservatively marked live. So no reachable path is ever mismarked unreachable. 3. Substitution soundness: replacing a use of `v` by constant `c` is valid precisely because the invariant guarantees `v` equals `c` on every executable path reaching that use — dead (non-executable) paths are excluded from the guarantee by construction and are never actually taken at runtime, so ignoring them is sound, not merely convenient. Because the fixed point is the least one satisfying these monotone constraints, SCCP is also maximally precise for this lattice: it detects every constant that is provably constant relative to a 3-level (per-variable, non-relational) abstraction. It is not complete in an absolute sense — see pitfalls below — because real constancy can depend on relationships between variables that this lattice, by design, cannot express.

Worked example

Consider this snippet (already lightly desugared), with SSA renaming applied at the merge point: ``` B0: a := 10 b := 2 goto B1 B1: c := a b // 10 2 if c > 15 goto B2 else B3 B2: d1 := c - 20 goto B4 B3: d2 := c + 100 goto B4 B4: d := phi(d1 from B2, d2 from B3) e := d / 2 return e ``` Trace of the worklist algorithm (rows show the state after each processing step):

StepActionValue[a]Value[b]Value[c]condLive edges addedValue[d1]Value[d2]Value[d]Value[e]
0init⊤⊤⊤⊤B0→B1 (entry)⊤⊤⊤⊤
1visit B0: `a:=10`10⊤⊤⊤—⊤⊤⊤⊤
2visit B0: `b:=2`102⊤⊤—⊤⊤⊤⊤
3visit B0: `goto B1`102⊤⊤B0→B1 marked; enqueue B1⊤⊤⊤⊤
4visit B1: `c:=ab` folds `102`10220⊤—⊤⊤⊤⊤
5visit B1: branch, `20>15` folds TRUE10220TRUEonly B1→B2 enqueued (B1→B3 not added)⊤⊤⊤⊤
6visit B2: `d1:=c-20` folds `20-20`10220TRUE—0⊤⊤⊤
7visit B2: `goto B4`10220TRUEB2→B4 marked0⊤⊤⊤
8visit φ at B4: only B2→B4 is executable, B3→B4 is not, so meet ignores `d2` entirely10220TRUE—0⊤0⊤
9visit B4: `e:=d/2` folds `0/2`10220TRUE—0⊤00
—worklists empty → fixed point10220TRUEB3, `d2` unreachable0⊤ (never visited)00

Block `B3` is never added to `CFGWorklist`, so it stays unreachable, `d2` is never computed and correctly remains `⊤` (irrelevant, since dead), and the phi at `B4` never mixes in `d2`. The whole function collapses to: ``` return 0; ``` with blocks `B1`'s branch, `B2`'s subtraction, and all of `B3` deleted by a subsequent dead-code-elimination pass that consults the `ExecutableEdge` and `Value` tables SCCP produced. Notice this result — `d2` never merges into `d` — is more precise than running plain constant propagation (which would compute the phi's meet without excluding unreachable predecessors, forcing `d2 = c+100 = 120`, meeting `0 ∧ 120 = ⊥`, and losing the constant entirely). This is the concrete payoff of doing reachability and propagation together.

Common pitfalls & traps

PitfallWhy it happensConsequence
Folding floating-point expressions eagerly`x + 0.1 + 0.1` folded at compile time may use a different rounding path than the runtime IEEE-754 sequence, or the host compiler's arithmetic may differ from the target'sSilent numerical divergence between compiled and "as if interpreted" semantics
Folding integer overflow without matching target semanticsCompile-time evaluator uses infinite-precision or host-word-size arithmetic; target int is 32-bit and wrapsWrong constant baked in; undefined-behavior-dependent bugs masked or introduced
Treating division/modulo by a possibly-zero operand as foldable`c / d` where `d` is `⊥` or provably zero on some pathDivision-by-zero trap must be preserved, not folded away, if it is observable (e.g., signals, traps)
Ignoring exceptional/trapping operationsSome "pure" arithmetic ops can trap (overflow checks, NaN policies)Folding removes an observable trap, changing program behavior
Merging values across genuinely unreachable code incorrectlyNaive propagation (without the reachability flag) still takes the meet over all static predecessors, even provably-dead onesLoses precision (as shown above) or, worse, folds a branch using a value never actually produced
Assuming propagation is a single passValues and reachability are mutually dependent; a single top-down pass misses fixed-point-only discoveries (e.g., loop-carried constants)Under-optimization; some real constants left undetected
Propagating through aliased memory / pointers naively`p = 5; q = 10; use(p)` — if `p` may alias `q`, `p` is not safely `5` anymoreMiscompilation if alias analysis isn't consulted before propagating through loads/stores

Pioneers & history

Origins · 1 minMark N. Wegman and how this was discovered
MN
Mark N. Wegman
b. 1949– · IBM Research; co-inventor of sparse conditional constant propagation and (with Ron Cytron and others) of the efficient algorithm for constructing SSA form

Wegman's work at IBM in the late 1980s, alongside F. Kenneth Zadeck, unified two optimizations — constant propagation and unreachable-code elimination — that had previously been implemented as separate, weaker passes, into a single provably-optimal fixed-point algorithm operating on the newly practical SSA representation.

History
1976
— Kildall formalizes data-flow analysis as a lattice-theoretic fixed-point framework, the mathematical foundation constant propagation is later phrased in.
1979
— Constant propagation appears as a distinguished data-flow problem in early compiler optimization literature, alongside reaching definitions and liveness.
1986
— SSA form is introduced by Cytron, Ferrante, Rosen, Wegman, and Zadeck at IBM, providing the sparse representation SCCP exploits.
1991
— Wegman and Zadeck publish "Constant Propagation with Conditional Branches," formalizing sparse conditional constant propagation (SCCP) and proving its optimality within its lattice.
1990
s–2000s — SCCP and its variants (including interprocedural constant propagation) become standard passes in GCC, LLVM, and JIT compilers for Java and JavaScript.

Where this matters

Every optimizing compiler runs constant folding and propagation as one of its earliest and most frequently repeated passes: LLVM's `InstCombine` and `SCCP` passes, GCC's tree-level constant propagation (`tree-ccp`), and javac/JIT constant folding in the JVM all implement close variants of the algorithm above. It is essential after inlining, since inlined function calls typically supply concrete arguments that only become "constant" once substituted into the callee's body — modern compilers deliberately interleave inlining, constant propagation, and dead-code elimination in a loop precisely because each unlocks the next. Interprocedural constant propagation extends the idea across function boundaries, letting a compiler specialize a function when all call sites pass the same literal argument. In hardware description languages and template metaprogramming (C++ `constexpr`, Rust `const fn`), aggressive constant folding is the mechanism that lets "compile-time computation" be a first-class language feature rather than a compiler curiosity. JIT compilers (V8, HotSpot's C2) run cheap constant-propagation passes at every tier, since discovering that a loaded object's field is monomorphic and constant can eliminate a virtual dispatch entirely. Finally, constant propagation is a prerequisite step that exposes dead branches for Loop Optimizations and Loop-Invariant Code Motion and is one of the canonical example analyses used to teach the Data-Flow Analysis Framework itself, since its lattice is small enough to reason about by hand yet rich enough to demonstrate every subtlety of fixed-point convergence.

:::quiz[In the worked example, why does Value[d2] remain ⊤ rather than becoming a concrete constant?]

  • Because d2's defining block B3 is never proven reachable, so its statement is never visited ✓
  • Because c+100 cannot be folded due to overflow rules
  • Because phi nodes always ignore the second argument
  • Because d2 depends on floating-point arithmetic

Ask the tutor: How would you extend SCCP's lattice to also detect linear relationships between variables, like `y = 2x + 1`?

Is constant folding the same thing as constant propagation?

No. Folding is the local, syntactic act of evaluating an expression whose operands are already known constants, such as rewriting `3 4` to `12`. Propagation is the broader data-flow problem of discovering which* variables are guaranteed to hold a single constant value at a given program point, potentially across many basic blocks and merge points. In practice they are implemented together, because folding an expression can produce a new constant that then needs to be propagated to its uses, and propagating a constant into an expression can make that expression newly foldable. The SCCP algorithm interleaves both inside one worklist loop rather than running them as two separate passes.

Why must SCCP be run on SSA form rather than ordinary code with mutable variables?

SSA form gives each variable exactly one static definition site, so "the value of `x`" is unambiguous and can be tracked with a single lattice cell rather than a whole set of reaching definitions per program point. This is what makes the analysis sparse: work is proportional to the def-use graph rather than to every (variable, program-point) pair, which is what plain non-SSA constant propagation must track. Without SSA, you still can do constant propagation (it predates SSA historically), but it is a denser, less efficient data-flow problem and cannot as cleanly separate "which value" from "which control paths are live," which is the key trick behind SCCP's extra precision.

Can constant propagation ever make a program run incorrectly?

Yes, if implemented carelessly, most commonly in three ways: folding arithmetic using the compiler host's number semantics instead of the target's exact width/rounding/overflow behavior; propagating through memory (pointers, array elements, object fields) without verifying no aliasing write could have changed the value; and eliminating an operation whose side effect (a trap, a signal, an observable exception) was semantically required even though its numeric result was foldable. A correct implementation restricts folding to pure, side-effect-free, target-faithful evaluation, and consults alias analysis before propagating through any memory operation.

How does SCCP handle a variable that is constant along one branch but not the other?

The meet operator combines the two incoming values at the merge point (usually a phi node): if both branches are proven reachable and disagree on the constant, the result is `⊥` (not constant), and no substitution happens there — this is the correct, conservative answer, since at runtime the variable really could be either value depending on which branch executed. If one of the two branches is proven unreachable by the same analysis (via the `ExecutableEdge` flags), its value is excluded from the meet entirely, which is precisely how SCCP recovers extra precision beyond naive propagation, as shown in the worked example where the dead branch's value never poisons the phi.

Does constant propagation subsume dead-code elimination?

Not fully, but it directly enables it. SCCP's byproduct — the set of edges never marked executable, and the blocks unreachable through them — is exactly the input a dead-code (or more precisely, unreachable-code) elimination pass needs to physically delete those blocks and instructions. Separately, once a variable's uses are all folded away, its own defining instruction may become dead even in reachable code (an assignment computed but never used), which is the job of a related pass, plain dead-code elimination based on liveness, not constant propagation itself. The two passes are complementary and are typically run in the same optimization pipeline, iterated to a joint fixed point.

Is interprocedural constant propagation different from the intraprocedural version described here?

Interprocedural constant propagation extends the same lattice idea across function call boundaries: instead of assuming a function's parameters could be anything (`⊥`), the compiler examines all call sites and takes the meet of the actual arguments supplied, potentially discovering that every caller passes the same literal value. This requires building a call graph and is generally more expensive and less precise in the presence of indirect calls, function pointers, or virtual dispatch, since the full set of call sites may not be staticaly known. It is the same underlying lattice and meet operation, just applied over a richer, whole-program flow graph rather than a single function's CFG.

Why can't constant propagation detect that `y = x - x` is always zero, even if x is unknown?

Because the standard lattice described here is non-relational: it tracks one independent lattice value per variable and has no way to represent a relationship between two variables (like "these two operands are always equal, whatever they equal"). Detecting `x - x = 0` regardless of `x`'s actual value requires either purely syntactic algebraic simplification (a separate, simpler rewrite rule independent of data-flow) or a more expensive relational abstract-interpretation domain that tracks equalities between variables. This is a genuine, honest limitation of standard constant propagation, not a bug — more precision costs strictly more analysis time and complexity.

Before this

Where this leads