「寄存器分配与图着色」

从活跃性分析出发,讲解寄存器分配的核心算法:冲突图构建、图着色与溢出处理、线性扫描分配,并延伸到 SSA 解构、伪寄存器到物理寄存器的映射,以及 LLVM 与 HotSpot 中的工程取舍,附带可运行的 Python 实现。

1. 寄存器分配问题概览

一句话总结: 中间代码使用数量无限的伪寄存器,寄存器分配的任务是把它们一一映射到数量有限的物理寄存器,映射不下时溢出到内存。

编译器在前端生成的中间代码里,每个局部变量和每个临时值都各占一个「伪寄存器」(virtual register)。伪寄存器数量不受机器限制,一个表达式 t4 = (t1 + t2) * 2 会依次引入 t1、t2、t3、t4 四个名字。但真实 CPU 只有有限个物理寄存器,x86-64 上可供编译器自由使用的通用寄存器不过十几个,因此分配阶段必须决定:谁住进寄存器、谁住进栈上的溢出槽(spill slot)。

# 中间代码里的伪寄存器: 数量不受限
IR = [
    ("MOV", "t1", 5),           # t1 = 5
    ("MOV", "t2", 3),           # t2 = 3
    ("ADD", "t3", "t1", "t2"),  # t3 = t1 + t2
    ("MUL", "t4", "t3", 2),     # t4 = t3 * 2
    ("RET", "t4"),
]

# 目标机器只有 4 个物理寄存器可用
PHYSICAL_REGISTERS = [f"r{i}" for i in range(4)]

# 朴素分配: 遇到伪寄存器就占一个物理寄存器
def naive_allocate(ir, n_regs=4):
    table, used = {}, []
    for inst in ir:
        for opnd in inst[1:]:
            if isinstance(opnd, str) and opnd.startswith("t"):
                if opnd not in table:
                    if len(used) >= n_regs:      # 寄存器耗尽
                        table[opnd] = f"spill_{opnd}"
                    else:
                        reg = f"r{len(used)}"
                        used.append(reg)
                        table[opnd] = reg
    return table

print(naive_allocate(IR))

分配质量直接决定生成代码的快慢:寄存器访问比内存访问快一个量级,次优的分配可能让同一份程序多出 30%~50% 的 load/store 指令。寄存器分配因此被公认为代码生成阶段最有价值也最复杂的单个问题。现代编译器普遍把它建模为图着色问题:把每个伪寄存器的生命周期画成一段区间,区间重叠的两个伪寄存器不能共享同一个物理寄存器。

分配对象数量存放位置访问代价
伪寄存器无限物理寄存器最快
活跃变量有限溢出槽需 load/store
常量可重物化不占位置按需生成

2. 活跃性分析

一句话总结: 活跃性分析沿控制流图反向传播 live-in/live-out 集合,为每个变量计算出它「活」的区间,这是构建冲突图的前提。

一个变量的活跃区间(live range)指它从最后一次被定义、到最后一次被读取之间的程序范围。寄存器分配只关心活跃区间重叠的变量,因为只有它们才需要同时占有寄存器。活跃性分析是经典的数据流问题:对每个基本块求 live-in(块入口处活跃的变量)与 live-out(块出口处仍活跃的变量),用迭代法沿图反向传播直至不动点。

def liveness(cfg, use, defs):
    """use[n]: 块 n 中被读取的变量; defs[n]: 块 n 中被定义的变量."""
    live_in = {n: set() for n in cfg}
    live_out = {n: set() for n in cfg}
    changed = True
    while changed:
        changed = False
        for n in cfg:
            new_out = set()
            for succ in cfg[n]:
                new_out |= live_in[succ]
            new_in = use[n] | (new_out - defs[n])
            if new_in != live_in[n] or new_out != live_out[n]:
                live_in[n] = new_in
                live_out[n] = new_out
                changed = True
    return live_in, live_out

# 简单控制流图: 0 -> 1 -> 2 -> 3 -> 4
cfg = {0: [1], 1: [2], 2: [3], 3: [4], 4: []}
use = {0: set(), 1: {"a"}, 2: {"b"}, 3: {"a", "b"}, 4: set()}
defs = {0: {"a"}, 1: {"b"}, 2: {"c"}, 3: {"d"}, 4: set()}

li, lo = liveness(cfg, use, defs)
for n in cfg:
    print(f"块{n}: live_in={sorted(li[n])} live_out={sorted(lo[n])}")

迭代从空集出发,每次传播把出口的活跃性并入入口,直到两次迭代结果完全相同。数据流方程与编译器优化的许多经典算法(可达定义、可用表达式)结构一致,理解了活跃性就理解了数据流分析的一般套路。活跃性分析的结果还会被死代码消除与寄存器重命名复用,是整个优化后端的公共基础设施。

3. 冲突图构建

一句话总结: 两个伪寄存器若存在某个同时活跃的程序点就构成冲突,把所有冲突对记入无向图,寄存器分配就化为图的着色问题。

