gitbook/手把手带你写一门编程语言/docs/420555.md

393 lines
23 KiB
Markdown
Raw Permalink Normal View History

2022-09-03 22:05:03 +08:00
# 20怎么实现一个更好的寄存器分配算法实现篇
你好,我是宫文学。
在上一节课,我们已经介绍了寄存器分配算法的原理。不过呢,我们这门课,不是停留在对原理的理解上就够了,还要把它具体实现出来才行。在实现的过程中,你会发现有不少实际的具体问题要去解决。而你一旦解决好了它们,你对寄存器分配相关原理的理解也会变得更加通透和深入。
所以,今天这一节课,我就会带你具体实现寄存器分配算法。在这个过程中,你会解决这些具体的技术问题:
* 首先我们会了解如何基于我们现在的LIR来具体实现变量活跃性分析。特别是当程序中存在多个基本块的时候分析算法该如何设计。
* 第二我们也会学习到在实现线性扫描算法中的一些技术点包括如何分配寄存器、在调用函数时如何保存Caller需要保护的寄存器以及如何正确的维护栈桢。
解决了这些问题之后,我们会对我们的语言再做一次性能测试,看看这次性能的提升有多大。那么接下来,就让我们先看看实现变量活跃性分析,需要考虑哪些技术细节吧。
## 实现变量活跃性分析
我们先来总结一下在实现变量活跃性分析的时候我们会遇到哪几个技术点。我们一般要考虑如何保存变量活跃性分析的结果、如何表达变量的定义以及如何基于CFG来做变量活跃性分析这三个方面。
现在我们就一一来分析一下。
首先,我们要设计一个数据结构,把活跃性分析的结果保存下来,方便我们后面在寄存器分配算法中使用。
这个数据结构很简单我们使用一个Map即可。这个Map的key是指令而value是一个数组也就是执行当前指令时活跃变量的集合。
```plain
liveVars:Map<Inst, number[]> = new Map();
```
确定了数据结构以后,我们再讨论一下算法的实现。在算法的执行过程中呢,我们倒着扫描一条条指令。对于每条指令,我们要分析它的操作数。如果操作数是一个变量下标,那我们就把这个变量加到活跃变量的集合中。所以,往集合里加变量实现起来很简单。
可是从集合里减变量就不那么简单了。为什么呢根据我们上一节课讲过的算法我们需要在变量声明的时候把这个变量从集合里去掉。可是我们当前的LIR中并没有记录哪个变量是在什么时候声明的也就没办法知道变量的生存期是从什么时候开始的了。
那怎么来解决这个问题呢我的办法是向LIR里再加一条指令这条指令专门用来指示变量的声明。我把这条指令的OpCode叫做[declVar](https://gitee.com/richard-gong/craft-a-language/blob/master/20/asm_x86-64.ts#L126)。
由于这条指令并不能转化成具体的可执行的指令,所以你可以把它叫做伪指令。它仅用于我们的寄存器分配算法。
好了在加入了这条指令以后我们就能对一个基本块进行变量活跃性分析了。具体实现你可以参考代码LivenessAnalyzer其中的核心逻辑我放在下面了
```plain
//为每一条指令计算活跃变量集合
for (let i = bb.insts.length - 1; i >=0; i--){
let inst = bb.insts[i];
if (inst.numOprands == 1){
let inst_1 = inst as Inst_1;
//变量声明伪指令从liveVars集合中去掉该变量
if (inst_1.op == OpCode.declVar){
let varIndex = inst_1.oprand.value as number;
let indexInArray = vars.indexOf(varIndex);
if (indexInArray != -1){
vars.splice(indexInArray,1);
}
}
//查看指令中引用了哪个变量就加到liveVars集合中去
else{
this.updateLiveVars(inst_1, inst_1.oprand, vars);
}
}
else if (inst.numOprands == 2){
let inst_2 = inst as Inst_2;
this.updateLiveVars(inst_2, inst_2.oprand1, vars);
this.updateLiveVars(inst_2, inst_2.oprand2, vars);
}
result.liveVars.set(inst, vars);
vars = vars.slice(0); //克隆一份,用于下一条指令
}
```
我们可以用这个算法跑一个例子看看,这个例子是上一节课的示例程序的前半截:
```plain
function foo(p1:number,p2:number,p3:number,p4:number,p5:number,p6:number){
let x7 = p1;
let x8 = p2;
let x9 = p3;
let x10 = p4;
let x11 = p5;
let x12 = p6 + x7 + x8 + x9 + x10 + x11;
let sum = x12;
return sum;
}
```
你可以运行node play example\_reg.ts -v --dumpAsm来显示分析的结果。我把终端的输出放到下面了。你能看到每个语句对应的活跃变量集合跟我们前一节的分析是吻合的。也证明我们的实现是正确的。
```plain
function: foo
bb:LBB0
[ 5, 4, 3, 2, 1, 0 ]
declVar var6
[
  6, 5, 4, 3,
  2, 1, 0
]
movl var0, var6
[ 6, 5, 4, 3, 2, 1 ]
declVar var7
[
  7, 6, 5, 4,
  3, 2, 1
]
movl var1, var7
[ 7, 6, 5, 4, 3, 2 ]
declVar var8
[
  8, 7, 6, 5,
  4, 3, 2
]
movl var2, var8
[ 8, 7, 6, 5, 4, 3 ]
declVar var9
[
  9, 8, 7, 6,
  5, 4, 3
]
movl var3, var9
[ 9, 8, 7, 6, 5, 4 ]
declVar var10
[
  10, 9, 8, 7,
   6, 5, 4
]
movl var4, var10
[ 10, 9, 8, 7, 6, 5 ]
declVar var13
[
  13, 10, 9, 8,
   7,  6, 5
]
movl var5, var13
[ 13, 10, 9, 8, 7, 6 ]
addl var6, var13
[ 13, 10, 9, 8, 7 ]
addl var7, var13
[ 13, 10, 9, 8 ]
addl var8, var13
[ 13, 10, 9 ]
addl var9, var13
[ 13, 10 ]
addl var10, var13
[ 13 ]
declVar var11
[ 11, 13 ]
movl var13, var11
[ 11 ]
declVar var12
[ 12, 11 ]
movl var11, var12
[ 12 ]
movl var12, returnSlot
[]
```
可是,这个例子中仅有一个基本块,所以我们的算法只需要把这个基本块的代码从下往上扫描一遍就行了。
可如果存在多个基本块,该如何处理呢?比如,我们如果在示例程序中加入循环语句以后,就会产生不止一个基本块了。难道我们只需要把每个基本块分别做一下分析就可以了吗?
不是的。当存在多个基本块的时候基本块之间的关系会形成一个CFG也就是控制流图。一个基本块的活跃变量的情况会影响它前序的基本块的变量活跃性分析结果。这里我们具体展开来看一下。
## 基于CFG的变量活跃性分析
在前面几节课我们讲到if语句和for循环语句的时候说到它们的执行流程可以用一个CFG来表示。我借用了之前我在[《编译原理之美》](https://time.geekbang.org/column/intro/100034101)课程中用过的一个例子来和你说明一下如何基于CFG来做数据流分析。
我们先来看第一个图这是一个带有if分支的CFG。这个CFG里每个基本块都编了号。
![](https://static001.geekbang.org/resource/image/86/f7/86bdebdbfbabe87e5f9f5a8e500132f7.jpg?wh=1080x1202)
在进行变量活跃性分析的时候根据我们上一节课提到过的由下到上的顺序我们需要倒着从第5个基本块进行分析。你会看到第5个基本块需要活跃变量x这就形成了对基本块4的需求。所以我们知道了基本块4一开始的活跃变量集合不是空集而是{x}。
![](https://static001.geekbang.org/resource/image/93/e6/93895b76a9da8314b01db5d6306f2ae6.jpg?wh=1080x1202)
再进一步基本块4又形成了对2和3的需求要求它们提供{a,b,c,d}共4个活跃变量。
依次类推基本块2和3又形成了对基本块1的需求要求基本块1提供{a,b,c}共3个活跃变量。
到这就完事了。这看上去也不复杂呀就是沿着CFG中的边逆向遍历一遍图就行了呗
慢着我们刚刚举的例子只是一个比较简单的情况。在这个例子中图里没有形成环是个有向无环图是图的数据结构中几乎最简单的一种算法处理比较容易。但是我们实际的程序会形成更复杂的图。比如for循环语句就会形成带有环的图使得里面的基本块形成循环依赖这会导致算法复杂度的提升。
我们接着来看看下面的例子这个例子中我们增加了从基本块4到1的控制流从而构成了环路。我们还是沿着刚才的计算顺序分别计算基本块5->4->3->2->1这会形成下面的活跃变量集合。
![](https://static001.geekbang.org/resource/image/06/af/068cf59b926a582dfe82e59feb2b01af.jpg?wh=1080x1202)
但是这并没有计算完毕。你看由于存在着从4到1的环路所以1的输出会形成对4的活跃变量的需求。所以我们这里又要重新计算一遍基本块4的活跃变量进而导致我们需要对基本块3、2和1都再次计算一遍引起它们的活跃变量集合的变化。
![](https://static001.geekbang.org/resource/image/58/16/580523c02799ab7a7fde4b99dec53d16.jpg?wh=1080x1202)
这样的循环可能会重复多次,直到每个基本块的活跃变量集合不再有变化为止。
上面这些就是我们对基于CFG的变量活跃性分析的算法思路的分析。你会看到它比针对单个基本块的分析确实复杂了不少接下来就让我们实现一下吧。
**首先,我们要对数据结构做一个调整。**在这部分,我们需要记录下每个基本块初始的活跃变量集合。这个集合可能不再是一个空集,因为后序基本块可能要求前序基本块必须提供某些活跃变量。
所以,我们要记下每个基本块初始的活跃变量集合。在打印活跃变量的时候,把这个初始的集合显示在最下面。
```plain
/**
* 变量活跃性分析的结果
*/
class LivenessResult{
liveVars:Map<Inst, number[]> = new Map();
initialVars:Map<BasicBlock, number[]> = new Map();
}
```
**第二我们要为每个函数构建CFG。**当前每个函数里已经保存了一些基本块但它们并没有表达成直观的CFG。比如我们现在还没有简单的方法知道每个基本块都有哪些前序基本块和后续基本块。因此我们专门设计一个CFG的类来体现图的数据结构。
```plain
class CFG{
//基本块的列表。第一个和最后一个BasicBlock是图的root。
bbs:BasicBlock[];
//每个BasicBlock输出的边
edgesOut:Map<BasicBlock, BasicBlock[]>=new Map();
//每个BasicBlock输入的边
edgesIn:Map<BasicBlock,BasicBlock[]> = new Map();
...
}
```
在这个CFG类中有两个Map很有用。一个Map记录了所有进入某个基本块的边另一个Map则记录了从该基本块到其他基本块的边。通过这两个Map我们可以很容易地沿着这些边进行正向或逆向的遍历。
在这里我们通过了一个专门的buildCFG方法来构建CFG。如果你用node play example\_if.ts -v --dumpAsm命令可以打印出为每个函数构建的CFG出来。 我附了一张截图:
![图片](https://static001.geekbang.org/resource/image/c2/8e/c2018e37471b82650e68c97158c55b8e.png?wh=410x766)
**第三我们实现要实现基于CFG的活跃变量分析算法。**这个算法的思路是逆向遍历整个CFG而且只要某个基本块的分析结果会影响到前序的基本块那我们就需要持续不停地进行迭代分析直到每个基本块的活跃变量集合都不再变化为止。
我放了一块比较关键的代码,具体实现你可以参考代码库中的[analyzeFunction](https://gitee.com/richard-gong/craft-a-language/blob/master/20/asm_x86-64.ts#L1809)方法。
```plain
//持续遍历图直到没有BasicBlock的活跃变量需要被更新
let bbsToDo:BasicBlock[] = bbs.slice(0);
while (bbsToDo.length>0){
let bb = bbsToDo.pop() as BasicBlock;
this.analyzeBasicBlock(bb, result);
//取出第一行的活跃变量集合作为对前面的BasicBlock的输入
let liveVars = bb.insts.length == 0? [] : (result.liveVars.get(bb.insts[0]) as number[]);
let fromBBs = cfg.edgesIn.get(bb);
if (typeof fromBBs != 'undefined'){
for (let bb2 of fromBBs){
let liveVars2 = result.initialVars.get(bb2) as number[];
//如果能向上面的BB提供不同的活跃变量则需要重新分析bb2
if (!this.isSubsetOf(liveVars, liveVars2)){
if (bbsToDo.indexOf(bb2) == -1)
bbsToDo.push(bb2);
let unionVars = this.unionOf(liveVars, liveVars2);
result.initialVars.set(bb2, unionVars);
}
}
}
}
```
这里你仍然可以用node play example\_if.ts -v --dumpAsm命令来显示分析后的结果。
好了在实现了基于CFG的分析算法以后现在我们已经彻底完成了变量活跃性分析。接下来就是具体实现线性扫描算法了
## 实现线性扫描算法
根据我们上节课原理篇的安排,在原来的简单寄存器分配算法的基础上,我们要进行一些调整,把它改成线性扫描算法。
**第一个重要的技术点,也是其中****最主要的修改是对lowerOprand方法的修改。**在这个方法中,我们会把变量下标类型的操作数(也就是逻辑寄存器)映射成物理寄存器。
在lowerOprand方法中我们会调用[getFreeRegister](https://gitee.com/richard-gong/craft-a-language/blob/master/20/asm_x86-64.ts#L1582)方法来获取一个寄存器。在这个方法里呢,算法会首先试图复用已分配过的寄存器,也就是检查现在已经被分配了寄存器的变量,看看现在哪个变量的生存期已经结束了,这样就可以腾出这个寄存器来了。
如果没有可复用的寄存器,那么就需要从未分配的寄存器里分配出一个来。那如果所有寄存器都用完了呢?
这个时候我们就需要溢出Spill一个现成的寄存器。我们现在溢出寄存器的算法比较简单只要找到第一个可用的寄存器就把它溢出就好了。
**第二个重要的技术点,是在调用函数的前后,要对寄存器做保护和重载。**对寄存器做保护实际上就是把它溢出到内存中就可以了等函数调用完毕我们再把它们从内存加载到寄存器里来。这里你可以参考spillVar和reloadVar方法。
这里还有个技术细节要讨论一下。在调用函数的时候,我们到底需要保护哪些寄存器呢?这个是需要计算一下的,我们这里还是要利用变量活跃性分析的结果,也就是函数调用时的活跃变量。
但是,如果在调用\_foo前后变量活跃性集合是不同的我们应该以哪个集合为准呢你可以看看下面的例子并思考一下。
```plain
[x1, x2, x3]
foo(x1);
[x2, x3]
```
答案是函数调用之后的活跃变量集合。因为在例子中x1作为参数使用过以后后面就不再用它了所以就没有必要保护它的值了。
**第三个重要的技术点,就是栈桢的维护。**采用新的寄存器分配算法以后,我们栈桢的内容会有所不同。这个时候了,我们就没有必要再在内存里逐个保存参数和本地变量了,而是只为溢出的变量提供空间就可以了。
我们以这张图为例分析一下:
![图片](https://static001.geekbang.org/resource/image/ba/98/ba63dd73b4f96a7062b82bb7e639d098.png?wh=632x576)
这里栈帧维护的重点是要能够准确计算出每个被溢出的变量的地址相对于rbp的偏移量。
而这里就有一个不确定的因素了就是在溢出变量的存储空间上部是为Callee保护的寄存器而留出的空间。但是我们到底需要保存几个Callee保护的寄存器这要在寄存器分配算法执行完毕以后才能知道。
所以你会看到所有被Spill的变量的准确内存地址是需要在算法的最后调整一次的。你可以看一下lowerFunction中的这段代码
```plain
//把spilledVars中的地址修改一下加上CalleeProtectedReg所占的空间
if (this.usedCalleeProtectedRegs.length >0){
let offset = this.usedCalleeProtectedRegs.length*8;
for (let address of this.spilledVars2Address.values()){
let oldValue = address.value as number;
address.value = oldValue+offset;
}
}
```
好了,关于各种技术实现的细节,讲到这里就差不多了。现在我们已经拥有了一个升级版的寄存器分配算法。采用这个算法生成的汇编文件,看上去就很顺眼了,你会看到大部分指令的操作数都是寄存器了。
那么现在又到了检验我们的成果的时候了。是不是现在这个版本的性能会提升很多呢毕竟在之前的版本中我们在使用本地变量和参数的时候都要访问内存。而且根据我们前面的经验内存会比寄存器慢差不多100倍呀。
## 再次进行性能比拼
与其在这猜测,不如直接动手验证吧。
你可以运行make example\_fibo命令再来构建一次斐波那契数列的例子然后用./example\_fibo命令来执行它。并且你还可以用make fibo命令来编译一遍fibo.c也就是C语言版本的斐波那契数列程序。
在这节课的Makefile文件中我给fibo命令添加了-O2编译选项也就是生成的代码是优化过的也会使用寄存器。
我再一次把计算结果贴上来你可以看看下面的表格。不过这里你要注意一下在表格里我把当前实现的这个TypeScript的版本叫做PlayScript称呼起来更加方便一些。这是我比较喜欢的一个名称我在[《编译原理之美》](https://time.geekbang.org/column/intro/100034101)课程中就用过这个名称。
![图片](https://static001.geekbang.org/resource/image/da/d6/da91bf11f0148772057f8ca0895af3d6.jpg?wh=1920x1080)
而且,我仍然做了一张曲线图,让你能够更直观地看到各个版本之间的差别。
![图片](https://static001.geekbang.org/resource/image/c9/34/c950d8f99b497ea5e58c29d8129f4c34.png?wh=1466x854)
从这些数据和图表中,你能得到什么结论呢?你可以停一两分钟,自己先想一下。
首先采用了线性扫描算法以后我们程序的性能果然有提升图中的蓝线超过了采用简单寄存器算法的版本也超过未优化的C语言版本。
不过,你有没有觉得有点不对劲?哪里不对劲呢?**看上去这性能的提升也没有特别大呀还不到1倍**。在我内心中,其实期待着更大的性能提升。毕竟我们说过,内存读写的速度可比寄存器的速度慢上百倍呢。
那么是什么原因导致了这个结果你可以想一下。其实你想想我们前面介绍过的关于CPU的架构的知识就知道了。导致这个结果的原因其实是**CPU的高速缓存**。
在程序运行期间,我们栈桢里的那点数据,都被放到高速缓存去了,导致读写速度要比内存快得多。这个例子也从侧面反映出了,保证数据的局部性有多么重要。
那是不是我们费这么大劲升级的寄存器分配算法其实没啥用呢?
也不是的。在我测试的时候我的电脑只运行了这一个比较占用CPU的程序。而如果你写的是一个服务器程序有大量并发访问每个并发访问都要访问内存中不同地方的数据那么CPU的高速缓存的内容就要不断地刷新它对内存访问的增速作用就会大打折扣。
这个时候,采用优化的寄存器分配算法的程序,在性能上一定会有碾压。如果你有兴趣,可以搭建一个这样的测试环境测一下,看看实际的性能差别到底会多大。
好了,这是我从数据中看到的第一个疑问,以及对这个疑问的分析。
**然后呢,还有第二个疑问:用-O2参数优化了的C语言的版本还是比PlayScript的优化版快快了大约50%,这又是什么原因导致的呢?**按理说,这两个版本都是用寄存器来作为操作数的,性能差异应该不大才对呀。
你可以比较一下[example\_fibo.s](https://gitee.com/richard-gong/craft-a-language/blob/master/20/example_fibo.s)和[fibo.s](https://gitee.com/richard-gong/craft-a-language/blob/master/20/fibo.s)这两个汇编代码的差别。你现在看这些汇编代码应该越来越亲切了吧?
你看虽然实现的都是相同的功能但我们生成的汇编代码确实跟C语言也就是llvm生成的不一样区别有几个方面。
首先是使用的具体寄存器不一样,但这个其实对性能没有什么影响。
第二方面的差别呢是使用的某些指令不同。比如我们做减法的时候用的是subl指令而fibo.s中用的是leal指令。leal指令能用一条指令完成计算和给另一个寄存器赋值的动作所以性能确实更高一点。
但这也不是导致50%那么多的性能差异的原因啊。如果你不信你可以把example\_fibo.s中的subl和movl两条指令用leal指令来替换一下然后再编译一下看看性能其实没有太大区别。
那这个主要的原因到底在哪呢你如果再仔细看fibo.s你会发现其实它是在**运行逻辑**上做了比较大的优化。
比如斐波那契数列的公式是f(n) = f(n-1) + f(n-2)。
把f(n-2)展开后又得到f(n) = f(n-1)+f(n-3)+f(n-4)。
像这样把最后一项持续展开又得到f(n) = f(n-1) + f(n-3) + f(n-5) + … + f(2)或f(1)。
你会注意到采用上面这样的算法fibo.s中的递归调用变成了循环调用每个循环要把n的值减少2。
把递归转化为循环,其实是编译技术中常见的一个技术,它能有效地减少总的函数调用次数,从而减少每次函数调用由于建立新栈桢、保护寄存器等引起的开销,看来这就是我们性能不如人的主要原因了。
当然了,我们目前对优化技术的接触还不太多。其实我们这两节课学习到的寄存器分配算法,算是后端优化技术的一种,其他优化技术其实还很多。
看来,我们仍然有不少知识点需要探索呀!不过也没关系,就把这些挑战当成我们进一步学习的动力吧!
## 课程小结
今天这节课我们就讲到这里了,通过这节课,我希望你记住这几个知识点:
首先在变量活跃性分析的具体实现上我们需要能够知道每个变量是在什么时候定义的、什么使用使用的。在我们原来的LIR中能够获取变量使用的信息但缺少变量定义的信息。所以我增加了一个伪指令用来弥补这个缺陷。
第二在存在多个基本块的情况下我们要首先计算出CFG然后采用基于图的算法来计算每个基本块的变量活跃性集合。这个计算过程可能要迭代多次直到所有基本块的变量活跃性集合不再变化为止。
第三在具体实现线性扫描算法时其中的重点就是根据变量的活跃性寻找可用的寄存器。如果寄存器数量不足我们就要选择一个变量溢出到内存中。而且在调用函数时也是通过溢出到内存的方法来保存Caller需要保护的寄存器。最后我们要计算清楚每个溢出的变量的准确内存地址和所占空间从而正确的维护栈桢。
## 思考题
你能否研究一下PlayScript当前生成的汇编代码看看它还有哪些地方可以进一步优化的优化的思路是什么呢欢迎在留言区分享你的观点。
欢迎你把这节课分享给更多感兴趣的朋友。我是宫文学,我们下节课见。
## 资源链接
[这节课的示例代码在这里!](https://gitee.com/richard-gong/craft-a-language/tree/master/20)