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).