Registerssimplified

Graph Colouring Allocation

Chaitin-Briggs: repeatedly remove any node with fewer than k neighbours and push it on a stack, because such a node is always colourable later. When everything has k or more, push the cheapest optimistically. Then pop and assign.

The question

How does an allocator colour a graph when colouring is NP-complete?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

The interference graph plus a stack. During simplification the graph shrinks and the stack grows; during assignment the stack drains and the colouring is built. The stack is the representation that matters: its order encodes the *reason* each node can be coloured, since a node is only pushed when it is provably colourable given whatever colours its remaining neighbours will take.

What this phase may assume or do

The colouring must satisfy the graph: no two adjacent nodes share a register. Beyond that, the algorithm's central claim is a legality argument of its own — a node with fewer than k neighbours may be removed and coloured later, because however its neighbours are coloured they use at most k-1 distinct registers and one is therefore free. That argument is sound only if the node is removed *with* its edges and the degrees of its neighbours are decremented accordingly, and only if k is the number of registers actually available at that node, which precoloured and pinned registers can reduce.

Key points

  • A node with fewer than k neighbours is always colourable later, whatever its neighbours get — that single observation is the algorithm.
  • Simplification removes such nodes onto a stack, which decrements neighbours' degrees and often cascades.
  • Briggs's optimism: when nothing is below k, push the cheapest node anyway, because its neighbours may share colours. It costs nothing to try.
  • Assignment pops the stack and takes any colour not used by an already-coloured neighbour; only optimistically-pushed nodes can fail.
  • Real allocators interleave conservative coalescing and live-range splitting, and iterate the whole cycle after inserting spill code.
  • Reporting *why* a value was chosen for spilling is far more useful than reporting that it was — which is why our engine records the heuristic's reasoning.

The degree-less-than-k argument

simplifiedThe step order is our engine's: it scans the value list in definition order and takes the first node below k, which makes the trace deterministic and reproducible. A production allocator chooses among the eligible nodes by a priority function — most-constrained-first, or by spill cost — and gets different, usually better, results. Our determinism is for teaching, not for quality.

The whole algorithm rests on one observation, and it is worth stating carefully because everything else is bookkeeping. If a node has fewer than k neighbours, it can be coloured no matter what colours those neighbours end up with. There are at most k-1 of them, they can consume at most k-1 distinct colours, and there are k colours, so at least one is free. Therefore: remove it, remember it, and deal with it later — its colourability is already guaranteed.

Removing it helps, because it decrements its neighbours' degrees and may bring *them* below k. So the algorithm simplifies repeatedly, building a stack, and each node on the stack carries an implicit promise: "when you get to me, a colour will exist".

Chaitin's original algorithm stopped when nothing was below k and spilled immediately. Briggs's contribution was to notice that this is unnecessarily pessimistic: a node with k or more neighbours might still colour, because those neighbours may share colours among themselves. So push it *optimistically* — pick the cheapest one by the spill heuristic, push it anyway, and only spill it at assignment time if no colour turns out to be free. That optimism is free when it works and costs nothing when it does not, which is why every modern colouring allocator is Chaitin-*Briggs*.

Our engine simplifying the graph from [[interference-graph]] with k = 3simplified
StepRemaining degreesActionStack (bottom → top)
1%0:3 %1:3 %2:6 %3:4 %4:3 %5:3 %6:2%6 has 2 < 3 — simplify%6
2%0:3 %1:3 %2:5 %3:4 %4:3 %5:2%5 now has 2 — simplify%6 %5
3%0:3 %1:3 %2:4 %3:4 %4:2%4 now has 2 — simplify%6 %5 %4
4%0:3 %1:3 %2:3 %3:3Nothing below 3. Push the cheapest optimistically: %2, at 2 uses over 5 instructions%6 %5 %4 %2
5%0:2 %1:2 %3:2%0 has 2 — simplify%6 %5 %4 %2 %0
6%1:1 %3:1%1 — simplify%6 %5 %4 %2 %0 %1
7%3:0%3 — simplify%6 %5 %4 %2 %0 %1 %3

Popping the stack

Assignment reverses the process. Pop a node, look at the colours already given to its neighbours, and take any colour that is not among them. Because the stack was built by removing nodes whose degree was below k, most pops find a free colour by construction. The optimistically-pushed nodes are the ones that might not, and when one does not, it spills.

The chart below is what our engine actually produces for this function with three registers. %3 pops first and takes rax because nothing is coloured yet. %1 pops next and must avoid %3's rax, so it takes rcx. %0 avoids both and takes rdx. Then %2 pops — and its neighbours %0, %1 and %3 hold all three registers, so the optimism did not pay off and %2 spills. The remaining three pops find colours easily, because their only coloured neighbours are few.

