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 + 3becomes5) and propagate known-constant values into later uses - Common Subexpression Elimination (CSE): compute a repeated expression like
a*bonce, 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 * 8becomesi << 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
*panda[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:
-O0for local dev builds (fast compiles, full debuggability),-O2/-O3plus LTO/PGO for release builds - Binary size and instruction-cache pressure matter as much as raw instruction count on modern hardware, which is why
-Osexists as its own goal rather than a slower-O2 - Interprocedural optimization (LTO, whole-program analysis) is often worth more than a higher
-Olevel, 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-mathbreaks IEEE 754 semantics by allowing reassociation that changes rounding results, unsafe for numerically sensitive code- Debugging
-O2/-O3builds 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
-O0and 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
| Level | Compile time | Debuggability | Typical use |
|---|---|---|---|
-O0 | Fastest | Full | Local development, debugging |
-O1 | Fast | Reduced | Quick sanity builds |
-O2 | Moderate | Poor | Default release build |
-O3 | Slowest | Poor | Performance-critical release, adds vectorization and aggressive inlining |
-Os/-Oz | Moderate | Poor | Size-constrained targets (embedded, mobile) |
| Scope | Sees | Example pass |
|---|---|---|
| Local | One basic block | Peephole optimization |
| Global/intraprocedural | One function’s control-flow graph | LICM, loop unrolling |
| Interprocedural/whole-program | Across function and file boundaries | Inlining 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.
Related Terms
Referenced by