# 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的初始化表达式就是x,x对应的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)