JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Intermediate Representations and SSA Form

Between your source code and the machine code lies a private language the compiler talks to itself — and the moment it rewrites that language so every variable is assigned exactly once, a whole world of optimizations suddenly becomes easy. This is the idea of an intermediate representation, and its quiet superstar, SSA form.

The language the compiler speaks to itself

In the previous guide you walked the whole compiler pipeline: the front end parses your text into a tree and checks it makes sense, the back end emits real instructions, and in between sits the part where the actual cleverness happens. That middle is where we live now. The first thing the middle end does is throw away your source text entirely. It does not optimize C, or Rust, or C++ — those are far too tangled, full of syntax and special cases. Instead it translates your program into its own private language called an intermediate representation, or IR, and from then on it only ever reasons about the IR.

Why bother with a third language that is neither your source nor the machine's instructions? Because a good IR is deliberately simple and regular in a way neither end is. It has no nested expressions and no syntactic sugar; every operation does one small thing to named values. The classic shape is three-address code, where each instruction names at most one operator and three operands — typically two inputs and one output, as in t1 = a + b. A messy source expression like x = a + b * c becomes a flat little sequence, each step holding exactly one operation, each intermediate value given a fresh name.

source:        x = a + b * c;

three-address code:
    t1 = b * c
    t2 = a + t1
    x  = t2

Notice: each line has one operator and a fresh result name.
The nested tree is gone; the order of evaluation is now explicit.
One nested source expression flattened into three-address code. Each instruction is tiny and uniform, which is exactly what makes a pass easy to write: it only ever has to understand one simple shape.

There is a second reason an IR earns its keep, and it is the engineering one. If you support 5 source languages and 4 target chips, going straight from each language to each chip is 20 separate translators. Funnel everything through one shared IR and you write 5 front ends plus 4 back ends — 9 pieces, not 20 — and every optimization you write on the IR helps all 5 languages and all 4 chips at once. That single decision is why the IR sits at the waist of the pipeline, narrow and shared, with the languages fanning in above it and the machines fanning out below.

The annoying obstacle: a name that means different things

Plain three-address code is already much nicer than source, but it still carries one nuisance straight from your original program: a variable can be assigned more than once. Look at a value named x. If you ask "what is x here?", the honest answer is "it depends which assignment most recently ran," and that depends on the control flow — which branch was taken, how many times the loop went round. Every analysis the compiler wants to do has to drag along this question of "which definition of x reaches this use?", and answering it across branches and loops is genuinely fiddly.

Here is a concrete picture of the pain. Suppose x = 1, then an if-branch may do x = 2, and afterwards you read x. There are two definitions of the single name x, and which one reaches the read depends on the branch. Any optimization that wants to, say, propagate a constant into the read first has to prove which definition is live there — and it has to redo that reasoning for every variable, at every use, all over the function. The name x is doing too many jobs, and that overloading is the obstacle.

The trick: assign every name exactly once

The fix is startlingly simple to state and deep in its consequences. Rewrite the IR so that every variable is assigned exactly once. This is static single assignment, almost always called SSA form. Whenever the original program would reassign x, you instead invent a brand-new name: the first becomes x1, the second x2, the third x3, and every use is rewritten to refer to the specific version that defines it. Now a name and its defining instruction are one and the same thing. Ask "what is x2?" and there is exactly one answer, computed in exactly one place — no control-flow detective work required.

The word static matters and is a common point of confusion: it means each name appears on the left of exactly one assignment in the text of the program, not that the value is computed only once when the program runs. A name defined inside a loop body, like i2, has one defining instruction but executes thousands of times — single assignment is a property of the static code, not of the dynamic execution. With that clarified, the payoff is immediate. Because each name has a unique definition, "which definition reaches this use?" stops being a question at all; the use literally names its definition. Optimizations that were tangled webs of reaching-definition analysis collapse into trivial lookups.

Where the branches meet: the phi node

There is one place where the one-name-one-definition rule seems to break, and resolving it is the whole magic of SSA. Go back to the if-branch: x1 = 1 before the branch, x2 = 2 inside the branch, and afterwards you read x. After the merge point, which name should the read use — x1 or x2? It genuinely depends on which path ran, so there is no single answer to bake in. SSA solves this with a special pseudo-instruction placed exactly at the merge, the phi node. It reads something like x3 = phi(x1 from the fall-through edge, x2 from the branch edge): it selects x1 if control arrived from the first predecessor block and x2 if it arrived from the other, and defines one fresh name x3 that every later use refers to.

before SSA:                  after SSA:
    x = 1;                       x1 = 1
    if (cond)                    branch cond -> B_then, B_join
        x = 2;          B_then:  x2 = 2; jump B_join
    use(x);             B_join:  x3 = phi(x1 from entry, x2 from B_then)
                                 use(x3)

The phi node sits at the merge and picks the right incoming
version based on which block control came from.
A phi node placed where two control-flow paths merge. It is not a real machine instruction — it is a bookkeeping marker that says "the value here is x1 or x2 depending on the path," and it is removed before code generation.

Two honest caveats keep phi nodes from feeling like sleight of hand. First, a phi is not a real instruction the CPU can run — no chip has a "pick a value based on which block you came from" opcode. It is purely a label inside the IR. When the compiler leaves SSA on its way to machine code, it removes every phi by inserting ordinary copies at the ends of the predecessor blocks (put x3 = x1 on one incoming edge, x3 = x2 on the other), which the register allocator then mostly makes vanish. Second, phi nodes are why a value that flows back around a loop still obeys single-assignment: the loop header carries a phi merging the initial value with the value from the previous iteration, giving the loop variable one definition even though it visibly changes each time around.

Why optimizers fell in love with SSA

All of this machinery exists to make optimizations both simpler and stronger, and it is worth seeing one work end to end. Take dead code elimination, which deletes computations whose results are never used. In SSA this becomes almost embarrassingly easy: since every name has exactly one definition, a value is dead precisely when its name appears in zero later uses. Sweep the IR, mark every name that is used, and delete the defining instruction of any name not marked — no flow analysis, no reasoning about reassignment, just a count of uses per name. Constant propagation is similarly direct: if x2 is defined as x2 = 7, then every reader of x2 — and there is no ambiguity about which readers those are — can be handed the literal 7.

These optimizations run as small, focused passes, each reading the IR and rewriting it a little, and SSA is what lets them chain cleanly: one pass folds a constant, which makes a branch always-taken, which makes a block unreachable, which lets the next pass delete it — and because the IR stays in SSA throughout, each pass needs no global re-analysis to trust the names it sees. This is exactly why production compilers adopted SSA so widely. The most famous example, LLVM IR, is an SSA-based three-address IR, and the next guide is devoted entirely to reading it and watching its pass pipeline transform real code.