CMPUT-415 · Lesson 10 of 12 · 34 min

Code generation and register allocation

The back end turns optimized IR into machine code. It has three classic tasks. Instruction selection chooses machine instructions to implement each IR operation, exploiting the target's addressing modes and specialized instructions. Register allocation maps the unlimited virtual registers or temporaries of the IR onto the handful of physical registers the processor has, storing values in memory, spilling them, when registers run out. Instruction scheduling orders instructions to keep the processor's pipeline busy. Each task is hard in general: optimal instruction selection for DAGs, optimal register allocation and optimal scheduling are all NP-complete problems, so back ends use well-engineered heuristics. This lesson targets RISC-V, which CMPUT-229 used, though the techniques are those of every production back end.

Instruction selection as tree tiling

After lowering, each basic block can be viewed as a forest of expression trees, with nodes for operations such as add, multiply, load and constants. Each machine instruction corresponds to a small tree pattern, a tile: an add instruction covers one + node with two register operands; RISC-V's addi covers a + node whose right operand is a 12-bit constant; a load lw rd, off(rs) covers a load node whose address is a register plus a small constant, taking three IR nodes in one instruction. Instruction selection is the problem of covering the tree with tiles so that every node is covered exactly once and tiles connect at their roots and leaves, at minimum total cost. Simple RISC instruction sets have few interesting tiles; complex ones such as x86-64, with memory operands and scaled-index addressing, have many.

Two algorithms dominate. Maximal munch, a greedy top-down method, starts at the root, picks the largest tile that matches there, and recursively tiles the subtrees left uncovered. It produces an optimal tiling in the sense that no two adjacent tiles could be combined into a larger one, though not necessarily the minimum-cost tiling. Dynamic programming, as in the code generator generators BURG and iburg (Christopher Fraser, David Hanson and Todd Proebsting, early 1990s), computes bottom-up the minimum-cost tiling of every subtree for every kind of result and gives a truly optimal tiling for trees. LLVM's SelectionDAG instruction selector matches patterns written in its TableGen description language over a DAG per basic block, and its newer GlobalISel framework selects instructions over whole functions; GCC matches RTL patterns from its machine description files.

Maximal munch tiling for x = a[i] + p.f, compared with one instruction per IR node
# Instruction selection by maximal munch over expression trees, for a
# RISC-V-like target. Larger tiles (addi with an immediate, a load with an
# offset, slli for multiplying by a power of two) are tried before small ones.
# Tree nodes: ("+", a, b), ("*", a, b), ("load", addr), ("const", n), ("reg", name)
from itertools import count
regs = (f"v{i}" for i in count(1))                 # unlimited virtual registers
def munch(t, code):
    op = t[0]
    if op == "reg": return t[1]
    if op == "const":
        r = next(regs); code.append(f"li    {r}, {t[1]}"); return r
    if op == "load":
        a = t[1]                                     # tile: load with base + 12-bit offset
        if a[0] == "+" and a[2][0] == "const" and -2048 <= a[2][1] < 2048:
            base = munch(a[1], code); r = next(regs)
            code.append(f"lw    {r}, {a[2][1]}({base})"); return r
        base = munch(a, code); r = next(regs); code.append(f"lw    {r}, 0({base})"); return r
    if op == "+":
        for x, y in ((t[1], t[2]), (t[2], t[1])):    # tile: addi when one side is a small constant
            if y[0] == "const" and -2048 <= y[1] < 2048:
                s = munch(x, code); r = next(regs); code.append(f"addi  {r}, {s}, {y[1]}"); return r
        a, b = munch(t[1], code), munch(t[2], code); r = next(regs)
        code.append(f"add   {r}, {a}, {b}"); return r
    if op == "*":
        for x, y in ((t[1], t[2]), (t[2], t[1])):    # tile: shift for a power of two
            if y[0] == "const" and y[1] > 0 and y[1] & (y[1] - 1) == 0:
                s = munch(x, code); r = next(regs)
                code.append(f"slli  {r}, {s}, {y[1].bit_length() - 1}"); return r
        a, b = munch(t[1], code), munch(t[2], code); r = next(regs)
        code.append(f"mul   {r}, {a}, {b}"); return r
