You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

291 lines
22 KiB
Markdown

2 years ago
# 30 | 后端总结:充分发挥硬件的能力
你好,我是宫文学。
后端的工作主要是针对各种不同架构的CPU来生成机器码。在[第8讲](https://time.geekbang.org/column/article/249261),我已经对编译器在生成代码的过程中,所做的主要工作进行了简单的概述,你现在应该对编译器的后端工作有了一个大致的了解,也知道了后端工作中的关键算法包括指令选择、寄存器分配和指令排序(又叫做指令调度)。
那么今天这一讲,我们就借助在第二个模块中解析过的真实编译器,来总结、梳理一下各种编译器的后端技术,再来迭代提升一下原有的认知,并加深对以下这些问题的理解:
* 首先在第8讲中我只讲了**指令选择**的必要性,但对于如何实现指令选择等步骤,我并没有展开介绍。今天这一讲,我就会带你探索一下指令选择的相关算法。
* 其次,关于**寄存器分配算法**我们探索过的好几个编译器比如Graal、gc编译器等采用的都是线性扫描算法那么这个算法的原理是什么呢我们一起来探究一下。
* 最后,我们再回到**计算机语言设计**的主线上来,一起分析一下不同编译器的后端设计,是如何跟该语言的设计目标相匹配的。
OK我们先来了解一下指令选择的算法。
## 指令选择算法
回顾一下,我们主要是在[Graal](https://time.geekbang.org/column/article/258162)和[Go语言](https://time.geekbang.org/column/article/266379)的编译器中分析了与指令选择有关的算法。它们都采用了一种模式匹配的DSL只要找到了符合模式的指令组合编译器就生成一条低端的、对应于机器码的指令。
**那为什么这种算法是有效的呢?这种算法的原理是什么呢?都有哪些不同的算法实现?**接下来,我就给你揭晓一下答案。
我先给你举个例子。针对表达式“a\[i\]=b”它是对数组a的第i个元素赋值。假设a是一个整数数组那么地址的偏移量就是`a+4*i`所以这个赋值表达式用C语言可以写成“`*(a+4*i)=b`”把它表达成AST的话就是下图所示的样子。其中赋值表达式的左子树的计算结果是一个内存地址。
![](https://static001.geekbang.org/resource/image/a2/d3/a234b0a6b2bb153f2857989e16106bd3.jpg)
图1a\[i\]=b的AST
那么,我们要如何给这个表达式生成指令呢?
如果你熟悉x86汇编你就会知道上述语句可以非常简单地表达出来因为x86的指令对数组寻址做了优化参见第8讲的内容
不过这里为了让你更容易理解算法的原理我设计了一个新的指令集。这个指令集中的每条指令都对应了一棵AST的子树我们把它叫做**模式树Pattern Tree**。在有的算法里,它们也被叫做**瓦片Tiling**。对一个AST生成指令就是用这样的模式树或瓦片来覆盖整个AST的过程。所以这样的算法也叫做**基于模式匹配的指令生成算法**。
![](https://static001.geekbang.org/resource/image/4b/e9/4ba05f685d18635767a7f8f3924b90e9.jpg)
图2指令集中的指令和对应的模式树
你可以看到在图2中对于每棵模式树它的根节点是这个指令产生的结果的存放位置。比如Load\_Const指令执行完毕以后常数会被保存到一个寄存器里。这个寄存器又可以作为上一级AST节点的操作数来使用。
图2中的指令包含把常数和内存中的值加载到寄存器、加法运算、乘法运算等。其中有两个指令是特殊设计的目的就是为了让你更容易理解接下来要探究的各种算法。
**第一个指令是#4Store\_Offset**,它把值保存到内存的时候,可以在目的地址上加一个偏移量。你可以认为这是为某些场景做的一个优化,比如你在对象地址上加一个偏移量,就能获得成员变量的地址,并把数值保存到这个地址上。
**第二个指令是#9Lea**它相当于x86指令集中的Lea指令能够计算一个地址值特别是能够利用间接寻址模式计算出一个数组元素的地址。它能通过一条指令完成一个乘法计算和一个加法计算。如果你忘记了Lea指令可以重新看看第8讲的内容。
基于上述的指令和模式树,我们就可以尝试来做一下模式匹配,从而选择出合适的指令。**那么都可以采用什么样的算法呢?**
**第一个算法,是一种比较幼稚的算法。我们采取深度优先的后序遍历,也就是按照“左子节点->右子节点->父节点”的顺序遍历,针对每个节点去匹配上面的模式。**
* 第1步采用模式#2把内存中a的值也就是数组的地址加载到寄存器。因为无论加减乘除等任何运算都是可以拿寄存器作为操作数的所以做这个决策是很安全的。
* 第2步同上采用模式#1把常量4加载到寄存器。
* 第3步采用模式#2把内存中i的值加载到寄存器。
* 第4步采用模式#8把两个寄存器的值相乘得到4\*i的值。
* 第5步采用模式#5把两个寄存器的值相加得到a+4\*i的值也就是a\[i\]的地址。
* 第6步采用模式#2把内存中b的值加载到寄存器。
* 第7步采用模式#3把寄存器中b的值写入a\[i\]的地址。
![](https://static001.geekbang.org/resource/image/8a/5e/8afb39c83db4de7c6758f486ca27395e.jpg)
图3用比较幼稚的算法做模式匹配
最后形成的汇编代码是这样的:
```
Load_Mem a, R1
Load_Const 4, R2
Load_Mem i, R3
Mul_Reg R2, R3
Add_Reg R3, R1
Load_Mem b, R2
Store R2, (R1)
```
**这种方法,是自底向上的做树的重写。**它的优点是特别简单缺点是性能比较差。它一共生成了7条指令代价是193+1+3+4+1+3+4
在上述步骤中我们能看到很多可以优化的地方。比如4\*i这个子表达式我们是用了3条指令来实现的总的Cost是1+3+4=8而如果改成两条指令也就是使用Mul\_mem指令就不用先把i加载到寄存器Cost可以是1+6=7。
```
Load_Const 4, R1
Mul_Mem i, R1
```
**第二种方法是类似Graal编译器所采用的方法自顶向下的做模式匹配。**比如当我们处理赋值节点的时候算法会尽量匹配更多的子节点。因为一条指令包含的子节点越多那么通过一条指令完成的操作就越多从而总的Cost就更低。
所以,算法的大致步骤是这样的:
* 第1步在#3和#4两个模式中做选择的话选中了#4号。
* 第2步沿着AST继续所深度遍历其中+号节点第1步被处理掉了所以现在处理变量a采用了模式#2把变量加载到寄存器。
* 第3步处理\*节点。这个时候要在#7和#8之间做对比最后选择了#7因为它可以包含更多的节点。
* 第4步处理常量4。因为上级节点在这里需要一个寄存器作为操作数所以我们采用了模式#1把常量加载到寄存器。
* 第5步处理变量b。这里也要把它加载到寄存器因此采用了模式#2。
![](https://static001.geekbang.org/resource/image/a4/1b/a4aac19471190a449bab1b947ae5711b.jpg)
图4Maximal Munch算法的匹配结果
到此为止我们用了5条指令就做完了所有的运算生成的汇编代码是
```
Load_Mem a, R1
Load_Const 4, R2
Mul_Mem R2, i
Load_Mem b, R3
Store_Offset R3, (R1,R2)
```
这5条指令总的Cost是183+1+6+3+5
上述算法的特点,是在每一步都采用了**贪婪策略**这种算法策略有时候也叫做“Maximal Munch”意思就是每一步都去咬最大的一口。
贪婪策略会生成比幼稚的算法更优化的代码但它不一定是最优的。你看下图中的匹配策略它也是用了5条指令。
![](https://static001.geekbang.org/resource/image/69/3b/69ffa14568a9f85654a92074458bb43b.jpg)
图5最优的匹配策略
生成的汇编代码如下:
```
Lead_Mem a, R1
Load_Mem i, R2
Lea (R1,R2,4), R1
Load_Mem b, R2
Store R2, (R1)
```
这个新的匹配结果总的Cost是173+3+4+3+4比前一个算法的结果更优化了。那我们用什么算法能得到这样一个结果呢
一个思路,是**找出用模式匹配来覆盖AST的所有可能的模式并找出其中Cost最低的**。你可以采用**暴力枚举**的方法在每一个节点去匹配所有可能的模式从而找出多组解。但显然这种算法的计算量太大所需的时间会根据AST的大小呈指数级上升导致编译速度无法接受。
所以我们需要找到一个代价更低的算法,这就是**BURS算法**也就是“自底向上重写系统Bottom-Up Rewriting System”。在[HotSpot的C2编译器](https://time.geekbang.org/column/article/258162)中就采用了BURS算法。这个算法采用了**动态规划**Dynamic Programming的数学方法来获取最优解同时保持了较低的算法复杂度。
**那么要想理解BURS算法你就必须要弄懂动态规划的原理。**如果你之前没有学过这个数学方法,请不要紧张,因为动态规划的原理其实是相当简单的。
我在网上发现了一篇能够简洁地说清楚动态规划的[文章](https://cloud.tencent.com/developer/article/1475703)。它举了一个例子,用最少张的纸币,来凑出某个金额。
比如说假设你要凑出15元怎么做呢你还是可以继续采用贪婪算法。首先拿出一张10元的纸币也就是小于15的最大金额然后再拿出5元来。这样你用两张纸币就凑出了15这个数值。这个时候贪婪策略仍然是有效的。
但是如果某个奇葩的国家发行的货币不是按照中国货币的面额而是发行1、5、11元三种面额的纸币。那么如果你仍然使用贪婪策略一开始拿出一张11元的纸币你就还需要再拿出4张1元的这样就一共需要5张纸币。
但这显然不是最优解。最优解是只需要三张5元的纸币就可以了这就像我们用贪婪算法去做指令生成得到的可能不是最优解是同样的道理。
那如何采用动态规划的方法来获取最优解呢它的思路是这样的假设我们用f(n)来代表凑出n元钱最少的纸币数那么
* 当一开始取11元的话Cost = f(4) + 1
* 当一开始取5元的话Cost = f(10) + 1
* 当一开始取1元的话Cost = f(14) + 1。
所以我们只需要知道f(4)、f(10)和f(14)哪个值最小就行了。也就是说f(15)=min(f(4), f(10), f(14)) + 1。 而f(4)、f(10)和f(14)三个值也可以用同样的方法递归地求出来最后得到的值分别是4、2、4。所以f(15)=3这就是最优解。
这个算法最棒的一点是整个计算中会遇到的f(14)、f(13)、f(12)、f(11) … f(3)、f(2)这些值,**一旦计算过一遍,就可以缓存下来,不必重复计算,从而让算法的复杂性降低**。
所以动态规划的特点是通过子问题的最优解得到总的问题的最优解。这种方法也可以用于生成最优的指令组合。比如对于示例程序来说假设f(=)是以赋值运算符为根节点的AST所生成的指令的总的最低Cost那么
* 当采用#3的时候Cost = 4 + f(+) + f(b)
* 当采用#4的时候Cost = 5 + f(a) + f(\*) + f(b)。
所以你能看出,通过动态规划方法,也能像凑纸币一样,求出树覆盖的最优解。
BURS算法在具体执行的时候需要进行三遍的扫描。
**第一遍扫描**是自底向上做遍历也就是后序遍历识别出每个节点可以进行的转换。我在图6中给你标了出来。以a节点为例我们可以对它做两个操作第一个操作是保持一个mem节点不动第二个操作是按照模式#1把它转换成一个reg节点。
![](https://static001.geekbang.org/resource/image/dd/2d/dddd87f278c8111d4787756380e08b2d.jpg)
图6识别AST的每个节点可以进行的转换
**第二遍扫描**是自顶向下的,运用动态规划的方法找出最优解。
**第三遍扫描**又是自底向上的,用于生成指令。
好了,那么到目前为止,你就已经了解了指令生成的算法思路了。这里我再补充几点说明:
* 示例中的指令和Cost值是为了便于你理解算法而设计的。在这个示例中最优解和最差解的Cost只差了2也就是大约12%的性能提升。而在实际应用中,优化力度往往会远远大于这个值。
* 在[第6讲](https://time.geekbang.org/column/article/247700)探究IR的数据结构时我提到过有向无环图DAG它比起刚才例子中用到的树结构能够消除一些冗余的子树从而减少生成的代码量。LLVM里在做指令选择的时候就是采用了DAG但算法思路是一样的。
* 示例中到的两个算法贪婪算法和BURS算法它们花费的时间都与节点数呈线性关系所以性能都是很高的。其中BURS算法的线性系数更大一点做指令选择所需的时间也更长一点。
OK那么接下来我们来探究第二个算法寄存器分配算法。
## 寄存器分配算法
在解析Graal编译器和Go的编译器的时候我都提到过它们的寄存器分配算法是线性扫描算法。我也提到过线性扫描算法的性能比较高。
**那么,线性扫描算法的原理是什么呢?**总的来说,线性扫描算法理解起来其实相当简单。我用一个例子来带你了解下。
假设我们的程序里有从a到g共7个变量。通过数据流分析中的变量活跃性分析你其实可以知道每个变量的生存期。现在我们已知有4个物理寄存器可用那么我们来看一下要怎么分配这几个物理寄存器。
**在第1个时间段**a、b、c和d是活跃的那我们刚好把4个物理寄存器分配给这四个变量就行了。
![](https://static001.geekbang.org/resource/image/39/75/39dc35a5b607cd3066017c08d0191975.jpg)
**在第2个时间段**a的生存期结束而一个新的变量e变得活跃那么我们就把a原来占用的寄存器刚好给到e就可以了。
![](https://static001.geekbang.org/resource/image/b6/4a/b64ae2b4cfd318f9eeeeaa54ab83984a.jpg)
**在第3个时间段**我们把c占用的寄存器给到f目前仍然是使用4个寄存器。
![](https://static001.geekbang.org/resource/image/48/63/48fb912ba5c59f8c04373a91510b5563.jpg)
**在第4个时间段**b的生存期结束。这时候只需要用到3个寄存器。
![](https://static001.geekbang.org/resource/image/ca/04/ca60eb79fedac105f3cbfd6f8c509404.jpg)
**在最后一个时间段**只有变量d和g是活跃的占用两个寄存器。
![](https://static001.geekbang.org/resource/image/04/0a/04b2d9a8cyy51a6916cc286b29ea9a0a.jpg)
可以看到,在上面这个例子中,所有的变量都可以分配到物理寄存器。而且你也会发现,这个例子中存在多个变量因为生存期是错开的,因此也可以共享同一个寄存器。
但是,如果没有足够的物理寄存器的话,我们要怎么办呢?那就需要把某个变量溢出到内存里了。也就是说,当用到这个变量的时候,才把这个变量加载到寄存器,或者有一些指令可以直接用内存地址作为操作数。
给你举另一个例子,我们来看看物理寄存器不足的情况会是什么样子。在这个例子中,我们有三个物理寄存器。
**在第1个时间段**,物理寄存器是够用的。
![](https://static001.geekbang.org/resource/image/0b/d1/0b511374c86b9a14c1bec1d52c718cd1.jpg)
**在第2个时间段**变量d变得活跃现在有4个活跃变量所以必须选择一个溢出到内存。我们选择了a。
![](https://static001.geekbang.org/resource/image/b8/cf/b81c0a71067d26f68f6902e26f1040cf.jpg)
**在第3个时间段**e和f变得活跃现在又需要溢出一个变量才可以。这次选择了c。
![](https://static001.geekbang.org/resource/image/3c/88/3c6f242389253e6f5e9dc43f62a4fa88.jpg)
**在第4个时间段**g也变得活跃这次把d溢出了。
![](https://static001.geekbang.org/resource/image/c6/64/c6a906528d9d8331bb7de9b87163cc64.jpg)
以上就是**线性扫描算法的思路:线性扫描整个代码,并给活跃变量分配寄存器。如果物理寄存器不足,那么就选择一个变量,溢出到内存中。**你看,是不是很简单?
在掌握了线性扫描算法的思路以后,我再给你补充一点信息:
* 第一,线性扫描算法并不能获得寄存器分配的最优解。所谓最优解,是要让尽量多的操作在寄存器上实现,尽量少地访问内存。因为线性扫描算法并没有去确定一个最优值的目标,所以也就谈不上最优解。
* 第二,线性扫描算法可以采用一些策略,让一些使用频率低的变量被溢出,而像高频使用的循环中的变量,就保留在寄存器里。
* 第三,还有一些其他提升策略。比如,当存在多余的物理寄存器以后,还可以把之前已经溢出的变量重新复活到寄存器里。
好了上述就是线性扫描的寄存器分配算法。另外我们再来复习一下在第8讲中我还提到了另一个算法是图染色算法这个算法的优化效果更好但是计算量比较大会影响编译速度。
接下来,让我们再回到计算机语言设计的主线上,一起讨论一下编译器的后端与语言设计的关系。
## 编译器后端与语言的设计
编译器后端的目的,是要能够针对不同架构的硬件来生成目标代码,并尽量发挥硬件的能力。那么为了更好地支持语言的设计,在编译器后端的设计上,我们需要考虑到三个方面的因素。
* **平衡编译速度和优化效果**
通常我们都希望编译后的代码越优化越好。但是在有些场景下编译速度也很重要。比如像JVM这样需要即时编译的运行时环境编译速度就比较重要。这可能就是Graal的指令选择算法和编译器分配算法都比较简单的原因吧。
Go语言一开始也把编译速度作为一个重要的设计考虑所以它的后端算法也比较简单。我估计是因为Go语言的发起者Robert Griesemer、Rob Pike和Ken Tompson都具有C和C++的背景甚至Ken Tompson还是C语言的联合发明人他们都深受编译速度慢之苦。类似浏览器、操作系统这样比较大的软件即使是用很多台机器做编译还是需要编译很久。这可能也是他们为什么想让Go的编译速度很快的原因。
而Julia的设计目标是用于科学计算的所以其使用场景主要就是计算密集型的。Julia采用了LLVM做后端做了比较高强度的优化即使会因此导致运行时由于JIT而引起短暂停顿。
* **确定所支持的硬件平台**
确定了一门语言主要运行在什么平台上那么首先就要支持该平台上的机器码。由于Go语言主要是用于写服务端程序的而服务端采用的架构是有限的所以Go语言支持的架构也是有限的。
硬件平台也影响算法的选择比如现在很多CPU都支持指令的乱序执行那你在实现编译器的时候就可以省略指令重排序指令调度功能。
* **设计后端DSL**
虽然编译器后端要支持多种硬件但我们其实会希望算法是通用的。所以各个编译器通常会提供一种DSL去描述硬件的特征从而自动生成针对这种硬件的代码。
在Graal中我们看到了与指令选择有关的注解在Go的编译器中我们也看到了对IR进行转换的DSL而LLVM则提供了类似的机制。
## 课程小结
今天这一讲,我把后端的两个重要的算法拿出来给你单独介绍了一下,并一起讨论了后端技术策略与计算机语言的关系。你需要记住这几个知识点:
* **关于指令选择**从IR生成机器码或LIR通常是AST或DAG中的多个节点对应一条指令所以你要找到一个最佳的组合把整个AST或DAG覆盖住并且要找到一个较优的或最优的解。其中你还要熟悉贪婪算法和动态规划这两种不同的算法策略这两种算法不仅仅会用于指令选择还会用于多种场景。理解了这两种算法之后就会给你的工具库添加两个重要的工具。
* **关于寄存器分配**线性扫描算法比较简单。不过在一些技术点上我们去深入挖掘一下其实会发现还挺有意思的。比如当采用SSA格式的IR的时候寄存器分配算法会有什么不同等等。你可以参考看看文末我给出的资料。
* **关于编译器后端的设计**我们要考虑编译速度和优化程度的平衡要考虑都能支持哪些硬件。因为要支持多种硬件通常要涉及后端的DSL以便让算法尽量中立于具体的硬件架构。
我把本讲的知识点也整理成了思维导图,供你复习和参考:
![](https://static001.geekbang.org/resource/image/82/93/82c8e25b80368f16cfb2173b4f00a193.jpg)
## 一课一思
动态规划算法是这节课的一个重要知识点。在学过了这个知识点以后,你能否发现它还可以被用于解决哪些问题?欢迎分享你的经验和看法。
## 参考资料
* 对动态规划方法的理解,我建议你读一下[这篇文章](https://cloud.tencent.com/developer/article/1475703),通俗易懂。
* 在《编译原理之美》的[第29讲](https://time.geekbang.org/column/article/158315),有对寄存器分配算法中的图染色算法的介绍,你可以去参考一下。
* 这两篇关于线性扫描算法的经典论文,你可以去看一下:[论文1](http://web.cs.ucla.edu/~palsberg/course/cs132/linearscan.pdf)[论文2](https://dash.harvard.edu/bitstream/1/34325454/1/tr-21-97.pdf)。
* 这篇文章介绍了针对[SSA格式的IR的线性扫描算法](http://cgo.org/cgo2010/talks/cgo10-ChristianWimmer.pdf),值得一看。