AnyLearn
All lessons
Programmingadvanced

IR, Optimization, and Code Generation

The typed AST is high-level — too high for a CPU. Learn why compilers lower to an intermediate representation first, what SSA form buys you, how classic optimizations (constant folding, dead-code elimination, CSE) transform IR, and how instruction selection and register allocation finally produce machine code.

Not signed in — your progress and quiz score won't be saved.
Lesson progress1 / 9

Why a compiler needs an IR

Going straight from AST to machine code sounds attractive but is a dead end. An AST is too high-level: it has no notion of registers, instruction latency, or memory layout. Raw assembly is too low-level: optimizations written against x86 opcodes have to be rewritten for ARM.

An intermediate representation (IR) sits in the middle. It's:

  • Low enough that optimizations are machine-agnostic (no registers yet, but no tree structure either).
  • High enough that analysis is still easy (explicit data flow, no aliasing ambiguity in SSA).
  • Retargetable: write one set of optimizations, compile to any target by swapping the back-end.

LLVM IR is the canonical example: one optimizer, dozens of targets (x86, ARM, WASM, RISC-V). GCC uses GIMPLE then RTL. The IR phase is what makes the M×NM \times N compiler problem into M+NM + N.

Full lesson text

All 9 steps on one page — for reading, reference, and search.

Show

1. Why a compiler needs an IR

Going straight from AST to machine code sounds attractive but is a dead end. An AST is too high-level: it has no notion of registers, instruction latency, or memory layout. Raw assembly is too low-level: optimizations written against x86 opcodes have to be rewritten for ARM.

An intermediate representation (IR) sits in the middle. It's:

  • Low enough that optimizations are machine-agnostic (no registers yet, but no tree structure either).
  • High enough that analysis is still easy (explicit data flow, no aliasing ambiguity in SSA).
  • Retargetable: write one set of optimizations, compile to any target by swapping the back-end.

LLVM IR is the canonical example: one optimizer, dozens of targets (x86, ARM, WASM, RISC-V). GCC uses GIMPLE then RTL. The IR phase is what makes the M×NM \times N compiler problem into M+NM + N.

2. Three-address code

The simplest IR style is three-address code (TAC): each instruction has at most two operands and one result. Every complex expression is broken into a sequence of instructions with temporary variables.

For z = (a + b) * (a + b) - c:

t1 = a + b
t2 = a + b
t3 = t1 * t2
t4 = t3 - c
z  = t4

Properties: each instruction is trivial to analyze (one operation, explicit operands). Temporaries (t1, t2, ...) are unlimited — the register allocator sorts out physical registers later. Notice t1 and t2 compute the same thing — common subexpression elimination (CSE) will spot this. TAC makes the redundancy obvious in a way the AST does not.

3. SSA form: the optimizer's superpower

Static Single Assignment (SSA) adds one rule: every variable is assigned exactly once. Reuse a variable? Give it a new version.

; Plain TAC                   ; SSA form
x = 1                         x_1 = 1
x = x + 1      -->            x_2 = x_1 + 1
y = x * 2                     y_1 = x_2 * 2

Where control flow merges (after an if/else), SSA inserts a ϕ\phi (phi) node that picks the right version:

if (cond)  x_2 = 5
else       x_3 = 7
           x_4 = phi(x_2, x_3)   ; x_4 is whichever ran

SSA makes use-def chains trivial: the single definition of x_2 is right there — no data-flow analysis needed to find it. This unlocks fast implementations of constant propagation, dead-code elimination, and dozens of other passes. LLVM, GCC (GIMPLE), and Cranelift all use SSA internally.

4. Classic optimizations on IR