def naive(t, code):                                  # one instruction per node, no big tiles
    op = t[0]
    if op == "reg": return t[1]
    r = next(regs)
    if op == "const": code.append(f"li    {r}, {t[1]}"); return r
    if op == "load": a = naive(t[1], code); code.append(f"lw    {r}, 0({a})"); return r
    a, b = naive(t[1], code), naive(t[2], code)
    code.append(f"{'add ' if op == '+' else 'mul '}  {r}, {a}, {b}"); return r
# x = a[i] + p.f, where a is an int array in register a0, i is in a1, and the
# struct pointer p is in a2 with field f at offset 8
tree = ("+", ("load", ("+", ("reg", "a0"), ("*", ("reg", "a1"), ("const", 4)))),
             ("load", ("+", ("reg", "a2"), ("const", 8))))
for name, sel in (("maximal munch", munch), ("one node per instruction", naive)):
    regs = (f"v{i}" for i in count(1)); code = []
    result = sel(tree, code)
    print(f"{name}: {len(code)} instructions, result in {result}")
    for c in code: print("    " + c)
Output
maximal munch: 5 instructions, result in v5
    slli  v1, a1, 2
    add   v2, a0, v1
    lw    v3, 0(v2)
    lw    v4, 8(a2)
    add   v5, v3, v4
one node per instruction: 8 instructions, result in v1
    li    v5, 4
    mul   v4, a1, v5
    add   v3, a0, v4
    lw    v2, 0(v3)
    li    v8, 8
    add   v7, a2, v8
    lw    v6, 0(v7)
    add   v1, v2, v6
An expression tree for a of i plus p dot f: a plus node at the root with two load children. The left load's address is a plus of register a0 and a times node multiplying a1 by the constant 4. The right load's address is a plus of register a2 and the constant 8. Shaded regions show tiles: slli covers the times node and the constant 4; add covers the plus of a0; lw with offset 0 covers the left load; lw with offset 8 covers the right load together with its plus node and constant; a final add covers the root.
A tree tiled by maximal munch: larger tiles such as lw 8(a2) cover several IR nodes at once

The larger tiles make a real difference: multiplying by 4 becomes a shift, and the field access p.f becomes a single load with an offset, so maximal munch needs 5 instructions where the naive translation needs 8, two of them just to load the constants 4 and 8 into registers. On x86-64, the whole address a + i × 4 would fit inside a single load's addressing mode. Real selectors also handle conditional branches by fusing a comparison with the branch that uses it (RISC-V's blt rs1, rs2, label tests and branches in one instruction), and recognize idioms such as fused multiply-add, bit-field extraction and rotates.

Register allocation: the problem

Registers are the fastest storage in a computer, and there are few of them: RISC-V has 31 usable integer registers, of which a function can freely use around 25 after accounting for the stack pointer, return address and other reserved roles; x86-64 has 16. The IR, by contrast, uses as many temporaries as it likes. The register allocator must decide which values live in registers at each point, and when there are not enough, which to keep in memory. A good allocation can easily double the speed of a loop compared with a poor one. Two values can share a register only if they are never needed at the same time, so the allocator needs to know when each value is needed: its live range, computed by liveness analysis.

A variable is live at a point if its current value may be used later along some path before being overwritten. Liveness is a backward data-flow problem: working backward through a block, the variables live before an instruction are those it uses plus those live after it, minus the one it defines. Across a CFG, the variables live at the end of a block are those live at the start of any successor, iterated to a fixed point; lesson 11 develops this general framework. Two variables interfere if one is live at the point where the other is defined; interfering variables need different registers.

Graph colouring

Gregory Chaitin and colleagues at IBM (1981 to 1982) turned register allocation into graph colouring. The interference graph has a node per variable and an edge between interfering variables; assigning K registers is colouring the graph with K colours so that adjacent nodes differ. Graph colouring is NP-complete, but Chaitin's heuristic works well. Its key observation: a node with fewer than K neighbours can always be coloured, whatever colours its neighbours get. So the allocator repeatedly removes such a node from the graph and pushes it on a stack, simplifying the graph; then it pops nodes one by one, giving each a colour unused by its already-coloured neighbours. If at some point every remaining node has K or more neighbours, a node is chosen to spill, ideally one with many neighbours and few uses, weighted by loop depth.

Preston Briggs, Keith Cooper and Linda Torczon (1989 to 1994) improved this with optimistic colouring. Instead of spilling immediately when simplification gets stuck, the allocator pushes a spill candidate on the stack anyway and continues; when it is popped, its neighbours may happen to use fewer than K colours, in which case it gets a register after all. Only nodes that truly find no free colour are spilled. Spilling a variable means storing it to a stack slot after each definition and loading it before each use, which splits its long live range into tiny ones; the allocator then rebuilds the graph and tries again.

