Amethyst Studio
4489 words
22 minutes
寄存器分配 - 线性扫描(Linear Scan)

这篇博客里面,我们来讨论编译器构造里面的一个重要的话题,寄存器分配。

寄存器分配#

它描述的是这样的一个问题:在我们把前端语法转换成低级的机器序列后,操作数(operand)此时还没有物理实体,而是虚拟的,例如下面的简单代码:

fn demo() -> Int {
  return 1 + 2 * 3
}

这样的代码在经过语法解析,IR转换,优化之后,紧接着会转换成机器指令,它在刚刚从高级IR转换成低级IR的时候,可能会是下面这种形态:

demo:
  v0 = imove 1
  v1 = imove 2
  v2 = imove 3
  v3 = imul v1, v2
  v4 = iadd v0, v3
  ret v4

假设我们现在有若干可选的物理寄存器,我们需要确定,这里的v0,v1, v2, v3, v4都被分配给谁,如果有一些虚拟寄存器无法被分配物理寄存器,那么我们就需要做溢出,将原有的指令序列进行变动,将一些值放到栈上去。

对于我们的例子而言,假设我们有三个寄存器r0, r1, r2,此时可以正好分配完全,只要做如下的分配策略即可:

v0 -> r0
v1 -> r1
v2 -> r2
v3 -> r1
v4 -> r0

但是,如果我们只有两个寄存器r0, r1,这个时候物理寄存器就不太够用了,此时需要决定溢出,原本保存在寄存器里的值,把它变换到栈上去,需要把指令序列变换一下,例如变换成下面的样子:

demo:
  v0 = imove 1
  storei sp, v0
  v1 = imove 2
  v2 = imove 3
  v4 = imul v1, v2
  v5 = iload sp
  v6 = iadd v5, v4
  ret v6

我们会多了一次store和load指令。这一次我们会发现,尽管代码中出现的虚拟寄存器多了,但是此时是可以被分配的了,我们的分配策略如下:

v0 -> r0
v1 -> r0
v2 -> r1
v4 -> r0
v5 -> r1
v6 -> r0

所谓寄存器分配问题,就是问这样一个问题,对于任意一个包含虚拟寄存器的指令序列,如何进行物理寄存器上的分配,把哪个寄存器分配到哪个物理寄存器上,选择哪个虚拟寄存器进行溢出,能够使得我们的程序运行效率更快?

寄存器分配问题是一个NP-Hard问题#

需要注意的是,寄存器分配问题是一个np-hard问题,它不存在在多项式时间内可以找到的最优解,所以现有的在各种编译器里面所实现的寄存器分配算法,基本上都不是最优算法,这是工程权衡。使用brute force或者回溯图着色这样的算法虽然可以找到最优解,但是它的复杂度一般都在指数级,大约在O(2^n)左右,其中n可以看做是虚拟寄存器的数量,但是在软件工程下,一个函数内的指令数可能非常多,造成的虚拟寄存器的数量也非常多,数百个虚拟寄存器是常有的时,如果采用最优算法,编译速度无法保证,因此现存的在各种编译器内部实现的寄存器分配pass,基本上都是采用启发式算法,通常来看,主要是两种基础算法的变体:图着色,以及线性扫描。

从实践上来看,图着色一般可以生成质量较高的代码,但是运行速度比较慢,可能需要反复递归。线性扫描算法的运行效率比较高,但是生成代码的质量一般。

在一些追求编译速度的情景下,譬如说一些JIT场景,通常会采用线性扫描算法及其变体。在一些追求运行效率的情景下,例如AOT,或者高度优化的编译器(例如gcc),通常使用图着色算法及其变体。

稍微提一点的是,LLVM虽然也是高度优化的代码,但是却并没有采用图着色算法,高度优化的代码其实是PBQP,某种程度上来说,可以把它看成是一种线性扫描算法的变体,只是这种变体与最开始的线性扫描思路已经有很大不同了。

本博客会逐渐介绍多种寄存器分配算法,不过就这一篇而言,这一篇博客探讨的是线性扫描算法。但是需要注意的是,真实在工程中使用的线性扫描算法,其实比这篇博客里面讲的内容要复杂很多,真实工程中使用的线性扫描算法为了提升生成代码的效果,往往会在遍历的过程中收集更多的信息,然后根据业务场景设置更多的启发式因子。这篇博客只会探讨核心的思路。

