Compilers — Build a Small Language from Start to Finish
Reading What -O2 Did
In one line
The stages you have built so far — parsing, semantic analysis, intermediate representation, optimization, register allocation, and code generation — are found as they are inside gcc and clang too. If you cut their output (the assembly of -S, the LLVM IR of -emit-llvm, the machine code of objdump -d) by function and count it, what optimization did — whether it melted away a call, folded a loop into a single constant, turned a division into a multiplication, or turned recursion into a loop — shows up in numbers.
Why this was needed
Even if you know "building with -O2 makes it faster," if you do not know what got faster, you can only guess when facing a performance problem. Conversely, when you meet a bug that "goes wrong only at -O2," there is something you can check before suspecting the compiler — whether that code relies on undefined behavior. In both cases, the answer is to read directly what the compiler produced. This module attaches that reading eye to what you built yourself in the previous nine modules.
How it works
Each function in the material /opt/fixtures/mini/real/opt.c aims at one transformation.
| Function | What -O2 does | What you see in the assembly |
|---|---|---|
sum_to |
Computes the loop that adds 1 through 100 in its entirety | Two lines, movl $5050, %eax and ret |
sum_squares |
Melts in the square call (inlining) |
call square disappears |
div10 |
Division (idiv, tens of cycles) into multiplication and a shift | movabsq $7378697629483820647 · imulq · sarq |
fact |
Tail recursion into a loop | call fact disappears and a backward jump appears |
always |
x + 1 > x always true (assuming no signed overflow) |
movl $1, %eax · ret |
fib |
Partially unrolls the recursion and uses up all the registers | The instruction count can exceed ten times that of -O0 |
The magic number of division. x / 10 equals x × 0x6666666666666667 ÷ 2^66 (with one more correction for negatives). 0x6666…67 is 2^66 / 10 rounded up. Multiplication takes a few cycles and division tens, so when the divisor is a constant, the compiler almost always converts it this way. If you change the divisor and recompile, the magic number changes too — that is why this lab's grader builds variants.
Undefined behavior and optimization. always(int x) { return x + 1 > x; } overflows when x is INT_MAX, but in C, signed integer overflow is undefined behavior. The compiler may assume "that does not happen," so it folds the whole expression to 1. The unsigned uwrap cannot be treated that way because overflow is defined as wrapping, and the actual comparison remains. This is why Mini defined overflow as "wraps" — once defined, the interpreter, the VM, and native code must all give the same answer, and the optimizer cannot change that answer.
LLVM IR and SSA. clang -S -emit-llvm shows LLVM's intermediate representation as text. At -O0, every local variable is one alloca (a stack slot), with a load each time it is used and a store each time it is changed — the same approach as module 8's code generator. At -O1, those slots disappear (mem2reg, SROA), and a phi appears where two paths merge — exactly the φ whose position you computed with dominance frontiers in module 7.
-O0: fib %2 = alloca i64 -O1: fib %6 = phi i64 [ … ], [ … ]
store i64 %0, ptr %3 (alloca·load·store 없음)
%4 = load i64, ptr %3
A JIT does the same thing. The JVM's JIT (HotSpot C2) and the browser's V8 run these stages while running. The only difference is that facts seen while running (this call site always gets this type) are added to the input, so the transformations you read in this module — inlining, constant folding, loop transformation — also show up as they are in JIT logs.
What it looks like in the field
- Compiler Explorer and
objdump -d. In a performance review, the fastest way to check "was this loop vectorized" or "was this function inlined" is to read the output.objdump -d --no-show-raw-insn 실행파일(the placeholder is the executable) shows the same thing on a binary that is already baked. - Bugs that appear only at -O2. Signed overflow, uninitialized variables, and violations of pointer aliasing rules happen to be right at -O0 and wrong at -O2. You check first with
-fwrapv(defines signed overflow as wrapping) or-fsanitize=undefined. - Build options are the contract. Changing the
-Olevel of a release binary should be treated with the same weight as changing the code. Even with the same source, the instruction count differs by ten times, and the result of undefined behavior changes.
What you will do in the next lab
In readasm.py, you build a parser that cuts gcc assembly into per-function instruction lists, the instruction count and call list, finding functions folded into a single constant, extracting the magic number of division, counting backward jumps (the trace of loops), counting the per-function instructions and instruction kinds of LLVM IR, and comparing the memory instructions and φ of -O0 and -O1. Finally, you count opt.c and the fib from the code generator you built in module 8 side by side and leave them in a report.