Liveness, the interference graph and Chaitin–Briggs colouring with four and with three registers
# Register allocation by assigning K registers to a graph (Chaitin-Briggs): liveness analysis,
# the interference graph, simplify with optimistic spilling, then select.
# Straight-line code from a loop body, with the registers live on exit.
CODE = [("a", ["x"]), ("b", ["x", "y"]), ("c", ["a", "y"]), ("d", ["a", "b"]),
        ("e", ["c", "d"]), ("f", ["b", "e"]), ("g", ["e", "f", "c"]), ("h", ["g", "d"])]
LIVE_OUT = {"h", "f"}
# 1. Liveness, computed backwards: live_before = uses U (live_after - def)
live, after = set(LIVE_OUT), []
for d, uses in reversed(CODE):
    after.append(set(live)); live = (live - {d}) | set(uses)
after.reverse()
print("live into the block:", sorted(live))
# 2. Interference: a definition interferes with everything live after it
nodes = {d for d, _ in CODE} | {"x", "y"}
adj = {n: set() for n in nodes}
for (d, _), out in zip(CODE, after):
    for v in out - {d}: adj[d].add(v); adj[v].add(d)
for v in live:                                        # values live on entry interfere pairwise
    adj[v] |= live - {v}
print("interference degrees:", {n: len(adj[n]) for n in sorted(nodes)})
def allocate(K):
    work, stack, g = set(nodes), [], {n: set(adj[n]) for n in nodes}
    while work:
        low = sorted(n for n in work if len(g[n] & work) < K)
        n = low[0] if low else max(sorted(work), key=lambda m: len(g[m] & work))   # optimistic spill candidate
        stack.append((n, not low)); work.remove(n)
    reg, spilled = {}, []
    while stack:
        n, candidate = stack.pop()
        used = {reg[m] for m in adj[n] if m in reg}
        free = [r for r in range(K) if r not in used]
        if free: reg[n] = free[0]
        else: spilled.append(n)
    return reg, spilled
for K in (4, 3):
    reg, spilled = allocate(K)
    names = [f"s{i + 1}" for i in range(K)]
    assigned = ", ".join(f"{n}->{names[reg[n]]}" for n in sorted(reg))
    print(f"K={K}: {assigned}" + (f"   spilled: {spilled}" if spilled else "   (no spills)"))
Output
live into the block: ['x', 'y']
interference degrees: {'a': 4, 'b': 5, 'c': 5, 'd': 5, 'e': 4, 'f': 5, 'g': 2, 'h': 1, 'x': 2, 'y': 3}
K=4: a->s3, b->s2, c->s4, d->s3, e->s1, f->s2, g->s1, h->s1, x->s2, y->s1   (no spills)
K=3: a->s2, b->s1, d->s3, e->s2, f->s1, g->s2, h->s2, x->s1, y->s3   spilled: ['c']
An interference graph with nodes x, y, a, b, c, d, e, f, g and h. Edges connect values that are live at the same time. With three colours, nodes are shaded in three colours, and one node is drawn with a dashed outline as spilled because all three colours are used by its neighbours.
The interference graph of the example: with three registers, one value must be spilled

With four registers every value fits. With three, the colouring gets stuck and one value, c, is spilled to memory: it is live across the definitions of d, e and f while a, b or their successors also need registers. Because the allocator is optimistic, it spills only the value that genuinely found no free colour after its neighbours were coloured, not every candidate it was unsure about. In a full compiler the spilled value would get a frame slot (lesson 9), and the allocator would insert the loads and stores and repeat.

Coalescing and pre-colouring

Two refinements matter in practice. Copy instructions such as x = y, which arise from φ removal (lesson 8), from calling conventions and from the IR generator, are free if x and y share a register. Coalescing merges the nodes of a copy's source and destination when they do not interfere, deleting the copy. Coalescing too aggressively can make the graph impossible to colour with K registers, so allocators use conservative tests, such as Briggs's: merge only if the combined node would have fewer than K neighbours of significant degree. Lal George and Andrew Appel's iterated register coalescing (1996) interleaves simplification and conservative coalescing and is the standard textbook allocator. Pre-coloured nodes represent physical registers with fixed roles: arguments must arrive in a0 to a7, results leave in a0, and a call destroys every caller-saved register, which the allocator models by making values live across a call interfere with all caller-saved registers, steering them into callee-saved ones.