线性扫描的基础思想#

笼统地说,线性扫描的基础思路是这样,我有一段程序,使用了4个虚拟寄存器,v0, v1, v2, v3。然后我经过分析知道,v0在时刻0到时刻3被使用,v1在时刻1到时刻9被使用,v2在时刻5到时刻8被使用,v3在时刻7到时刻10被使用,那么问,这四个虚拟寄存器怎么分配物理寄存器?

时刻: 0 1 2 3 4 5 6 7 8 9 10
 v0:  |-----|
 v1:    |---------------|
 v2:            |-----|
 v3:                |------|

先考虑个简单情景,假设的目标机有3个寄存器r0, r1, r2。

我们可以这样想,首先,把v0分配给r0,把v1分配给r1。

对于v2来说,v0此时已经结束,所以不再占有r0寄存器,把v2分配给r0寄存器就好了。

接着对于v3来说,此时r0, r1寄存器都还被占用,幸好还剩下一个寄存器,那么就把v3分配到r2寄存器。完美。

最终的分配策略就是这样:

v0 -> r0
v1 -> r1
v2 -> r0
v3 -> r2

换一种情景,假设目标机只有2个寄存器,r0和r1呢?

这个时候我们一开始还是同样的心态,v0分配到r0, v1分配到r1, v2分配到r0,此时v3不好分配,因为这个时候,r0和r1都被占用了。

那么,最简单的策略出现了,直接溢出v3即可。因为没有寄存器了,所以就溢出呗。

所以,我们得到了下面的分配策略:

v0 -> r0
v1 -> r1
v2 -> r0
v3 -> spill to stack

改进策略:

不过,上面的策略其实不太好,溢出v3其实并不太好。因为我们更倾向于,把短命的值尽可能地直接分配寄存器。长命的值,尽可能分配到栈上去。因为这样一来,在后续遍历其它虚拟寄存器的时候,短命的值会更有可能空出寄存器。也就是说,对于上面的指令流而言,把v1溢出到栈上,实际上是一个比把v3溢出到栈上更好一些的操作。

单块简单形式的线性扫描#

前面是一个基础思想的演示,现在我们来思考一个简单的例子,看下面的代码:

physical register: r0, r1

    demo:
I0:   v0 = imove 1
I1:   v1 = imove 2
I2:   v2 = iadd v0, v1
I3:   ret v2

这个例子当然一眼就可以看出来结果。但我们需要思考整个的流程。首先的第一个问题是,我们怎么定义时刻?

如果你简单地认为I0就是0时刻,I1是1时刻,以此类推,那么你的结论就是v0在时刻0到时刻2上被使用,v1在时刻1到时刻2上被使用,v2在时刻2到时刻3上被使用。那么你会发现,当你分配v0 -> r0,v1 -> r1的时候,v2分配不了。因为这个时候r0和r1在时刻2上还没有结束,但是其实v2可以被分配到r0或者r1。

比较正确的方式是,对于一条指令而言,它存在两个时刻,一个use时刻一个def时刻,use时刻在前,def时刻在后,换句话说,它的时间线是这样:

    demo:                 |use |def
I0:   v0 = imove 1        |0   |1
I1:   v1 = imove 2        |2   |3
I2:   v2 = iadd v0, v1    |4   |5
I3:   ret v2              |6   |7

在这个时刻表下,v0真正的使用时刻,实际上是时刻1到时刻4,它在时刻1被定义,在时刻4被使用。v1的使用时刻是时刻3到时刻4,v2的使用时刻是时刻5到时刻6。

时刻:      0 1 2 3 4 5 6 7
 v0:         |-----|
 v1:             |-|
 v2:                 |-|

这么一来就清楚了,把r0分配给v0,r1分配给v1,轮到v2的时候,此时r0和r1都空了下来,因此r0和r1都可以分配,这里分配r0好了,那么最终的结果就是:

v0 -> r0
v1 -> r1
v2 -> r0

存在分支块的线性扫描#

以上是只考虑单个块的,现在,让我们思考一个稍微复杂点的情况,如果是多个块。先来考虑最简单的情况:

对于简单的语句: cond? (1 + 2) * 3 : 2 * 4 + 5,生成的代码可能是这样:

physical register: r0, r1

    demo:
I0:   v1 = 1
I1:   v2 = 2
I3:   br cond, then, else

    then:
T0:   v3 = v1 + 2
T1:   v5 = v3 * 3
T2:   jmp merge

    else:
E0:   v4 = v2 * 4
E2:   v5 = v5 + 5
E1:   jmp merge

    merge:
M0:   ret v5

为了简化讨论,这里的cond假设不参与寄存器分配。

这里真正让人头疼的是,时刻似乎出现了并行的,因为指令流进入到了then块它就不可能进入到else块里面去。但是,其实我们其实仍然可以按照线性序来对时刻进行排序,就像下面这样:

    entry:                 | use | def
I0:   v1 = 1               | 0   | 1
I1:   v2 = 2               | 2   | 3
I3:   br cond, then, else  | 4   | 5

    then:
T0:   v3 = v1 + 2          | 6   | 7
T1:   v5 = v3 * 3          | 8   | 9
T2:   jmp merge            | 10  | 11

    else:
E0:   v4 = v2 * 4          | 12  | 13
E2:   v5 = v4 + 5          | 14  | 15
E1:   jmp merge            | 16  | 17

    merge:
M0:   ret v5               | 18  | 19

一个粗暴的观点是这样认为的,v1的使用时刻是[1, 6](时刻1到时刻6,后文以后都用这种记号),v2的使用时刻是[3, 12]等等,所以我们有:

v1: [1, 6]
v2: [3, 12]
v3: [7, 8]
v4: [13, 14]
v5: [9, 18]

这里额外提一下v5的问题,指令流里面v5被定义了两次,但是取最早被定义的时刻为初时刻,所以是9,而不是15。

如果我们这么来定义区间的话,我们很快会发现问题,当我们分配v1->r0, v2->r1,要分配v3的时候,我们就必须spill掉一个虚拟寄存器了。

但问题是,回到原先的指令序列里面,我们会发现其实分配v3->r1是没有问题的,因为走then块的时候,v2其实并不会被使用。

这个问题的根源在于我们时刻安排其实并不合理,then块和else块本来是并行的,但是我们的时刻安排却是串行的。要解决这个问题,我们需要允许一个虚拟寄存器的使用时刻可能会存在hole,或者说,使用时刻是多段时刻的组合。

就这个例子而言,v1的使用时刻应该这么看,它有两段,第一段是entry块里第一次定义的时刻到entry块终止的时刻,也就是[1, 5],第二段是then块里,v1最后一次被使用的时刻,也就是[6],换言之,v1的使用时刻,实际上是[1, 5] U [6]

同理,v2的使用时刻实际上是[3, 5] U [12],以此类推:

v1: [1, 5] U [6]
v2: [3, 5] U [12]
v3: [7, 8]
v4: [13, 14]
v5: [9, 11] U [15, 17] U [18]

一旦我们把区间这么来定义,我们就很自然地得到寄存器分配:

v1 -> r0
v2 -> r1
v3 -> r0
v4 -> r0
v5 -> r0

带循环的线性扫描#

下面再考虑一个情景,思考一个斐波那契数列的例子,下面我们就直接列出时刻。

    entry:                  | use | def
I0:   v0 = 0                | 0   | 1
I1:   v1 = 1                | 2   | 3
I2:   v2 = 1                | 4   | 5
I3:   jmp loop              | 6   | 7

    loop:
L0:   cond = v0 < 10        | 8   | 9
L1:   br cond, body, exit   | 10  | 11

    body:
B0:   v3 = v2               | 12  | 13
B1:   v2 = v1 + v2          | 14  | 15
B2:   v1 = v3               | 16  | 17
B3:   v0 = v0 + 1           | 18  | 19
B4:   jmp loop              | 20  | 21

    exit:
E0:   ret v2                | 22  | 23

如果我们按照先前的思路,我们会得到(cond不参与寄存器分配):

v0 = [1, 7] U [8] U [18, 19]
v1 = [3, 7] U [14, 17]
v2 = [5, 7] U [14, 15]
v3 = [13, 16]

