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

78. Building SSA

You don’t need to finish the CFG before you can put it into SSA. Ask each block “what is x here?”, let it ask its predecessors, and promise to fill in the answer later where a loop isn’t finished yet.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Trace readVariable through a block’s own definitions and then its predecessors.
  • Explain incomplete phis in unsealed blocks, and what sealing a block does.
  • Recognise a trivial phi and say how VIR removes it, including in cascades.
  • Contrast on-the-fly construction with the dominance-frontier approach.

SSA without a second pass

Last chapter you renamed a program into SSA by hand, looking at the whole CFG at once. A compiler that works that way needs the finished CFG first, in some non-SSA form, and then a second pass to rename everything and place phis.

VIR’s builder doesn’t do that. Like chapter 48’s single-pass compiler, it emits instructions as it walks the syntax tree, and the IR comes out in SSA form directly. There is no intermediate version with mutable variables. The trick is to treat “what is the value of x here?” as a question a block can answer, or pass on.

Each block keeps a notepad

Intuition

Each room in a building has a whiteboard. When you set the thermostat in a room, you write the new setting on that room’s board. To find the setting in a room with a blank board, you ask in the room you came from. If people arrive from two rooms that give different answers, the room needs a rule: “it depends on which door you came through”.

Inside one block, SSA is just a table. Writing x records which value it now holds; reading looks it up. From builder.ts:

writeVariable(v, block, value) {
  let m = this.defs.get(block);
  if (!m) this.defs.set(block,
    (m = new Map()));
  m.set(v, value);
}
readVariable(v, block) {
  const local =
    this.defs.get(block)?.get(v);
  if (local !== undefined) return local;
  return this.readVariableRecursive(
    v, block);
}

v is any key the front end chooses for a variable, and defs maps a block to its table. An assignment never emits an instruction. It only writes into the table. That’s why x = 2 shows up in VIR as nothing more than a const 2.

Asking the predecessors

When a block has no entry for v, readVariableRecursive asks the blocks that lead to it. Simplified:

if (!this.sealed.has(block)) {
  val = incompletePhi(v, block);
} else if (b.preds.length === 1) {
  val = this.readVariable(
    v, b.preds[0]);
} else if (b.preds.length === 0) {
  val = undefinedConst();
} else {
  const phi = this.newPhi(block);
  this.writeVariable(v, block, phi.id);
  val = this.addPhiOperands(v, phi);
}
this.writeVariable(v, block, val);
  • One predecessor: the answer is whatever it is there.
  • No predecessors: the variable was read before any write. It’s undefined.
  • Several: make a phi, record it first, then read v in every predecessor to get its operands. Recording it first matters in loops: if the search comes back round to this block, it finds the phi and stops, instead of searching forever.

The last line caches the answer, so the next read in this block is a plain look-up.

Loops: promise now, pay later

Intuition

A wedding planner is seating guests while invitations are still going out. For a table whose guests aren’t all confirmed, she puts a card saying “reserved” and a note to finish it once the last reply arrives.

A loop header is entered from before the loop and from the end of the body. When the builder lowers the loop test, it hasn’t lowered the body yet, so the back edge doesn’t exist. The header’s list of predecessors is incomplete.

Braun and colleagues call a block sealed once all its predecessors are known[1]. Reading a variable in an unsealed block creates an empty, incomplete phi and remembers it. Sealing the block fills in every incomplete phi:

seal(block) {
  const inc = this.incomplete.get(block);
  if (inc) {
    for (const [v, phi] of inc)
      this.addPhiOperands(v, phi);
    this.incomplete.delete(block);
  }
  this.sealed.add(block);
}

The front end decides when. For a loop, lower.ts seals the header right after it emits the back edge:

fb.br(head.id); // the back edge
fb.seal(head.id);

Step through the real sum function. A dashed block is unsealed:

Step throughBuilding SSA while lowering
function sum(n) {
let s = 0;
for (let i = 0; i < n; i++) {
s = s + i;
}
return s;
}
Five blocks of the sum loop: b0 entry, b1 loop header, b2 body, b3 increment, and b4 exit to the right. Each shows its instructions and its table of current definitions. A dashed border means the block is not sealed yet.b0%0 = param%1 = const 0%2 = const 0defs: n=%0 s=%1 i=%2b1 · unsealedb2 · unsealedb3 · unsealedb4 · unsealedback edge
b0, the entry, is sealed from the start: it has no predecessors to wait for. Each assignment just records "in b0, s is %1" in b0’s own table of definitions. Then b0 jumps to the loop header b1.
1/10
Definition

On-the-fly SSA construction (Braun et al.) builds SSA while the IR is generated. Each block records the current value of each variable. Reading a variable a block doesn’t define asks its predecessors, creating a phi where several meet. In an unsealed block, one whose predecessors aren’t all known yet, the phi is left incomplete until the block is sealed. Phis that turn out to choose only one value are removed.

Trivial phis

Look again at step 8. The header asked “what is n?” and got a phi, because it couldn’t know yet that the loop never changes n. Once sealed, the phi was %4 = φ(%0, %4). Whichever edge you take, it is %0. A phi whose operands are all one value, or the phi itself, is trivial:

let same = null;
for (const op of phi.args) {
  if (op === same || op === phi.id)
    continue;
  if (same !== null) return phi.id;
  same = op; // first other value
}
// replace every use of phi with same,
// then retry phis that used this phi

tryRemoveTrivialPhi rewires every use of the phi (including branch conditions and returns, and the definition tables) to same, and deletes the phi. Removing one phi can make another phi trivial, so it retries every phi that used it.

You can see the removals in real VIR. The ids jump: %4 is missing from sum, and here %5 is missing:

function f(c) {
  let x = 1; let y = 5;
  if (c) { x = 2; }
  return x + y;
}
  b3:  ; preds b1 b2
    %4 = phi %3, %1
    %6 = binop + %4, %2
    return %6

Reading y at the join made %5 = φ(%2, %2). Both edges agree, so it was replaced by %2. In the module’s list of values, instruction 5 is left behind as a dead entry noted removed-trivial-phi.

Try itIs this phi trivial?
// Returns the one value the phi always
// means, or null if it really merges.
function trivial(id, args) {
  let same = null;
  for (const op of args) {
    if (op === same || op === id) {
      continue;
    }
    if (same !== null) return null;
    same = op;
  }
  return same;
}
const t = trivial;
console.log(t('%5', ['%2', '%2']));
console.log(t('%4', ['%0', '%4']));
console.log(t('%3', ['%2', '%10']));

The other way: dominance frontiers

The classic algorithm, from Cytron and colleagues in 1991[4], works on a finished CFG. For a definition of x in block B, follow the dominator tree (chapter 76): the blocks where B’s dominance stops, the first ones B does not strictly dominate, are its dominance frontier. Those are exactly the joins where a path from B meets a path that avoids it, so that’s where x needs phis. Each new phi is itself a definition of x, so its block’s frontier gets phis too, until no more are added. A second walk over the dominator tree then renames every use.

LLVM’s mem2reg pass does this. It places phis “using dominator frontiers”, then rewrites loads and stores in a depth-first walk[2]. Braun’s method needs no dominator tree and works while the IR is still being made, which suits a front end that lowers straight from a syntax tree.

Under the hood

V8’s Maglev meets the loop problem too, while building SSA from Ignition bytecode in one forward pass. It answers it differently: a prepass finds which variables are assigned inside each loop, so Maglev can create those loop phis before it processes the body, and fill in the back-edge input at the end[3]. VIR’s builder needs no prepass. It may create a few phis it doesn’t need, and then deletes them as trivial.

Your turn

ExerciseRemove trivial phis

Write removeTrivial(phis). phis maps each phi's id to its list of operands; an operand is either another phi's id or some other value (any string). A phi is trivial when, apart from itself, its operands are all one value. Replace trivial phis by that value, repeatedly, until no phi is trivial. A phi whose only operand is itself becomes 'undef'.

Return an object giving, for every phi, what it finally stands for: a non-phi value, 'undef', or its own id if it really merges two values. Operands must be looked up through earlier replacements.

// phis: { id: [operand, ...] }
// Return { id: replacement } for
// every phi (its own id if it stays).
function removeTrivial(phis) {
  const out = {};
  for (const p in phis) out[p] = p;
  return out;
}
Extra practicereadVariable on a finished CFG

Write readVar(v, b, preds, defs) for an acyclic CFG whose blocks are all sealed. If block b defines v in defs[b], return that value. Otherwise, with no predecessors, return 'undef'; with one, ask it; with several, ask each one in order. If they all give the same answer, return it (no phi needed); if not, return the string 'phi(' + answers.join(',') + ')'.

// preds: { block: [pred, ...] }
// defs: { block: { name: value } }
function readVar(v, b, preds, defs) {
  return defs[b][v];
}

What you learned

  • On-the-fly SSA construction keeps a table of definitions per block. Assignments only write into it.
  • readVariable looks locally, then asks predecessors: one predecessor passes the question on; several make a phi, recorded first so loops terminate.
  • An unsealed block (a loop header, before its back edge) gets incomplete phis. seal fills them in.
  • A trivial phi chooses only one value (or itself). It is replaced, and phis that used it are retried.
  • The dominance-frontier algorithm places phis on a finished CFG instead; LLVM’s mem2reg uses it.

Next: what you can compute once a program is in SSA, starting with facts that flow round loops.

Check your understanding

0/6
  1. 1.

    While lowering x = 2;, what does VIR’s builder do for the assignment itself?

  2. 2.

    Why is a loop header unsealed while the builder lowers the loop test?

  3. 3.

    What does seal(b) do with b’s incomplete phis?

  4. 4.

    Which of these phis is trivial?

  5. 5.

    Why does the builder record a new phi as the variable’s value before reading its operands?

  6. 6.

    How does the dominance-frontier approach differ from Braun’s?

References

  1. [1] Matthias Braun, Sebastian Buchwald, Sebastian Hack, Roland Leißa, Christoph Mallon, Andreas Zwinkau (2013). Simple and Efficient Construction of Static Single Assignment Form. Compiler Construction (CC 2013), LNCS 7791, Springer, pp. 102–122. doi:10.1007/978-3-642-37051-9_6 (checked 2026-10-31)
  2. [2] The LLVM Project. LLVM’s Analysis and Transform Passes. LLVM documentation. llvm.org/docs/Passes.html (checked 2026-10-31)
  3. [3] Toon Verwaest, Leszek Swirski, Victor Gomes, Olivier Flückiger, Darius Mercadier, Camillo Bruni (2023). Maglev - V8’s Fastest Optimizing JIT. V8 blog (v8.dev), 5 December 2023. v8.dev/blog/maglev (checked 2026-10-31)
  4. [4] Ron Cytron, Jeanne Ferrante, Barry K. Rosen, Mark N. Wegman, F. Kenneth Zadeck (1991). Efficiently Computing Static Single Assignment Form and the Control Dependence Graph. ACM Transactions on Programming Languages and Systems (TOPLAS), vol. 13, no. 4, October 1991, pp. 451–490. bernsteinbear.com/assets/img/cytron-ssa.pdf (checked 2026-11-01)