Compiler Optimizations

Compiler Optimizations

Definition: Transformations applied to an Intermediate Representation to improve execution speed or reduce binary size while strictly preserving observable program behavior (the “as-if” rule).

How It Works

  • Constant Folding / Propagation: evaluate constant expressions at compile time (2 + 3 becomes 5) and propagate known-constant values into later uses
  • Common Subexpression Elimination (CSE): compute a repeated expression like a*b once, reuse the result instead of recomputing it
  • Dead Code Elimination (DCE): remove unreachable blocks and stores to variables that are never read afterward
  • Function Inlining: replace a call site with the callee’s body, removing call/return overhead and exposing more optimization opportunities across the old call boundary
  • Loop-Invariant Code Motion (LICM): hoist computations that don’t change across iterations out of the loop body
  • Loop Unrolling: duplicate loop body iterations to cut branch and counter-check overhead per unit of work
  • Strength Reduction: replace expensive operations with cheaper equivalents, e.g. i * 8 becomes i << 3
  • Auto-Vectorization: rewrite scalar loop bodies into SIMD instructions that process several elements per instruction
  • Tail Call Optimization: reuse the current stack frame for a call in tail position instead of pushing a new one
  • Passes run repeatedly and in a chosen order, because one pass often exposes new opportunities for another: inlining exposes new CSE targets, DCE cleans up after constant propagation
  • Optimization is staged by level: -O0 (none), -O1/-O2 (balanced), -O3 (aggressive, vectorization and heavier inlining), -Os/-Oz (optimize for size)
  • Link-Time Optimization (LTO) defers some passes until link time so the optimizer sees across translation-unit boundaries; Profile-Guided Optimization (PGO) feeds real execution profiles back into the compiler to prioritize hot paths
  • Optimizations are scoped: local (within one basic block), global/intraprocedural (within one function, using its control-flow graph), and interprocedural/whole-program (across function and file boundaries, which is what LTO enables)
  • A pass manager schedules and runs passes, often to a fixpoint: it keeps re-running a set of passes until none of them change the IR anymore, since inlining can expose a new constant, and that constant can expose new dead code
  • Compilers also track legality: a transformation is only applied if it provably cannot change observable behavior, which is why aliasing analysis (does *p and a[i] refer to the same memory?) gates so many optimizations — when the compiler can’t prove two pointers are distinct, it must assume they might alias and skip the optimization

Under the Hood

Given: the loop body recomputes x = 2 * 3 on every iteration and adds it to sum along with a[i]. Step 1, constant folding: 2 * 3 becomes 6 at compile time. Step 2, dead code elimination: any leftover temporary nothing reads gets dropped. Step 3, loop-invariant code motion: since x = 6 never changes between iterations, it moves outside the loop. Answer: the loop body shrinks to sum += 6 + a[i], with x = 6 computed once instead of n times.

Given: an array index multiply a[i] compiles to i * 8 for an 8-byte element. Step: strength reduction replaces the multiply with i << 3, or replaces it entirely with an accumulated pointer increment in the loop. Answer: the loop does an add or shift each iteration instead of a multiply, cheaper on most pipelines.

Given: y = a*b + c; z = a*b - c; computes a*b twice. Step 1, CSE recognizes both expressions as the same computation and evaluates a*b once into a temporary t. Step 2: the code becomes t = a*b; y = t + c; z = t - c. Answer: one multiply instead of two, at the cost of one extra register/temporary to hold t.

Given: if (false) { launch_missiles(); } sits in otherwise live code. Step 1: constant folding evaluates the condition to false at compile time. Step 2: dead code elimination proves the branch is unreachable and removes the entire block, including the call. Answer: the call disappears from the binary; nothing about program behavior changes because that path could never execute.