但是,很快会发现这个结果并不正确,典型的是v1的时刻,[14, 17]是因为第一次使用是14,最后一次定义是17,这显然是有些奇怪的。事实上,v1在body块内,它的起始段应该是body的起始时间,也就是12才比较合理,并且,结束时间,也应该是body的结束时间。

这里的核心区别,就在于循环结构,还影响了虚拟寄存器的活跃信息。body块在进入和出去的时候,v1寄存器都是活跃的,所以v1寄存器在body块内的,使用时刻实际上是整个[12, 19],而并不是它第一次use和第一次def的时刻[14, 17]。

经过正确处理活跃区间后,我们会得到更合理的区间:

v0: [1, 7] U [8, 19]      // 贯穿整个循环
v1: [3, 7] U [12, 19]     // 在body内全程活跃
v2: [5, 7] U [12, 21]     // 同理
v3: [13, 16]              // 短命,只在body内短暂存在

此时再进行线性扫描,分配结果就会清晰很多:v3作为短命的临时值,很容易获得寄存器,而v0、v1、v2这些贯穿循环的长命值,则需要更谨慎地决定是否溢出。

线性扫描的核心算法流程#

总结一下,经典线性扫描算法(Linear Scan Register Allocation)的基本步骤如下:

  1. 活跃区间分析(Live Interval Analysis) 对整个函数(考虑控制流,包括分支和循环)计算每个虚拟寄存器的活跃区间(Live Interval)。这是整个算法最关键也最复杂的一步,需要精确处理phi节点、循环回边、控制流合并点等情况。

  2. 按起始点排序 把所有虚拟寄存器的活跃区间按照起始时间排序。

  3. 线性扫描 + 活跃集合维护 维护一个当前活跃的物理寄存器集合(或已分配的虚拟寄存器集合)。 按顺序处理每一个虚拟寄存器:

    • 首先把已经过期的(结束时间早于当前虚拟寄存器起始时间)的寄存器释放掉;
    • 如果还有空闲物理寄存器,直接分配;
    • 如果没有空闲寄存器,则需要选择一个“牺牲者”进行溢出(spill)。常见的启发式是选择当前活跃区间中结束时间最晚的那个(即“最远使用”策略)。
  4. 重写代码(Rewrite) 根据最终的分配结果,插入必要的store/load指令,把被溢出的虚拟寄存器存放到栈帧中,并在使用点重新加载。

整个过程只进行一次线性遍历,因此时间复杂度非常优秀,通常接近O(n),其中n是虚拟寄存器的数量。这也是为什么线性扫描在JIT编译器中大受欢迎的原因。

线性扫描的优缺点与工程实践#

线性扫描最大的优点是速度快,实现相对简单,在编译时间敏感的场景(JIT、快速调试构建等)中表现优秀。

但它的缺点也很明显:由于只做了一次线性遍历,缺乏全局视角,所以生成的代码质量通常不如图着色算法,尤其在寄存器压力大的函数中,可能会产生较多的溢出指令。

在实际工程中,现代的线性扫描实现往往会加入大量启发式改进,例如:

  • 二次扫描(Second Chance Binning)
  • 活跃区间分裂(Live Interval Splitting)
  • 基于使用频率的溢出代价评估
  • 与窥孔优化、指令调度相结合

这些改进让线性扫描在很多场景下的代码质量已经非常接近图着色,但编译速度仍然保持明显优势。

结语#

寄存器分配是编译器后端中最优雅也最残酷的博弈之一:我们要在有限的物理寄存器资源与程序的复杂控制流之间,找到一个尽量好的平衡点。

线性扫描以其简洁直观的思想,成为了理解整个寄存器分配问题的最佳入门算法。它像一条清澈的小溪,虽然不像图着色那样深邃复杂,却以极高的效率流淌在无数现代编译器的血液里。

当你下次看到JIT编译器在毫秒级完成一次函数编译时,很可能背后就有线性扫描(或其变体)在默默工作。

至此,线性扫描算法的核心思想就介绍完了。

寄存器分配 - 线性扫描(Linear Scan)
https://ziyue.cafe/posts/regitser-allocation-linear-scan/
Author
Kaida Amethyst
Published at
2026-03-25