8.4 Target Code Generation, Register Allocation & Peephole Optimization
π‘ Core Intuition: The 3-Second Mental Modelβ
π³ The Everyday Analogy: The Master Carpenter and the Finite Toolbeltβ
Imagine a master carpenter building custom furniture:
- In the design studio (Frontend & ICG), the architect sketches plans using an unlimited assortment of specialized virtual tools.
- On the actual construction job site (Backend Hardware Silicon), the carpenter has a toolbelt with only 4 leather pockets (Physical Registers).
- Register Allocation via Graph Coloring: If the hammer and the screwdriver are needed at the exact same second, they cannot share the same pocket; they interfere. The carpenter creates a conflict chart: if two tools conflict, they must be assigned different colored pockets.
- Spilling to Memory: If 6 critical tools are needed simultaneously, but the toolbelt holds only 4, the carpenter must place the least urgent tool in the chest across the room (Spill to RAM). Walking across the room to fetch it costs 100 times more effort!
- Peephole Optimization: A senior foreman standing beside the bench observing every motion through a narrow 3-step window. If the worker puts the hammer down and picks it right back up a split second later, the foreman corrects them: "Keep it in your hand; eliminate unnecessary motion!"
π» Bridging to Computer Science: Lowering IR to Hardware Siliconβ
Target code generation is the final phase of compilation:
- Instruction Selection: Mapping abstract three-address quadruples into target-machine assembly instructions.
- Register Allocation vs. Register Assignment:
- Allocation: Selecting which subset of variables and temporaries reside in high-speed hardware registers at each execution point.
- Assignment: Deciding the specific physical hardware register (
RAX,RBX,RCX, etc.) bound to each selected variable.
- Graph Coloring Formulation: Formulating global register allocation as an NP-complete -vertex coloring problem over an Interference Graph.
- Peephole Optimization: A local target-level optimization that slides a small window across generated machine code, replacing redundant sequences with optimal hardware instructions.
πTable of Contents
- ποΈ Architectural Foundations: The Challenges of Code Generation
- π Formal Foundations: Register & Address Descriptors
- π¨ Register Allocation via Graph Coloring (Chaitin-Briggs & Kempe Heuristic)
- π Peephole Optimization Techniques
- π Real-World Production Context: Graph Coloring (AOT) vs Linear Scan (JIT)
- π Numerical Applications & Worked Examples
- π― Exam & Interview Pitfall Check
ποΈ Architectural Foundations: The Challenges of Code Generationβ
Register Allocation vs Instruction Scheduling
The two foundational challenges of target code generation
Register Allocation & Assignment
- β’Maps infinite virtual variables to a small, fixed set of K physical registers.
- β’Formulated as NP-Complete K-Vertex Coloring on an Interference Graph.
- β’Minimizes catastrophic memory spill traffic between CPU and RAM.
- β’Governed by Kempe's heuristic (Simplify, Spill, Select).
Instruction Scheduling
- β’Reorders legal machine instructions to prevent CPU pipeline stalls.
- β’Hides memory load latencies and floating-point execution delays.
- β’Must preserve all true (RAW), anti (WAR), and output (WAW) dependencies.
- β’Frequently competes with register allocation: scheduling increases live ranges!
π Formal Foundations: Register & Address Descriptorsβ
In a basic code generator operating on basic blocks, the compiler maintains two dynamic runtime data structures to track variable locations:
1οΈβ£ Register Descriptor (reg_desc)β
Tracks what variables or temporaries are currently held in each physical CPU register.
- denotes that physical register contains the current, valid value of variables and .
- At the start of a basic block, all registers are initially empty: .
2οΈβ£ Address Descriptor (addr_desc)β
Tracks the current location(s) where the value of a named variable can be found (in memory, in register R1, in register R2, etc.).
- indicates variable currently resides both in its assigned RAM memory location and inside CPU register .
The getReg() Register Allocation Decision Stack
Priority cascade for acquiring a register for instruction x = y op z
1. Reuse Register of Operand y
If y is currently in register Ry, and y has no further uses (dead), reuse Ry for x.
2. Allocate Any Free Register
If an unused physical register R is currently empty, allocate R directly to x.
3. Spill an Occupied Register (Lowest Eviction Cost)
Select an occupied register R to spill. Emit ST (store) to memory if value is dirty, then reuse R.
π¨ Register Allocation via Graph Coloring (Chaitin-Briggs & Kempe Heuristic)β
Modern optimizing compilers formulate register allocation as the Graph Coloring Problem: Given an Interference Graph and physical registers, can we assign one of the colors to each vertex such that no two adjacent vertices share the same color?
πΈοΈ Constructing the Interference Graphβ
- Vertices (): Every variable, temporary, or virtual register.
- Edges (): An undirected edge exists between and if and are simultaneously live at some program point. If and are live at the same time, they cannot occupy the same physical register!
Kempe's Heuristic for K-Register Allocation
The Simplify - Spill - Select algorithmic pipeline
Simplify
Find node v with degree < K. Push v onto stack; remove v and its incident edges from graph.
Spill Decision
If all nodes have degree >= K, pick a node to spill to RAM and remove it.
Select (Coloring)
Pop nodes from stack in reverse order; assign each node a free color != its neighbors.
Kempe's Theorem (1879):β
If an undirected graph contains a vertex with degree :
- The graph is -colorable if and only if the reduced graph is -colorable!
- Proof Intuition: When we pop back off the stack, its neighbors occupy at most colors. Since , there is guaranteed to be at least one color free among the colors for vertex !
π Peephole Optimization Techniquesβ
Peephole Optimization is a local target-code optimization technique that slides a small window (typically 2 to 4 instructions) over the generated assembly, replacing inefficient instruction sequences with faster or smaller equivalents.
1οΈβ£ Redundant Load/Store Eliminationβ
; Before:
ST x, R0 ; Store R0 into memory variable x
LD R0, x ; Redundant! R0 already contains x!
; After:
ST x, R0 ; Second instruction completely erased!
2οΈβ£ Unreachable Code Eliminationβ
Instructions immediately following an unconditional branch that have no incoming label can never be reached:
; Before:
JMP L2
ADD R1, R2, R3 ; Dead code: unreachable!
L2: ...
; After:
JMP L2
L2: ...
3οΈβ£ Flow of Control Optimization (Jump Threading)β
; Before:
JMP L1
...
L1: JMP L2 ; Jump to a jump!
; After:
JMP L2 ; Direct branch!
4οΈβ£ Algebraic Simplification & Strength Reductionβ
- Replace or with nothing (no-op elimination).
- Replace integer multiplication by powers of 2 with arithmetic left shifts:
- Replace integer division by powers of 2 with arithmetic right shifts:
5οΈβ£ Machine Idiomsβ
Leveraging target-specific hardware instructions:
- Clearing a register:
MOV R0, 0( bytes on x86) is replaced byXOR R0, R0( bytes, zero memory access, executes in 0 cycles on modern register alias tables). - Incrementing a counter:
ADD R0, 1is replaced withINC R0.
π Real-World Production Context: Graph Coloring (AOT) vs Linear Scan (JIT)β
In production runtimes, register allocation strategies balance code quality against compilation speed:
-
Chaitin-Briggs Graph Coloring Allocator (AOT Compilers: GCC, Clang, Rustc):
- Builds global interference graphs and applies iterative Kempe coloring with optimistic spilling.
- Generates high-efficiency machine code with minimal memory spills.
- High time complexity ( to ), making it ideal for Ahead-Of-Time (AOT) compilation where optimization quality outweighs compilation latency.
-
Linear Scan Register Allocation (JIT Compilers: V8, JVM HotSpot, .NET Core):
- In Just-In-Time (JIT) engines running in real time inside web browsers or interactive servers, graph coloring introduces prohibitive compilation stalls.
- JITs employ Linear Scan: they sort variable live intervals linearly by start point and allocate registers in a single forward pass in time.
- Delivers roughly 90% of the performance of graph coloring at a tiny fraction of the compilation time.
π Numerical Applications & Worked Examplesβ
Example 1: Interference Graph Construction & K-Coloring Traceβ
Consider the following straight-line code with 6 variables :
(1) a = 1
(2) b = 2
(3) c = a + b
(4) d = b + c
(5) e = c + d
(6) f = d + e
Assume are dead immediately after their last uses, and only is live at the end of the block.
- Determine the live range of each variable.
- Construct the Interference Graph.
- Apply Kempe's heuristic to find the minimum number of physical registers () required to execute this block without spilling.
Step-by-Step Analytical Trace:β
-
Live Range Analysis:
- : Defined at (1), last used at (3). Live during: .
- : Defined at (2), last used at (4). Live during: .
- : Defined at (3), last used at (5). Live during: .
- : Defined at (4), last used at (6). Live during: .
- : Defined at (5), last used at (6). Live during: .
- : Defined at (6), live at exit.
-
Interference (Simultaneous Liveness):
- At line (2): is live, is defined interferes with . Edge .
- At line (3): and are live, is defined. After (3), is dead. Live set interferes with . Edge .
- At line (4): and are live, is defined. After (4), is dead. Live set interferes with . Edge .
- At line (5): and are live, is defined. After (5), is dead. Live set interferes with . Edge .
- At line (6): and are live, is defined. After (6), and are dead. Live set interferes with . Edge .
The interference graph forms a linear chain:
- Graph Coloring with Kempe's Heuristic:
- Maximum degree of any node in the chain is (internal nodes have degree 2; endpoints have degree 1).
- Test registers (
R1,R2):- Node has degree . Push to stack. Graph becomes .
- Node now has degree . Push to stack.
- Node now has degree . Push to stack.
- Push .
- Stack: (top to bottom).
- Pop and assign colors:
- Color .
- Color (adjacent to ).
- Color (adjacent to ).
- Color (adjacent to ).
- Color (adjacent to ).
- Color (adjacent to ).
- Conclusion: Minimum registers required (
R1,R2). Zero spilling required!
Example 2: Multi-Pass Peephole Optimization on Raw Assemblyβ
A code generator produces the following raw assembly sequence:
(1) MOV R0, a
(2) ADD R0, 0
(3) MOV b, R0
(4) MOV R0, b
(5) MUL R0, 4
(6) MOV c, R0
(7) JMP L1
(8) ADD R1, R2, R3
(9) L1: JMP L2
(10) L2: RET
Apply peephole optimizations to reduce the instruction count. List each transformation applied.
Step-by-Step Analytical Trace:β
- Algebraic Identity:
- Line (2)
ADD R0, 0adds zero toR0. - Action: Delete line (2).
- Line (2)
- Redundant Store/Load Elimination:
- Line (3) writes
R0to memory variableb. Line (4) immediately loads memory variablebintoR0. R0already holds the value ofb!- Action: Delete line (4).
- Line (3) writes
- Strength Reduction:
- Line (5)
MUL R0, 4multipliesR0by constant . - Action: Replace with arithmetic left shift:
SHL R0, 2.
- Line (5)
- Unreachable Dead Code Elimination:
- Line (7) is an unconditional branch
JMP L1. Line (8)ADD R1, R2, R3has no label and immediately followsJMP L1. It can never execute. - Action: Delete line (8).
- Line (7) is an unconditional branch
- Jump-to-Jump Elimination (Jump Threading):
- Line (7) jumps to
L1. AtL1, line (9) immediately jumps toL2. - Action: Retarget line (7) directly to
L2:JMP L2. - Label
L1now has zero incoming edges and can be deleted.
- Line (7) jumps to
Final Optimized Assembly Stream:β
(1) MOV R0, a
(2) MOV b, R0
(3) SHL R0, 2
(4) MOV c, R0
(5) JMP L2
(6) L2: RET
Instruction count reduced from 10 to 6 (40% instruction reduction).
Example 3: getReg() Register Allocation & Spill Traceβ
Consider the three-address statement:
Given the machine status:
- Two physical registers: .
reg_desc[R0] = { y }reg_desc[R1] = { z }addr_desc[y] = { R0 }(not in memory; dirty)addr_desc[z] = { R1, memory_z }(in memory)- Variable is dead after this statement; variable is live after this statement.
Determine the register allocated for by getReg() and describe the emitted assembly instructions.
Step-by-Step Analytical Trace:β
- Evaluate getReg() Priorities:
- Priority 1: Can we reuse the register holding ?
- is held in .
- is dead after this statement.
- is not used by any other variable in .
- Decision: can be safely allocated for result without spilling!
- Priority 1: Can we reuse the register holding ?
- Instruction Generation:
- Since is in and is in , the compiler emits:
ADD R0, R1 ; Computes y + z, storing result x into R0
- Descriptor Updates:
reg_desc[R0] = { x }( is removed).addr_desc[x] = { R0 }addr_desc[y] = { }( is dead).reg_desc[R1] = { z }(remains unchanged).
π― Exam & Interview Pitfall Checkβ
Question 1: "What is the formal difference between Register Allocation and Register Assignment?"
Key Focus Points:
- Register Allocation: The decision of which values and variables should reside in registers at each execution point (selecting the working set).
- Register Assignment: The mapping of a specific allocated variable to a specific hardware register (e.g., binding variable to register
RAXvsRBX).
Question 2: "Why is Kempe's graph coloring heuristic guaranteed to find a valid color when popping a node with degree ?"
Key Focus Points:
- A node with degree has at most neighbors in the interference graph.
- When is popped from the stack during the Select phase, its neighbors have been assigned at most distinct colors.
- Because the architecture provides distinct colors (registers), at least one color is guaranteed to remain unassigned and available for node .
Trap 1: Assuming variables in the same basic block always interfere.
Answer: Misconception. Two variables interfere if and only if their live ranges overlap simultaneously. If variable is defined and dies before variable is created, their live ranges are disjoint. They do not interfere and can share the exact same physical register.
Trap 2: Spilling variables based purely on high graph degree.
Answer: Performance pitfall. When choosing which variable to spill, spilling an inner-loop variable with high degree causes catastrophic memory thrashing because memory loads and stores are inserted inside the loop. Compilers weight spill costs by loop depth (), prioritizing spilling variables used outside loops even if their degree is lower.
Trap 3: Unsafe peephole elimination on memory-mapped I/O registers.
Answer: Hardware bug. In low-level systems programming, writing twice to a memory address (e.g. *dev_ctrl = 1; *dev_ctrl = 2;) might appear redundant to a naive peephole optimizer. However, hardware registers have read/write side effects (e.g. sending commands or pulsing pins). Compilers must respect the volatile keyword and prohibit dead store elimination on device registers.