本页目录

寄存器分配

编译优化里最体现 NP 难度的一关:把无限虚拟寄存器映射到有限物理寄存器,映射不下时溢出到栈。图着色(Chaitin/Briggs)和线性扫描(Poletto/Sarkar)是两种近似解,SSA 的弦图性质让着色变得容易。

概述

SSA 形式优化阶段使用无限虚拟寄存器⁠——每个新值都有独立的 %v1%v2。真正的目标机器只有少量物理寄存器(x86-64 有 16 个通用寄存器,ARM64 有 31 个)且受 calling convention 约束。寄存器分配是把虚拟寄存器映射到物理寄存器——映射不下时溢出(spill)⁠到栈。这是编译优化中最能体现 NP 难度的一关——最优寄存器分配是 NP-complete(图着色),所以实际用的是近似算法。

前置:活度分析

数据流分析 的 liveness 直接给寄存器分配提供输入:

对于两个虚拟寄存器 v1 和 v2:
  如果存在某个程序点, v1 和 v2 同时活跃(live)
    → v1 和 v2 不能共享同一物理寄存器
    → 在 interference graph 中 v1 和 v2 有一条边

活跃区间(live range)的构造:从每个定义点出发,沿 SSA 的 def-use 链,到所有使用点,这之间的"段"就是该虚拟寄存器的活跃区间。如果有 phi 合并,把 phi 来源和目标的活跃区间连起来。

图着色分配(Chaitin/Briggs)

构造干涉图

节点 = 虚拟寄存器; 边 = 两个虚拟寄存器在任一点同时活跃:

v1 定义于 BB1, 最后使用于 BB3
v2 定义于 BB2, 最后使用于 BB3
在 BB3 入口处 v1 和 v2 都活跃 → v1 — v2 有边

着色 = 给每个节点分配颜色(物理寄存器),相邻节点颜色不同

算法(Chaitin-style):

while 图中还有节点:
    if 存在节点 n, degree(n) < K (K = 可用物理寄存器数):
        从图中移除 n, 把 n 压入栈       ← n 一定能被着色 (邻接节点 < K)
    else:
        选一个 spill 代价最小的节点 s
        标记 s 为 spill, 从图中移除      ← 牺牲它, 放到栈上
        调用 spill_code(s): 生成 store/load 把 s 的值转移到栈

从栈中依次弹出, 给每个节点分配一个颜色 (不和已着色的邻接节点冲突)

Briggs 的改进⁠:不立即判定 degree < K 的节点一定可着色——先乐观地移除它们,在弹出阶段如果发现无法着色的节点,再做 spill。Briggs 的乐观着色在实践中产生的 spill 显著少于 Chaitin 的悲观算法(Chaitin 主动 spill 所有 degree≥K 的节点,很多本可着色的也被溢出了)。

Spill 代码生成

当寄存器不够时,把某个虚拟寄存器的值"溢"到栈:

spill 前:                         spill 后:
  v1 = ...                         v1 = ...
  ... = v1 (use)                   spill [v1_slot] = v1        ← 定义后立即 store
                                    v1_reload = load [v1_slot]  ← 使用前 load
                                    ... = v1_reload

但 spill 改变了活跃区间——原来 v1 只在 ... = v1 处使用,现在 spill 把它的活跃区间拆成两段(v1v1_reload),可能让干涉图更稀疏,从而缓解着色压力。Spill 重新建干涉图并重新分配⁠——这是迭代的。

线性扫描(Poletto/Sarkar):更快但更多 spill

线性扫描用一个简化的活跃模型:不是点精确的干涉图,而是活跃区间的线性顺序⁠:

对所有虚拟寄存器的活跃区间按起始位置排序:
  维护一个"当前活跃"列表
  扫描每个区间:
    expire_old_intervals()  ← 移除已结束的区间, 释放它们占用的物理寄存器
    if 有空闲寄存器:
        分配它
    else:
        从当前活跃中选一个 spill 代价最小的, spill 它
        把腾出的寄存器分配给新区间

线性扫描的复杂度是 O(n log n)(排序),远快于图着色的 O(n²) 或更高。代价:它产生的 spill 比图着色多(因为它不看干涉图,可能 spill 了两个不干涉的区间)。线性扫描在 JIT 编译中常用——编译时间压倒一切,多几个 spill 可接受。

SSA 对寄存器分配的助益

SSA 形式下,变量之间的 def-use 关系天然形成弦图(chordal graph)⁠结构——弦图的着色可以用贪心算法(O(n))得到最优解。但注意:实际寄存器分配通常在 SSA 解构(插入 copy)之后进行,φ 解构引入的 copy 可能破坏弦图性质;不过 coalescing 阶段会尽量合并这些 copy,使着色仍然高效。

Coalescing:消除 copy

v1 = ...
v2 = v1           ← copy: 需要一条 mov 指令
... = v2

如果 v1 和 v2 分配同一寄存器 r:
  r = ...
  ... = r         ← copy 消掉了

coalescing 不是无代价的——把 v1 和 v2 合并成一个节点,可能增加它的 degree,使它变得不可着色。所以 coalescing 是保守的⁠:只 coalesce 在着色之后不会引入 spill 的 copy。Briggs 保守 coalescing 检查合并后的节点 degree < K(可用寄存器);George 保守 coalescing 检查 v1 的所有邻接节点是否"已经和 v2 不冲突或 degree < K"。

Calling Convention 与分配器的互相影响

物理寄存器不仅受虚拟寄存器争用,还受 calling convention 约束:

  • 参数寄存器⁠(x86-64: rdi, rsi, rdx, rcx, r8, r9):函数调用的前 6 个参数必须放在这些寄存器里。
  • 返回值寄存器⁠(rax):函数返回值在 rax。
  • 被调用者保存(callee-saved, x86-64: rbx, rbp, r12–r15):被调函数如果修改了这些寄存器,必须在返回前恢复。由被调函数 spill/restore,调用方不动作。
  • 调用者保存(caller-saved, x86-64: rax, rcx, rdx, rsi, rdi, r8–r11):调用方在 call 前后如果要用这些寄存器,必须自己 spill/restore。

寄存器分配器必须遵守这些约束:⁠函数 prologue 里 spill callee-saved 的物理寄存器到栈⁠,如果在分配过程中使用了它们;在 call 指令前后,不能假设 caller-saved 物理寄存器里的值在 call 之后仍然存活。

参考

  • Chaitin (1982): "Register Allocation and Spilling via Graph Coloring" — 图着色分配的原始论文
  • Poletto/Sarkar (1999): "Linear Scan Register Allocation" — 线性扫描的原始论文
  • LLVM: lib/CodeGen/RegAllocGreedy.cpp (默认分配器), lib/CodeGen/RegAllocBasic.cpp (baseline) — 工业级分配器,结合线性扫描和图着色的混合

Keywords: register allocation, interference graph, graph coloring, Chaitin, Briggs, optimistic coloring, linear scan, spilling, spill code, splitting, coalescing, copy elimination, liveness, live range, chordal graph, register pressure, caller-saved, callee-saved, calling convention, prologue, epilogue, SSA-based allocation