5.3.11 · D2Build Systems & Toolchain

Visual walkthrough — GCC - Clang flags — optimization (-O0 to -O3, -Os), warnings (-Wall, -Wextra), sanitizers

3,289 words15 min readBack to topic

Every word we use — register, memory, loop-invariant, reduction, redzone — will be built from zero before it is used. If you have never seen assembly, start at line one; nothing is assumed.


The object we will follow

We track exactly one program for the whole page:

int sum(int *a, int n) {
    int s = 0;
    for (int i = 0; i < n; i++)
        s += a[i];
    return s;
}

In plain words: we walk an array of n whole numbers and add them all into one running total s. Nothing more. Our whole story is: how many instructions does each iteration cost, and why?

Before we can read a single line of what the compiler emits, we need three words.

Two of these families of change are only legal because of one rule, so we pin it down before we use it:


A note on what we count

Every version of the loop also pays a loop-control cost each iteration: compare i < n, then a branch (jump back if not done), then increment i. Call this fixed bundle . Because is paid by every version we compare, it cancels out of every comparison — so we will omit from the cost formulas and only track the work that actually differs between optimization levels. (Whenever i lives in memory, that increment also drags a load and store; we account for those explicitly below.)


Step 1 — Draw the machine: where numbers live

WHAT. We lay out the two homes for data — the CPU with its few registers, and RAM with our variables s, i, and the array a — connected by the bus.

WHY. Every optimization on this page is ultimately a sentence about this picture: "keep this number in a register instead of sending it along the bus." You cannot see the speedup until you can see the two places and the wire between them.

PICTURE. The CPU box holds registers (fast, few). The RAM box holds s, i, a[0..n-1]. The road between them is the load/store bus — every trip along it costs time.

Figure — GCC - Clang flags — optimization (-O0 to -O3, -Os), warnings (-Wall, -Wextra), sanitizers

Step 2 — The -O0 loop: send everything down the bus, every pass

WHAT. We expand one iteration of the loop as -O0 literally emits it. s and i both live in memory, so every use is load … use … store. In particular the index i is not only loaded to test the condition and index the array — after i++ it must be stored back to memory too, because a debugger must be able to read i at any breakpoint.

WHY. -O0's contract is debuggability: if you set a breakpoint anywhere and ask a debugger "what is s? what is i?", the answer must be sitting in memory at a known address. That guarantee forces the load-and-store-every-iteration behaviour. It is slow on purpose, not by mistake.

PICTURE. Follow the red arrows: each iteration sends six numbers down the bus (load i, load base, load a[i], load s, store s, store i), and also does the address multiply and the loop-control bundle .

Figure — GCC - Clang flags — optimization (-O0 to -O3, -Os), warnings (-Wall, -Wextra), sanitizers

Step 3 — Register allocation: keep the counter on the counter

WHAT. The first optimization. The compiler notices s and i are used every iteration and never need to be inspected mid-loop by anyone, so it keeps them in registers for the whole loop. It writes s back to memory once, at the end, and i never needs a store at all.

WHY. This is legal by the as-if rule we defined above: the final return s is identical, and nobody outside can tell whether s or i lived in RAM or a register during the loop. So the compiler is free to keep them in registers, deleting , , , and from every pass.

PICTURE. The six red bus-trips of Step 2 collapse to one (load a[i]). s and i now sit inside the CPU box — no arrows to RAM for them until the loop ends.

Figure — GCC - Clang flags — optimization (-O0 to -O3, -Os), warnings (-Wall, -Wextra), sanitizers

Why this tool and not another? Because the bottleneck we saw in Step 2 was bus traffic, and register allocation is precisely the tool that removes bus traffic. Match the tool to the cost.


Step 4 — Loop-invariant code motion: kill the surviving multiply

WHAT. From Step 3 we still pay (the multiply ) and, at -O0, a base reload every pass. The base address never changes inside the loop, so we hoist it out; and instead of recomputing base + i*4 from scratch, we keep a pointer and just march it forward by 4 bytes each iteration.

WHY. A computation whose inputs do not change across iterations — the base — is called loop-invariant; doing it once instead of n times is free savings. And the offset marches in a fixed stride, so a running +4 (an add) replaces the multiply . Both rewrites give a provably identical address, so the as-if rule permits them.

PICTURE. The "compute base" box moves above the loop (violet). Inside the loop, a single arrow just advances the pointer — no multiply, no reload.

Figure — GCC - Clang flags — optimization (-O0 to -O3, -Os), warnings (-Wall, -Wextra), sanitizers

Step 5 — Recognize the reduction, then vectorize (SIMD)

WHAT. The compiler sees the loop is a reduction: many values folded into one accumulator by an associative operation (+). That pattern unlocks the big trick — SIMD — adding several array elements in a single instruction.

WHY. SIMD (Single Instruction, Multiple Data) means one CPU instruction operates on a lane-vector of values at once (say 8 ints). Instead of 8 separate adds, one wide add handles 8 elements. This is what -O2/-O3 auto-vectorization does — see SIMD and Auto-Vectorization.

But there is a subtlety, and it earns its own figure: integer + is associative, so grouping ((a+b)+c)+d vs (a+b)+(c+d) gives the same answer — that is why the compiler is allowed to add elements out of the original order.

PICTURE. Eight elements stream into eight lanes of one vector register; a single wide-add folds them; a final small step sums the 8 lane-totals into s.

Figure — GCC - Clang flags — optimization (-O0 to -O3, -Os), warnings (-Wall, -Wextra), sanitizers