Given: a small helper inline int square(int x) { return x * x; } is called inside a hot loop for (i=0;i<n;i++) total += square(a[i]);. Step 1: inlining replaces the call with the helper’s body directly at the call site: total += a[i] * a[i]. Step 2: with the multiply now visible in the loop alongside total, the optimizer can consider further transforms (vectorization, strength reduction) that were invisible while square was an opaque call. Answer: inlining alone saves call/return overhead, but its bigger value is unlocking every optimization pass that follows it, which is why compilers treat it as an early, high-priority pass rather than a final cleanup step.

Why It Matters

  • Lets developers write clear, high-level code while the compiler produces efficient machine output, instead of hand-tuning assembly
  • Optimization level is a real tradeoff: -O0 for local dev builds (fast compiles, full debuggability), -O2/-O3 plus LTO/PGO for release builds
  • Binary size and instruction-cache pressure matter as much as raw instruction count on modern hardware, which is why -Os exists as its own goal rather than a slower -O2
  • Interprocedural optimization (LTO, whole-program analysis) is often worth more than a higher -O level, because it lets the compiler inline and specialize across files it previously treated as opaque boundaries
  • Optimization directly shapes what a profiler shows: without inlining, a profile blames a tiny wrapper function for time actually spent in the code it calls

Common Pitfalls

  • Over-inlining bloats binary size, causing L1 instruction-cache misses that make “optimized” code slower in practice
  • Compilers treat Undefined Behavior as license to optimize aggressively: assuming signed overflow never happens can eliminate a check the developer meant to keep
  • -ffast-math breaks IEEE 754 semantics by allowing reassociation that changes rounding results, unsafe for numerically sensitive code
  • Debugging -O2/-O3 builds is painful: variables live only in registers or vanish entirely, and breakpoints appear to jump around from reordering and inlining
  • Assuming more passes always help: some passes interact badly, and a later pass can undo the benefit of an earlier one on pathological input
  • Benchmarking with -O0 and drawing conclusions about algorithmic performance: unoptimized builds don’t reflect what shipped code actually does, and can make one algorithm look faster than another purely due to missing inlining or CSE
  • Assuming LTO is free: it moves optimization work to link time, which can make incremental builds and CI pipelines noticeably slower even though the resulting binary runs faster

Comparison

LevelCompile timeDebuggabilityTypical use
-O0FastestFullLocal development, debugging
-O1FastReducedQuick sanity builds
-O2ModeratePoorDefault release build
-O3SlowestPoorPerformance-critical release, adds vectorization and aggressive inlining
-Os/-OzModeratePoorSize-constrained targets (embedded, mobile)
ScopeSeesExample pass
LocalOne basic blockPeephole optimization
Global/intraproceduralOne function’s control-flow graphLICM, loop unrolling
Interprocedural/whole-programAcross function and file boundariesInlining across translation units via LTO

Example

gcc -O3 on a multiply-accumulate loop over an array both unrolls the loop and emits AVX SIMD instructions instead of one scalar multiply per element:

// source
for (int i = 0; i < n; i++) sum += a[i] * 2;

// after -O3: strength reduction (i*2 -> shift), LICM, and auto-vectorization
// collapse this into a handful of SIMD multiply-add instructions processing
// 4-8 elements per loop iteration instead of one.

LLVM’s opt tool exposes the same passes individually (-instcombine, -gvn, -loop-unroll), which is how compiler engineers inspect exactly which transformation changed the IR at each step. GCC exposes a similar knob with -fdump-tree-all, printing the IR after every pass in the pipeline.

Real numbers: on numerically heavy code (matrix multiply, image filters), -O3 with auto-vectorization on a modern x86-64 chip with AVX2 commonly yields 2-4x throughput over -O2 without vectorization, because eight 32-bit floats move through the pipeline per SIMD instruction instead of one.

Rust’s rustc and Swift both lower to LLVM IR and inherit the same opt-style pass pipeline, which is why performance characteristics and even some LLVM-specific compiler flags carry over between otherwise unrelated languages that share the LLVM backend.

Dig deeper