Legalityimplementation

Optimization Levels

What `-O0` through `-O3`, `-Os` and `-Oz` actually select, why a higher number is not automatically faster, and why the only way to choose between two of them for your program is to measure both.

The question

What do the optimization levels really change, and how do I pick one?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

A named preset over a pass pipeline: a level is not a dial on an amplifier but a curated list of passes, their order, the number of times some of them repeat, and the thresholds their cost models use — inlining budgets, unroll factors, vectorization profitability cutoffs. The program's representation does not change with the level; what changes is how many transformations get a chance at it and how generous their budgets are.

What this phase may assume or do

No optimization level changes what is legal. Every pass at -O3 obeys exactly the preconditions it obeys at -O1; a higher level runs more passes with larger budgets, it does not relax any rule. The two flags that genuinely do change the language are separate and named as such: -ffast-math (and -Ofast, which implies it) alters floating-point semantics, and -fno-strict-aliasing or -fwrapv remove assumptions the standard grants. Everything else at every level is the as-if rule, unchanged.

Key points

  • An optimization level is a preset over a pass pipeline plus a set of cost-model thresholds; it changes nothing about what is legal.
  • The -O0 to -O1 step is the biggest one on every axis: most of the speed, most of the debuggability loss.
  • -O3 buys speed mainly by spending code size, which is why it can lose to -O2 on instruction-cache-bound code.
  • Unrolling can push register allocation into spilling, converting register accesses in the hottest loop into memory accesses.
  • -Os and -Oz optimize a different axis on purpose; -Og optimizes while keeping the debugging experience.
  • -Ofast is not a level in the same sense — it implies -ffast-math and changes what floating-point code means.
  • -flto, -march and PGO frequently matter more than the difference between -O2 and -O3.
  • Which level is faster for a given program is an empirical question about that program on that machine, and the procedure is to build both and measure.

What each level selects

implementationWhich passes each level enables is a per-compiler, per-version decision with no standard behind it. GCC documents its -O levels as explicit lists of -f flags and moved auto-vectorization into -O2 in GCC 12, which had previously been a headline -O3 feature; Clang builds its levels from pass-pipeline presets that are restructured between releases; Rust exposes opt-level = 0..3, "s", "z" mapping onto LLVM pipelines; MSVC uses /Od /O1 /O2 /Ox with different meanings again. Any statement of the form "-O2 does X" is a statement about one compiler at one version.

The levels are presets, and the names understate how discrete they are. -O0 is not "a bit slower" — it is a fundamentally different compilation strategy, where every variable lives in memory, every statement boundary is preserved, and nothing moves. That is why it builds fast and debugs perfectly, and why it can be several times slower at run time than -O1.

The step from -O0 to -O1 is the largest one in every direction: most of the runtime gain, most of the debugging loss, and a moderate increase in compile time. -O2 adds the expensive interprocedural and loop work and is the default that mainstream distributions build with. -O3 adds transformations that trade code size for speed — more aggressive inlining and unrolling, and vectorization that -O2 in GCC historically declined — and is where the "higher is faster" intuition starts to fail.

-Os optimizes for size while keeping most speed optimizations that do not grow code; -Oz (Clang, and GCC since 12) goes further and accepts real slowdowns to shrink further. -Og is the newer and genuinely useful one: optimize, but only in ways that keep the debugging experience intact, which is the level to build with when you need to attach a debugger to something that also has to run at a realistic speed.

The levels, and what each costs on the axes that are not speedtypical
LevelRoughly what it selectsCompile timeCode sizeDebuggability
-O0Almost nothing. Every value in memory, every statement boundary preserved.LowestLarge — no cleanup at allPerfect: every variable readable, every line steppable
-O1The cheap, high-value passes: folding, DCE, simple inlining, register promotion.LowUsually smaller than -O0Good; some variables already unavailable
-O2The full middle-end: loop optimization, interprocedural analysis, aggressive inlining.ModerateComparable to -O1, sometimes largerPoor: inlining and reordering break the line correspondence
-O3-O2 plus size-for-speed trades: more unrolling, more inlining, more vectorization.HighLarger, sometimes much largerPoor, and harder to reason about than -O2
-Os-O2 minus the transformations that grow code.ModerateSmallest of the speed-oriented levelsPoor
-OzSize above all, accepting slower code to get it.ModerateSmallestPoor
-OgOptimizations that do not destroy the debugging experience.LowBetween -O0 and -O1Good — the point of the level
-Ofastspec-O3 plus -ffast-math: this one changes what the program means.HighLargerPoor, plus different numeric results

