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.

217 lines
16 KiB
Markdown

2 years ago
# 38中端优化第1关实现多种本地优化
你好,我是宫文学。
上一节课我们设计了IR的数据结构并且分析了如何从AST生成IR。并且这些IR还可以生成.dot文件以直观的图形化的方式显示出来。
不过我们上一节课只分析了if语句这还远远不够。这节课我会先带你分析for循环语句加深对你控制流和数据流的理解。接着我们就会开始享受这个IR带来的红利用它来完成一些基本的本地优化工作包括公共子表达式删除、拷贝传播和死代码删除让你初步体会基于IR做优化的感觉。
那么我们先接着上一节课继续把for循环从AST转换成IR。
## 把For循环转换成IR
同样地我们还是借助一个例子来做分析。这个例子是一个实现累加功能的函数bar函数接受一个参数a然后返回从1到a的累加值。
```plain
function bar(a:number):number{
let sum:number = 0;
for(let i = 1; i <= a; i++){
sum = sum + i;
}
return sum;
}
```
这里我先直接画出最后生成的IR图的样子
![图片](https://static001.geekbang.org/resource/image/13/81/137c1353cd0bac2638f94103c2217b81.jpg?wh=1080x1387)
你一看这个图,肯定会觉得有点眼花缭乱,摸不清头绪。不过没关系,这里面是有着清晰的逻辑的。
**第一步,我们先来看控制流的部分。**
![图片](https://static001.geekbang.org/resource/image/f7/5e/f7278yye0c0d34914631cc0d6287645e.jpg?wh=1080x1387)
在程序开头的时候依然还是一个Start节点。
而下面的LoopBegin节点则代表了整个for循环语句的开始。开始后它会根据for循环的条件确定是否进入循环体。这里我们引入了一个If节点来代表循环条件。If节点要依据一个if条件所以这里有一条黑线指向一个条件表达式节点。
当循环条件为true的时候程序就进入循环体。循环体以Begin开头以LoopEnd结尾。而当循环条件为false的时候程序则要通过LoopExit来退出循环。最后再通过Return语句从函数中返回。
并且LoopEnd和LoopExit各自都有一条输入边连接到LoopBegin。这样循环的开始和结束就能正确地配对不至于搞混。
不过你可能注意到了一个现象Start节点的后序节点并不马上是循环的开始LoopBegin。为什么呢因为其实有两条控制流能够到达LoopBegin一条是从程序开始的上方进去另一条是在每次循环结束以后又重新开始循环。所以LoopBegin相当于我们上一节见过的Merge节点两条控制流在这里汇聚。而我们在控制流中如果用一条蓝线往下连接其他节点只适用于单一控制流和流程分叉的情况不包括流程汇聚的情况。我们上节课也说过每个ControlNode最多只有一个前序节点。
那控制流的部分就说清楚了。**第二步,我们就来看一下数据流。**
在数据流中我们需要计算i和sum这两个变量。我们先看i
```plain
function bar(a:number):number{
let sum1:number = 0;
for(let i1 = 1; i <= a; i2 = i + 1){
sum2 = sum + i;
}
return sum;
}
```
这里变量i被静态赋值了两次。一开始被赋值为1后来又通过i++来递增。为了符合SSA格式我们要把它拆分成i1和i2两个变量然后再用Phi节点把它们聚合起来用于循环条件的判断。
我们把与i有关的数据流加入到图中就是下面这样
![图片](https://static001.geekbang.org/resource/image/ae/3f/aea5cc2403282bdfeaff3c4b82628f3f.jpg?wh=1080x1387)
我再解释一下这张图。i1=1这个表达式在刚进入循环时被触发一次循环结束后会触发i2 = i + 1。所以在i<=a这个条件中的i在刚进入循环的时候会选择i1而在循环体中循环过一次以后会选择i2。因此我们图中这个phi节点有一条输入边指向LoopBegin用于判断控制流到底是从上面那条边进入的还是从LoopEnd返回的。
对于i2 = i + 1中的i也是一样。它在一开始等于i1循环过一次以后就等于i2了。
我们可以用同样的方式加入与sum变量有关的数据流
![图片](https://static001.geekbang.org/resource/image/b4/31/b4abf8a448yy61621533b9f7e3cf2231.jpg?wh=1080x1387)
这张图中sum1在循环体外被赋值为0后来在循环体内则是执行sum2 = sum + i。这里的sum也是刚进入循环体的时候取sum1循环过一次以后就取sum2所以这里也需要一个Phi节点。
到这里借助Phi节点sum的值也已经算出来了。那么在最后的return语句中是不是就可以直接把这个值返回了呢
不可以。为什么呢因为return语句是在for循环语句之后的而我们刚才计算的sum值是循环体内的sum值。我们在程序里必须要保证是在退出循环以后再获取的这个值不能违背这个控制流带来的约束。所以我们添加了一个ValueProxy节点以LoopExit作为输入确保这个值的计算是在循环之外。但它实际的值就是刚才由Phi节点计算出的sum的值。
![图片](https://static001.geekbang.org/resource/image/fc/b2/fc4d40b7135a1bd178e5425e0f3f77b2.jpg?wh=1080x1387)
到此为止整个for循环的IR就生成完毕了。一开始你感觉会有点复杂但如果你逐渐习惯了控制流和数据流的思维方式分析起来就会越来越快了。
不过回报和付出总是相匹配的。我们花了这么大代价来生成这个IR会让某些优化工作变得异常简单接下来我们就来体会一下吧
## 公共子表达式删除
首先我们看看怎么利用这个IR来删除公共子表达式。
我们来看下面这个示例程序。这个程序中有两个变量x和y它们的定义都是a + b所以它们有公共的子表达式。并且变量z的定义中也有a+b这个公共的子表达式。
```plain
//删除公共子表达式
function commonSubExp(a:number, b:number):number{
let x = a + b;
let y = a + b;
let z = a + b + 10;
let m = x + y + z
return m;
}
```
如果用我们的IR来删除这个示例程序中的公共子表达式我们甚至都不需要等到优化阶段而是在生成IR的时候顺带手就可以做了。
你可以运行一下node play example\_opt1.ts --dumpIR命令生成下面的图。我手工在节点旁边标注了一下变量名称。你能看到图中只有一张子图代表“a+b”这个公共子表达式而且它被多个变量的定义引用了。
![](https://static001.geekbang.org/resource/image/77/d4/775d8a811ed496944d2f0e83972c12d4.jpg?wh=1080x1387)
那具体这个公共子表达式是怎么被共享的呢首先为了保存我们的IR图我们设计了一个graph类里面保存了所有节点的列表。
```plain
//IR图
export class Graph{
nodes:IRNode[] =[];
}
```
然后在遍历AST生成IR的时候我们会先生成针对某个AST节点的DataNode然后再加入到Graph中。这个节点实际上就代表了一个子图。我们以加法运算节点为例这个子图包含了一个BinaryOpNode还有left和right这两个input。
但是这个子图可能在Graph中已经存在了。比如在上面的示例程序中当处理变量x的定义的时候程序就为“a+b”这个表达式生成了一个BinaryOpNode它的左右两个input分别是参数a和b然后我们把这个节点加入到了Graph中。而当处理变量y的定义的时候程序也会生成一个BinaryOpNode它的左右两个input也是参数a和b。
这个时候我们就没有必要把第二个BinaryOpNode或者说子图加入到Graph中了我们直接用之前那个子图就行了。所以我们要添加一个功能用来比较两个DataNode节点是不是相同的。如果我们准备加入的节点在Graph中已经存在那就返回原来的节点。这部分具体实现你可以参考[ir.ts](https://gitee.com/richard-gong/craft-a-language/blob/master/38/ir.ts)中的Graph类中的[addDataNode()](https://gitee.com/richard-gong/craft-a-language/blob/master/38/ir.ts#L25)方法。另外为了比较两个节点是否相同我还为每个DataNode都实现了一个equals()方法。
接下来你可以继续看看变量z的定义。在变量z定义中也存在“a+b”这个子表达式它直接引用了原来的DataNode节点然后再跟常量10相加。
最后在变量m的定义中我们先使用了一个临时变量来计算“x+y”。你在图中能看到这个临时变量的两个input都指向了代表“a+b”的DataNode。这就说明变量x和y引用的都是同一个DataNode。
这部分的具体实现是这样的,在[IRGenerator](https://gitee.com/richard-gong/craft-a-language/blob/master/38/ir.ts#L531)程序的[visitVariable()](https://gitee.com/richard-gong/craft-a-language/blob/master/38/ir.ts#L748)方法中根据变量的符号我们可以从Graph中把对应的DataNode都查出来。这是因为IRGenerator在处理好AST以后会生成一个IRModule而IRModule中就保存每个变量跟DataNode的对应关系。
好了现在你已经了解了如何基于我们的IR来删除公共子表达式了。接下来我们再看看它在处理其他优化任务时是否也同样方便。我们看一下拷贝传播。
## 拷贝传播
实际上基于我们的IR来处理拷贝传播也是手到擒来几乎不需要做什么额外的工作。
我们看一段示例代码。在这段代码中变量x的定义是a+b。然后我们又用了x来定义y那你推理一下就知道现在y也应该等于a+b。
```plain
// 拷贝传播
function copyPropagation(a:number, b:number):number{
let x = a + b;
let y = x;
let z = y - x;
return z;
}
```
你仍然可以用我们现在的编译器加上dumpIR选项来生成.dot图我把它放在下面了。
![](https://static001.geekbang.org/resource/image/38/6f/38d8855ce975f9f7165a9f8a4018106f.jpg?wh=1080x1387)
你会看到变量x和y都引用了相同的DataNode。这里具体的实现你可以看一下[IRGenerator](https://gitee.com/richard-gong/craft-a-language/blob/master/38/ir.ts#L531)中的[visitVariableDecl()](https://gitee.com/richard-gong/craft-a-language/blob/master/38/ir.ts#L800)方法。在声明变量y的时候我们会获取变量初始化表达式对应的DataNode再把它跟该变量绑定。而变量y的初始化表达式就是xx对应的DataNode就是图中的Plus节点所以这个节点也跟变量y关联到了一起。拷贝传播就是这么在处理变量声明的过程中自然而然地发生了。
最后你在图中再看一下变量z的定义。你会看到减法运算的左右两个input都是指向了同一个DataNode。所以接下来我们就可以自然而然地做一个优化了直接计算出z=0就可以了。在做优化的时候我们经常会遇到这种情况就是一个优化的处理结果为其他优化创造了机会。就像当前的例子拷贝传播的结果就是给减法运算的优化创造了机会。
不过在实际的优化算法中我们通常会让IR经历多个Pass的处理每个Pass处理一种优化场景。并且经常同一种优化算法会被使用多次原因就是在做完优化A以后可能又制造出了优化B的机会。
最后我们再看看死代码删除的情况看看我们的IR又会带来什么惊喜。
## 死代码删除
我们还是看一个存在死代码的例子程序。这个例子中有x、y、z和dc共4个变量。你用肉眼看一下就能发现定义dc变量的这行代码是多余的。因为在定义出dc以后再也没有代码用到它了。
```plain
//删除死代码
function deadCode(a:number, b:number):number{
let x = a + b;
let y = a - b;
let dc = a - 2;
let z = x + y + 10;
return z;
}
```
我之前给你介绍过变量活跃性分析的数据流方法。我们可以自底向上地遍历这个代码块并不断更新一个“活跃变量”的集合。等分析到声明dc这一行的时候我们会发现当前活跃变量集合里是没有dc的这样就知道这行代码是死代码了。
如果使用我们现在的IR那应该如何检测死代码呢我们还是先看编译器生成的IR图看看死代码在图中有什么特点。
![](https://static001.geekbang.org/resource/image/72/2d/72186cf24872016e89d78295888c372d.jpg?wh=1080x1387)
我在图中标出了作为死代码的dc变量。你从图中可以直观地看到这个节点有一个显著的特点就是没有其他节点引用它因此它不是任何其他节点的input。
你应该记得我们在DataNode中设置了一个uses属性指向所有使用该节点的其他节点是一个反向的链接。那这个时候其实dc变量对应的DataNode的uses列表是空的。所以只要是uses为空的节点我们就可以把它从图中去掉。而我们把Minus\_6去掉以后常量2也没有任何节点使用了所以我们也可以把它去掉。
你看,现在我们要去除死代码的话,简单到**只是查询DataNode的uses属性是否为空集合**就行了。是不是太方便了具体实现你可以看看ir.ts中的[DeadCodeElimination](https://gitee.com/richard-gong/craft-a-language/blob/master/38/ir.ts#L800)类。
不过需要注意的是上面只是产生死代码的其中一个场景还有另一个场景是出现在return、break等语句之后的代码也都是死代码。这种类型死代码也是在生成IR的时候就可以去掉的。也就是在遇到return语句以后我们不再为同一个块中的其他语句生成IR就行了。
## 课程小结
今天的内容就是这些。今天这节课我首先接着上一节分析了如何为for循环语句生成IR让你熟悉另一种常用的IR结构接着分析了如何基于该IR实现几种常见的本地优化算法。我希望你记住以下的重点
首先在For循环中LoopBegin和Merge节点一样都是实现了多个控制流的汇聚。LoopEnd代表一次循环的结束而LoopExit代表退出循环它们都要跟LoopBegin配对。对于循环变量我们需要用Phi节点来获取其不同控制流分支上的取值。
第二在生成IR的过程中我们顺手就可以实现对公共子表达式的删除这需要实现DataNode的比较。并且要求在DataNode加入Graph的过程中不能存在相同的DataNode或者子图。
第三在生成IR的过程中我们通过处理变量声明也可以自然而然地实现拷贝的传播。
第四如果一个IR的uses属性是一个空集合那我们就可以判断出它是一个没有用的变量可以把它删除掉这就实现了死代码删除的功能。
最后一种优化工作的结果会为其他的优化创造机会。所以编译器在优化一个IR的时候会前后多次调用同一个优化算法。
## 思考题
今天我们讨论的这些优化的例子都是本地优化的情况也就是在同一个基本块中代码做优化没有考虑控制流跳转的情况。那你能不能分析一下当存在if语句和循环语句的情况下能不能也像这节课这样实现公共子表达式的删除、常量传播和死代码删除
欢迎你把这节课分享给更多感兴趣的朋友。我是宫文学,我们下节课见。
## 资源链接
[这节课示例代码的目录在这里!](https://gitee.com/richard-gong/craft-a-language/tree/master/38)