Skip to content

What the optimizer does: twenty-nine instructions, or one

Level: 201 · working knowledge

One line: Sum ten numbers in a loop and ask for optimization, and the machine code contains no loop, no array and no addition — just the answer, because the optimizer is allowed to replace your program with any program that behaves the same.

This is the experiment from Laurie Kirk's Thinking Like a Compiler: Obfuscation from the Other Side (RE//verse 2026), run in Rust. The talk builds an array of 1..=10 in C++, sums it with std::accumulate, and shows the two ends of the optimizer's range in Compiler Explorer ↗: at -O0 a stack frame, ten stores, and calls to begin, end and accumulate; at -O3 the entire function is mov eax, 55 followed by ret.

Rust does the same thing, through the same optimizer.

#[unsafe(no_mangle)]
pub extern "C" fn calc_accum() -> i32 {
    let numbers: [i32; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    numbers.iter().sum()
}

Unoptimized: everything you wrote, in order

rustc --edition 2024 --emit asm -C opt-level=0 accum.rs
rustc 1.98.0, x86_64-apple-darwin — abridged: .cfi and label lines removed
_calc_accum:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$64, %rsp
	movl	$1, -40(%rbp)
	movl	$2, -36(%rbp)
	movl	$3, -32(%rbp)
	movl	$4, -28(%rbp)
	movl	$5, -24(%rbp)
	movl	$6, -20(%rbp)
	movl	$7, -16(%rbp)
	movl	$8, -12(%rbp)
	movl	$9, -8(%rbp)
	movl	$10, -4(%rbp)
	leaq	-40(%rbp), %rdi
	movl	$10, %esi
	callq	__RNvMNtCsl7QZrza34zr_4core5sliceSl4iter...   # numbers.iter()
	movq	%rdx, -56(%rbp)
	movq	%rax, -48(%rbp)
	jmp	LBB8_2
LBB8_1:
	callq	__RNvNtCsl7QZrza34zr_4core9panicking19panic_cannot_unwind
LBB8_2:
	movq	-56(%rbp), %rsi
	movq	-48(%rbp), %rdi
	callq	__RINvYINtNtNtCsl7QZrza34zr_4core5slice4iter4Iter...   # .sum()
	movl	%eax, -60(%rbp)
	movl	-60(%rbp), %eax
	addq	$64, %rsp
	popq	%rbp
	retq

Twenty-nine instructions. The array is really built on the stack, one movl per element; iter() and sum() are really called; there is even a branch to a panic handler. The two long mangled names are the same information a C++ reader gets from std::array<int, 10ul>::begin() — the module path and generic arguments, encoded so the linker can tell two instantiations apart.

Optimized: the answer, and nothing else

rustc --edition 2024 --emit asm -C opt-level=3 accum.rs
rustc 1.98.0, x86_64-apple-darwin — abridged: .cfi and label lines removed
_calc_accum:
	pushq	%rbp
	movq	%rsp, %rbp
	movl	$55, %eax
	popq	%rbp
	retq

Five instructions, and three of them are the macOS frame-pointer convention rather than anything to do with the sum. The work is movl $55, %eax — the same mov eax, 55 the talk gets from Clang, from the same LLVM optimizer that Clang uses. The array, the iterator, the ten additions and the panic branch are all gone, because none of them is observable from outside the function.

That is worth stating precisely, because it is the rule the whole stage runs on: an optimization may change how long a program takes, how large it is, and what its assembly looks like. It may not change what the program does. Everything LLVM did here follows from that permission — it proved the result is always 55, so a function returning 55 is a legal replacement.

Verified output of what_the_optimizer_does.rs — regenerated by tools/run_examples.py, never hand-typed.

calc_accum()      = 55
black_box version = 55
same answer       = true

Those three lines come from an unoptimized build — the runner behind every example in this library compiles without -O, so the recorded answer key is the twenty-nine-instruction version's output. It is identical to the optimized one, which is the point.

Where this bites

A benchmark that measures nothing. If a timed loop's inputs are constants, LLVM folds the whole thing away and you measure an empty function — a real result in this repo timed at 0.0 ms before std::hint::black_box was wrapped around the inputs, which is what the example above uses to keep its second sum honest. The story is in Scale the denominator away.

A debugger that lies. Step through an optimized build and the line marker jumps backwards, locals read as "optimized out", and functions you can see in the source have no frame in the backtrace. Nothing is broken: the code those lines described no longer exists. This is why [profile.dev] exists at all, and why reduced debug info is a trade rather than a free win.

Timing you did not ask for. Constant folding is why a cryptographic routine cannot be written naively — an optimizer that shortcuts a comparison the moment it can prove the answer has just made the comparison's duration depend on the data. Defending against that means fighting the optimizer deliberately, with volatile reads and black_box, and it is the same machinery as everything above, pointed the other way.

The same machinery, hostile

The talk's subject is the last of those, generalized: if a compiler pass can rewrite your program into a faster one, a pass can rewrite it into an unreadable one, and the toolchain will run it just as obediently. That is what an obfuscator is — not a separate tool bolted on afterwards, but an LLVM pass in the same pipeline, inserted at whichever of the three stages suits it. Control-flow flattening is that map: source, compilation, linking, and what an analyst finds left over at each.

Try it yourself

Compiler Explorer ↗ takes Rust as well as C++ — pick a Rust compiler in the language dropdown, paste the function above, and put -C opt-level=3 in the options box. Two things are worth doing while you are there: set the two panes to different optimization levels to watch the function collapse, and switch the compiler while keeping the source fixed, which shows how little of this is about the language.

See also

  • What a compiler does before your program runs — the other route to 55: const evaluation runs your loop during the build, with no optimizer involved
  • Compile times — codegen is where this work happens, and usually where a build's seconds go
  • LLVM and its IR — the language this transformation is actually performed in, before any assembly exists
  • Static vs dynamic dispatch — monomorphization, the thing that gives the optimizer a concrete function to work on in the first place
  • black_box is a hint — what the function in the example above promises: a value, not the work that produced it, so a summing loop can still become a formula

Po polsku

Liczby z tej strony są najlepszą odpowiedzią na pytanie, które w polskich dyskusjach wraca co miesiąc: czy iteratory są wolniejsze od zwykłej pętli for. Ta sama funkcja to dwadzieścia dziewięć instrukcji przy -C opt-level=0 i pięć przy -C opt-level=3, przy czym trzy z tych pięciu to konwencja ramki stosu na macOS, a cała praca mieści się w movl $55, %eax. Tablica, iterator, dziesięć dodawań i gałąź do obsługi paniki znikają, bo z zewnątrz funkcji nie da się ich zaobserwować. To jest „abstrakcja bez kosztu” (zero-cost abstraction) pokazana, a nie zadeklarowana — i nie jest to sztuczka Rusta, bo robi to dokładnie ten sam optymalizator LLVM, z którego korzysta Clang.

Zasada, na której stoi cały ten etap, ma w polskiej literaturze o C++ swoją nazwę: reguła „jak gdyby” (as-if rule). Optymalizator może zmienić czas działania, rozmiar i postać kodu maszynowego — nie może zmienić tego, co program robi. Wszystko powyżej wynika z tego jednego pozwolenia: skoro da się dowieść, że wynik zawsze wynosi 55, to funkcja zwracająca 55 jest legalnym zamiennikiem. Przekształcenie, które zjadło tu całą pętlę, nazywa się po polsku zwijaniem stałych (constant folding).

Trzy konsekwencje warto znać z góry. Po pierwsze pomiary: cargo run i cargo build budują wersję nieoptymalizowaną, więc zdanie „sprawdziłem i Rust wyszedł wolny” prawie zawsze znaczy „mierzyłem wersję debug” — do mierzenia jest --release, a gdy dane wejściowe są stałymi, to i tam pętla zniknie i zmierzysz pustą funkcję (od tego jest std::hint::black_box). Po drugie debugger: w zoptymalizowanym buildzie zmienne pokazują się jako optimized out, a znacznik linii skacze do tyłu — nic się nie zepsuło, po prostu tego kodu już nie ma. Po trzecie kryptografia: optymalizator, który skraca porównanie, gdy tylko zna wynik, uzależnia czas tego porównania od danych, więc kod o stałym czasie działania pisze się przeciwko niemu. I to samo pozwolenie, obrócone w drugą stronę, daje obfuskator — nie osobne narzędzie uruchamiane po fakcie, tylko zwykły przebieg (pass) w tym samym potoku LLVM.

Szukaj po polsku: zwijanie stałych · reguła „jak gdyby” · abstrakcja bez kosztu · rust black_box benchmark · rust opt-level asm