OptimizationWhat it doesExample
Constant foldingEvaluate constant expressions at compile timet1 = 2 * 3t1 = 6
Constant propagationReplace a variable with its known-constant valuex = 6; y = x + 1y = 7
Dead-code elimination (DCE)Remove instructions whose result is never usedt2 = a + b with no uses → deleted
Common subexpression elimination (CSE)Reuse a value computed earlierReplace second a + b with t1
Loop-invariant code motion (LICM)Hoist loop-invariant computations out of the loopMove n * sizeof(int) before the loop
InliningReplace a call with the callee's bodyEliminates call overhead, enables further opts

These passes are typically run to a fixed point (repeat until the IR stops changing). SSA makes many of them O(n)O(n) where non-SSA implementations are O(n2)O(n^2) or require iterated dataflow.

5. Before and after: constant folding + DCE

A concrete before/after to make the savings tangible:

; Before optimization
t1 = 2
t2 = 3
t3 = t1 * t2      ; 2 * 3
t4 = t3 + x
t5 = t4 * 0       ; anything * 0 = 0
return t5

; After constant folding + algebraic simplification + DCE
return 0

Steps taken:

  1. Constant fold t1 * t26, then t3 + x stays (x not constant).
  2. Algebraic identity _ * 0 = 0 replaces t4 * 0 with 0.
  3. DCE removes t1, t2, t3, t4, t5 — none of their values are used in the final return 0.

The entire body collapses to a single constant. This is why -O2 code is often unrecognizable to a human inspecting the assembly — aggressive folding, inlining, and DCE can eliminate whole functions.

6. Compiler back-end pipeline

From typed AST to machine code through the IR pipeline:

flowchart LR
  A["Typed AST"]
  B["IR lowering (TAC / SSA)"]
  C["Optimization passes (fold, DCE, CSE, LICM)"]
  D["Instruction selection"]
  E["Register allocation (graph coloring)"]
  F["Assembly / machine code"]
  A --> B
  B --> C
  C --> D
  D --> E
  E --> F

7. Instruction selection

SSA IR instructions (add, mul, load, store) don't map 1:1 to ISA instructions. Instruction selection tiles the IR with patterns that map to real instructions, minimizing the tile count (cost).