有了每个基本块的 live-in/live-out,就能构建冲突图(interference graph):对块内每一条「定义」指令,凡是与这个定义在同一时刻活跃的变量都与它冲突。严谨的定义是——变量 a 在指令 d 处被定义,若 b 在 d 处活跃且 a != b,则 a 与 b 冲突。冲突图里每条边表示「这两个伪寄存器不能共用物理寄存器」。

def build_interference(cfg, live_in, live_out, defs):
    all_vars = set()
    for s in defs.values():
        all_vars |= s
    ig = {v: set() for v in all_vars}
    for n in cfg:
        defined = defs[n]
        live = live_out[n] | (live_in[n] - defined)
        for d in defined:
            for v in live:
                if v != d:
                    ig[d].add(v)
                    ig[v].add(d)
    return ig

ig = build_interference(cfg, li, lo, defs)
for v in sorted(ig):
    print(f"{v} 冲突: {sorted(ig[v])}")
# 冲突图可视化 (点->边)
def render_graph(ig):
    lines = ["graph G {"]
    for v, nbrs in ig.items():
        for n in nbrs:
            if v < n:
                lines.append(f'  "{v}" -- "{n}"')
    lines.append("}")
    return "\n".join(lines)

print(render_graph(ig))
冲突来源例子原因
定义点活跃a 定义处 b 仍活两者同时需要存储
参数传递调用前后实参活跃调用约定占用寄存器
常驻变量循环计数、指针全程活跃

4. 图着色算法

一句话总结: Kempe 着色法反复剥离低度节点后倒序赋色,颜色不够时选择溢出对象,是经典图着色分配器的骨架。

冲突图确定后,寄存器分配等价于给图的每个顶点染上 k 种颜色(k 为可用物理寄存器数),相邻顶点不得同色。若 k 色不可行,说明某些变量必须溢出到内存。Kempe 的经典算法分两阶段:简化阶段反复删掉度数小于 k 的顶点压入栈——这样的顶点无论邻居染什么色都总能找到空色;选择阶段倒序弹栈,给每个顶点赋一个与已着色邻居不同的颜色,若找不到则标记为溢出。

def kempe_coloring(interference, k):
    stack = []
    remaining = {v: set(nbrs) for v, nbrs in interference.items()}
    while remaining:                      # 简化阶段
        node = min(remaining, key=lambda v: len(remaining[v]))
        stack.append(node)
        for nbr in list(remaining[node]):
            remaining[nbr].discard(node)
        del remaining[node]

    color = {}
    while stack:                          # 选择阶段
        n = stack.pop()
        used = {color[x] for x in interference[n] if x in color}
        for c in range(k):
            if c not in used:
                color[n] = c
                break
        else:
            color[n] = None               # 无法着色: 溢出
    return color

print(kempe_coloring(ig, k=3))
阶段操作复杂度
简化删低度顶点入栈O(V + E)
选择倒序赋色、检测溢出O(V * k)
溢出插入 load/store 后重来反复迭代

溢出处理是整个分配器的难点:挑出溢出变量后要插入 store(定义后)与 load(使用前)指令,然后重新做活跃性分析与着色,通常迭代几次才稳定。挑选溢出对象有启发式:优先溢出度数高、定义使用频繁度低的变量,避免把循环体核心变量赶出寄存器。

5. 线性扫描分配

一句话总结: 线性扫描按活跃区间起点排序一遍扫描完成分配,简单快速、适合 JIT,缺点是可能与图着色最优解存在差距。

图着色分配质量高,但需要完整构建冲突图,构建与着色都有不小的常数开销。对即时编译(JIT)这类对编译时间敏感的场景,业界更常用线性扫描(linear scan):先把每个伪寄存器的活跃区间按起始点排序,维护一个「活跃中」的区间集合,遇到新区间时若寄存器已满,就踢掉结束点最晚的区间(或自己溢出),一趟扫描即可完成。

from dataclasses import dataclass

@dataclass
class Interval:
    v: str
    start: int
    end: int

def linear_scan(intervals, k):
    intervals = sorted(intervals, key=lambda iv: iv.start)
    active = []                     # 活跃中区间, 按 start 排序
    assign = {}
    for iv in intervals:
        active = [a for a in active if a.end >= iv.start]
        if len(active) < k:
            used = {assign[a.v] for a in active}
            for r in range(k):
                if r not in used:
                    assign[iv.v] = r
                    active.append(iv)
                    break
        else:
            spill = max(active, key=lambda a: a.end)
            if spill.end > iv.end:          # 新区间更长, 挤掉旧的
                assign[iv.v] = "spill"
            else:
                assign[spill.v] = "spill"
                active.remove(spill)
                assign[iv.v] = 0            # 简化: 占据被挤掉的寄存器
                active.append(iv)
    return assign

ivs = [
    Interval("a", 0, 3), Interval("b", 1, 4),
    Interval("c", 2, 5), Interval("d", 3, 6),
]
print(linear_scan(ivs, k=2))
算法复杂度分配质量适用场景
图着色高优静态编译(GCC/Clang -O2)
线性扫描低良JIT(HotSpot C1、V8 基线)
贪心变体低良LLVM 默认的 Greedy

