Viscosity
Review
Part VI · The compiler middle-end · Day 20

80. Constant folding and propagation

Some arithmetic in a program can be done before it ever runs. Doing it means one sum, one substitution, and a careful respect for JavaScript’s odd corners, and, done optimistically, it can even prove which branches are never taken.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • Fold an instruction whose operands are constants, without changing what the program means.
  • Propagate constants through SSA values, and explain how folding and propagation feed each other.
  • Merge constants at a phi with a three-level lattice.
  • Explain how sparse conditional constant propagation uses unreachable edges to find more constants.

Work you could do in advance

const DAY = 60 * 60 * 24;
let k = 4;
let n = k * 2;
if (n > 5) { /* ... */ }

Every time this runs, the machine multiplies 60 by 60 by 24 and gets 86400. It multiplies 4 by 2 and gets 8, then checks whether 8 is more than 5. The answers never change. A compiler can work them out once, while compiling, and write the answers into the program instead.

Folding

Intuition

Planning a road trip, you add up the distances on paper the night before. You don’t redo the same sum at every petrol station.

In SSA it is one rule: if every operand of an instruction is a known constant, and the operation has no side effects and can’t throw, compute it now and replace the instruction with a const.

%1 = const 60
%2 = binop * %1, %1   →  %2 = const 3600

The catch: the compiler must get exactly the answer the program would have got at run time. In JavaScript that includes all of these:

Try itAnswers a folder must copy exactly
console.log('1' + 2);
console.log(1 / 0);
console.log(0.1 + 0.2);
console.log(Object.is(0 * -1, -0));
console.log(2 ** 53 + 1);

'1' + 2 is a string. Dividing by zero is not an error. 0.1 + 0.2 has the rounding of a 64-bit float (chapter 22). -0 is a real value, and 2 ** 53 + 1 can’t be represented. A folder written in JavaScript gets these right for free by doing the same operation. A folder written in another language must copy JavaScript’s rules exactly, or it changes the program.

Definition

Constant folding replaces an operation whose operands are all constants with its result, computed at compile time. It is only allowed when the operation has no side effects, can’t throw, and gives exactly the result it would give at run time.

Propagation

Folding needs operands that are constants. After %2 = const 3600, any instruction using %2 now has a constant operand. In SSA that needs no checking at all: %2 is defined once, so every use of it is 3600. Replace each use with the constant, and fold whatever that makes foldable. Each fold can enable more folds further down.

%3 = const 24
%4 = binop * %2, %3   →  %4 = const 86400

Without SSA you’d need chapter 79’s reaching definitions first, to be sure no other assignment to the variable could reach the use.

Definition

Constant propagation replaces uses of a value known to be a constant with the constant itself. Together with folding it runs to a fixpoint: propagate, fold what became foldable, and repeat until no new constants appear.

Constants meet at phis

What about %6 = φ(%4, %5)? If both operands are 2, %6 is 2. If one is 2 and the other 3, %6 is not a constant. And inside a loop, an operand may not have been worked out yet. That gives a lattice with three levels:

  • ⊤ (“top”): no information yet;
  • a constant: 2, or 'hi', or true;
  • ⊥ (“bottom”): varies, not a constant.

Merging ⊤ with anything gives the other thing. Merging two equal constants gives that constant. Anything else gives ⊥. Start every value at ⊤ and you have chapter 79’s optimistic analysis again. It is what finds x constant in a loop like this one, which never changes it:

b1:  ; loop header
  %5 = phi %1, %5   ; %1 = const 7

Optimistically, %5 starts at ⊤, so merging gives 7, and 7 is consistent. It is the trivial phi of chapter 78, seen by an analysis. A loop counter φ(0, i + 1) goes to ⊥: 0 merged with 1 varies.

Using the branches too

Plain propagation treats every block as reachable. But if a branch condition folds to true, the else side can never run, and its values should not count. Sparse conditional constant propagation (SCCP) tracks this with a second kind of fact: which CFG edges are executable. It starts optimistic about both. Every value is ⊤ and every block is unreachable until proven otherwise:

Step throughConstants flowing through a CFG
let x = 1;
let y;
if (x > 0) { y = 2; } else { y = 3; }
let z = y * 10;
A diamond-shaped CFG. b0 branches to b1 and b2, which join at b3. Each instruction shows its lattice value. Unreachable blocks are faded and executable edges are highlighted.b0 · unreachable%0 = const 1⊤%2 = const 0⊤%3 = > %0, %2⊤b1 · unreachable%4 = const 2⊤b2 · unreachable%5 = const 3⊤b3 · unreachable%6 = φ(%4, %5)⊤%7 = const 10⊤%8 = * %6, %7⊤⊤ = no information yet · solid edge = executable
SCCP starts doubly optimistic. Every value is ⊤, "no information yet", and every block is assumed unreachable until some executable edge leads to it.
1/7

SCCP keeps two worklists: SSA values whose lattice value changed (to revisit their users), and CFG edges that just became executable (to visit the block they lead to). A phi only merges operands arriving on executable edges. A branch on a constant marks only one edge. Plain propagation would merge 2 with 3 at the phi and say “varies”. SCCP proves z is 20, and that b2 is dead.

LLVM’s sccp pass is this algorithm. Its documentation sums it up as: assume values are constant and blocks dead unless proven otherwise, replace values proven constant, and turn branches proven to go one way into unconditional ones. It also notes that the pass leaves dead code behind for a later DCE pass[1].

Under the hood

V8’s TurboFan has a ConstantFoldingReducer. Its typer works out a type for each node. If the type is a single value (null, undefined, NaN, -0, one particular object, or one plain number) and the node can be removed safely, the reducer replaces it with that constant[2]. Viscosity, by contrast, has no constant folding pass: it emits your own source with a new data layout, and leaves arithmetic alone. But its numeric inference uses the same machinery as SCCP, an optimistic fixpoint over SSA values, on the smaller number/maybe-not lattice.

Your turn

ExerciseFold and propagate

Write fold(insts) for straight-line SSA code. Each instruction has an id and an op: 'const' (with a value), 'param' (unknown), or one of 'add', 'sub', 'mul' with two args, the ids of earlier instructions.

Return a new list, in the same order. Any arithmetic instruction whose args are all known constants, either a 'const' or something you have already folded, becomes { id, op: 'const', value } with the result. Everything else is returned unchanged. Use OPS to compute.

// insts: [{ id, op, args, value }]
// op: 'const' | 'param' | 'add'
//   | 'sub' | 'mul'; args are ids
//   of earlier instructions.
const OPS = {
  add: (a, b) => a + b,
  sub: (a, b) => a - b,
  mul: (a, b) => a * b,
};
function fold(insts) {
  return insts;
}
Extra practiceThe constant lattice

Write meet(a, b) for the three-level constant lattice. TOP means "no information yet", BOT means "varies", and anything else is a constant. Merging TOP with x gives x; BOT with anything gives BOT; two constants give that constant if they are the same value in JavaScript's strictest sense (so 0 and -0 differ), and BOT otherwise.

const TOP = 'top';
const BOT = 'varies';
// a, b: TOP, BOT, or a constant.
function meet(a, b) {
  return a;
}

What you learned

  • Constant folding computes an operation on constants at compile time. It must match run-time semantics exactly, quirks included, and must not fold anything with side effects or that can throw.
  • Constant propagation replaces uses of a constant value with the constant. In SSA every use of a constant value is that constant. Folding and propagation feed each other until a fixpoint.
  • At phis, constants merge in a three-level lattice: ⊤, a constant, ⊥. Starting at ⊤ is what lets loops keep their constants.
  • SCCP is also optimistic about edges. Branches on constants make only one edge executable, and phis ignore the rest.

Next: deleting the code these passes just made useless.

Check your understanding

0/6
  1. 1.

    Which of these may a JavaScript compiler fold to a constant?

  2. 2.

    In SSA, %2 = const 3600. Why can every use of %2 be replaced by 3600 without further checks?

  3. 3.

    In the three-level constant lattice, what is merge(⊤, 5)?

  4. 4.

    x = 1; if (x > 0) y = 2; else y = 3; z = y * 10; What does plain constant propagation (without edge tracking) conclude about z?

  5. 5.

    What makes SCCP find more constants than plain propagation?

  6. 6.

    What does Viscosity share with SCCP?

References

  1. [1] The LLVM Project. LLVM’s Analysis and Transform Passes. LLVM documentation. llvm.org/docs/Passes.html (checked 2026-10-31)
  2. [2] The V8 project authors (2018). constant-folding-reducer.cc: TurboFan’s ConstantFoldingReducer (V8 source code). V8 JavaScript engine, official source repository (github.com/v8/v8), src/compiler. github.com/v8/v8/blob/main/src/compiler/constant-folding-reducer.cc (checked 2026-10-31)