Step 6 — Edge case: the remainder (tail loop) when n is not a multiple of W

WHAT. SIMD in Step 5 chews elements 8 at a time. But what if n = 20 and W = 8? Two full vector passes handle 16 elements; the last 4 don't fill a lane. The compiler emits an epilogue — a small ordinary scalar loop (the tail loop) — to mop up the leftover elements one by one.

WHY. A wide add always processes exactly elements; it cannot process a partial group without either reading past the array (that would be the very out-of-bounds bug of Step 7) or masking lanes off. So the general, safe recipe is: vectorize the bulk, scalar-clean the remainder. The remainder count is — anywhere from to elements.

PICTURE. A bar of 20 elements: two full green vector blocks of 8, then an orange tail of 4 handled by a small scalar loop that feeds into the same s.

Figure — GCC - Clang flags — optimization (-O0 to -O3, -Os), warnings (-Wall, -Wextra), sanitizers

Step 7 — Edge case: when -O3 is slower than -O2

WHAT. -O3 may unroll and inline so aggressively that the loop body grows large. If the hot code no longer fits in the instruction cache (i-cache) — the tiny fast store the CPU keeps of recently used instructions — the CPU stalls fetching code from slow memory.

WHY. Every optimization is a bet. Inlining bets that removing call overhead beats the cost of bigger code. When the bigger code overflows the i-cache, the bet loses and the program runs slower. This is why the parent says measure, don't assume — see CPU Caches and Instruction Cache.

PICTURE. Left: -O2 body fits inside the i-cache box (green, fast fetch). Right: -O3 body spills past the i-cache edge (red overflow), forcing slow fetches from RAM.

Figure — GCC - Clang flags — optimization (-O0 to -O3, -Os), warnings (-Wall, -Wextra), sanitizers

Step 8 — Degenerate case: when the source is broken (UB)

WHAT. Now break the program: read a[i] where i runs past the array, or overflow a signed int. The whole chain of transforms above assumed a correct program. With undefined behaviour (UB) the as-if rule's premise is false, so its conclusion — "same observable behaviour" — no longer holds.

WHY. The optimizer is allowed to assume UB never happens. If your code has UB, the optimizer may delete a bounds check, assume a signed add never overflows, or assume a pointer is non-null — because a correct program would guarantee those. The result: works at -O0, crashes at -O2 — not an optimizer bug, a program bug (see Undefined Behaviour in C/C++).

PICTURE. Left: at -O0 the out-of-bounds a[4] happens to read stray memory and "works." Right: at -O2 the optimizer, assuming i < n always, reorders/removes a guard, and execution derails into the poisoned redzone.

Figure — GCC - Clang flags — optimization (-O0 to -O3, -Os), warnings (-Wall, -Wextra), sanitizers

The one-picture summary

Here is the whole journey on one canvas: the same source at the top, the cost per iteration dropping down the ladder as each optimization removes work — and the trap door on the right where UB voids the guarantee.

Recall Feynman retelling — say it in plain words

Imagine adding up a long grocery list. The -O0 way: for every single item you walk to the fridge, grab the running total on a sticky note, add one number, walk it back to the fridge — and also walk your place-in-the-list counter back and forth too. Painfully slow, but if a friend interrupts and asks "what's the total, and where are you?", it's always right there on the fridge.

The compiler's first fix (register allocation): keep both the total and the counter in your hand the whole time; only stick the total on the fridge once, at the end. Second fix (loop-invariant motion): stop re-figuring where the list even is every time — remember the spot, and just slide your finger down one line instead of multiplying to find it. Third fix (SIMD): grab eight items at once and add them in one motion, because addition doesn't care about grouping — and if the list doesn't divide evenly by eight, you finish the last few by hand (the tail).

Sometimes you go too eager (-O3): you spread so many tools across the counter (unrolled, inlined code) that there's no room to work and you slow down — that's the i-cache overflowing. So you measure, and often -O2 (or the compact -Os) wins.

And the trap door: all of this only works if your list is honest. If your program has undefined behaviour — reading past the end of the list, overflowing a number — the compiler was allowed to assume that never happens, so it may quietly cut a safety check. Now the fast version "breaks" while the slow one "worked." It's not the optimizer lying; it's your program that lied first. The sanitizer puts a tripwire (a poisoned redzone) right past your list so the lie is caught the instant it happens.

Recall

One-iteration -O0 cost, in symbols? ::: — six bus trips, one multiply, one add (loop-control omitted, common to all). After register allocation? ::: — one load, the surviving multiply, one register add; and stay in registers. What does loop-invariant motion remove that register allocation left behind? ::: The per-iteration multiply (), replaced by a running , giving . What legal rule lets the compiler delete those loads/stores/multiplies? ::: The as-if rule — only observable behaviour of a correct program must be preserved. What does SIMD divide the per-element cost by, and what handles the leftovers? ::: The vector width ; the last elements are done by a scalar tail loop. Why can -O3 lose to -O2? ::: Aggressive inlining/unrolling can overflow the instruction cache, stalling instruction fetch. Why does "works at -O0, crashes at -O2" almost always mean UB? ::: The optimizer is allowed to assume UB never happens; if it does, the as-if guarantee's premise is false.


Prerequisites and neighbours: Compilation Pipeline (preprocess-compile-assemble-link) · Makefiles and CFLAGS/LDFLAGS · Debugging with GDB and -g · Continuous Integration pipelines.