Ask an optimiser a simple question — "does the value in x here come from this
assignment or that one?" — and in ordinary
"Assigned once" sounds impossible for real code (loops! reassignments!), and the trick that rescues it is
beautiful: whenever the source would reassign a variable, SSA instead invents a fresh version
—
In straight-line code, converting to SSA is pure renaming. Each assignment to a variable bumps its
subscript; each use takes the most recent subscript. Watch a = b + c; b = a - d; a = a * b
become single-assignment:
| original | SSA | note |
|---|---|---|
a = b + c | a1 = b0 + c0 | first def of a → a1 |
b = a - d | b1 = a1 - d0 | uses newest a (a1); new b → b1 |
a = a * b | a2 = a1 * b1 | uses a1 and b1; redef a → a2 |
Now every name on the left is unique, and every name on the right names exactly one definition.
There is no ambiguity to analyse away — the dependency structure is written directly into the subscripts.
Constant propagation, dead-code elimination and value numbering all become nearly trivial on this form,
because "the value of a1" is a single, immutable fact.
Straight-line renaming breaks the moment two paths meet. Consider an if that assigns
x on one branch and differently on the other; after the join, which version is "current"?
Both are, depending on the path taken at run time. SSA reconciles them with a φ-function
placed at the top of the merge block:
Read x flowing in, one fresh name
You could drop a φ for every variable at every join — that is maximal SSA, correct but wildly wasteful. Minimal SSA (Cytron, Ferrante, Rosen, Wegman & Zadeck, 1991) places a φ for a variable only where it is genuinely needed, and the precise criterion is the dominance frontier.
A node
This is the deep pay-off of dominance theory: the abstract "who dominates whom" structure of the CFG tells you exactly where merges of a variable's versions can occur, and hence exactly where a φ must sit — no more, no fewer.
That single property is why SSA underpins essentially every modern optimiser (LLVM, GCC's GIMPLE, HotSpot). Constant propagation becomes a sparse walk of def-use links; dead-code elimination is "a definition nobody uses is dead — delete it"; global value numbering can prove two computations equal by comparing their unique names. Problems that were global data-flow fixpoints collapse into local, name-based reasoning.
Here is the straight-line renamer: keep a per-variable counter, bump it on each definition, and rewrite every use with the current version. We also print the φ that the following merge would need, conceptually.
The output is a1 = b0 + c0, b1 = a1 - d0, a2 = a1 * b1 — each left
side unique — followed by the illustrative a3 = phi(a2, a1). That φ is not something the CPU
executes; it is a piece of bookkeeping that must be resolved before code generation, which is the next
point.
No processor has a φ instruction. Before final code generation the compiler must translate out
of SSA, and the standard method is to realise each φ as ordinary copy instructions
placed on the incoming edges. For x3 = x1 at the end
of x3 = x2 at the end of
Naïve copy insertion is subtly wrong in two famous cases. In the lost-copy problem, a
copy inserted on an edge overwrites a value that is still live along a critical edge; in the
swap problem, two φ-functions at a loop header effectively want to exchange two values
(x3=φ(x1,y1); y3=φ(y1,x1)), and inserting the copies in sequence clobbers one before it is
read. Both are fixed by care about ordering — splitting critical edges, and using a temporary or a
parallel-copy (all φ-copies of a block happen "simultaneously"). Getting out of SSA correctly is as much
a part of the story as getting in.
Nothing mnemonic, charmingly — it is just the next available Greek letter. The SSA paper's authors needed
a symbol for the merge operator and reached for φ ("phi") much as mathematicians reach for
merge or use block-argument notation
(Swift's SIL, MLIR, Cranelift) that avoids the mystique altogether, passing the incoming values as
arguments to the block, which is provably equivalent to φ-functions.
The commonest conceptual error is to imagine the machine "running" x3 = phi(x1, x2). It does
not — a φ has no operational meaning at a single point in time; its "choice" depends on the edge control
arrived on, which is information a straight-line machine has already thrown away. That is precisely why φ
must be lowered to copies on the predecessor edges before code generation. And when you do lower it,
remember the lost-copy and swap problems: inserting the copies carelessly
(wrong order, or onto a critical edge shared by another path) will clobber a still-live value. Split
critical edges and treat a block's φ-copies as a single parallel copy, and both problems disappear.