LLVM里的寄存器分配 - 线性扫描算法(二)

本文深入介绍了LLVM中用于寄存器分配的线性扫描算法,探讨了为什么不使用图着色算法,以及线性扫描算法的工作原理、术语、计算Live Interval的过程、工作流程和面临的问题。线性扫描算法虽然会产生一定冗余代码,但因其速度快而被LLVM采用。文章还讨论了算法中的关键概念如live interval、interference和active list。

1 背景介绍

在上一篇博文 LLVM 里的寄存器分配 - 准备工作(一) 里,我主要整理了 LLVM 在做寄存器分配前所做的准备工作,介绍了 LLVM 是在怎样的 MIR 上做的寄存器分配。接下来,就需要讲讲 LLVM 是如何做寄存器分配了。虽然我学习的第一个寄存器分配算法是图着色算法,但由于目前的 LLVM 版本里使用的寄存器分配器均是线性扫描算法的变种(事实上 LLVM3.0 版本以前的寄存器分配器默认使用的就是线性扫描算法),因此本文主要介绍线性扫描算法的理论知识以及相关术语,图着色算法的具体过程可以参考文档 Global Register Allocation

2 为什么不使用图着色算法?

在开始研究 LLVM 里的寄存器分配算法之前,我一直以为 LLVM 使用的是图着色算法,后来了解多一点才发现 LLVM 至始至终就没有考虑过图着色。这是为什么呢?这个问题其实在 LLVM3.0 的发布会议上,这一版本寄存器分配器的开发者 Olesen 在他的 报告 里提到过,如下所示:

![Graph Coloring](https://img-blog.csdn.net/20180119194338963?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvcXFfMjk2NzQzNTc=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)
不难发现,不使用图着色算法的根本原因是它要先构造好完整的干涉图,才能在图上着色,而干涉图的构造代价太过高昂。应该说,图着色过程中的大部分开销都集中于干涉图的构造阶段。因此,虽然图着色生成的代码质量很高,但是对于讲究编译效率的现代编译器来说,其时间成本是不可忽视的。对于对编译时间更加敏感的 JIT 编译器来说,则更是如此。

除此之外,在实际的编译工作中,人们发现了另外一个问题。由于在目前所有的硬件架构中,寄存器的数目都是有限的,故对于大型程序来说,寄存器不够用可以说是必然存在的情况。换句话说,大型程序一定会产生大量溢出。问题就出现在这里,因为图着色算法把重点放在解决“如何把所有的程序变量尽可能地分配到寄存器中”,如果程序一定会大量产生溢出,那么,关注“如何高效地溢出”比关注“如何尽量减少溢出”更有价值。例如,可以关注如何选择溢出的变量,以达到尽可能减少对性能的影响这一问题。这一着重点的改变,就是目前 LLVM 里贪心寄存器分配器的中心思想。

图着色算法的最后一个问题,就是它难以处理实际寄存器分配问题中碰到的复杂局面。例如,在 X86 架构中,存在 register alias(如 AL/AH/AX)、pre-color 等问题,图着色算法在解决这类问题时需要较为复杂的调整。这些因素都促使 LLVM 选择了线性扫描算法。

3 线性扫描算法

在 LLVM3.0 版本以前,默认的寄存器分配器的核心算法就是线性扫描算法。这种算法生成的代码相对图着色有 10%-30% 的冗余代码(见参考文献【2】),但速度远快于图着色。在 LLVM3.0 版本以后,新加入的 basic/greedy 寄存器分配器在线性扫描算法的基础上进行了进一步强化,可以看作是线性扫描算法的变种。正因如此,新版分配器里的一些关键概念与线性扫描经典论文 Linear Scan Register Allocation 中并无出入。

3.1 术语

live interval:这是线性扫描算法的一个至关重要的概念。假设 IR 里的指令按数字编号,变量 v 的 live interval 是区间 [i, j],需要满足下述条件:不存在编号为 j’(j’> j)的指令,使得变量 v 在 j’处活跃,且不存在编号为 i’(i’< i)的指令,使得变量 v 在 i’处活跃。这个定义是对 live ranges 的保守估计,因为在区间 [i, j] 里,可能存在部分子区间变量 v 并不活跃,这些区间在 live interval 的定义里被忽视掉了,这就是传统的线性扫描算法未能充分利用寄存器的原因。

live range:这里采取文献 Register Allocation 中的定义,即认为 live range 是严格意义上变量活跃时所有程序点的集合。例如,若有一变量的生命期是 [12, 96], [96, 112], [112, 256],上述三个区间都是此变量的 live range,而区间 [12, 256] 则是此变量的 live interval。注意,这里的这个概念和 LLVM 里的 Segment 是一样的。

注释:由于上述英文术语的中文含义很接近,难以区分,故本文不对其进行翻译,下文直接采取其英文名称。

interference:在线性扫描里,不存在相交图(interference graph),相交发生在各个 live interval 重叠的部分。显然,需要特别关注每个 interval 的起始位置和结束位置,各个 live interval 的相交关系只会在这两个位置发生改变。下面给出了一个例子,在不同的程序点,不同的 live interval 存在相交关系,g 与其它的 live interval 均不相交:

![interference](https://img-blog.csdn.net/20180119213343451?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvcXFfMjk2NzQzNTc=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)
**original list**:这个名字是我自己起的,用于表示最开始存放了所有 live interval 的表。要注意的是,该表里各个 live interval 是按照起始位置的升序排列的。从下文中的算法可以看到,起始位置最小的 live interval,也就是最早出现的 live interval,最先被扫描到。

active list:用于表示已经分配了寄存器的 live interval 的表,表中的 live interval 都按照结束位置递增的顺序排列。这么排的原因不难想象,结束位置最小的 live interval 最先结束,然后就可以把分配给该 live interval 的寄存器回收到寄存器池中,等待下一次分配。

3.2 计算 live interval

在开始执行线性扫描算法前,要先获得 live interval,算法分配寄存器都是给 live interval 分配的,因此编译器还要事先做一点准备工作。首先,编译器要将控制流图 CFG 线性化并给每条指令编号。在 LLVM 里,是通过深度优先序来线性化 CFG 的,同时通过 slot index 来给每条指令编号,相邻指令之间编号的差值为 16。Poletto 的论文里,也选择了深度优先序列。实际上,这并不是一个巧合。其次,需要对 IR 做后向数据流分析,就可以获得每个变量的活跃信息,并进一步得到 live interval。迭代数据流分析的关键过程每本编译原理的教材都会介绍,在此不再赘述。

3.3 工作过程

在线性扫描算法里,active list 是最核心的数据结构,其作用可以在下图中的伪代码看出来:

![linear scan](https://img-blog.csdn.net/20180119220650177?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvcXFfMjk2NzQzNTc=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)
在上述的线性扫描算法中,LinearScanRegisterAllocation 函数按照 original list 里的顺序(起始位置的升序)对 live interval 进行扫描,每一次扫描是对一个新的 live interval 执行一系列操作。我们可以在此函数中发现 active list 的使用,该 list 用于存放当前程序点(即正在扫描的 live interval 起始位置处指令后的程序点)分配了寄存器的 live interval,这些 interval 是互相干涉的。

每当开始扫描一个新的 live interval 时,算法会调用 ExpireOldInterval 函数,从头到尾遍历 active list。一旦发现 active list 里有哪个 interval 的结束位置是在新扫描的 interval 开始位置之后,就把它从 active list 中移除,并把它所占有的寄存器回收到寄存器池中。这说明这两个 interval 不相交,分配到同一个物理寄存器也不打紧。

显然,active list 的大小最多等同于可分配寄存器的总数。在最坏的情况下,当新一轮扫描开始时,active list 的大小可能已经达到了最大。若此时新扫描的 live interval 的起始位置在 active list 中所有 interval 的结束位置后,即它与之前所有的 interval 相交,此时寄存器就不够用了,必须溢出到内存中。溢出的 live interval 可以是 active list 里的任意一个 interval,也可以是新扫描的 interval,具体如何抉择就看分配器的设计者如何选择启发式了。在 Poletto 的论文里,作为溢出选择的是结束位置距离当前程序点最远的 live interval(图中的 last interval),或者当前扫描的 live interval。如果是当前的 interval 结束得更迟,则直接给它分配一个栈槽;否则,把分配给 last interval 的寄存器分配给当前的 interval,并把 last interval 溢出到栈槽中。

选择这样的启发式的原因不难理解:如果把这样的 interval 溢出了,剩下的 interval 的剩余干涉期较短,更容易被移出 active list,从而更容易回收寄存器。根据 Poletto 论文的说法,这一启发式效果非常好。

3.4 例子

可以参照文献 Register Allocation 中的实例来思考线性扫描算法,该文档给出的例子十分详细。

4 线性扫描算法的问题

根据 LLVM 官方的说法,线性扫描这样简单的一个算法,工作效果却“令人惊奇”的好。为什么说它简单?从前面的介绍不难发现,线性扫描里的 live interval 概念是十分粗糙的,这个概念忽视了区间内所有变量并不活跃的 hole,这样的设计导致算法十分简洁。正因如此,我们才可以进一步“压榨”此算法,得到质量更好的代码,这也是 LLVM 后来提出 Greedy 寄存器分配算法的原因。

此外,线性扫描在做了分配后,还要依赖后面的虚拟寄存器重写器(rewriter)来清除代码。理论上,重写器只需要把 MIR 中的虚拟寄存器替换成分配到的物理寄存器即可。然而,由于线性扫描可能会做一些“愚蠢”的事情(例如两次把一个栈槽中的值加载到同一个寄存器中),重写器必须采取一些技巧来解决这些问题。这导致代码极其难以维护,且代价极其高昂,线性扫描算法有一半的时间都花在重写器中。新式的 Greedy 寄存器分配算法尽可能避免了这类问题,从而使得重写器代码干净简洁,只需要实现重写虚拟寄存器的功能,不需要搞定那些“愚蠢”的问题。

其实,上面那些“愚蠢”问题的出现很大一部分是因为线性扫描不支持变量生命期的拆分,这也是新式寄存器分配算法所解决的问题。至于 Basic/Greedy 等寄存器分配算法是如何运行的,这就是我们后面系列的内容。

5 参考文献

[1] Register Allocation in LLVM 3.0
[2] Hacker News - Register Allocation
[3] Linear Scan Register Allocation
[4] Register Allocation
[5] 知乎 - 寄存器分配问题?XlousZeng 的回答

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值