A classic technique is tree pattern matching (IBURG, LLVM's SelectionDAG): the IR is turned into a tree, and a dynamic-programming pass finds the minimum-cost tiling of that tree into ISA instruction patterns.

For example, on x86-64:

  • add r0, r1 can implement TAC t = a + b directly.
  • lea rax, [rcx + rcx*2] can implement t = x + x*2 (i.e., 3*x) in one instruction — a multiply-by-constant optimization invisible at the IR level.
  • Vector instructions (vmulps) can replace four scalar mul operations if the auto-vectorizer recognizes the pattern.

Instruction selection is where ISA quirks (addressing modes, fused multiply-add, SIMD) are exploited.

8. Register allocation: graph coloring

SSA uses unlimited virtual registers; a real CPU has ~16 (x86-64) or ~32 (ARM64). Register allocation assigns virtual registers to physical ones, inserting spill code (load/store to the stack) when demand exceeds supply.

The standard algorithm: graph coloring.

  1. Build an interference graph: nodes are virtual registers, edges connect registers that are live simultaneously.
  2. Try to k-color the graph (kk = number of physical registers). Two interfering registers can't share a color (= physical reg).
  3. If a node can't be colored, spill it: assign it a stack slot, insert loads before each use and stores after each def, and retry.

Coloring is NP-complete in general, but the Chaitin-Briggs heuristic works well in practice. LLVM uses a different approach — linear scan allocation — for faster compile times at the cost of some code quality. Both are crucial: bad allocation means excessive stack traffic, which kills performance on memory-bound code.

9. Linking, relocations, and the final binary

Code generation emits object files (.o), not the final executable. Each object file contains:

  • Text section: machine instructions, with placeholder addresses for external symbols.
  • Data/BSS sections: initialized and zero-initialized globals.
  • Relocation records: instructions to the linker: "patch address at offset 0x42 with the address of printf".
  • Symbol table: exported names and their offsets within this file.

The linker resolves relocations across all object files, assigns final virtual addresses, and produces the executable. Static linking bakes everything in; dynamic linking leaves some relocations unresolved until the loader runs.

For JIT compilers (JVM HotSpot, V8, LLVM MCJIT), all of this happens at runtime — IR lowering, instruction selection, register allocation, and linking occur while the user waits. The trade-off: faster startup than AOT, but shorter optimization budgets per function.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Why do compilers introduce an IR rather than generating machine code directly from the AST?
    • Machine code is too slow to generate; the IR is an execution format that runs on a virtual machine.
    • The IR decouples machine-agnostic optimizations from target-specific code generation, enabling one optimizer for many back-ends.
    • The AST is too small to contain all the information the CPU needs about register sizes.
    • Regulators require an IR for safety-critical software; it is not a technical necessity.
  2. What is the purpose of a phi (φ) node in SSA form?
    • It marks a virtual register that must be spilled to the stack.
    • It selects among multiple versions of a variable at a control-flow join point, maintaining the single-assignment property.
    • It records the type of an expression for the type checker.
    • It represents a function call whose return value flows to multiple uses.
  3. Given the IR sequence `t1 = 4; t2 = t1 * 0; return t2`, what does constant folding followed by DCE produce?
    • `t1 = 4; t2 = 0; return t2` — folding applies but DCE cannot remove loads.
    • `return 0` — folding replaces `t1 * 0` with `0` and DCE removes unused temporaries.
    • `return 4` — the multiply by zero is not recognized as an identity.
    • No change — constant folding only applies to additions and subtractions.
  4. In graph-coloring register allocation, what does it mean to 'spill' a virtual register?
    • To assign it a physical register with the lowest index to minimize instruction encoding size.
    • To store its value in a stack slot and insert loads/stores around each use/def when no physical register is available.
    • To merge two virtual registers that don't interfere into a single physical register.
    • To eliminate the register from the interference graph because its value is constant.
  5. Common subexpression elimination (CSE) applied to the IR `t1 = a + b; t2 = a + b; t3 = t1 * t2` produces:
    • `t1 = a + b; t3 = t1 * t1` — CSE replaces the redundant computation and reuses `t1`.
    • `t3 = (a + b) * (a + b)` — CSE folds both adds into the multiply.
    • `t1 = a + b; t2 = a + b; t3 = t1 * t2` — CSE only applies inside loops.
    • `t1 = 2 * (a + b); t3 = t1 * t1` — CSE rewrites as a strength-reduced multiply.

Related lessons

AI
intermediate

Training: Optimization and Regularization

Go from a raw neural network to one that actually generalizes. Covers loss functions (MSE, cross-entropy), gradient descent variants (SGD, momentum, Adam), learning-rate effects, overfitting vs underfitting, and the regularization toolkit (L2/dropout/early stopping/batch norm).

9 steps·~14 min·audio
Programming
advanced

Semantic Analysis and Type Checking

The parser only checks syntax — it happily accepts `x = y + z` even if none of those names exist. Semantic analysis adds meaning: it builds symbol tables, resolves names, enforces scoping rules, and runs the type checker that catches the errors a grammar cannot. Learn what happens between the raw AST and the typed AST the IR generator gets.

9 steps·~14 min·audio
Programming
advanced

Parsing: Tokens to ASTs

The parser takes a flat token stream and recovers the hierarchical structure the programmer intended. Learn context-free grammars, recursive-descent parsing, operator precedence, the difference between a parse tree and an AST, and when LL versus LR parsers matter — including the classic dangling-else ambiguity.

9 steps·~14 min·audio
Programming
advanced

Lexical Analysis: Source to Tokens

Before a compiler can understand your code it has to chop it into meaningful pieces. Learn how scanners work, how regular expressions become finite automata, why maximal munch is the rule, and what a real token stream looks like — the foundation every later compiler phase depends on.

9 steps·~14 min·audio