The spill reason our engine records is not "no register was available". It is the reason %2 was the one chosen to be at risk in the first place: live across five instructions with only two uses, the worst ratio in the function. Reporting *why* rather than *that* is a deliberate design choice in the engine, because "the allocator spilled it" is not something a reader can learn anything from.

The assignment our engine produces after popping the stack — three registers
program points →home
%3rax
%1rcx
%0rdx
%2⤓ spilled
%4rcx
%5rax
%6rcx
3 registers: rax, rcx, rdxin a registerspilled to the stack

Read it asThe chart is sorted in pop order rather than by definition point, which is the order the algorithm actually decides in. Note that only one value spilled even though the peak pressure was four and only three registers were available — one over the bound, one spill. That is the algorithm behaving exactly as the bound predicts, not luck.

The parts we are not showing

A production Chaitin-Briggs allocator has two more phases interleaved with these, and both matter more than the simplify loop does.

*Coalescing* merges the two ends of a copy instruction into one node when they do not interfere, deleting the copy. It is essential — out-of-SSA and two-address instruction selection generate copies by the thousand — and it is dangerous, because merging two nodes produces a node with the union of their edges and can push a colourable graph into an uncolourable one. Conservative coalescing rules (Briggs's and George's) exist precisely to merge only when the result is provably still colourable. [[coalescing-and-rematerialization]] is where this is argued.

*Live-range splitting* does the reverse: it cuts one long range into several short ones with copies between them, so that a value can live in a register where the pressure is low and in memory where it is high. Without splitting, a spill is all-or-nothing for the whole range, which is exactly the pessimism our engine exhibits.

And the loop is bigger than it looks. Inserting spill code creates new tiny live ranges for the reload temporaries, which changes the interference graph, which may require another round. Real allocators iterate the whole build-simplify-select-spill cycle until it converges, typically in two or three rounds.

  • Build the interference graph.
  • Coalesce copies conservatively, so that merging cannot make the graph uncolourable.
  • Simplify: push nodes with degree below k.
  • Freeze: when stuck, give up on coalescing a copy and try simplifying again before spilling anything.
  • Spill: optimistically push the cheapest high-degree node.
  • Select: pop and assign; anything with no free colour becomes an actual spill.
  • If anything spilled, insert spill code and start again from the top with the new ranges.

How it works

The steps, in the order the compiler takes them.

  • Build the interference graph and compute each node's degree.
  • While nodes remain: find one whose remaining degree is below k, remove it from the graph and push it on the stack.
  • If no such node exists, choose the node with the lowest spill cost — fewest uses per unit of live range, with a penalty for crossing a call — record why, and push it optimistically.
  • Repeat until the graph is empty.
  • Pop each node, collect the registers already assigned to its neighbours, and assign the first register not in that set.
  • If no register is free, mark the node spilled and give it a stack slot.
  • A production implementation then rewrites the code with spill loads and stores and repeats the entire process on the new code.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • Degrees are not decremented when a node is removed, the simplify loop stalls immediately, and everything with any neighbours gets spilled. The code is correct and full of stack traffic.
  • Precoloured nodes are treated as ordinary ones and get simplified, so ABI-mandated registers are reassigned and arguments arrive in the wrong places.
  • Coalescing is done unconservatively, two merged nodes produce a node the graph can no longer colour, and the allocator spills far more than before coalescing was enabled.
  • The spill heuristic ignores loop depth, so the allocator spills a value used every iteration of an inner loop in preference to one used once outside it, and the function is several times slower.
  • The rebuild loop after inserting spill code does not converge — each round's reload temporaries create new pressure — and compilation either takes far too long or hits an iteration cap and produces poor code.

When it helps

  • Ahead-of-time compilation at optimization levels where compile time is not the binding constraint, which is most production builds.
  • Functions under real register pressure, where a global view of conflicts finds allocations a sweep cannot.
  • Anywhere copies must be eliminated, since coalescing is a graph operation and has no natural expression in a linear sweep.

When it hurts

  • JIT compilation, where the graph construction alone can cost more than the code will ever save — see [[linear-scan-allocation]].
  • Very large functions, where the quadratic graph and the iteration to convergence combine into a compile-time problem.
  • Debug builds, where the whole point is that every value stays findable in its stack slot.

What it costs

Every one of these is paid by something.

  • Global colouring buys better allocations and coalescing, and costs a graph quadratic in simultaneous liveness plus repeated rebuilds after spilling.
  • Briggs's optimism buys allocations Chaitin's version would have spilled, and costs a second chance to fail late — the node is only discovered to be uncolourable during assignment, after the graph has been discarded.
  • A cheaper spill heuristic buys compile time and costs code quality directly: choosing the wrong value to spill in a hot loop is the single most expensive mistake this phase can make.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Linear scan: no graph, one sweep in start order, spill the interval ending last. Far faster and worse — see [[linear-scan-allocation]].
  • SSA-based allocation, which exploits the chordality of strict-SSA interference graphs to colour optimally in polynomial time, moving all the difficulty into spill decisions.
  • LLVM's greedy allocator: priority-ordered assignment with live-range splitting and eviction, which behaves like colouring in spirit without building an explicit graph.
  • PBQP: encode registers and constraints as a partitioned quadratic assignment problem and solve it. Handles irregular register files (which x86 has plenty of) better than colouring, and costs more time.

