1. 寄存器分配为什么绕不开图着色
寄存器分配这件事,在编译器后端的地位有点像装修时的水电改造,平时没人想起来,一旦做不好整屋性能都会崩。我见过不止一次,辛辛苦苦把优化 pass 调得风生水起,内联、向量化、循环展开全开,跑分结果却纹丝不动,最后定位发现是寄存器分配把热循环里的几个变量全部挤到了内存,原本一条加法指令变成 load、add、store 三条。问题不在优化,而在门没守住。
图着色寄存器分配(Graph Coloring)就是把这个"守门"问题数学化的经典方案。它的核心思想非常朴素:把每个虚拟寄存器(编译器 IR 里的临时变量)当成图的一个节点,两个节点如果生命周期重叠、不能共用同一个物理寄存器,就在它们之间连一条边。物理寄存器就是颜色,分配寄存器就是给整张图染色,要求相邻节点颜色不同。如果物理寄存器有 k 个,就是经典的 k-着色问题。
这套模型在上世纪 80 年代初由 Chaitin 在 IBM 的 PL.8 编译器中引入,此后三十多年一直是 AOT 编译器(GCC、LLVM 的若干分配器、各种研究性编译器)的主流方案。它的价值和难点都在于:分配质量高,但实现细节极多,从活跃性分析、干涉图构建、简化排序到溢出处理,任何一个环节偷懒,最后都会在生成的代码质量上暴露出来。这篇文章我打算从一个实践者的角度,把整条链路拆开讲清楚,包括原理、流程、伪代码和工程里最容易踩的坑。
1.1 最影响后端性能的 pass,往往是寄存器分配
很多人以为指令调度、循环优化是后端性能的大头,实际在寄存器压力大的目标架构上,寄存器分配常常是收益最明显的 pass。原因很简单:内存访问比寄存器访问慢一个数量级以上,而现代 CPU 的寄存器文件又极其有限。x86-64 只有 16 个通用整数寄存器,ARM64 多一些,有 31 个,但相比编译器中可能出现的成百上千个虚拟寄存器,仍是杯水车薪。
寄存器分配的本质是一个受限资源下的映射问题:把无限多个虚拟寄存器映射到有限的物理寄存器,映射不下的变量就得"溢出"(spill)到内存栈槽里,每次使用前 load,每次定义后 store。一个变量的生命周期如果横跨了多次访问,溢出的代价会成倍放大。所以"尽量少溢出"和"溢出放在代价最低的地方"就是寄存器分配器的主要目标。
这里有个反直觉的事实:寄存器分配的建模粒度不是"变量"而是"活跃区间"。同一个源代码变量在不同阶段可以分配到不同寄存器,两个不同的虚拟寄存器也可以在生命周期不重叠时复用同一个物理寄存器。图着色模型天然支持这种复用:只要节点间没有边,它们就可以涂同一个颜色。
1.2 问题的数学本质:把冲突变成一个图
把寄存器分配抽象成图着色,关键是定义"冲突"(interference)。两个虚拟寄存器如果在某个程序点同时活跃,且其中一个的活跃区间覆盖了另一个的定义点,它们就不能共用一个物理寄存器。
为什么这一条如此重要?因为物理寄存器在任意时刻只能保存一个值。如果变量 a 和变量 b 都活着,你不可能让它们睡在同一个寄存器里,否则更新 a 就会弄丢 b。这种冲突关系天然构成一张无向图,节点是虚拟寄存器,边是"不能同色"。于是"用 k 个寄存器完成分配"就等价于"用 k 种颜色给这张图染色,并保证相邻节点异色"。
1.3 为什么图着色是 NP 完全的,我们还要用它
图着色问题是经典的 NP 完全问题,这意味着在一般情况下不存在多项式时间的精确算法。既然这样,为什么还要用图着色做寄存器分配?
答案是:我们不需要最优解,只需要一个足够好的近似解。编译器对寄存器分配的要求是"在可接受的编译时间内,尽可能少地产生溢出代码"。图着色模型提供了一套系统化的近似框架——用贪心简化(Simplify)、启发式溢出候选选择和乐观着色来逼近一个可行解。实践表明,这套框架在绝大多数真实程序上的分配质量显著优于更简单的局部分配或线性扫描(线性扫描会在第 5 章重点对比)。
另一个角度是:寄存器分配的图虽然大,但不是随便一张图。它由程序的控制流和数据流关系决定,在 SSA 形式下甚至具备特殊的结构性质(后面会提到弦图),实际可着色性比理论最坏情况好得多。正因如此,即使问题是 NP 完全的,图着色方案依然在工程中稳坐主力位置。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从三地址码到干涉图:活跃性分析是地基
干涉图不是凭空画出来的,它的唯一输入是活跃性分析(liveness analysis)的结果。活跃性分析做错了,干涉图就画错,后面的简化、着色、溢出全白搭。我在实际调试寄存器分配器时,遇到的大多数"Spill 异常多"问题,追到根上都是活跃性分析的 bug,而不是着色算法的 bug。
活跃性分析要回答的问题是:每个程序点,哪些变量在未来还会被读取?一个变量如果在某个点之后还有机会被读取,就说它在该点"活着";如果之后永远不会再读了,就说明它已经"死"了。干涉图里所有边,本质上都来自两个活着的时间段在某个点上的重叠。
2.1 活跃性分析:数据流方程和控制流边界
活跃性分析是一个经典的反向数据流问题,因为它要从程序的出口往回推。对每个基本块 B,需要计算两个集合:IN[B] 和 OUT[B],分别表示进入和离开 B 时活跃的变量集合。数据流方程如下:
code复制IN[B] = USE[B] ∪ (OUT[B] − DEF[B])
OUT[B] = ∪(所有后继基本块 S 的 IN[S])
其中 USE[B] 是在 B 中被读取但未在本块内定义的变量,DEF[B] 是在 B 内被定义的变量。方程的含义很直观:一个变量如果在本块被读取,它进入块时就必须是活跃的;如果变量在本块被定义,那么只要它在本块之后不再被读取,离开本块时它就可以死了。
整个分析通过迭代进行:先初始化所有块的 IN、OUT 为空,然后从出口块开始反复应用上述方程,直到所有集合不再变化。注意点是控制流会合处的合并,例如 if-else 两个分支结束后汇合,变量只要在任一分支需要,汇合点就必须认为它活着。
这里有个工程细节值得提:很多教材上的活跃性分析是"指令级"的,但实现时要决定一条指令内部右值读取和左值写入的顺序。寄存器分配实践中的通用建模方式是:指令"先读后写",即右值读取发生在左值写入之前。因此分析活跃性时,一条指令结束后才更新活跃状态,源操作数在该指令结束后可能已经死掉,结果操作数从该指令结束后开始活跃。这个约定允许一条指令的源操作数和结果操作数复用同一个物理寄存器,因为硬件先读后写,不会互相覆盖。
2.2 干涉图的构建规则
有了每个点的活跃信息,构建干涉图就变成机械操作了。遍历每条指令"d = s1 op s2",在指令执行结束后的活跃变量集合中,d 会与所有仍然活跃的变量产生干涉——除了一个特例:如果指令是移动指令"d = s",且 d 与 s 之间不存在来自其他路径的冲突,那么它们不干涉,因为我们期望后续通过合并(Coalesce)让它们落到同一个寄存器,从而直接消除这条 mov。
我为每个虚拟寄存器创建一个节点,在干涉节点之间添加无向边。预着色的物理寄存器(比如调用约定的参数寄存器)也作为特殊节点参与建图,但颜色固定。整个构建过程的复杂度通常是 O(N²),N 是虚拟寄存器数量,因为最坏情况下每个指令都要和活跃集合中的每个变量建立关系。工程上会用位矩阵表示边集,压缩活跃集合,避免频繁分配内存。
2.3 一个手算示例:看干涉图怎么建立
用下面这段三地址码来走一遍流程:
code复制t1 = 1
t2 = 2
t3 = t1 + t2
t4 = t1 + 3
return t3 + t4
按指令结束边界更新活跃性。行 1 结束后 t1 活跃;行 2 结束后 t1、t2 都活跃(t1 后面还要用);行 3 指令先读 t1、t2,写 t3,结束后 t2 死亡,t1 和 t3 活跃;行 4 读 t1,写 t4,结束后 t1 死亡,t3 和 t4 活跃;return 前 t3、t4 都活跃。
得到的干涉边是:
- t1 与 t2:行 2 结束到行 3 执行期间同时活跃,有边。
- t1 与 t3:行 3 结束后到行 4 执行前 t1、t3 都活跃,有边。
- t3 与 t4:行 4 结束后到 return 前都活跃,有边。
- t1 与 t4:行 4 结束后 t1 已死,不干涉。
- t2 与 t3、t2 与 t4:生命周期错开,不干涉。
所以干涉图是三条边:t1–t2、t1–t3、t3–t4。这是一条链状结构,2 个寄存器就能全部涂完:t2 用 R1,t1 用 R2,t3 用 R1,t4 用 R2,完全合法。这个例子虽然小,但足够说明图着色的核心收益:4 个虚拟寄存器在只有 2 个物理寄存器时也能全部塞下,靠的正是生命周期不重叠的部分共享同一寄存器。
3. Chaitin-Briggs 算法的五步流水线:简化、合并、冻结、溢出、选择
Chaitin 最早提出的流程是:构建干涉图、简化、溢出候选选择、选择着色,然后对真正溢出的节点重新处理。Briggs 在 1992 年的博士论文里加入了合并和冻结阶段,并提出了乐观着色,形成了现在文献里最常见的 Chaitin-Briggs 算法。整套流程可以用五个词概括:简化(Simplify)、合并(Coalesce)、冻结(Freeze)、溢出(Spill)、选择(Select)。
它的基本逻辑是:如果图里存在度数小于 k 的节点,那么这个节点无论在简化后的图上涂什么颜色,都不会让图变得不可着色,因为它的邻居最多只有 k−1 种颜色被占用,总能找到一个颜色给它。反复删掉这种"好说话"的节点,如果能把整个图删空,说明这个图是可以通过贪心方式 k-着色的。当图里所有节点度数都 ≥ k 时,就得靠启发式选一个"牺牲品"作为潜在溢出,把它也压栈,继续简化。
3.1 简化:不断删掉"好说话"的节点
简化阶段维护一个栈。算法循环执行以下步骤:
- 如果存在度数小于 k 的节点,把它从图中移除并压入栈。
- 如果所有节点度数都 ≥ k,说明当前图无法通过简单贪心继续下去,选择一个溢出候选节点,移除并压栈,同时标记为"可能溢出"。
不断重复,直到图为空。移除节点时,其所有邻居的度数都会减一,因此原本卡在 k 度上的节点可能随着简化进行降到 k 以下,这就是"简化"能产生连锁效应的原因。
回头用 2.3 节那个例子,假设 k=2。第一次迭代,t2 和 t4 度数都是 1,可以删 t2(或 t4)入栈。删掉 t2 后,t1 度数降为 1,可以继续删;删掉 t1 后,t3 度数降为 1,再删;最后删 t4。整个图被一步步拆光,说明它显然可以 2-着色。
3.2 合并:消除 mov 指令的黄金机会
在简化之前,Chaitin-Briggs 算法还做了一件大事:合并移动指令两端的临时变量。每条"d = s"移动指令,如果 d 和 s 之间没有干涉,把它们合并成同一个节点是安全的,合并后这条 mov 就可以整个删除,因为源和目标变成了同一个寄存器。这直接减少了指令数量,也让后续简化更容易。
但合并不是无限度的:合并两个节点会引入一个新节点,它的邻居集是原来两个节点邻居集的并集,可能让新节点的度数暴涨,反而使图更难着色。因此工程上必须采用保守合并标准。两个常用判据是:
- Briggs 判据:合并后的新节点,其"显著邻居"(度数 ≥ k 的邻居)数量小于 k,则合并是安全的。
- George 判据:如果要把 a 并入 b,要求 a 的每个邻居 t,要么已经和 b 冲突,要么 t 的度数小于 k。
看不懂判据没关系,记住核心:合并的目标是减少 mov,但不能以牺牲可着色性为代价。实际实现中,移动指令会按启发式排序,优先尝试合并那些两端节点度都不高的移动指令。
3.3 冻结与溢出:当简化卡住的时候
如果合并因为保守判据无法进行,或者合并后图依然难以简化,算法进入冻结阶段。冻结的含义是:对某些与移动指令相关的节点,我们放弃继续尝试合并它们,把相关移动指令从"可合并"集合中剔除,然后回到简化流程。这类节点通常处在一个高度纠缠的冲突子图里,合并它们只会让情况恶化。
溢出阶段处理的是简化已经走不动的情形:图里所有节点度数都 ≥ k,且没有可合并的移动指令可做。这时必须选一个节点作为"潜在溢出"(possible spill),把它压栈标记,继续简化。注意它只是"潜在"溢出,因为乐观着色给了它一次在最后翻盘的机会(3.4 节详述)。
选择溢出节点是启发式活。常见的成本模型是综合考虑使用次数、循环嵌套深度和当前节点的度数。一个在循环深处被反复读写的变量,如果被溢出,代价极高;一个只是初始化一次、之后再不碰的变量,溢出代价就低得多。典型启发式是计算每个节点的"溢出代价 / 度数",优先选择比值最小的节点。
3.4 选择与乐观着色:栈弹出时的最后机会
整个简化过程把所有节点压进了一个栈,选择阶段就是从栈顶开始反向弹出,逐个恢复节点并分配颜色。每个节点恢复时,它的邻居可能已经有部分被着色,只要存在一个与所有已着色邻居都不同的颜色,就分配给它。
关键在于:那些在简化阶段被标记为"潜在溢出"的节点,在弹出时仍然有机会找到可用颜色。因为简化的过程是动态的,节点被移除时它的邻居度数已经变化,等它回来时,它周围的颜色可能并没有占满。只有在弹出时确实找不到任何可用颜色的潜在溢出节点,才会被标记为"真正溢出"(actual spill)。这正是 Briggs 乐观着色比原始 Chaitin 算法领先的地方——原始算法把"可能溢出"直接当成"必须溢出",显然会制造大量不必要的内存访问。
选择阶段完成后,如果没有任何真正溢出节点,分配成功;否则,对所有真正溢出节点插入 load/store,重新构建活跃性信息,再走一遍整个流程。
为了便于理解,我写一个简化版的流程伪代码,忽略合并/冻结细节:
python复制def graph_color(interfere_graph, colors):
stack = []
potential = set()
g = interfere_graph.copy()
while g.has_nodes():
n = g.find_degree_lt(len(colors))
if n is None:
n = g.choose_spill_candidate()
potential.add(n)
g.remove_node(n)
stack.append(n)
result = {}
for n in reversed(stack):
used = {result[nb] for nb in interfere_graph.neighbors(n) if nb in result}
free = set(colors) - used
if free:
result[n] = min(free)
elif n in potential:
# 真正溢出了
return {"spill": True, "node": n}
else:
raise RuntimeError("着色失败,算法存在缺陷")
return {"spill": False, "assign": result}
这段代码是我在实际验证算法时常用的骨架,真实编译器里还要处理预着色节点、合并队列和冻结集合,但骨架不变。
4. 溢出处理:图着色真正考验工程能力的地方
图着色算法的学术框架并不复杂,难的是溢出处理。毫不夸张地说,两个实现同样算法的编译器,最终代码质量可能相差很远,差距基本都出在"选谁溢出"和"溢出代码怎么插"这两个问题上。
4.1 溢出节点的选择:循环深度和访问次数是核心指标
一个变量被溢出后,每一次使用都会变成一个 load,每一次定义都会变成一个 store。如果这些使用和定义发生在循环体内,这些额外访存会被乘以循环的迭代次数。所以溢出成本估算必须考虑两个维度:访问次数和循环嵌套深度。
工程上常用如下简化公式:
code复制cost(n) = Σ (对 n 的每次读/写点) 10^loop_depth(point)
spill_cost(n) = cost(n) / degree(n)
除以度数是因为度数越高的节点越难着色,优先溢出它通常能更快降低图的"拥挤度"。当然,这只是一个启发式,实际还可以加入"是否在移动指令两端""是否跨调用点"等修正因子。真正实现时,最好在 IR 阶段就记录每个指令的循环深度信息,不要到溢出阶段再重新分析,否则编译时间会很难看。
4.2 溢出代码插入后为什么要重新走全流程
选中溢出节点后,事情并没有结束。所有对该节点的读会被替换成从栈槽 load 到新临时变量,所有写会被替换成把值 store 到栈槽。关键点是:load/store 本身会引入新的临时变量,而这些新临时变量的生命周期会与周围变量产生新的干涉关系。因此必须回到活跃性分析,重新构建干涉图,重新简化、合并、选择,直到不再产生真正溢出为止。
这也是初学者最容易理解偏的地方:溢出不是一个"打完补丁就行"的单次操作,而是一个迭代过程。第一次选中的溢出节点可能引发连锁反应,导致其他节点也溢出;但由于每轮都会重新建图,过程通常会在二到三轮内收敛。如果迟迟不收敛,就要怀疑是不是溢出选择启发式出了问题,或者活跃性分析过于乐观。
4.3 理想情况下的收敛轮数
在我自己的实践经验里,如果溢出节点的成本模型选得合理,绝大多数函数一轮或两轮溢出就能收敛。第一轮往往会留下少量真正溢出的节点;插入 load/store 后重新分配,新的干涉图里这些节点的栈槽读写变成明确的临时变量,反而更容易分配,所以第二轮通常就能成功。
这里有个调优技巧:如果连续三轮还是溢出不减,不要继续盲目加大溢出惩罚,先检查是不是出现"同一个变量被反复标记为溢出"的死循环。某些实现里,一个节点被真正溢出后,如果成本模型没有排除它,下一轮它还会被选中,导致无限往复。稳妥的做法是:一旦节点被真正溢出并插入 load/store,就把它的栈槽视为已固定,新的临时变量参与分配,原节点在本轮不再作为溢出候选。
5. 图着色和线性扫描的取舍:不是非此即彼
很多做 JIT 的朋友一听"图着色"就觉得太重,开口就是"我们用线性扫描就够了"。这话有一定道理,但不完全对。图着色和线性扫描解决的是同一个问题,却处在两个极端:一个追求分配质量,一个追求分配速度。理解它们的差异,才知道自己的场景该选谁。
5.1 两种算法的核心逻辑对比
线性扫描算法(Linear Scan)是 Poletto 和 Sarkar 在 1999 年提出的。它把所有变量的活跃区间按起始位置排序,用一个活动区间列表维护当前活跃的变量,逐条扫描;每当遇到一个新活跃区间开始,如果当前活跃区间数已经等于 k,就从里面挑一个溢出。整个过程单遍完成,不需要构建全局干涉图,也不存在反复迭代的溢出过程,因此速度极快,适合对编译延迟敏感的 JIT 环境。
图着色走的是"全局冲突建模 + 贪心简化"的路线,它能看到整个函数所有变量的生命周期重叠情况,在复杂控制流和频繁跳转的场景下,往往能用更少的寄存器完成分配,或者产生更少的溢出。代价是构建干涉图本身就是 O(N²) 的开销,简化、合并、冻结、选择一轮接一轮,编译时间明显上升。
5.2 一张表看清适用范围
| 维度 | 图着色(Chaitin-Briggs) | 线性扫描 |
|---|---|---|
| 核心开销 | 构建干涉图,O(N²) | 活跃区间排序,近似 O(N log N) |
| 分配质量 | 高,全局视野 | 中,活跃区间密集时溢出偏多 |
| 编译时间 | 较慢 | 快 |
| 实现复杂度 | 高,涉及合并/冻结/溢出迭代 | 低,单遍扫描 |
| 适用场景 | AOT 编译器、优化 -O2/-O3 | JIT、解释器、全 JIT 编译 |
| 控制流处理 | 天然支持复杂控制流 | 对嵌套循环、频繁跳转容易吃亏 |
| 溢出策略 | 全局成本模型 + 迭代 | 按活跃区间末尾分配启发式 |
这不是说线性扫描质量差到不能用。在现代 JIT 里,编译等待时间本身就是用户体验,线性扫描在大多数热点函数上已经够用。但如果你有充分的时间做 AOT 优化,或者目标芯片寄存器特别少、spill 代价特别高,图着色通常更值得。
5.3 现代编译器里的混合形态
现代编译器并非只能二选一。LLVM 默认的 greedy register allocator 本质上是图着色思想的一种变体,但它面向 SSA 形式做了大量改进,采用全局活跃区间优先级队列来模拟简化过程;GCC 的 IRA(Integrated Register Allocator)和 LRA(Local Register Allocator)也融合了多个层面的分配策略。这些分配器不是教科书意义上的纯 Chaitin-Briggs,但建图、冲突分析、spill 成本模型这些核心思想全部来自图着色体系。
所以我的建议是:如果正在做编译器,先把标准图着色算法跑通、把干涉图可视化搞清楚,再去看 LLVM greedy allocator 的源码,你会发现它的很多设计决策都是在解决图着色框架下的特定瓶颈。
6. 工程实战中绕不开的那些细节
前几章把图着色算法和溢出处理讲了一遍,但真正动手把算法落地到自己的编译器里,还会撞上一堆教材里一笔带过的实际问题。这里挑几个我实际踩过的点,希望对你有帮助。
6.1 预着色节点:物理寄存器是固定的颜色
目标机器上有些物理寄存器是不能随意分配的。比如调用约定规定前几个参数必须放在 rdi、rsi、rdx 这些寄存器里,函数返回值必须放在 rax 里。在干涉图模型里,这些物理寄存器就是"预着色节点"——颜色固定,不参与简化入栈,但会出现在冲突判断中。
处理预着色节点最常见的问题是把它们误当成普通节点压入简化栈。正确做法是:构建干涉图时保留它们作为固定节点,任何普通节点与它们冲突,就意味着该普通节点不能使用对应颜色。选择阶段为一个普通节点分配颜色时,必须跳过所有与它相邻的预着色节点占用的颜色。如果一个高优先级的移动指令想合并两个节点,而其中一个的邻居包含了一堆预着色节点,合并判据也要把这些固定颜色考虑进去,否则很容易生成非法代码。
6.2 跨调用点的变量怎么建模
函数调用是寄存器分配建模的另一个重灾区。硬件调用约定里,caller-saved 寄存器的值在调用返回后可能被破坏,callee-saved 寄存器则由被调用方负责保存。要在图中正确表达这一点,必须在每个调用指令点对那些"跨调用存活"的变量建立约束。
最简单的建模方式是:在每个调用点,把所有 caller-saved 物理寄存器当作一个"伪活跃变量",那么任何跨调用存活的普通变量都会与这些伪节点冲突,从而无法分配到 caller-saved 寄存器。实现上不必真的插入一堆假节点,通常只需要在活跃性分析阶段额外记录"此点有调用",在选择颜色时把跨调用变量限制到 callee-saved 颜色集合即可。代价是 callee-saved 寄存器数量有限,跨调用变量一旦多起来,spill 概率会上升。
我在实际调优时的一个体会是:千万不要轻视调用的建模,很多编译器 bug 都是"变量跨调用存活却分配到了 caller-saved 寄存器,被调用方覆盖后读出垃圾值"。这类 bug 很难查,因为它只在特定调用序列下偶发。强烈建议在验证阶段加一条不变量检查:跨调用存活的变量,其分配结果必须落在 callee-saved 集合里。
6.3 SSA 形式如何处理
现代编译器前端优化大多工作在 SSA(静态单一赋值)形式上,但经典图着色寄存器分配算法诞生时还没有 SSA。SSA 的 phi 指令需要在寄存器分配之前降级为拷贝指令,而降级方式会影响后续的合并和着色效果。
更深入一点:SSA 形式的干涉图具有弦图(chordal graph)结构,理论上可以通过最大基数搜索在线性时间内完成精确着色,不必使用启发式简化。这就是 Braun 和 Hack 等人研究的 SSA-based register allocation 的出发点。不过工程上大多数编译器仍然倾向于把 phi 降级成 mov,然后交给通用的图着色分配器,因为这样实现更简单,SSA 在寄存器分配阶段带来的额外收益不足以抵消复杂度。
如果你把 SSA 降级后的 mov 交给 Chaitin-Briggs,合并阶段的保守判据就变得格外重要:phi 降级会产生大量 mov,如果能安全合并掉,分配质量会有明显提升;如果合并判据过于激进,又可能破坏可着色性。
6.4 调优与验证:从 spill 统计到干涉图可视化
最后分享一些我实际调试寄存器分配器时用的方法,这些方法看着土,但非常管用。
第一,先在小目标上跑。如果你在交叉编译器上开发,完全可以临时把目标机器的寄存器数量改成 8 甚至 4,加大寄存器压力,让 spill 现象放大,这样更容易暴露活跃性分析和干涉图构建的问题。等小寄存器数下吞吐正常了,再恢复真实寄存器数。
第二,把干涉图可视化。构建干涉图时输出 Graphviz 格式的 dot 文件,用工具渲染出来,直接看冲突子图的形状。很多着色问题一眼就能从图上发现:比如某个节点明明是孤立节点却被莫名标记为溢出,八成是活跃性分析把它误判为与全图冲突。文本日志里的一堆数字,远不如一张图直观。
第三,加不变量检查。选择阶段完成后,遍历每条指令,确认其中同时活跃的变量没有分配到同一个物理寄存器;对跨调用点再做一次 callee-saved 约束检查。这些检查在开发早期会拖慢编译速度,但能拦截一大批隐蔽错误,等算法稳定后再关掉。
第四,看 spill 统计而不是只看运行时间。分配质量最直接的反映是每个函数溢出 load/store 的数量。热点函数如果 spill 数量明显高于同类代码,优先去查它的干涉图是否过于稠密,而不是盲目调溢出成本参数。密集的原因可能是活跃性分析过于保守(比如把已经死掉的变量还维持活跃),也可能是后端指令选择引入了过多同时活跃的临时值。
我自己写寄存器分配器时吃过最大的亏,就是一开始迷信溢出启发式参数调整,调来调去性能没改善,最后静下心把活跃性分析重写了一遍,溢出数量直接降了一个数量级。这个经验后来也成了我检查别人分配器时的第一反应:溢出异常,先查活跃性,再查干涉图,最后才轮到着色算法本身。
