The back end turns optimised, machine-independent IR into a concrete instruction stream for a real ISA. Three classic, interacting subproblems dominate — choosing instructions (selection), mapping unbounded virtual values onto finite registers (allocation), and ordering instructions to keep the pipeline busy (scheduling) — all bound by the target's calling convention. We work each one, then walk a function from IR to x86-64 and AArch64.
The back end is where portability ends and the silicon begins. By the close you will have walked optimised IR all the way to x86-64 and AArch64 assembly, naming every instruction and register chosen.
Optimisation (Part 07) left us with clean, machine-independent IR — unbounded virtual registers, abstract operations, no notion of the target. Code generation lowers this to a concrete instruction stream: real opcodes, real registers, a real stack frame, obeying a real ABI. Three subproblems dominate — and they interact, which is what makes the back end hard.
Selection fixes which instructions exist — and so which registers they demand. Allocation may then insert spill code, creating new loads and stores that selection never saw. Scheduling reorders to hide latency — but spreading live ranges apart raises register pressure, fighting the allocator. No order is globally optimal; real compilers iterate and approximate.
The ISA dictates everything downstream. A register is a small, fast, named store inside the CPU; an addressing mode is a rule for computing an operand's memory address. The deepest split is RISC vs CISC.
add [rax], rbxbase + index*scale + dispISA choice reshapes the back end: CISC's memory operands make instruction selection matter more (one instruction can do a load + an add); RISC's load/store regularity makes scheduling and register pressure the main game. A good selector exploits exactly the addressing modes the target offers.
The three ISAs a modern back end is most likely to target. They differ in register count, encoding and addressing — each pushing codegen in a different direction.
| Property | x86-64 | AArch64 (ARM64) | RISC-V (RV64) |
|---|---|---|---|
| Class | CISC | RISC (load/store) | RISC (load/store) |
| GP registers | 16 (RAX…R15) | 31 + XZR/SP | 32 (x0=zero … x31) |
| Instr. width | 1–15 bytes (variable) | 4 bytes fixed (+ 16-bit Thumb on A32) | 4 bytes (+ 2-byte "C" compressed) |
| Mem operands on ALU | yes | no | no |
| Addressing | base+index*scale+disp | base+offset, pre/post-index | base+12-bit disp only |
| Flags register | RFLAGS (implicit) | NZCV (explicit, opt-in) | no flags — compare-and-branch |
| Calling-convention args | RDI, RSI, RDX, RCX, R8, R9 | X0–X7 | a0–a7 (x10–x17) |
Dense code, but variable-length decode and few registers stress the allocator. Two-operand form (dst is also a source) forces extra copies.
Three-operand, 31 GPRs, clean encoding — the allocator breathes easy. x31 reads as zero or means SP by context.
Minimal, extensible base (RV64I) plus optional extensions (M, A, F, D, C, V). x0 is hard-wired zero — a free constant.
Instruction selection maps IR operations to target instructions. It is rarely one-to-one. The mapping is both many-to-one (a multiply-by-4 plus add becomes a single lea) and one-to-many (a 64-bit divide may expand to a sequence). The selector must find a covering of the IR by available instructions — ideally the cheapest one.
IR t1 = i*4; t2 = b + t1; v = load t2 — the whole address calculation and the load fold into a single x86 instruction:
mov rax, [rbx + rcx*4] ; one instr
A high-level IR op may have no single instruction. A 128-bit add, a select, or saturating arithmetic each expand to several real instructions.
lea, madd, fused multiply-add) cuts instruction count and register pressureCover the IR DAG/tree completely, every node accounted for, choosing tiles (instructions) that minimise total cost. This is the tiling problem.
From dead-simple to provably optimal, selection techniques trade implementation effort against code quality.
| Approach | Idea | Code quality | Notes |
|---|---|---|---|
| Macro / template expansion | Emit a fixed instruction sequence per IR op, independently | poor | Trivial; misses every cross-op opportunity. Needs heavy peephole cleanup |
| Tree pattern matching (tiling) | Cover the expression tree with instruction "tiles"; maximal munch | good | Greedy; pick the largest matching tile at each node |
| BURS / DP tiling | Dynamic programming over the tree; bottom-up rewrite system | optimal (per tree) | iburg, BEG, lburg — cost-minimal tiling in linear time |
| DAG-based selection | Match over the DAG, sharing common sub-expressions | better than trees | NP-hard in general; LLVM's SelectionDAG |
Top-down greedy: at each node pick the largest tile that matches, recurse on the leaves it left uncovered. Fast and produces good code; not guaranteed optimal because a locally-large tile can force costlier choices below.
Two passes: a bottom-up labelling pass computes the minimum cost to produce each non-terminal at each node (dynamic programming), then a top-down pass reads off the optimal tiles. iburg (Fraser, Hanson, Proebsting) generates such selectors from a cost-annotated grammar.
Take a[i] = a[i] + 1 where a is a 4-byte array at base rbx and i is in rcx. The address tree MEM(+ (base, * (i, 4))) can be covered by tiles. On x86-64, a single addressing mode swallows the whole sub-tree.
A naive tiler would emit a lea to compute the address, a load, an add and a store. A good selector recognises the whole address sub-tree as one addressing-mode tile — and on x86 may fold the increment into a single memory-destination add.
Real selectors operate on a DAG, not a tree, so common sub-expressions are shared. LLVM has shipped two selectors; the newer one was built to fix the older's weaknesses.
.td TableGen rules + custom C++MachineInstr list-O0; growing elsewhereWhatever the selector, a final peephole pass scans short instruction windows and rewrites local patterns — killing redundant moves, folding mov+add into lea, replacing mul-by-constant with shifts, removing xor reg,reg redundancies. It mops up what selection missed.
The IR has unbounded virtual registers; the machine has k physical ones (16 on x86-64, 31 on AArch64). Register allocation maps virtual registers onto physical registers, and when there are not enough, spills some values to memory (stack slots).
A value is live from its definition to its last use. Its live range is the set of program points where it holds a needed value. Two values interfere if their live ranges overlap — they cannot share a register.
When pressure exceeds k, a value is kept in a stack slot and loaded/stored around each use. Spills cost memory traffic — the allocator picks which values to spill to minimise that cost.
Computed by backward dataflow: a variable is live at a point if some path forward uses it before redefining it. Liveness gives the live ranges the allocator reasons about.
The ABI splits registers: callee-saved survive a call (the allocator prefers them for values live across calls); caller-saved must be spilled by the caller if needed after a call. The allocator is ABI-aware.
Build the interference graph: one node per value, an edge between any two that interfere (overlapping live ranges). Assigning registers so that no two interfering values share one is exactly graph k-colouring — colour the nodes with k colours (registers) so no edge joins two same-coloured nodes. Chaitin (1981) made this the standard formulation.
If the graph is k-colourable, every value fits in a register. b and c don't interfere, so they share RBX; likewise d,e.
General graph k-colouring is NP-complete (Chaitin's reduction). So allocators use heuristics — simplify, spill, select — rather than seeking an exact optimum.
Some values are pinned to specific registers (ABI args in RDI/RSI…, the div result in RAX/RDX). These appear as pre-coloured nodes the rest must avoid.
The classic colouring allocator works by graph simplification. The key lemma: a node with fewer than k neighbours (low-degree) can always be coloured after its neighbours, so remove it and recurse.
Original formulation: a node that must be spilled is spilled immediately and the graph rebuilt.
Key improvement: a high-degree node is optimistically pushed onto the stack anyway. At select time it might still find a free colour — because several of its neighbours often share colours. Only if no colour is free does it actually spill. This colours many graphs Chaitin would have spilled.
Choose the spill candidate minimising cost / degree, where cost ≈ Σ (use/def frequency, loop-depth weighted). Spill cheap, rarely-used, high-degree values.
Two refinements make colouring allocators competitive: removing pointless copies (coalescing) and breaking up troublesome ranges (splitting).
If y = mov x and x,y don't otherwise interfere, give them the same register and the move vanishes. Merge their nodes in the graph.
But aggressive coalescing raises degree and can make a colourable graph un-colourable. Hence conservative coalescing:
x already interferes with y or is low-degreeThe opposite move: split one long live range into several shorter ones connected by copies. A value can then live in a register where pressure is low and be spilled only across the busy region — instead of spilling the whole range.
Coalescing merges ranges (fewer moves, higher degree); splitting divides them (more moves, lower degree, easier colouring). A good allocator interleaves both — the iterated register coalescing of George & Appel (1996) is the canonical scheme.
Graph colouring is powerful but slow — building and simplifying the interference graph is super-linear. JITs and -O0 need speed. Linear scan (Poletto & Sarkar, 1999) skips the graph entirely.
[start, end] over a linearised instruction numberingHotSpot's client (C1) compiler, early V8, many JITs and WebAssembly engines. Linear in practice; near-optimal for straight-line-heavy code. Extended forms (Wimmer–Mössenböck) handle holes and SSA.
At the dashed cut, v1,v2,v3,v5 overlap. With k=3 one must spill — pick the furthest-ending (v2).
A beautiful result reshaped allocation theory: the interference graph of a program in SSA form is chordal — and chordal graphs are colourable in polynomial time.
The chromatic number equals the maximum register pressure (max simultaneously-live values) — but spilling to get pressure below k, and resolving φ-copies on SSA destruction, remain NP-hard. SSA tames colouring, not the whole problem.
Cast allocation as a Partitioned Boolean Quadratic Problem: each value gets a cost vector over its candidate registers/spill, and each interference edge a cost matrix. Minimise total cost.
-regalloc=pbqp)LLVM's production allocator is Greedy — a global, priority-ordered allocator with live-range splitting and eviction, neither pure colouring nor pure linear scan, but fast and high-quality.
Modern CPUs are pipelined: an instruction takes several cycles, and the next can start before the previous finishes — unless a hazard stalls it. Scheduling reorders instructions to hide latency and keep functional units busy, without changing the result.
An instruction needs a result not yet ready. add after a load that misses cache stalls until the value arrives. RAW (read-after-write) is the common true dependence.
Two instructions want the same functional unit (one divider, one load port) in the same cycle. One must wait.
A branch's direction isn't known until late, so the pipeline may fetch wrong instructions. Mitigated by branch prediction; the scheduler fills any branch delay slots (on older RISC).
Integer add: ~1 cycle. Load (L1 hit): ~4. Multiply: ~3. Divide: 20–40. FP varies. The scheduler knows the target's latency table and issues independent work into the shadow of a slow instruction.
An out-of-order core (most desktop x86/ARM) reorders dynamically, so scheduling matters less — but still helps fetch & register pressure. An in-order core (many embedded, some Atom/A55) depends critically on a good static schedule.
Within a basic block, build the data-dependence DAG: nodes are instructions, edges are dependences (true RAW, plus anti/output and memory ordering), weighted by latency. List scheduling greedily picks, each cycle, a ready instruction (all predecessors done) of highest priority — usually longest path to the end (critical path).
Issue i1 and i2 (the two independent loads) back-to-back so their 4-cycle latencies overlap; by the time i3 needs both, they're ready. A naive in-order emit would stall twice.
Among ready instructions, prefer the one on the longest remaining path to the block's end. Ties broken by latency, then by register-pressure impact.
Local (one block) is standard; superblock / trace scheduling crosses blocks along hot paths for more freedom.
Moving an instruction's definition earlier (to overlap latency) lengthens its live range — more values live at once — raising register pressure and risking spills. The two phases pull opposite ways.
Many compilers schedule before allocation (to expose parallelism) and again after (to clean up spill code and finalise ordering).
For loops, overlap iterations: start iteration i+1's independent work while i finishes. The body becomes a steady-state kernel bracketed by a prologue and epilogue.
On big out-of-order cores the hardware already overlaps iterations dynamically, so software pipelining's payoff is largest on in-order and explicitly-parallel machines.
The ABI (Application Binary Interface) is the contract that lets separately-compiled functions interoperate: who passes arguments where, who preserves which registers, how the stack is laid out. The back end must obey it exactly — it constrains the allocator and shapes every call.
RDI, RSI, RDX, RCX, R8, R9 — then the stackXMM0–XMM7RAX (and RDX for 128-bit / second word)callRSP a leaf function may use without adjusting RSP| Caller-saved (volatile) | Callee-saved (preserved) |
|---|---|
| RAX RCX RDX | RBX RBP |
| RSI RDI | R12 R13 |
| R8 R9 R10 R11 | R14 R15 |
Windows x64 uses RCX, RDX, R8, R9 + 32-byte shadow space, no red zone. AArch64 AAPCS64 passes in X0–X7, returns in X0; X19–X28 callee-saved. Same idea, different registers.
Each call gets a stack frame: storage for spills, locals, callee-saved registers and outgoing arguments. The prologue sets it up; the epilogue tears it down. The stack grows down on x86-64.
push rbp ; save caller's RBP
mov rbp, rsp ; set frame pointer
sub rsp, 48 ; reserve locals+spills
; (keep 16-byte aligned)
push rbx ; save callee-saved used
pop rbx ; restore callee-saved
mov rsp, rbp ; or: leave
pop rbp
ret
A frame pointer (RBP) gives stable offsets & easy unwinding; -fomit-frame-pointer frees RBP as a GPR and addresses locals off RSP — one more register for the allocator.
The calling convention is not advisory — it injects hard constraints straight into register allocation.
call — pre-coloured nodescall clobbers all caller-saved registers: model it as defining (killing) themA value needed after a call should live in a callee-saved register (it survives the call for free) — or be spilled. The allocator weighs the one-off cost of saving a callee-saved register in the prologue against repeated caller-saved spills.
RAX:RDX (or XMM pair) per SysV field classificationRDI (sret); the other args shift rightVariadic calls set AL = number of vector args used. The 16-byte alignment requirement at call forces the prologue's sub rsp to round up — the allocator and frame-layout pass cooperate to keep it.
Let's take one small function all the way down. It sums squared differences — one parameter passed, a loop, a return value.
C source:
int sumsq(int *a, int n) {
int s = 0;
for (int i = 0; i < n; i++)
s += a[i] * a[i];
return s;
}
a[i], the multiply, the adda, n, i, s, the loaded elementa[i] only once (CSE already shared it)Optimised SSA-ish IR (loop body):
loop:
%i = phi i32 [0, %entry], [%i.n, %loop]
%s = phi i32 [0, %entry], [%s.n, %loop]
%p = getelementptr i32, i32* %a, i32 %i
%x = load i32, i32* %p
%sq = mul i32 %x, %x
%s.n = add i32 %s, %sq
%i.n = add i32 %i, 1
%c = icmp slt i32 %i.n, %n
br i1 %c, label %loop, label %exit
Args arrive per the ABI: x86-64 a→RDI, n→ESI, return in EAX; AArch64 a→X0, n→W1, return in W0. Selection picks scaled loads and a fused multiply on ARM; allocation keeps the hot values in registers — no spills needed.
x86-64 (System V):
sumsq:
xor eax, eax ; s = 0 (return reg)
xor ecx, ecx ; i = 0
test esi, esi ; n <= 0 ?
jle .done
.loop:
mov edx, [rdi+rcx*4] ; x = a[i] (scaled)
imul edx, edx ; x*x
add eax, edx ; s += x*x
add ecx, 1 ; i++
cmp ecx, esi
jl .loop
.done:
ret ; result in EAX
AArch64 (AAPCS64):
sumsq:
mov w8, wzr // s = 0
mov w9, wzr // i = 0
cmp w1, #0
b.le .done
.loop:
ldr w10, [x0, w9, sxtw #2] // x = a[i]
madd w8, w10, w10, w8 // s += x*x (FMA-int)
add w9, w9, #1 // i++
cmp w9, w1
b.lt .loop
.done:
mov w0, w8 // return s
ret
xor eax,eax is the idiomatic zero on x86 (smaller, breaks dependences); wzr is the AArch64 zero register. madd fuses multiply-add into one instruction — selection exploited an ISA feature x86 lacks for integers. Both loops are leaf-free of spills, so neither needs a frame: pure red-zone/frameless code.
The final step turns the scheduled MachineInstr stream into bytes. Two paths: write assembly text and shell out to an external assembler, or emit an object file directly. LLVM's MC layer does both from one representation.
.s)-S prints.o) itself — the integrated assemblerclang -cMCInst is a target-neutral instruction record. The same MC stream drives:
MCAsmStreamer → textual .sMCObjectStreamer → .o via an MCCodeEmitter (encodes opcodes to bytes) and an MCAsmBackend (relaxation, fixups)One encoder, two outputs — the assembler and the compiler share machinery.
The emitter resolves each instruction to its binary form — opcode, ModRM/SIB on x86, fixed fields on AArch64 — and lays out sections (.text, .data, .rodata).
The compiler emits one object file at a time and cannot know the final address of anything outside it — an external function, a global, a string. It leaves a relocation: a note telling the linker "patch this field once you know the address". This is the bridge to Part 09.
call printf ; → R_X86_64_PLT32 reloc
lea rax, [rip + msg] ; → R_X86_64_PC32
.oLinking, dynamic loading, the GOT/PLT for position-independent code, and just-in-time compilation — where code is generated, relocated and executed at run time — are the subject of the next deck.
call, offers a 128-byte red zone — and constrains the allocator.s or .o; relocations defer addresses to the linkerRuntimes, JITs & Modern Backends — linking & loading, garbage collection, the GOT/PLT, just-in-time compilation, tiered execution and tracing JITs.