TT Lab
Get started
Learn Learning paths Courses

Compilers — Build a Small Language from Start to Finish

Reading What -O2 Did

Continue in TT Lab

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

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.