first commit

This commit is contained in:
张乾
2024-10-16 10:01:33 +08:00
parent eba250f4c7
commit 249864d679
44 changed files with 11697 additions and 0 deletions

View File

@ -0,0 +1,228 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 目标代码的生成和优化(一):如何适应各种硬件架构?
在编译器的后端我们要能够针对不同的计算机硬件生成优化的代码。在23讲我曾带你试着生成过汇编代码但当时生成汇编代码的逻辑是比较幼稚的一个正式的编译器后端代码生成部分需要考虑得更加严密才可以。
那么具体要考虑哪些问题呢?其实主要有三点:
指令的选择。同样一个功能,可以用不同的指令或指令序列来完成,而我们需要选择比较优化的方案。
寄存器分配。每款CPU的寄存器都是有限的我们要有效地利用它。
指令重排序。计算执行的次序会影响所生成的代码的效率。在不影响运行结果的情况下,我们要通过代码重排序获得更高的效率。
我会用两节课的时间带你对这三点问题建立直观认识然后我还会介绍LLVM的实现策略。这样一来你会对目标代码的生成建立比较清晰的顶层认知甚至可以尝试去实现自己的算法。
接下来,我们针对第一个问题,聊一聊为什么需要选择指令,以及如何选择指令。
选择正确的指令
你可能会问:我们为什么非要关注指令的选择呢?我来做个假设。
如果我们不考虑目标代码的性能可以按照非常机械的方式翻译代码。比如我们可以制定一个代码翻译的模板把形如“a := b + c”的代码都翻译成下面的汇编代码
mov b, r0 //把b装入寄存器r0
add c, r0 //把c加到r0上
mov r0, a //把r0存入a
那么,下面两句代码:
a := b + c
d := a + e
将被机械地翻译成:
mov b, r0
add c, r0
mov r0, a
mov a, r0
add e, r0
mov r0, d
你可以从上面这段代码中看到第4行其实是多余的因为r0的值就是a不用再装载一遍了。另外如果后面的代码不会用到a也就是说a只是个临时变量那么第3行也是多余的。
这种算法很幼稚,正确性没有问题,但代码量太大,代价太高。所以我们最好用聪明一点儿的算法来生成更加优化的代码。这是我们要做指令选择的原因之一。
做指令选择的第二个原因是实现同一种功能可以使用多种指令特别是CISC指令集可替代的选择很多但各自有适用的场景
对于某个CPU来说完成同样的任务可以采用不同的指令。比如实现“a := a + 1”可以生成三条代码
mov a, r0
add $1, r0
mov r0, a
也可以直接用一行代码采用inc指令而我们要看看用哪种方法总体代价最低
inc a
第二个例子把r0寄存器置为0也可以有多个方法
mov $0, r0 //赋值为立即数0
xor r0, r0 //异或操作
sub r0, r0 //用自身的值去减
...
再比如a * 7可以用 a< - a实现首先移位3位相当于乘8然后再减去一次a就相当于乘以7虽然用了两条指令但是可能消耗的总的时钟周期更少
在这里我想再次强调一下无论是为了生成更简短的代码还是从多种可能的指令中选择最优的我们确实需要关注指令的选择那么我们做指令选择的思路是什么呢目前最成熟的算法都是基于树覆盖的方法我通过一个例子带你了解一下什么是树覆盖算法
a[i] = b这个表达式的意思是给数组a的第i个元素赋值为b假设a和b都是栈里的本地变量i是放在寄存器ri中这个表达式可以用一个AST表示
你可能觉得这棵树看着像AST但又不大像那是因为里面有mem节点意思是存入内存mov节点栈指针(fp)它可以算作低级low-levelAST是一种IR的表达方式有时被称为结构化IR这个AST里面包含了丰富的运行时的细节信息相当于把LLVM的IR用树结构来表示了你可以把一个基本块的指令都画成这样的树状结构
基于这棵树我们可以翻译成汇编代码
load M[fp+a], r1 //取出数组开头的地址放入r1fp是栈桢的指针a是地址的偏移量
addi 4, r2 //把4加载到r2
mul ri, r2 //把ri的值乘到r2上即i*4即数组元素的偏移量每个元素4字节
add r2, r1 //把r2加到r1上也就是算出a[i]的地址
load M[fp+b], r2 //把b的值加载到r2寄存器
store r2, M[r1] //把r2写入地址为r1的内存
在这里我用了一种假想的汇编代码跟LLVM IR有点儿像但更简化易读
注意我们生成的汇编代码还是比较精简的如果采用比较幼稚的方法逐个树节点进行翻译代码会很多你可以手工翻译试试看
用树覆盖的方法可以大大减少代码量其中用橙色的线包围的部分被形象地叫做一个瓦片(tiling)那些包含了操作符的瓦片就可以转化成一条指令每个瓦片可以覆盖多个节点所以生成的指令比较少
那我们是用什么来做瓦片的呢原来每条机器指令都会对应IR的一些模式Pattern可以表示成一些小的树而这些小树就可以当作瓦片
我们的算法可以遍历AST遇到上面的模式就可以生成对应的指令以load指令为例它有几个模式任意一个节点加上一个常量就行这相当于汇编语言中的间接地址访问或者mem下直接就是一个常量就行这相当于是直接地址访问最后地址值还可以由下级子节点计算出来
所以从一棵AST生成代码的过程就是用上面这些小树去匹配一棵大树并把整个大树覆盖的过程所以叫做树覆盖算法245689这几个节点依次生成汇编代码
要注意的是覆盖方式可能会有多个比如下面这个覆盖方式相比之前的结果它在8和9两个瓦片上是有区别的
生成的汇编代码最后两句也不同
load M[fp+a], r1 //取出数组开头的地址放入r1fp是栈桢的指针a是地址的偏移量
addi 4, r2 //把4加载到r2
mul ri, r2 //把ri的值乘到r2上即i*4即数组元素的偏移量每个元素4字节
add r2, r1 //把r2加到r1上也就是算出a[i]的地址
addi fp+b, r2 //把fp+b的值加载到r2寄存器
movm M[r2], M[r1] //把地址为r2到值拷贝到地址为r1内存里
你可以体会一下这两个覆盖方式的差别
对于瓦片8中的加法运算一个当做了间接地址的计算一个就是当成加法
对于根节点的操作一个翻译成从store把寄存器中的b的值写入到内存一个翻译成movm指令直接在内存之间拷贝值至于这两种翻译方法哪种更好比较总体的性能哪个更高就行了
到目前为止你已经直观地了解了为什么要进行指令选择以及最常用的树覆盖方法了当然了树覆盖算法有很多比如Maximal Munch算法动态规划算法树文法等LLVM也有自己的算法
简单地说一下Maximal Munch算法Maximal Munch直译成中文是每次尽量咬一大口的意思具体来说就是从树根开始每次挑一个能覆盖最多节点的瓦片这样就形成几棵子树对每棵子树也都用相同的策略这样会使得生成的指令是最少的注意指令的顺序要反过来按照深度优先的策略先是叶子再是树根这个算法是Optimal的算法
Optimal被翻译成最佳我不太赞正这种翻译方法翻译成较优会比较合适它指的是在局部相邻的两个瓦片不可能连接成代价更低的瓦片覆盖算法除了Optimal的还有Optimum的Optimum是全局最优化的状态就是代码总体的代价是最低的
关于其他算法的细节在本节课就不展开了因为根据我的经验在学指令选择时最重要的还是建立图形化的直观的理解理解什么是瓦片如何覆盖会得到最优的结果
接下来我们继续探讨开篇提到的第二个问题寄存器分配
分配寄存器
寄存器优化的任务是最大程度地利用寄存器但不要超过寄存器总数量的限制
因为我们生成IR时是不知道目标机器的信息的也就不知道目标机器到底有几个寄存器可以用所以我们在IR中可以使用无限个临时变量每个临时变量都代表一个寄存器
现在既然要生成针对目标机器的代码也就知道这些信息了那么就要把原来的IR改写一下以便使用寄存器时不超标
那么寄存器优化的原理是什么呢我用一个例子带你了解一下
下图左边的IR中adf这三个临时变量不会同时出现假设a和d在这个代码块之后成了死变量那么这三个变量可以共用同一个寄存器就像右边显示的那样
实际上这三行代码是对b + c + e + 10这个表达式的翻译所以a和d都是在转换为IR时引入的中间变量用完就不用了这和在23讲我们把8个参数以及一个本地变量相加时只用了一个寄存器来一直保存累加结果是一样的
所以通过这个例子你可以直观地理解寄存器共享的原则如果存在两个临时变量a和b它们在整个程序执行过程中最多只有一个变量是活跃的那么这两个变量可以共享同一个寄存器
在27和28讲中你已经学过了如何做变量的活跃性分析所以你可以很容易分析出在任何一个程序点活跃变量的集合然后你再看一下哪些变量从来没有出现在同一个集合中就行看看下面的这个图
上图中凡是出现在同一个花括号里的变量都不能共享寄存器因为它们在某个时刻是同时活跃的那a到f哪些变量从来没碰到过呢我们再画一个图来寻找一下
下图中每个临时变量作为一个节点如果两个变量同时存在过就画一条边这样形成的图叫做寄存器干扰图(Register Interference Graph, RIG)在这张图里凡是没有连线的两个变量就可以分配到同一个寄存器例如a和bb和da和db和ea和e
那么问题来了针对这个程序我们一共需要几个寄存器怎么分配呢
一个比较常用的算法是图染色算法只要两个节点之间有连线节点就染成不同的颜色最后所需要的最少颜色就是所需要的寄存器的数量我画了两个染色方案都是需要4种颜色
不过我们是手工染色的那么如何用算法来染色呢假如一共有4个寄存器我们想用算法知道寄存器是否够用应该如何染色
染色算法很简单如果想知道k个寄存器够不够用你只需要找到一个少于k条边的节点把它从图中去掉接着再找下一个少于k条边的节点再去掉如果最后整个图都被删掉了那么这个图一定可以用k种颜色来染色
为什么呢因为如果一个图蓝色边的是能用k种颜色染色的那么再加上一个节点它的边的数量少于k个比如是n那么这个大一点儿的图橙色边的还是可以用k种颜色染色的道理很简单因为加进来的节点的边数少于k个所以一定能找到一个颜色与这个点的n个邻居都不相同
所以我们把刚才一个个去掉节点的顺序反过来把一个个节点依次加到图上每加上一个就找一个它的邻居没有用的颜色来染色就行了整个方法简单易行
但是如果所需要寄存器比实际寄存器的数量多该怎么办呢当然是用栈了这个问题就是寄存器溢出Register Spilling溢出到栈里去我在21讲关于运行时机制时提到过像本地变量参数返回值等都尽量用寄存器如果寄存器不够用那就放到栈里另外再说一下无论放在寄存器里还是栈里都是活动记录的组成部分所以活动记录这个概念比栈桢更广义
还是拿上面的例子来说如果只有3个寄存器那么要计算一下3个寄存器够不够用我们先把a和b从图中去掉
这时你发现剩下的4个节点每个节点都有3个邻居所以3个寄存器肯定不够用必须要溢出一个去我们可以选择让f保存在栈里把f去掉以后剩下的cde可以用3种颜色成功染色
这就结束了吗当然没有f虽然被保存到了栈里但每次使用它的时候都要load到一个临时变量也就是寄存器中每次保存f也都要用一个临时变量写入到内存所以我们要把原来的代码修改一下把每个使用f的地方都加上一条load或save指令以便在使用f的时候把f放到寄存器用完后再写回内存修改后的CFG如下
因为原来有4个地方用到了f所以我们引入了f1到f4四个临时变量这样的话总的临时变量反而变多了从6个到了9个不过没关系虽然临时变量更多了但这几个临时变量的生存期都很短图里带有f的活跃变量集合比之前少多了所以即使有9个临时变量也能用三种颜色染色如下图所示
最后在选择把哪个变量溢出的时候你实际上是要有所选择的你最好选择使用次数最少的变量在程序内循环中的变量就最好不要溢出因为每次循环都会用到它们还是放在寄存器里性能更高
目前为止代码生成中的第二项重要工作分配寄存器就概要地讲完了我留给你一段时间消化本节课的内容在下一讲我会接着讲指令重排序和LLVM的实现
课程小结
目标代码生成过程中有三个关键知识点指令选择寄存器分配和指令重排序本节课我讲了前两个期望能帮你理解这两个问题的实质让你对指令选择和寄存器分配这两个问题建立直观理解这样你再去研究不同的算法时脑海里会有这两个概念的顶层的图形化的认识事半功倍与此同时本节课我希望你记住几个要点如下
相同的IR可以由不同的机器指令序列来实现你要理解瓦片为什么长那个样子并且在大脑里建立用瓦片覆盖一棵AST的直观印象最好具备多种覆盖方式从而把这个问题由抽象变得具象
寄存器分配是编译器必须要做的一项工作它把可以使用无限多寄存器的IR变成了满足物理寄存器数量的IR超出的要溢出到内存中保管染色算法是其中一个可行的算法
一课一思
关于指令选择你是否知道其他的例子让同一个功能可以用不同的指令实现欢迎在留言区分享你的经验
最后感谢你的阅读如果这篇文章让你有所收获也欢迎你将它分享给更多的朋友