Linear scan

Graph colouring is too slow for some settings, notably JIT compilers, where compile time is part of the program's running time. Massimiliano Poletto and Vivek Sarkar's linear scan allocator (1999) approximates each variable's live range by a single interval, from its first definition to its last use in a linear order of the code, then sweeps the intervals in order of their start points. It keeps the currently active intervals; when one ends, its register is freed; when a new interval starts and no register is free, it spills whichever active interval ends last, freeing a register for the longest time. Linear scan runs in nearly linear time and produces code only a little worse than colouring. Refinements that split intervals and fill holes in live ranges, such as the second-chance binpacking and the HotSpot client compiler's allocator by Christian Wimmer and Hanspeter Mössenböck (2005), are used in HotSpot, V8 and other JITs; LLVM's default greedy allocator is a priority-based hybrid that splits live ranges aggressively.

Linear-scan allocation of eight live intervals with three and with two registers
# Linear-scan register allocation (Poletto and Sarkar, 1999): sort live
# intervals by start, keep the active ones sorted by end, and when no register
# is free spill the interval that ends last. Used by many JIT compilers.
intervals = {"a": (1, 4), "b": (2, 6), "c": (3, 5), "d": (4, 8), "e": (5, 7),
             "f": (6, 9), "g": (7, 8), "h": (8, 9)}            # (first def, last use)
def linear_scan(K):
    free, active, where = [f"r{i}" for i in range(K)], [], {}
    for v, (start, end) in sorted(intervals.items(), key=lambda kv: kv[1][0]):
        for w in [w for w in active if intervals[w][1] <= start]:   # expire old intervals
            active.remove(w); free.append(where[w])
        if free:
            where[v] = free.pop(0); active.append(v)
        else:
            victim = max(active, key=lambda w: intervals[w][1])
            if intervals[victim][1] > end:                         # steal from the longest
                where[v] = where[victim]; where[victim] = "stack"
                active.remove(victim); active.append(v)
            else: where[v] = "stack"
        active.sort(key=lambda w: intervals[w][1])
    return where
for K in (3, 2):
    w = linear_scan(K)
    print(f"K={K}:", ", ".join(f"{v}->{w[v]}" for v in sorted(w)),
          f"| spilled: {[v for v in w if w[v] == 'stack'] or 'none'}")
Output
K=3: a->r0, b->r1, c->r2, d->r0, e->r2, f->r1, g->r2, h->r0 | spilled: none
K=2: a->r0, b->stack, c->r1, d->r0, e->r1, f->stack, g->r1, h->r0 | spilled: ['b', 'f']

Instruction scheduling

Modern processors overlap the execution of instructions in a pipeline, and an instruction that needs a result not yet available stalls. A load that misses in the first-level cache, or a multiply, may take several cycles. Instruction scheduling reorders independent instructions so that useful work fills those cycles. List scheduling, the standard algorithm, builds the dependence DAG of a basic block, assigns each instruction a priority such as the length of the longest path from it to the end, and fills each cycle with the highest-priority instruction whose operands are ready. Scheduling and register allocation interact: scheduling for parallelism lengthens live ranges and increases register pressure, while allocation introduces false dependences between unrelated values that happen to share a register. Compilers often schedule both before and after allocation. Out-of-order processors, which reorder instructions in hardware, reduce the benefit of static scheduling but do not eliminate it, and for in-order cores, common in embedded RISC-V designs, it matters greatly. Software pipelining applies scheduling across loop iterations, overlapping the start of one iteration with the end of the previous.

Emitting code

The last step writes assembly language or, more often today, object code directly. It lays out basic blocks, ideally so that the most frequent successor follows its predecessor and needs no jump; resolves branch targets and chooses short or long branch forms; emits constants and data; and produces symbols, relocations and debugging information for the linker (CMPUT-229 lesson 8 follows this path into linking and loading). Peephole optimization, examining a small sliding window of instructions and replacing patterns with cheaper equivalents, cleans up local inefficiencies such as a store immediately followed by a load from the same location, or a jump to the next instruction.

Worked examples

Worked problem

Choosing tiles

Tile load(+(reg s1, const 16)) + const 1 with maximal munch for RISC-V.

Show solution
  1. At the root, the + has a small constant operand, so the addi tile matches: addi r2, ?, 1.
  2. Its other operand is a load whose address is register plus 16, so the lw-with-offset tile matches: lw r1, 16(s1).
  3. Every node is covered by two tiles.

