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.
- 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
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
vin 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
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:
function sum(n) {let s = 0;for (let i = 0; i < n; i++) {s = s + i;}return s;}
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.
// 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.
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
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;
}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.
readVariablelooks 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.
sealfills 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
mem2reguses 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.
While lowering
x = 2;, what does VIR’s builder do for the assignment itself? - 2.
Why is a loop header unsealed while the builder lowers the loop test?
- 3.
What does
seal(b)do with b’s incomplete phis? - 4.
Which of these phis is trivial?
- 5.
Why does the builder record a new phi as the variable’s value before reading its operands?
- 6.
How does the dominance-frontier approach differ from Braun’s?
References
- [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] The LLVM Project. LLVM’s Analysis and Transform Passes. LLVM documentation. llvm.org/docs/Passes.html (checked 2026-10-31)
- [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] 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)