Why a higher number is not automatically faster

The transformations that -O3 adds beyond -O2 mostly buy speed by spending code size: inline more call sites, unroll more loop bodies, version more loops so a vectorized variant and a scalar fallback both exist. Each of those removes work per iteration and adds instructions to the binary — and instructions are a scarce resource for a reason that has nothing to do with the compiler.

A modern core has an instruction cache measured in tens of kilobytes. Code that does not fit is fetched from further away, and an instruction fetch that misses costs the same order of magnitude as a data miss while being much harder to see in a profile. Aggressive inlining across a call-heavy codebase can push a hot working set out of that cache, at which point the program executes fewer instructions per iteration and takes longer per iteration. The instruction cache is Computer Architecture's subject and is linked below; note that the limit is a property of the machine, not of the compiler.

There are quieter reasons too. Unrolling increases live values and can push register allocation into spilling, converting register accesses into memory accesses in the hottest loop in the program — [[spilling]]. Loop versioning doubles the branch footprint and can degrade branch predictor behavior. More inlining means larger functions, which means less precise analysis in some passes with per-function limits, so an aggressive setting occasionally *loses* an optimization it would have found at -O2. And more optimization means longer compile times, which for a large project is a real cost paid on every build.

None of this makes -O3 a bad choice. It makes it an empirical one. The measurements that exist consistently show that the difference between -O2 and -O3 is small in either direction for most programs and occasionally large in one direction for a specific one — and which direction is a property of the program, its working set, and the machine it runs on. The honest procedure is to build both and measure the workload you actually care about, which is measure-before-optimizing applied to the compiler itself.

  • Instruction-cache pressure — more inlined and unrolled code, less of the hot path resident. The commonest reason a higher level loses.
  • Register pressure and spilling — unrolling raises the number of simultaneously live values past the register file.
  • Branch footprint — versioned loops and specialized paths give the predictor more to track.
  • Compile time — a real, recurring cost on every build, and one that CI pays repeatedly.
  • Debuggability — the higher the level, the less the running code corresponds to the source, which changes how long the next production incident takes.
  • Semantics, in exactly one case-Ofast implies -ffast-math, which changes floating-point results. That is not an optimization level, it is a different language.

Choosing, and the flags worth knowing beyond the number

The default answer for most projects is -O2, and the reason is not that it is fastest — it is that it is the setting every distribution, every CI system and every compiler vendor tests most heavily, so it has the fewest surprises. Deviating from it is a decision that should be backed by a measurement of your workload.

For a size-constrained target — embedded firmware, a WebAssembly module served over a network, a container image where the binary is the payload — -Os or -Oz is chosen for the axis that actually matters, and the speed cost is accepted deliberately. For a service where debuggability under load is a first-class concern, -Og with full debug info is a defensible production setting.

Two flags matter more than the level for many programs, and both are orthogonal to it. -march=native (or a specific -march) tells the compiler which instruction set extensions it may use, and can matter far more than any -O step for vectorizable code — at the cost of a binary that will not run on older machines. -flto enables link-time optimization, which lets inlining and interprocedural analysis cross translation-unit boundaries and is frequently worth more than the -O2-to--O3 step, at the cost of link time and memory — [[link-time-optimization]].

And the flag that beats all of them for the right workload is -fprofile-use: profile-guided optimization replaces the cost model's guesses with measurements of your program, so the inlining and layout decisions are made about the paths that are actually hot. It costs a two-stage build and an instrumented run on a representative workload, and "representative" is doing a lot of work in that sentence — [[profile-guided-optimization]] and [[pgo-tradeoffs]].

How it works

