# What -O2 Actually Does: A Tour of Compiler Optimizations

> The gap between -O0 and -O2 is often 10x, and it's not magic — it's a pipeline of individually simple transformations over an intermediate representation. SSA, inlining, the passes that matter, and why your careful micro-optimization was already done for you.

URL: https://harshith.in/blog/how-compilers-optimize/
Author: Harshith Sunku (Staff Engineer) — https://harshith.in/about/
Tags: compilers, performance, systems

The same C source, compiled at `-O0` and `-O2`, routinely differs 10×
in speed — and the second version bears little resemblance to what you
wrote: loops vanish, function calls disappear, variables you named are
gone. This isn't a black box, and treating it as one leads to two equal
and opposite mistakes: hand-optimizing things the compiler already does
perfectly, and writing "clever" code that defeats the optimizer. A tour
of the actual pipeline fixes both.

## First, the Compiler Rewrites Your Code Into Math

Optimization doesn't happen on your source or on assembly — it happens
on an **intermediate representation**, and the key enabling trick is
**SSA form** (static single assignment): every variable is assigned
exactly once, reassignments become fresh versions.

```text
   x = 1;         x1 = 1
   x = x + 2;  →  x2 = x1 + 2
   y = x * 3;     y1 = x2 * 3
```

Why bother: in SSA, "where did this value come from" has exactly one
answer, so data-flow becomes a graph the compiler can reason over
mechanically. Most optimizations are graph rewrites on SSA, which is
why LLVM IR and GCC's GIMPLE both use it and why the same passes
recur across languages — the frontend's job is to lower to IR, and the
optimizer barely cares whether C or Rust or Swift produced it.

## The Passes That Earn Their Keep

Dozens run in sequence, each simple, each feeding the next (order
matters — this is why "-O2" is a *curated pipeline*, not a set):

- **Constant folding & propagation**: compute compile-time-known
  values, thread them forward. `int s = 60*60*24` becomes `86400`;
  branches on now-constant conditions get resolved.
- **Dead code elimination**: SSA makes unreachable and unused-result
  code obvious; it's deleted. (This is also the mechanism that
  deletes UB-guarded checks — same machinery, different premise; I've
  written about that sharp edge separately.)
- **Common subexpression elimination**: compute `a*b` once if it
  appears twice unchanged.
- **Inlining — the keystone.** Replacing a call with the callee's body
  isn't mainly about call overhead; it's that inlining *exposes the
  callee's internals to the caller's context*, unlocking constant
  propagation, DCE, and CSE across the old boundary. This is why
  small accessors cost nothing at -O2 and why LTO (inlining across
  translation units) matters — most other optimizations are
  amplifiers of inlining.
- **Loop transformations**: hoisting invariants out (LICM), unrolling
  to cut branch overhead and expose parallelism, strength reduction
  (replace loop-index multiplies with adds), and auto-vectorization
  (SIMD across iterations — its own deep topic, and a fragile one).
- **Instruction selection & scheduling** at the back end: map IR to
  actual opcodes, reorder to hide latency, allocate registers (the
  graph-coloring problem that decides which of your variables live in
  registers vs spill to stack).

Watch it happen — this is the single best way to learn it — on
**godbolt.org**: paste a function, flip -O0 to -O2, and read the
diff. Ternaries becoming branchless `cmov`, a divide-by-constant
becoming a magic-number multiply-shift, a whole sum loop collapsing to
a closed-form or a vectorized reduction. An hour of this recalibrates
your instincts permanently.

## What This Means For How You Write Code

The practical doctrine falls out of the pipeline: **write clear code
and let the optimizer do the local work** — it does constant folding,
strength reduction, and CSE more reliably than you will, and "manual"
versions often just obstruct it. Reserve your effort for the things
the compiler *cannot* do because they'd change observable behavior or
cross an opacity wall:

- **Algorithms and data layout** — no pass turns O(n²) into O(n log n)
  or fixes a cache-hostile access pattern. This is where your leverage
  actually is.
- **Opacity walls** the optimizer won't cross: calls it can't see
  (across a non-LTO boundary, through a function pointer, into a
  `dlopen`'d library), `volatile` and atomics (it must preserve those
  accesses exactly), potential **aliasing** (if two pointers *might*
  overlap, it must assume the pessimistic case — `restrict` is you
  promising they don't, and often the single most effective hint you
  can give), and observable side effects.
- **Information it lacks**: which branch is hot (`__builtin_expect`,
  or better, profile-guided optimization feeding real data back),
  whether a function is pure, target CPU features
  (`-march=native` vs a conservative baseline — a common
  left-on-the-table win).

The meta-point: the compiler optimizes *within* the semantics and
information you give it. Your job is to hand it good algorithms, not
obstruct it with cleverness, and feed it the facts (restrict, PGO,
march) it can't derive.

## Takeaways

- Optimization runs on SSA IR; most passes are data-flow graph
  rewrites, which is why they generalize across languages and
  frontends.
- Inlining is the keystone — it exposes context that unlocks constant
  propagation, DCE, and CSE across boundaries (and is why LTO pays).
- Godbolt is the learning tool: diff -O0 vs -O2 and read what
  actually happened.
- Write clear code; the compiler wins at local optimizations and
  often loses when you fight it.
- Spend your effort where passes can't reach: algorithms, data layout,
  aliasing (`restrict`), and feeding it facts (PGO, -march).
