COMPILER DESIGN / 6. CODE GENERATION
Code Generation & Register Allocation
Translating IR to real machine code — instruction selection, register allocation, scheduling
EXPLANATION
Code generation is the final phase — it translates the optimized IR into actual machine code for the target architecture. This is where all the abstract work becomes real instructions the CPU executes. Three sub-problems: 1. Instruction Selection: Map IR operations to target machine instructions. Not always 1:1: - IR "t = a + b" → x86 "add rax, rbx" (but which registers?) - IR "t = a * 4" → x86 "lea rax, [rax*4]" (load effective address — faster than imul) - IR "t = a[i]" → x86 "mov rax, [rbx + rcx*8]" (memory addressing modes) Modern compilers use tree pattern matching (BURG — Bottom-Up Rewriting Grammar) to find optimal instruction sequences. 2. Register Allocation: Map the unlimited IR temporaries to the finite CPU registers. x86-64 has 16 general-purpose registers. LLVM IR has infinite virtual registers. Graph Coloring approach: - Build interference graph: nodes = variables, edge = "live at same time" (can't share register) - Graph k-coloring where k = number of registers - If a variable can't get a register → SPILL to memory (store to stack, load when needed) - Spilling is expensive (extra load/store instructions) Liveness analysis: variable v is LIVE at point p if there exists a path from p to a use of v not going through a definition of v. Computed by dataflow analysis (backward analysis): - LIVEOUT(b) = ∪ LIVEIN(successor blocks) - LIVEIN(b) = USE(b) ∪ (LIVEOUT(b) - DEF(b)) 3. Instruction Scheduling: Reorder instructions to avoid pipeline stalls (without changing semantics). After a memory load, the result isn't available for 3-4 cycles on modern CPUs (load-use hazard). Scheduler tries to put independent instructions between the load and the use. Calling Convention implementation: - Save caller-saved registers before call, restore after - Set up stack frame: push rbp, mov rbp rsp, sub rsp N (for local variables) - Put arguments in right registers/stack positions - Clean up stack on return Object code and linking: - Assembler: converts assembly text to object file (.o) — binary machine code with relocation entries - Linker: combines .o files, resolves external references (printf, malloc), produces executable - Dynamic linker (ld.so): at load time, resolves shared library symbols Just-In-Time (JIT) compilation: - Start by interpreting (fast startup) - Profile which functions are "hot" (called frequently) - Compile only hot functions to native code at runtime - Can use runtime information unavailable to ahead-of-time compilers (actual types, branch frequencies) - Used by: V8 (JavaScript), JVM (Java), PyPy (Python), LuaJIT
DIAGRAM
REGISTER ALLOCATION — interference graph: TAC: Liveness: t1 = a + b t1: live at lines 2-4 t2 = t1 * c t2: live at lines 3-4 t3 = t2 - d t3: live at line 4 only result = t1 + t3 a,b,c,d: live before use Interference Graph: t1 ─── t2 (live at same time → can't share register) t2 ─── t3 (live at same time) t1 and t3: NOT interfering → CAN share register! Register assignment (3 registers: R1,R2,R3): t1 → R1, t2 → R2, t3 → R1 (reuse! t1 dead by then) ASSEMBLY OUTPUT (x86-64): ; int result = (a+b)*c - d + (a+b) ; After CSE: t1=a+b computed once mov rax, [a] ; load a add rax, [b] ; rax = a+b (t1 → rax) mov rbx, rax ; save t1 for later imul rax, [c] ; rax = t1*c (t2 → rax) sub rax, [d] ; rax = t2-d (t3 → rax, t1 already in rbx) add rax, rbx ; rax = t3 + t1 = result mov [result], rax ; store
CODE