77. SSA form
A variable that is assigned five times is really five values wearing one name. Give each of them its own name, and add one strange instruction where paths meet, and half of the optimizer’s questions answer themselves.
- Rename straight-line code so that every variable is assigned exactly once.
- Explain what a phi node does at a join, and why it has one operand per predecessor.
- Read the phis in real VIR, including a loop phi whose operand comes from the back edge.
- Say what SSA makes cheap for an optimizer, and which variables VIR keeps out of SSA.
Which x is this?
Here are four lines of three-address code:
x = a + 1;
x = x * 2;
y = x + 3;
x = 0;
Suppose an optimizer wants to know something about the x in line 3.
Is it a number? Is it a constant? Is it still needed later? Before it
can answer, it has to work out which assignment to x it is looking
at. Here it is line 2, but finding that out meant scanning backwards
and noticing that line 2 overwrote line 1. With branches and loops,
the scan turns into a search over the whole CFG, and every analysis
would have to repeat it.
The trouble is that one name, x, stands for three different values
over time. So stop doing that.
One name per value
A lab notebook kept in pen. You never cross out a measurement and write a new one over it. You write a new line: sample 1, sample 2, sample 3. Anyone reading a result later can see exactly which sample it came from, because every label was used only once.
Give every assignment a fresh version of the name, and make every use refer to the latest version:
x1 = a0 + 1;
x2 = x1 * 2;
y1 = x2 + 3;
x3 = 0;
Now line 3 says x2, and x2 has exactly one definition. No scanning.
a0 is version 0: a value that came in from outside, such as a
parameter.
A program is in static single assignment form (SSA) when every variable is assigned in exactly one place in the program text. Each use then refers to exactly one definition. “Static” means in the text: an assignment inside a loop still runs many times, but it is written only once, so it still defines only one SSA name.
Where paths meet
Straight-line code is easy, because there is always exactly one latest version. Branches break that:
x = a + 1;
if (x > 5) {
x = x * 2;
} else {
x = 0;
}
y = x + 3;
Rename both branches and you get x2 on the left and x3 on the
right. The block after the if has two predecessors, and they
disagree. No single name can go in y = ? + 3, because the right
answer depends on which way the program went.
A railway junction where two lines merge into one. A wagon arriving on the merged track might have come from either line. The signal box records which line it came in on, so you always know what it carries.
SSA’s answer is a new kind of instruction at the start of the join
block: x4 = φ(x2, x3). Step through the renaming:
A phi node (φ) sits at the start of a block and has one operand per predecessor, in predecessor order. When control enters the block along edge k, the phi takes the value of operand k. Phis only appear where different versions meet. They are not real machine instructions: before code generation, a compiler replaces them with copies at the ends of the predecessor blocks.
All the phis at the top of a block take their values at the same moment, as control crosses the edge. None of them sees another one’s new value. The extra exercise shows why that matters.
function f(a0) {
const x1 = a0 + 1;
let x2, x3, from;
if (x1 > 5) {
x2 = x1 * 2;
from = 'B1';
} else {
x3 = 0;
from = 'B2';
}
// x4 = φ(x2, x3): pick by edge
const x4 = from === 'B1' ? x2 : x3;
return x4 + 3;
}
console.log(f(9), f(1));Phis in real VIR
Viscosity’s IR, VIR, is in SSA form. Here is the if program written
as a function, lowered by the real front end and printed with
printFunc:
function f(c) {
let x;
if (c) { x = 1; } else { x = 2; }
return x * 10;
}
func f(%0):
b0:
%0 = param
%1 = const undefined
condbr %0, b1, b2
b1: ; preds b0
%2 = const 1
br b3
b2: ; preds b0
%3 = const 2
br b3
b3: ; preds b1 b2
%4 = phi %2, %3
%5 = const 10
%6 = binop * %4, %5
return %6
There are no variable names in it at all. In VIR an SSA value is
the instruction that defines it: %4 means “the result of instruction
4”. Because an instruction is written once, the value is assigned once.
%4 = phi %2, %3 lists its operands in the order of the preds
comment: %2 if control came from b1, %3 if from b2.
Loops need phis too, at the loop header, where the entry edge and the
back edge meet. Here is the header of function sum(n), whose body is
let s = 0; for (let i = 0; i < n; i++) s = s + i; return s;:
b1: ; preds b0 b3
%6 = phi %1, %7
%3 = phi %2, %10
%5 = binop < %3, %0
condbr %5, b2, b4
%3 is i: %2 (the 0) on the first entry from b0, and %10
(i + 1) each time round the back edge from b3. The operand %10
is defined later in the listing. That is fine: it is only read
when control arrives from b3, and by then b3 has run.
What SSA buys
With one definition per value, the questions from the start become look-ups:
- Where is this value defined? It is the instruction itself.
- Is it a constant? If
%1 = const 0, every use of%1is 0. No later assignment can change it, because there are none. - Where is it defined, relative to its uses? The definition dominates every ordinary use (chapter 76). A phi operand only has to be available at the end of the matching predecessor.
Not everything fits. A local that a closure captures can be changed
by another function, at a moment the CFG cannot see (chapters 41 and
57). VIR keeps those as bindings, read and written with load and
store. So do locals assigned inside a try, and arrays, which the
layout analyses of Part VII track by binding. Every other simple local
becomes SSA values.
V8’s Maglev compiler is a “somewhat traditional” SSA compiler over a CFG. While it walks Ignition’s bytecode it keeps an SSA value for each interpreter register, and where paths merge it inserts phi nodes “that know which value to pick depending on which path was taken”[1]. VIR does the same job for JavaScript source, and builds its SSA with the algorithm of Braun and colleagues[2]: the next chapter.
Your turn
Write toSSA(code) for straight-line code. Each instruction is [dst, op, a, b], where a and b are variable names (strings) or numbers. Return a new list in which:
- the n-th assignment to a name
vdefinesv+ n (x1,x2, ...); - each name used is replaced by its latest version, or version
0if it has not been assigned yet (a0); - numbers and
opare unchanged.
So [['x', '+', 'a', 1], ['x', '*', 'x', 2]] becomes [['x1', '+', 'a0', 1], ['x2', '*', 'x1', 2]].
// code: [[dst, op, a, b], ...]
// a and b are names or numbers.
function toSSA(code) {
// your code here
}Write enter(phis, env, k): control enters a block along predecessor edge k. phis is the block's list of [dst, operands], and env maps names to current values. Return a new env in which every phi's dst holds the value of its k-th operand. All phis must read the env as it was before any of them ran. Don't change env.
// phis: [[dst, [op0, op1, ...]], ...]
// env: name -> value. k: the edge.
function enter(phis, env, k) {
return env;
}What you learned
- In SSA form every variable is assigned in exactly one place in the text, so every use points at exactly one definition.
- Renaming straight-line code: each assignment makes a new version; each use takes the latest version, read before the assignment on the same line.
- A phi node at the start of a join block picks one operand per incoming edge. All of a block’s phis take their values at once.
- In VIR, a value is its defining instruction (
%4). Loop headers have phis whose second operand arrives on the back edge. - Captured variables stay out of SSA, as bindings with
loadandstore.
Next: how VIR’s builder creates all of this while it lowers, in one pass.
Check your understanding
0/6- 1.
After renaming
x = 1; x = x + 1; y = x;into SSA, what doesyread? - 2.
What does the "static" in static single assignment mean?
- 3.
A join block has three predecessors. How many operands does each of its phis have?
- 4.
In VIR,
b3has; preds b1 b2and starts with%4 = phi %2, %3. Control arrives fromb2. What is%4? - 5.
A loop header holds
%3 = phi %2, %10, but%10is defined further down the listing. Is that a bug? - 6.
Which JavaScript local does VIR keep as a binding, with
loadandstore, instead of SSA values?
References
- [1] 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)
- [2] 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)