The steps, in the order the compiler takes them.

  • The driver maps the -O flag to a pass-pipeline preset: which passes run, in what order, and how many times.
  • It also sets thresholds used by cost models — inlining budgets, unroll factors, vectorization profitability cutoffs, loop-versioning limits.
  • Each pass runs with its ordinary legality preconditions, unchanged by the level; only its willingness to act changes.
  • Size-oriented levels lower or zero the budgets for transformations that grow code, and disable those whose only benefit is speed at the cost of size.
  • -Og selects passes that preserve variable locations and statement boundaries, and skips those that destroy the line correspondence.
  • -Ofast additionally sets flags that change semantics rather than pipeline composition, which is why it is documented separately from the numbered levels.
  • The linker takes over for -flto, running the middle-end again across the whole program once every translation unit's IR is available.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A team upgrades from -O2 to -O3 and the service gets slower, and nobody looks at the binary size because the change was supposed to make things faster.
  • A numerical result changes after a build-flag cleanup added -Ofast, and the difference is attributed to a data change rather than to relaxed floating-point semantics.
  • CI times double after an optimization-level bump, and the cost is paid on every commit for a speedup nobody measured.
  • A production crash cannot be debugged because the binary was built at -O2 with no debug info, and reproducing it at -O0 makes the timing-dependent failure disappear.
  • A firmware image stops fitting in flash after a compiler upgrade changed which passes -O2 enables, and the fix is a flag rather than any source change.
  • A benchmark shows an enormous improvement at a higher level because the loop under test was removed entirely — the level did not make the work faster, it made the work absent.

When it helps

  • Setting a project default deliberately rather than by inheritance, with the axis that matters — speed, size, build time, debuggability — named explicitly.
  • Diagnosing a performance regression after a toolchain or flag change, where the level and its per-version contents are the first thing to diff.
  • Sizing a binary for a constrained target, where -Os, -Oz, LTO and --gc-sections together are worth more than any source-level work.
  • Explaining to a team why "just turn up the optimizer" is not a plan, with a mechanism rather than an opinion.

When it hurts

  • Treating the level as the performance knob. For most programs the difference between -O2 and -O3 is smaller than the effect of one data-structure choice, and both are dwarfed by algorithmic work.
  • Comparing levels on a microbenchmark. Instruction-cache pressure, the main mechanism by which a higher level loses, is invisible in a benchmark whose entire working set fits in cache.

What it costs

Every one of these is paid by something.

  • Higher levels buy per-iteration work removed and pay code size, compile time, and the correspondence between source and running code that debugging depends on.
  • Size-oriented levels buy a smaller binary — which for a served WebAssembly module or a flash-constrained device is the metric — and pay speed, deliberately.
  • -Og buys a debuggable optimized build and pays the transformations that would have broken variable locations, which is most of the loop and interprocedural work.
  • LTO buys cross-module inlining and interprocedural analysis and pays link time, peak build memory, and a much harder incremental-build story — [[incremental-compilation]].
  • PGO buys cost-model decisions based on measurement rather than heuristics and pays a two-stage build plus the ongoing obligation to keep the profile representative.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Per-function control instead of a whole-program level: __attribute__((optimize("O3"))), #pragma GCC optimize, or #[inline(always)] in Rust, applied to the small part of the program that measurement showed matters.
  • Profile-guided optimization, which replaces the guesses the levels encode with data from your workload — usually a larger win than any level change.
  • BOLT and similar post-link optimizers, which reorder the binary's code layout using a profile after linking, targeting exactly the instruction-cache behavior that -O3 can damage.
  • Doing nothing to the flags and fixing the algorithm. The level is a multiplier on constant factors; it does not change complexity, and most large wins are not constant-factor wins.

See it for yourself