线性扫描的近似性在于它只在区间端点做决策,看不到未来的精确干涉关系,某些情况下会多溢出一个本可以避免的变量。但它胜在不需要完整冲突图,JIT 编译器常把线性扫描作为基线分配器,需要更高质量时再升级到图着色。

6. SSA 解构与伪寄存器

一句话总结: SSA 形式靠 phi 节点合并不同来源的值,分配前要把 phi 展开成沿各前驱边的拷贝,并用临时变量打破循环依赖。

现代编译器把优化建立在 SSA(静态单赋值)形式上,每个变量只被赋值一次,合并点用 phi 节点表达。寄存器分配通常发生在 SSA 解构之后或之中:phi 节点不能直接翻译成机器指令,必须为每个前驱边生成一条从对应来源到目标寄存器的拷贝指令。多个拷贝同时发生时还可能出现循环依赖,例如 x 与 y 互换,需要引入临时变量。

def eliminate_phi(incoming, result):
    """把 phi 节点 (src1, src2 -> result) 翻译成沿各前驱的并行拷贝."""
    copies = [(src, result) for src in incoming]
    # 若存在循环依赖 (a=b; b=a), 用临时变量打破
    for i, (src, dst) in enumerate(copies):
        if any(dst == s for s, _ in copies[i + 1:]):
            tmp = f"__tmp{i}"
            copies[i] = (src, tmp)
            copies.append((tmp, dst))
    return copies

print("phi(x,y)->z:", eliminate_phi(("x", "y"), "z"))
print("phi(y,x)->x:", eliminate_phi(("y", "x"), "x"))  # 循环依赖

SSA 解构与寄存器分配交织的方式有三种:解构后分配最简单;分配时解构把 phi 当普通指令一起着色;基于 SSA 的分配利用 SSA 的弦图性质做快速合并,LLVM 的早期 SSA 分配器即属此类。无论哪种,目标都是减少插入的拷贝指令,尽量让同一变量的不同 SSA 版本落在同一寄存器,这一步叫合并(coalescing)。

def coalesce(pairs, interference):
    """把无冲突的拷贝对合并到同一寄存器."""
    merged = {}
    for src, dst in pairs:
        if not (dst in interference.get(src, set())):
            merged[dst] = merged.get(src, src)
    return merged

7. 寄存器分配的工程实践

一句话总结: 真实分配器还要处理调用约定、被调用者保存寄存器、可重物化值与分配预算,质量与编译时间的平衡决定了引擎形态。

教学算法假设所有物理寄存器等价,真实机器远非如此。x86-64 SysV 调用约定规定前六个整型参数走 rdi/rsi/rdx/rcx/r8/r9,返回值走 rax,且 rbx/rbp/r12-r15 是被调用者保存(callee-saved),调用者保存(caller-saved)寄存器则要在调用点由调用方保护。分配器把约定映射成「预着色」的伪寄存器,与普通伪寄存器一起参与着色。

def classify_args(param_count):
    """x86-64 SysV: 前 6 个整型参数用寄存器, 其余压栈."""
    regs = ["rdi", "rsi", "rdx", "rcx", "r8", "r9"]
    return {
        i: (regs[i] if i < 6 else f"stack[{i - 6}]")
        for i in range(param_count)
    }

print(classify_args(8))
工程要点说明
预着色参数/返回值寄存器预先固定
callee-saved函数进入保存、退出恢复,延长活跃区间
可重物化常量与地址可重新生成,不必溢出
分配预算超大函数降级到快速算法控制编译时间

可重物化(rematerialization)是重要的优化:常量、lea 地址、空对象引用等指令成本极低,与其溢出到内存再 load,不如每次需要时重新生成。分配器判断「重生成成本 < 溢出成本」就选择重物化。LLVM 的 Greedy 分配器把这些启发式揉进区间分裂与 spill 决策,HotSpot 的 C2 则在理想图(ideal graph)上做类似权衡,两者都是「高质量 + 可控编译时间」的工程折中。

8. 总结

主题核心结论
分配目标伪寄存器映射到有限物理寄存器,多则溢出
活跃性分析反向数据流迭代求 live-in/live-out
冲突图同时活跃者连边,分配化为图着色
图着色Kempe 简化 + 选择,失败则溢出并重来
线性扫描按区间排序一趟扫描,适合 JIT
SSA 解构phi 展开成并行拷贝,临时变量破循环
工程实践调用约定、可重物化、分配预算共同塑形

寄存器分配把「抽象的中间代码」落成「具体的机器资源」,是编译器后端最难也最值得投入的一环。从活跃性分析到冲突图、再到着色与溢出,算法链条环环相扣;而线性扫描、可重物化与预着色等工程技巧又说明,现实分配器永远要在最优解与编译开销之间寻找平衡点。理解了图着色这一模型,无论面对 GCC 的 IRA、LLVM 的 Greedy 还是 HotSpot 的 C2 分配器,都能快速看懂它们的取舍逻辑。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. MLIR 与多层次 IR
  2. 可复现构建与确定性输出
  3. 约束求解与类型类