Register Allocation
The intermediate representation lied to you, generously. When
three-address code
minted temporaries t_1, t_2, t_3, \dots without limit, it pretended the
machine had infinitely many registers. Real hardware does not: an x86-64 core has 16
general-purpose registers, ARM64 has 31, and a function routinely juggles hundreds of live values.
Register allocation is the phase that faces the lie head-on — it maps that unbounded set
of virtual registers onto the tiny fixed pool of physical ones, and decides which
unlucky values must be evicted to memory when the registers run out.
It matters enormously. Registers are the fastest storage a CPU has — a value in a register is free to use;
the same value in memory costs a load and a store every time. A good allocator can make the difference
between code that keeps its hot variables in registers across a whole loop and code that dutifully
shuttles them to the stack on every iteration. This is where the compiler earns much of its keep.
The problem: many virtuals, few physicals
State it plainly. The IR names values with virtual registers v_1, v_2, \dots —
as many as the code needed. The target offers k physical registers
(k = 16, 31, \dots). The allocator must assign each virtual to a physical such
that two values that are needed at the same time never share one register. When more
values are simultaneously needed than there are registers, some must spill to memory —
with loads before uses and stores after definitions — and spilling is exactly what we want to minimise.
The keystone is knowing when two values are "needed at the same time." That is
liveness: a value is live at a program point if it holds a value that may be
read later before being overwritten. Two values whose live ranges overlap cannot occupy
the same register — they interfere.
The interference graph
Overlapping live ranges cry out for a graph. Build the interference graph: one node per
value, and an edge between any two values whose live ranges overlap (that interfere). Now the whole
problem is reframed as pure combinatorics — assign each node a register so that adjacent nodes get
different registers. If registers are "colours", this is precisely graph colouring:
colour the interference graph with k colours, or spill if you cannot.
In the figure, the values a, b, c are all live together — a triangle — so they
need three distinct registers. Value d interferes only with
a and c, never with b, so
it can reuse b's register. Four values fit in three registers with no
spill — the allocator has found the reuse the schedule made possible.
Graph-colouring allocation (Chaitin)
The classic global allocator, due to Chaitin (1981) and refined by
Briggs, colours the interference graph with a beautifully simple heuristic resting on one
observation about node degree:
- A node with fewer than k neighbours can always
be coloured: whatever its neighbours take, at most k-1 colours are used up,
so one remains free.
- So repeatedly remove a degree-<k node and push it on a
stack (simplify). Removing it only lowers its neighbours' degrees, exposing more removable
nodes.
- Then pop the stack, giving each node a colour its already-coloured neighbours have
not used (select). A colour is guaranteed to be free, because each node had
< k neighbours when it was removed.
This simplify / select pair colours almost every real interference graph. The full
machinery — Briggs' optimistic pushing of high-degree nodes, and coalescing to delete
move instructions — is the subject of the dedicated
graph-colouring page.
Here we care about the whole pipeline, and about the moment the colours run out.
When colours run out: spilling
If some node cannot be coloured — its neighbours have used all k colours — that
value must spill. Spilling keeps the value in a stack slot and inserts a
load before each use and a store after each definition. This does three
things: it frees a register at every point the value is not being touched, it adds new (very
short) live ranges for the loaded/stored copies, and it therefore changes the code — so
the interference graph must be rebuilt and the whole colouring re-run. Allocation is a
loop that terminates when nothing spills.
Which value to spill? The allocator picks the one that hurts least — a common heuristic is to minimise
\dfrac{\text{spill cost}}{\text{degree}}, spilling a high-degree node (frees
lots of conflicts) whose uses are cheap and outside loops (a value used inside a tight loop is the
last thing you want in memory). The art of allocation is really the art of choosing good spills.
The fast alternative: linear scan
Graph colouring is powerful but not cheap: building the interference graph and iterating can dominate
compile time, which is fatal for a JIT
compiling at run time. Linear-scan allocation (Poletto & Sarkar, 1999) trades a
little code quality for a lot of speed. It never builds a graph at all. Instead it approximates each
value's live range as a single interval on a linearised ordering of the code, sorts the
intervals by start point, and sweeps once left to right, keeping a pool of free registers: give each
interval a free register as it begins, return the register when the interval ends, and if none is free,
spill the interval that ends latest.
// Linear-scan register allocation over live intervals [start, end].
type Interval = { name: string; start: number; end: number };
function linearScan(intervals: Interval[], k: number): Record<string, string> {
const byStart = [...intervals].sort((a, b) => a.start - b.start);
const active: Interval[] = []; // live now, sorted by end
const free: string[] = Array.from({ length: k }, (_, i) => `R${i + 1}`);
const assign: Record<string, string> = {};
for (const cur of byStart) {
// Expire intervals that have ended before cur starts; reclaim their registers.
for (let i = active.length - 1; i >= 0; i--) {
if (active[i].end < cur.start) {
free.push(assign[active[i].name]);
active.splice(i, 1);
}
}
if (free.length > 0) {
assign[cur.name] = free.pop()!; // a register is available
} else {
// Spill the active interval that ends latest (heuristic).
active.sort((a, b) => a.end - b.end);
const victim = active[active.length - 1];
if (victim.end > cur.end) { // cur outlives the victim: steal its register
assign[cur.name] = assign[victim.name];
assign[victim.name] = "SPILL";
active[active.length - 1] = cur;
} else {
assign[cur.name] = "SPILL"; // cur is the shortest-lived: spill it
}
}
active.push(cur);
}
return assign;
}
const ivals: Interval[] = [
{ name: "a", start: 0, end: 6 },
{ name: "b", start: 1, end: 3 },
{ name: "c", start: 2, end: 8 },
{ name: "d", start: 4, end: 5 },
{ name: "e", start: 5, end: 9 },
];
const result = linearScan(ivals, 2); // only k = 2 registers
for (const iv of ivals) console.log(`${iv.name} [${iv.start},${iv.end}] -> ${result[iv.name]}`);
With just two registers, the single sweep hands a and b the two registers,
reuses b's once it expires, and spills whatever cannot fit — all in
O(n \log n), dominated by the sort. No graph, no iteration to a fixpoint. This
is why HotSpot's client compiler, early V8, and many JITs allocate by linear scan: a hair worse than
Chaitin's code, produced an order of magnitude faster.
The four-colour theorem asks: can you colour any map so no two bordering countries share a colour, using
only four colours? Register allocation asks the identical question with the numbers changed — can you
colour the interference graph so no two interfering values share a register, using only
k colours? The correspondence is exact: countries are values, shared borders
are interferences, colours are registers. And it inherits the bad news too: deciding
k-colourability of a general graph is NP-complete, so no
allocator is always optimal. What saves us is that program interference graphs are unusually
well-behaved — code without goto spaghetti yields chordal graphs, which
can be coloured optimally in polynomial time. The compiler plays a rigged game and mostly wins.
The seductive mistake is to treat a spill as "cross this node out and colour the rest." It is not.
Spilling inserts new load and store instructions, and those instructions define and use
fresh short-lived values with their own live ranges — so the interference graph you were
colouring is now wrong. You must rebuild the graph and colour again; do a round or two and it
settles. A second trap: not every register is yours to give. Some are reserved (a stack pointer, a frame
pointer, the zero register), and the calling convention splits the rest into caller-saved
(clobbered across a call) and callee-saved (preserved). A value that is live across a call site
prefers a callee-saved register, or it must be spilled around the call. Register allocation and the
calling convention
are inseparable — colour as if all k are free and you will clobber a live
value across a call.