Answer: lw r1, 16(s1) then addi r2, r1, 1: two instructions instead of five naive ones.

Worked problem

Interference from liveness

In the code t1 = a + b; t2 = t1 * 2; t3 = a - t2; return t3 + b, which pairs interfere?

Show solution
  1. Before the first instruction, a and b are live. After it, t1, a and b are live, so t1 interferes with a and b.
  2. After t2 = t1 * 2, the live values are t2, a and b (t1 is dead), so t2 interferes with a and b.
  3. After t3 = a - t2, the live values are t3 and b, so t3 interferes with b.

Answer: Edges a–b, t1–a, t1–b, t2–a, t2–b and t3–b; three registers suffice, with t1, t2 and t3 able to share one.

Worked problem

Linear scan by hand

With two registers, allocate intervals p (1 to 10), q (2 to 3) and r (4 to 5).

Show solution
  1. p starts and takes r0; q starts and takes r1.
  2. At 4, q has ended, so r1 is free and r takes it.
  3. No spills are needed, though p holds r0 throughout.

Answer: p→r0, q→r1, r→r1; the expiry of q frees a register before r starts.

Common mistakes

  • MistakeTranslate each IR node to one instruction.

    InsteadUse larger tiles for addressing modes, immediates and fused operations.

  • MistakeSpill as soon as no node has fewer than K neighbours.

    InsteadPush a candidate optimistically; it may still find a colour when popped.

  • MistakeCoalesce every copy whose operands do not interfere.

    InsteadCoalesce conservatively so the merged node does not make the graph impossible to colour with K registers.

  • MistakeIgnore calls when allocating registers.

    InsteadMake values live across calls interfere with caller-saved registers, steering them to callee-saved ones.

  • MistakeSchedule instructions without regard to register pressure.

    InsteadBalance parallelism against live-range length; schedule before and after allocation if needed.

Summary and key terms

  • The back end selects instructions, allocates registers, schedules and emits code.
  • Instruction selection covers IR trees with tiles; maximal munch is greedy, dynamic programming is optimal for trees.
  • Liveness determines which values are needed together; interfering values need different registers.
  • Chaitin–Briggs colouring simplifies nodes of degree less than K, colours them in reverse, and spills optimistically.
  • Coalescing removes copies; pre-colouring handles calling conventions and calls.
  • Linear scan allocates live intervals in nearly linear time and is favoured by JIT compilers.
  • List scheduling fills pipeline delays; block layout and peephole optimization finish the code.
Instruction selection
Choosing machine instructions to implement IR operations.
Tile
A tree pattern corresponding to one machine instruction.
Maximal munch
Greedy instruction selection that takes the largest matching tile at each node.
Live range
The set of program points where a variable's value may still be used.
Interference graph
A graph with an edge between variables that are live at the same time.
Spilling
Keeping a variable in memory and loading it around each use when registers run out.
Coalescing
Merging the source and destination of a copy so they share a register.
Linear scan
Register allocation over live intervals in a single sweep.
Instruction scheduling
Reordering instructions to reduce pipeline stalls.
Peephole optimization
Replacing short instruction sequences with cheaper equivalents.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Which RISC-V instruction covers a load from register plus a 12-bit offset?

    Show answer

    lw rd, offset(rs1) (or ld for 64-bit values).

  2. How does maximal munch handle multiplication by 8?

    Show answer

    It uses a shift left by 3 (slli).

  3. What guarantees that a node can be coloured with K colours?

    Show answer

    Having fewer than K neighbours.

  4. In optimistic colouring, when is a node actually spilled?

    Show answer

    Only when it finds no free colour as it is popped.

  5. What is a pre-coloured node?

    Show answer

    A node representing a physical register, such as an argument register.

  6. Why do JIT compilers favour linear scan?

    Show answer

    It is much faster, and compile time is part of run time.

  7. In linear scan, which interval is spilled when no register is free?

    Show answer

    The active interval that ends last (or the new one if it ends later).

  8. What does list scheduling use to prioritize instructions?

    Show answer

    For example, the length of the longest dependence path to the end of the block.

  9. Why can register allocation hurt scheduling?

    Show answer

    Reusing a register creates false dependences between unrelated values.

  10. Give an example of a peephole optimization.

    Show answer

    Removing a load that immediately follows a store to the same location, or a jump to the next instruction.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz