Register Allocation by Graph Colouring

The simple code generator juggled two registers by hand. A real machine has a fixed, small pool — say k = 16 or 32 general registers — and a function full of variables and temporaries that vastly outnumber them. The allocator's job is to map that unbounded set of values onto k physical registers, keeping as much as possible out of memory. The beautiful insight, due to Chaitin and refined by Briggs, is that this is a graph problem in disguise.

Two values may share a register whenever they are never needed at the same time. So build a graph whose nodes are the values and whose edges join values that interfere — that are simultaneously live and therefore cannot share a register. Assigning registers so that no two interfering values collide is exactly colouring the graph with k colours, one colour per register. Register allocation is graph colouring.

Liveness builds the interference graph

The edges come from liveness analysis. A variable is live at a program point if its current value may be read later before being overwritten. Two variables interfere if there is a point where both are live at once — more precisely, an edge is added between a variable being defined and every other variable live at that definition (they must survive the write into different registers).

Compute live sets across the control-flow graph, walk each block backward adding an edge from each defined value to everything then live, and you have the interference graph. Its structure is everything the allocator needs: it has forgotten the code entirely and reduced allocation to pure combinatorics on a graph.

Simplify and select: the Chaitin–Briggs stack

Colouring is driven by one deceptively powerful observation about degree. It gives a sufficient condition for colourability and a way to peel the graph down to it:

This gives the two-phase algorithm. Simplify: repeatedly remove a degree-<k node and push it, until the graph is empty. Select: pop nodes off the stack one at a time, giving each the lowest-numbered colour not used by its (already-coloured) neighbours. Because each node was removed while it had < k neighbours, a colour is always available on the way back up. Watch it run on a small interference graph with k = 3 registers:

The triangle A\text{–}B\text{–}C forces three distinct colours, so this graph needs all three registers; node D, which does not interfere with B, happily reuses B's register (R2). Four values, three registers, zero spills.

Optimistic colouring and real spills

What if simplify gets stuck — every remaining node has degree \ge k? Chaitin's original allocator would immediately pick a node to spill to memory. Briggs' improvement is optimistic colouring: push the high-degree node onto the stack anyway (marking it a potential spill) and carry on simplifying. On the way back up in select, that node might still find a free colour — because its many neighbours, by luck, may not have used all k colours. Optimism pays off surprisingly often and colours graphs Chaitin's method would have spilled.

If select really does find no colour for a node, that node becomes an actual spill: its value is kept in memory, with loads before each use and stores after each definition. Crucially, spilling changes the code — those new loads and stores are short-lived new values — so the interference graph is rebuilt and the whole simplify/select process repeats. After a round or two the graph colours cleanly and allocation is done.

build interference graph repeat: SIMPLIFY: while some node has degree < k, remove it and push on stack if stuck: push a high-degree node optimistically (potential spill) SELECT: pop each node, assign a colour not used by neighbours if some node gets no colour → mark it an ACTUAL spill until no actual spills (spilled values get loads/stores; graph is rebuilt and we go again)

Colouring by simplify/select, in code

The allocator below takes an interference graph as an adjacency map and k registers. It simplifies (removing degree-<k nodes, or optimistically pushing the highest-degree one when stuck), then selects colours off the stack. Run on the graph from the figure with k = 3:

type Graph = Record<string, string[]>; function allocate(g: Graph, k: number): Record<string, number> | null { const nodes = Object.keys(g); const removed = new Set<string>(); const stack: string[] = []; const liveDeg = (v: string) => g[v].filter((u) => !removed.has(u)).length; // SIMPLIFY while (removed.size < nodes.length) { let pick = nodes.find((v) => !removed.has(v) && liveDeg(v) < k); if (pick === undefined) { // Stuck: optimistically push the highest-degree remaining node. let best = "", bd = -1; for (const v of nodes) if (!removed.has(v) && liveDeg(v) > bd) { bd = liveDeg(v); best = v; } pick = best; console.log(`simplify: optimistic push ${pick} (degree ${bd} >= k)`); } else { console.log(`simplify: push ${pick} (degree ${liveDeg(pick)} < ${k})`); } removed.add(pick); stack.push(pick); } // SELECT const colour: Record<string, number> = {}; while (stack.length) { const v = stack.pop()!; const used = new Set<number>(); for (const u of g[v]) if (u in colour) used.add(colour[u]); let c = 0; while (used.has(c)) c++; if (c >= k) { console.log(`select: ${v} -> ACTUAL SPILL (no colour < ${k})`); return null; } colour[v] = c; console.log(`select: ${v} -> R${c + 1}`); } return colour; } // Interference graph: triangle A-B-C, plus D interfering with A and C only. const g: Graph = { A: ["B", "C", "D"], B: ["A", "C"], C: ["A", "B", "D"], D: ["A", "C"], }; const result = allocate(g, 3); console.log("--- assignment ---"); if (result) for (const v of Object.keys(g)) console.log(`${v} -> R${result[v] + 1}`); else console.log("needs a spill; rebuild graph and retry");

Drop k to 2 and rerun: the triangle A\text{–}B\text{–}C cannot be 2-coloured, so select reports an actual spill — the signal to spill a value to memory, rebuild, and try again.

Coalescing: deleting the copies

One more trick makes colouring earn its keep. A move x := y is pure overhead if x and y can be given the same register — then the copy is a no-op and can be deleted. Coalescing merges the two nodes in the interference graph into one (provided they do not interfere), so the allocator assigns them a single register and the move vanishes. Overdo it, though, and the merged super-node has so many neighbours it becomes uncolourable, forcing a spill. Conservative coalescing (Briggs) merges two nodes only when the result is provably still colourable — capturing most move savings without provoking spills.

Deciding whether a general graph is k-colourable is NP-complete — there is no known efficient algorithm that is always optimal. But the allocator does not need optimal. The simplify/select heuristic is linear-ish and, thanks to the degree-<k rule, colours the overwhelming majority of real interference graphs perfectly; where it can't, it spills and retries. It trades guaranteed optimality for guaranteed speed and near-optimal results in practice — the same bargain maximal munch strikes in instruction selection. Interestingly, the interference graphs of programs without goto spaghetti are often chordal, a class that is colourable optimally in polynomial time — which is part of why the heuristic does so well.

The degree-<k rule is a one-way guarantee. If a node has fewer than k neighbours it is definitely colourable — but a node with degree \ge k is not necessarily uncolourable. A node with many neighbours that among them use only a few colours still has a colour free. This is exactly why Briggs' optimistic push works: never assume a high-degree node must spill; push it and find out during select.

The second trap is treating a spill as the end. When select fails, you do not just leave that value in memory and keep the rest of the assignment — spilling inserts new load/store instructions, which create new short live ranges and thus a different interference graph. You must rebuild the graph and run simplify/select again. Allocation is a loop, not a single pass; forgetting the rebuild yields an inconsistent, incorrect assignment.