See it for yourself

The flag, dump or tool that shows you this directly.

  • GCC's allocator, which descends from this family: -fdump-rtl-ira shows the conflict graph, the assignments and the spill decisions.
  • LLVM's default is not textbook colouring, but llc -regalloc=basic and -regalloc=greedy can be compared on the same input to see how much the allocator choice moves the output.
  • Count spills: llc -stats file.ll 2>&1 | grep -i "spill\|reload".
  • Ours: allocateByColoring in src/compilers/sim/regalloc.ts implements exactly the simplify-optimistic-push-select loop described here, and /compilers/registers shows the stack, the assignment and the recorded spill reasons.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The algorithm colours the graph directly." It removes nodes until the graph is empty, then colours them in reverse order. The removal order is the entire algorithm; the colouring step is trivial.
  • "A node with k or more neighbours must spill." That is Chaitin's version. Briggs's observation is that such a node often colours anyway, because its neighbours may not use k distinct colours.
  • "Once a value is spilled the allocator is done with it." Spill code introduces reload temporaries with their own tiny live ranges, so the graph changes and the whole process usually runs again.
  • "k is the number of registers." k is the number of registers *available to this value*, which precoloured neighbours, ABI constraints and fixed-operand instructions can reduce below the architectural count.

Misconceptions

The claim, and what is actually true.

Graph colouring allocation solves an NP-complete problem exactly.
It is a heuristic with a guaranteed escape hatch. The simplify rule is exact when it applies; the optimistic push is a guess, and spilling is what happens when the guess is wrong.
The order of simplification does not matter because everything below k is colourable.
It matters at the moment the algorithm gets stuck: which node is chosen for the optimistic push determines what may spill, and that is where all the code quality is.
Coalescing is a separate optimization you can bolt on afterwards.
Coalescing changes the graph, and merging aggressively can make a colourable graph uncolourable. It has to be interleaved with simplification and constrained by conservative rules.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

To colour a graph with k colours, keep pulling off any node with fewer than k neighbours and stacking it — such a node can always be coloured once its neighbours are, because they cannot possibly use up all k colours between them. When you get stuck, stack the least valuable node anyway and hope. Then unstack, giving each node a colour none of its already-coloured neighbours has. Anything with no colour available goes to memory.

practical

The number to check first is not the allocator's but the program's: peak simultaneous liveness. If it exceeds the register count, spills are inevitable and the fix is upstream — less inlining into this loop, less unrolling, a smaller working set. If peak pressure is below the register count and the allocator still spilled, that is a genuine allocation problem and worth reporting.

advanced

What is worth internalising about Chaitin-Briggs is the structure of its correctness argument, because it is unusual. Most compiler heuristics are "this usually helps"; this one has an exact theorem (degree below k implies colourable) covering the common case, an explicit guess for the rest, and a fallback that is always available. That shape — exact where you can prove it, optimistic where you cannot, with a safe escape — is a good template for heuristics generally. The historical footnote is instructive too: Chaitin spilled as soon as the exact rule stopped applying, and Briggs's observation that the guess is nearly free improved real code by a large margin for very little implementation. The lesson is not that optimism is good but that a heuristic which can cheaply verify its guess later should be allowed to guess.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

simplifiedOur implementation stops after one pass: it does not insert spill code and re-run, does not coalesce, and does not split live ranges. A real Chaitin-Briggs allocator iterates until no new spills appear, and the coalescing and freezing phases are interleaved with simplification rather than absent. The simplify-optimistic-select core shown here is faithful; the loop around it is not.
simplifiedThe step-by-step trace is our engine's deterministic order — first eligible node in definition order — chosen so the trace is reproducible. Production allocators pick among eligible nodes by priority and reach different, usually better, assignments from the same graph.
implementationLLVM's default greedy allocator is not Chaitin-Briggs: it assigns in priority order with eviction and live-range splitting rather than simplifying a graph. GCC's IRA is closer to this family but also heavily modified. Describing a specific compiler as "doing graph colouring" is usually an approximation of something more elaborate.

If you were asked this in an interview

  • Why is a node with fewer than k neighbours always colourable, and why does removing it help?
  • What did Briggs add to Chaitin's algorithm, and what does it cost when the optimism is wrong?
  • After inserting spill code, why does the allocator usually have to start over?

Connections