The flag, dump or tool that shows you this directly.

  • GCC: gcc -Q -O2 --help=optimizers prints every optimization flag and whether this level enabled it, and diffing that output between -O2 and -O3 is the exact answer to "what does the higher level add".
  • Clang: clang -O2 -mllvm -print-pipeline-passes prints the pass pipeline the level selected, which is the same question answered from the other side.
  • Build the same source at several levels and compare size output and the run time of your real workload — not a microbenchmark — on the machine you deploy to.
  • perf stat on both builds: look at instructions, L1-icache-load-misses and IPC together. A higher level that executes fewer instructions and runs slower is the instruction-cache story, visible directly.
  • Compiler Explorer with two panes at -O2 and -O3 on one function shows the unrolling and inlining decisions that differ, in assembly.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "-O3 is always fastest, so use it everywhere." It buys speed by spending code size, and on instruction-cache-bound code that trade can go the wrong way. It is a candidate to measure, not a default.
  • "Higher levels are more dangerous because they break the rules." Every level obeys the same legality preconditions. What higher levels do is expose latent undefined behavior in your source that lower levels happened not to act on.
  • "-Os is for embedded only." It is for anything where the binary is a payload, including WebAssembly served over a network and container images where the image size is the deploy time.
  • "-Ofast is just a faster -O3." It changes floating-point semantics, so it changes results. That belongs in a decision about numerical accuracy, not in a build-flag cleanup.

Misconceptions

The claim, and what is actually true.

The optimization level is a dial from "slow" to "fast".
It is a preset selecting a pipeline and a set of budgets. The axes it moves include code size, compile time and debuggability, and it moves them in different directions.
Higher optimization levels are less safe.
The legality rules are identical at every level. Higher levels expose undefined behavior that was already in the source; they do not introduce new licence to break rules.
If a program breaks at -O2 and works at -O0, the compiler has a bug.
Usually the program has undefined behavior. It is worth reducing and reporting either way, but the prior strongly favours the source — start with a sanitizer run.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

The -O flag picks a bundle of transformations rather than turning a dial. -O0 does almost nothing and debugs perfectly; -O2 is the sensible default everyone tests; -Os and -Oz shrink the binary instead of speeding it up; -Og optimizes while keeping the debugger useful. -O3 does more of the things that trade code size for speed, which sometimes wins and sometimes does not.

practical

Default to -O2, and change it only with a measurement of the workload you care about on the machine you deploy to. If you need speed, try -flto and an appropriate -march before reaching for -O3, and try PGO before either. If size is the metric, -Os or -Oz with LTO and --gc-sections. If you will have to debug this in production, -Og plus full debug info is a real option. And keep -Ofast out of the conversation entirely unless someone has revalidated the numerics.

advanced

The reason no level can be right for every program is that the cost models behind the thresholds are guessing about a machine the compiler cannot observe: the working-set size, the cache hierarchy, the branch predictor, and which paths are actually hot. A level is a fixed guess baked into a preset. Everything that beats a level does so by replacing a guess with a measurement — PGO replaces the branch-probability and inlining guesses with counts, BOLT replaces the code-layout guess with a profile of the running binary, and iterative-compilation systems search the flag space directly for one program. That framing is more useful than the level ladder, because it says where the remaining performance lives: not in a bigger number, but in giving the cost models information they currently do not have.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationThe mapping from level to passes is compiler- and version-specific and changes without ceremony: GCC 12 moved auto-vectorization into -O2, so advice written before it about -O3 being the vectorizing level is now wrong. Clang, MSVC and rustc all define their levels differently again. Check -Q --help=optimizers or the pipeline dump for the compiler in front of you rather than trusting any table, including this one.
spec-Ofast implies -ffast-math, which permits reassociating floating-point arithmetic, assuming no NaNs or infinities, and flushing denormals — all of which violate IEEE-754 and change computed results. This is a semantic change rather than an optimization setting, and code that is validated for numerical accuracy must be revalidated under it or must not use it.
typicalThat -O2 is the best-tested setting reflects how distributions, CI systems and compiler test suites are configured, not any property of the passes. Higher and lower levels genuinely do hit fewer testing hours, which is a small but real argument for staying near the default unless a measurement says otherwise.

If you were asked this in an interview

  • What is actually different between -O2 and -O3?
  • Give me a concrete mechanism by which a higher optimization level produces slower code.
  • A service is slow and someone proposes raising the optimization level. What do you suggest instead, and why?

Connections

Domains that do not exist yet
  • DevOps / Production Engineering — Build-flag policy and reproducible release builds
    An optimization level is a build-configuration decision with security, size and debuggability consequences that outlive whoever set it, and it must be identical between the build that was tested and the build that ships. Keeping that guarantee across a CI system is owned there; the compiler-side half is [[reproducible-compilation]].