The optimiser is where the compiler earns its keep. We build the data-flow analysis framework from the ground up — lattices, transfer functions, the iterative worklist — survey the four classic analyses and the transformations they enable, dig into loop and SSA-based optimisations, and end with the phase-ordering problem and the real GCC/LLVM pass pipelines. The one inviolable rule throughout: optimisations must preserve program semantics.
Optimisation is a misnomer — it is really code improvement. This deck builds the theory (data-flow analysis) and then applies it: the classic transformations, loop and SSA-based passes, and how real compilers sequence them.
-O0..-O3 / -Os / -Oz; pass managersAn optimiser rewrites code to improve some objective — but it may only do so if the rewrite is semantics-preserving. This is the “as-if” rule: the program must behave as if every step of the abstract machine ran, even if the compiler actually did something cheaper.
Fewer / cheaper instructions, fewer memory accesses, better cache & branch behaviour. The classic target.
Shrink the binary (-Os, -Oz) — matters for embedded, instruction cache, mobile download size.
Fewer cycles and memory ops mean less power — critical on battery & in the datacentre.
Finding genuinely optimal code is undecidable (reduces to the halting problem) or NP-hard in the cases that are decidable. “Optimisation” is a historical misnomer — we make code better, on a heuristic best-effort basis, never provably best.
The as-if rule constrains only observable effects: I/O, volatile accesses, program termination, and (in C++) the order of certain side-effects. Everything else — intermediate values, evaluation order, dead stores — is fair game to rewrite or delete.
A “faster” program that computes a different answer is not an optimisation — it is a miscompilation. Correctness first, always.
Optimisations are classified by the scope they reason over. Wider scope sees more opportunities but costs more analysis — and crosses harder boundaries (function calls, translation units, the whole program).
| Scope | Reasons over | Examples | Cost |
|---|---|---|---|
| Peephole | A small sliding window of adjacent instructions | Replace mul x,2 → shl x,1; remove redundant mov | Tiny |
| Local | One basic block (straight-line code) | Local CSE, local constant folding, value numbering | Low |
| Global (intraprocedural) | One whole function — the entire CFG | Data-flow analyses, LICM, global DCE, GVN | Moderate |
| Interprocedural (IPA/IPO) | Across function boundaries & the call graph | Inlining, constant arg propagation, devirtualisation | High |
| Link-Time (LTO) | Across translation units, at link time | Cross-TU inlining & DCE; ThinLTO scales it | High |
| Whole-Program | The entire program at once (closed world) | Aggressive devirtualisation, global layout | Very high |
| Profile-Guided (PGO) | Any scope, informed by runtime profiles | Hot/cold splitting, better inlining & layout, branch hints | 2-pass build |
Classic LTO serialises IR into the object files and re-optimises the merged whole at link time. ThinLTO (LLVM) keeps it parallel & incremental by exchanging only summaries plus on-demand imports.
Build instrumented → run a representative workload → feed the .profdata back. The compiler now knows which branches are hot, sizing inlining and block layout to reality rather than static heuristics.
Almost every global optimisation rests on data-flow analysis (DFA): a general method for computing, at every program point, a conservative summary of what must or may hold there. The framework has four parts — pick them, turn the crank, get a sound answer.
in[B] = MEET over predecessors P of out[P] (forward)
out[B] = transfer_B( in[B] )
= gen[B] ∪ ( in[B] − kill[B] )
# backward analyses swap in/out and use successors:
out[B] = MEET over successors S of in[S]
in[B] = transfer_B( out[B] )
Solve the system to a fixed point with the iterative worklist algorithm.
The meet operator combines facts arriving along different paths. Its choice (∪ vs ∩) is exactly what makes an analysis may or must.
The domain of facts forms a lattice: a partially-ordered set where every pair of elements has a greatest lower bound (meet, ∧) and a least upper bound (join, ∨). ⊤ (top) is “no information yet / everything possible”; ⊥ (bottom) is “over-defined / conflicting”. Iteration moves facts monotonically down.
The constant-propagation lattice: ⊤ = “unknown/undefined”, a middle of known constants, ⊥ = “not a constant (varies)”. Meet of two different constants = ⊥.
For may analyses the meet is set union (“could happen on some path”). For must analyses it is set intersection (“holds on every path”). The lattice is drawn so meet always moves toward less information.
To solve the equations we iterate to a fixed point. Initialise every block to ⊤, then repeatedly recompute blocks whose inputs changed until nothing changes. The worklist keeps us from rescanning stable blocks.
worklist ← all blocks
out[B] ← ⊤ for all B (∅ for a union analysis)
out[entry] ← boundary condition
while worklist not empty:
B ← pop(worklist)
in[B] ← MEET over preds P of out[P]
new ← gen[B] ∪ (in[B] − kill[B])
if new ≠ out[B]:
out[B] ← new
worklist ← worklist ∪ successors(B)
Backward analyses run on the reverse CFG: swap preds/succs and in/out, seed from the exit.
The algorithm computes the Maximal Fixed Point (MFP). The ideal answer is the Meet-Over-all-Paths (MOP) solution. For distributive frameworks MFP = MOP; otherwise MFP is a safe, conservative under-approximation (MFP ≤ MOP). Enumerating all paths is undecidable — hence we solve equations instead.
Visiting in reverse postorder (forward) or postorder (backward) typically converges in a handful of passes — bounded by the loop-nesting depth + 2.
Reaching definitions (forward, may): which assignments could reach a point unkilled? At the join block B4, in is the union of both predecessors' out — so d2 and d3 both reach.
gen[B2] = { d2 }
kill[B2] = { d1, d3 } # other defs of x
out[B2] = gen ∪ (in − kill)
= { d2 }
in[B4] = out[B2] ∪ out[B3] = {d2, d3}. The original d1 was killed on both arms, so it does not reach the use. This drives const-propagation: since two different values reach, x is not a known constant at B4.
Every data-flow analysis sits in one of four boxes, fixed by two independent choices: which direction information flows, and whether facts are may (union) or must (intersection).
Forward: a block's fact depends on its predecessors (what has happened so far). Backward: it depends on its successors (what will be needed later) — solved on the reverse CFG.
“True on some path.” Safe for optimistic deletion guards — e.g. a variable is live if used on any later path, so we must keep it.
“True on every path.” Needed to justify a rewrite — an expression is available only if already computed on all incoming paths, so we may reuse it.
A definition d reaches a point if there is a path from d to that point along which d is not overwritten. Powers use-def chains, constant propagation, loop-invariance tests.
gen[B] = defs in B not later redefined in B
kill[B] = all other defs of those vars
in[B] = ∪ out[pred] # MAY = union
out[B] = gen[B] ∪ (in[B] − kill[B])
init: out = ∅
A variable is live at a point if its current value may be used along some path before being redefined. The backbone of dead-code elimination and register allocation (interference).
use[B] = vars read in B before any def in B
def[B] = vars assigned in B
out[B] = ∪ in[succ] # MAY = union
in[B] = use[B] ∪ (out[B] − def[B])
init: in = ∅ ; solved on reverse CFG
Note how the equations mirror each other: reaching defs flows forward over gen/kill; liveness flows backward over use/def. Both are may analyses (union meet) — they answer “could this matter on some path?”, the safe question when deciding what to keep.
An expression a+b is available at a point if it has been computed on every path to that point and neither a nor b changed since. Justifies global CSE: reuse the earlier value.
gen[B] = exprs computed in B, operands not
later killed in B
kill[B] = exprs whose operands B redefines
in[B] = ∩ out[pred] # MUST = intersection
out[B] = gen[B] ∪ (in[B] − kill[B])
init: out = U (universal set); in[entry]=∅
An expression is very busy at a point if it will be evaluated on every path from there before any operand changes. Enables code hoisting (compute once, earlier) to shrink code size.
use[B] = exprs evaluated in B before operand def
def[B] = exprs killed by defs in B
out[B] = ∩ in[succ] # MUST = intersection
in[B] = use[B] ∪ (out[B] − def[B])
init: in = U ; solved on reverse CFG
For must (intersection) analyses, interior blocks must be initialised to the universal set U (full optimism), not ∅ — otherwise the first intersection collapses everything to empty and the fixed point is wrong. May/union analyses initialise to ∅.
The complete classification. Memorise the four-corner pattern and you can derive any of them from first principles.
| Analysis | Domain (facts) | Direction | Meet | Confidence | Init (interior) | Enables |
|---|---|---|---|---|---|---|
| Reaching Definitions | Set of definitions | Forward | ∪ union | may | ∅ | use-def chains, const prop |
| Live Variables | Set of variables | Backward | ∪ union | may | ∅ | DCE, register allocation |
| Available Expressions | Set of expressions | Forward | ∩ intersect | must | U (universe) | global CSE |
| Very-Busy Expressions | Set of expressions | Backward | ∩ intersect | must | U (universe) | code hoisting (size) |
The same framework yields many more: copy propagation, constant propagation (a non-distributive lattice), dominators (forward/must), nullness, taint, def-use, and the points-to analyses on the next slides. One engine, endless analyses.
The bread-and-butter scalar transforms. Each is cheap, enabling, and synergistic — one creates opportunities for the next, which is why optimisers run them repeatedly.
Evaluate constant expressions at compile time.
int s = 60 * 60 * 24;
// folds to:
int s = 86400;
Mind overflow & FP rounding — folding must match the target's runtime semantics exactly.
Substitute a variable's known constant value at its uses.
x = 4;
y = x + 2; // → y = 4 + 2
// → y = 6 (then folds)
Driven by reaching-definitions / SSA. A single value must reach the use.
After x = y, replace later uses of x with y.
x = y;
z = x + 1; // → z = y + 1
// x now possibly dead → DCE removes it
Cleans up the redundant copies that other passes (and SSA destruction) introduce.
Propagation exposes constants → folding collapses them → some variables become dead → DCE removes them → new copies/expressions become trivial. This cascade is exactly why a pass pipeline revisits the same simple passes many times.
If a+b is available, compute it once and reuse.
t1 = a + b;
...
x = a + b; // → x = t1
y = a + b; // → y = t1
Local CSE via value numbering; global CSE via available-expressions or GVN.
Remove computations whose results are never used (variable not live), and unreachable blocks.
x = expensive(); // x never used
// → deleted entirely
if (0) { ... } // unreachable → removed
Must respect side-effects: a call that does I/O is not dead even if its result is.
Apply identities & canonicalise.
x + 0 → x x * 1 → x
x * 0 → 0 x - x → 0
x & x → x !!b → b
(a+b)+c → a+(b+c) reassoc
FP is tricky: x*0.0 ≠ 0.0 for NaN/−0.0 unless -ffast-math.
DCE is only legal when (a) the value is provably dead and (b) the computation is side-effect-free. This is exactly why alias and effect analyses (later) gate so much: if the compiler cannot prove a store or call is pure and unobserved, it cannot remove it.
Replace an expensive operation with a cheaper equivalent.
y = x * 8; → y = x << 3;
y = x % 16; → y = x & 15; // unsigned
y = x / 2; → y = x >> 1; // unsigned
The headline use is in loops: turn a multiply by the loop index into an add per iteration (induction-variable strength reduction, next slide).
Replace a call with the callee's body. The single most enabling optimisation — it exposes the callee's internals to the caller's optimiser (const prop, CSE, DCE across the boundary).
int sq(int n){ return n*n; }
y = sq(a+1);
// inlines to:
y = (a+1)*(a+1); // now foldable/CSE-able
Inlining duplicates the body at each call site. Over-inlining bloats the binary, blows the instruction cache, and can slow code. Heuristics weigh callee size, call-site hotness (PGO), and recursion. __attribute__((always_inline)) forces it; noinline forbids it.
Loops are where programs spend their time, so they get the most attention. Loop-Invariant Code Motion hoists computations that produce the same value every iteration out to the pre-header — computed once instead of N times.
LICM needs a single dedicated block on the loop's entry edge to hoist into. Compilers insert one if absent — a recurring theme: normalise the CFG (loop-simplify form) first, then transform.
A basic IV steps by a constant (i += 1). A derived IV is a linear function of it (j = 4*i). IV strength reduction turns the multiply into an add:
for(i=0;i<n;i++) a[4*i]=0;
// → keep j, add 4 each iter:
for(j=0; j<4*n; j+=4) *(a+j)=0;
Replicate the body K times, stepping by K. Amortises loop overhead (test/branch/increment), exposes ILP, and feeds vectorisation — at the cost of code size. A remainder loop handles the leftover iterations.
Swap nested loops so the innermost stride is unit, walking arrays in row-major order for cache locality:
for j: for i: A[i][j] // bad: column stride
// interchange →
for i: for j: A[i][j] // good: unit stride
Legal only if the dependence direction permits the swap.
Modern CPUs offer SIMD units (SSE/AVX/AVX-512 on x86, NEON/SVE on ARM, V on RISC-V) that apply one operation to a vector of lanes. The vectoriser rewrites a scalar loop to process several elements per iteration.
// scalar: 1 add per iteration
for (i = 0; i < n; i++)
c[i] = a[i] + b[i];
// vectorised (width 4): 4 adds per iteration
for (i = 0; i + 4 <= n; i += 4)
c[i:i+4] = a[i:i+4] + b[i:i+4]; // one VADD
// + a scalar remainder/peel loop for the tail
Loop vectorisation widens across iterations; SLP (Superword-Level Parallelism) packs independent scalar statements within one block into a vector.
a[i]=a[i-1])restrict)restrict pointers, #pragma omp simd, aligned data, simple counted loops. Or write intrinsics when the auto-vectoriser gives up.
In SSA form (Part 06) each variable is assigned exactly once, so a use points directly at its single definition — def-use chains are explicit and free. Many analyses that needed full data-flow now become a sparse graph walk.
Sparse Conditional Constant Propagation (Wegman & Zadeck). Propagates constants and reachability together over the SSA + CFG lattices — strictly more powerful than running constant propagation and unreachable-code removal separately, because each unlocks the other.
Global Value Numbering assigns the same number to expressions that provably compute the same value — even across blocks & through φ-nodes. Subsumes CSE and catches redundancies syntactic CSE misses (e.g. a+b vs b+a, or values that flow through copies).
Optimistic: assume all code is dead, then mark live only what (transitively) feeds an observable effect (return, store, I/O). Anything unmarked — including whole control-dependence regions — is deleted. SSA makes the mark-and-sweep over def-use edges trivial.
Classic DFA stores a fact per variable per program point — dense and expensive. SSA attaches information to values, of which each has exactly one definition, so facts propagate along def-use edges only where they actually flow. Same answers, far less work — the reason SSA dominates modern middle-ends (LLVM, GCC's GIMPLE-SSA, V8, HotSpot).
No single optimisation is dramatic; the cascade is. Watch a snippet shrink as const-prop, folding, CSE, strength-reduction and DCE fire in sequence.
Each pass is simple; together they fold the whole function to return 64;. The art is in ordering and repeating them.
Two pointers alias if they may refer to the same storage. Almost every memory optimisation hinges on the question “can this store affect that load?” — and a conservative “maybe” kills the optimisation. Alias analysis is the silent ceiling on what a compiler can do.
*p = 1;
x = *q; // is x == 1 ? only if p,q alias
*p = 2; // can we drop the *p=1 store?
// only if nothing reads through
// an alias in between
If p and q may alias, the compiler must keep the load, keep the store, and forgo CSE/DCE/reordering across them.
Pointer aliasing is undecidable in general, so analysis is always conservative. restrict / __restrict and strict-aliasing rules exist precisely to give the compiler back the no-alias guarantees it cannot prove.
Nodes = functions, edges = “may call”. The substrate of all interprocedural work: inlining order, propagating facts across calls, devirtualisation.
Block d dominates n if every path from entry to n goes through d. The dominator tree underpins much of the optimiser:
Computed in near-linear time (Lengauer–Tarjan; or the simpler Cooper–Harvey–Kennedy iterative algorithm).
Optimisations interact: one pass creates opportunities for another — or destroys them. There is no universally best order, and the search space is enormous. This is the phase-ordering problem, and it has no clean solution.
The pragmatic answer: a good enough, robust order that works across most programs, with cheap normalising passes interleaved to keep each heavy pass fed with clean IR.
LLVM and GCC organise optimisation as a sequence of passes driven by a pass manager. The -O level selects which passes run and how aggressively.
| Level | Intent |
|---|---|
-O0 | None; fast compile, debuggable, predictable |
-O1 | Cheap wins, little code growth |
-O2 | Full opt, no risky size blow-up — the default |
-O3 | + aggressive inlining, unroll, vectorise (more size) |
-Os | Optimise for size (O2 minus size-growing passes) |
-Oz | Minimise size at almost any speed cost |
-Ofast | O3 + unsafe maths (-ffast-math) — breaks strict IEEE |
Passes are scoped: module, call-graph (SCC), function, loop. LLVM's New Pass Manager nests them and threads analysis results (with invalidation) so a transform can request, e.g., dominators or alias info on demand.
clang -O2 -mllvm -print-pipeline-passes
opt -passes='default<O2>' -print-after-all
gcc -O2 -fdump-tree-all -fdump-rtl-all
C and C++ declare some constructs undefined behaviour (UB): the standard imposes no requirements. The optimiser is then free to assume UB never happens — and to rewrite code on that assumption. This is a powerful, and famously controversial, source of speed.
// signed overflow is UB → compiler assumes
// it never happens → this loop terminates:
for (int i = 0; i <= n; i++) { ... }
// i+1 can't wrap, so i<=n is bounded → may
// use a wider IV, vectorise, hoist checks
int v = *p; // deref → p assumed non-null
if (p) { ... } // → branch can be deleted!
Strict aliasing, no-null-deref, no-signed-overflow, no-OOB and dereferenceable assumptions all feed real optimisations.
-O levels or compiler versionsSanitizers (-fsanitize=undefined,address) catch UB at runtime; flags like -fwrapv, -fno-strict-aliasing, -fno-delete-null-pointer-checks tame specific cases. Safer languages (Rust) eliminate most UB by construction.
-O0..-O3/-Os/-Ozllvm.orgCode Generation — instruction selection (tree-tiling / BURS), instruction scheduling, and register allocation (graph-colouring vs linear-scan): turning optimised IR into real machine code.