你好,我是宫文学。

上一节课,我们设计了IR的数据结构,并且分析了如何从AST生成IR。并且,这些IR还可以生成.dot文件,以直观的图形化的方式显示出来。

不过,我们上一节课只分析了if语句,这还远远不够。这节课,我会先带你分析for循环语句,加深对你控制流和数据流的理解。接着,我们就会开始享受这个IR带来的红利,用它来完成一些基本的本地优化工作,包括公共子表达式删除、拷贝传播和死代码删除,让你初步体会基于IR做优化的感觉。

那么,我们先接着上一节课,继续把for循环从AST转换成IR。

把For循环转换成IR

同样地,我们还是借助一个例子来做分析。这个例子是一个实现累加功能的函数,bar函数接受一个参数a,然后返回从1到a的累加值。

function bar(a:number):number{
    let sum:number = 0;
    for(let i = 1; i <= a; i++){
        sum = sum + i;
    }
    return sum;
}

这里,我先直接画出最后生成的IR图的样子:

图片

你一看这个图,肯定会觉得有点眼花缭乱,摸不清头绪。不过没关系,这里面是有着清晰的逻辑的。

第一步,我们先来看控制流的部分。

图片

在程序开头的时候,依然还是一个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:

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有关的数据流加入到图中,就是下面这样:

图片

我再解释一下这张图。i1=1这个表达式,在刚进入循环时被触发,一次循环结束后,会触发i2 = i + 1。所以,在i<=a这个条件中的i,在刚进入循环的时候,会选择i1;而在循环体中循环过一次以后,会选择i2。因此,我们图中这个phi节点有一条输入边指向LoopBegin,用于判断控制流到底是从上面那条边进入的,还是从LoopEnd返回的。

对于i2 = i + 1中的i,也是一样。它在一开始等于i1,循环过一次以后,就等于i2了。

我们可以用同样的方式加入与sum变量有关的数据流:

图片

这张图中,sum1在循环体外被赋值为0,后来在循环体内,则是执行sum2 = sum + i。这里的sum,也是刚进入循环体的时候取sum1,循环过一次以后就取sum2,所以这里也需要一个Phi节点。

到这里,借助Phi节点,sum的值也已经算出来了。那么在最后的return语句中,是不是就可以直接把这个值返回了呢?

不可以。为什么呢?因为return语句是在for循环语句之后的,而我们刚才计算的sum值,是循环体内的sum值。我们在程序里,必须要保证是在退出循环以后再获取的这个值,不能违背这个控制流带来的约束。所以,我们添加了一个ValueProxy节点,以LoopExit作为输入,确保这个值的计算是在循环之外。但它实际的值,就是刚才由Phi节点计算出的sum的值。

图片

到此为止,整个for循环的IR就生成完毕了。一开始,你感觉会有点复杂,但如果你逐渐习惯了控制流和数据流的思维方式,分析起来就会越来越快了。

不过,回报和付出总是相匹配的。我们花了这么大代价来生成这个IR,会让某些优化工作变得异常简单,接下来我们就来体会一下吧!

公共子表达式删除

首先,我们看看怎么利用这个IR来删除公共子表达式。

我们来看下面这个示例程序。这个程序中有两个变量x和y,它们的定义都是a + b,所以它们有公共的子表达式。并且,变量z的定义中也有a+b这个公共的子表达式。

//删除公共子表达式
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”这个公共子表达式,而且它被多个变量的定义引用了。

那具体这个公共子表达式是怎么被共享的呢?首先,为了保存我们的IR图,我们设计了一个graph类,里面保存了所有节点的列表。

//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中的Graph类中的addDataNode()方法。另外,为了比较两个节点是否相同,我还为每个DataNode都实现了一个equals()方法。

接下来,你可以继续看看变量z的定义。在变量z定义中也存在“a+b”这个子表达式,它直接引用了原来的DataNode节点,然后再跟常量10相加。

最后,在变量m的定义中,我们先使用了一个临时变量来计算“x+y”。你在图中能看到,这个临时变量的两个input都指向了代表“a+b”的DataNode。这就说明变量x和y引用的都是同一个DataNode。

这部分的具体实现是这样的,在IRGenerator程序的visitVariable()方法中,根据变量的符号,我们可以从Graph中把对应的DataNode都查出来。这是因为,IRGenerator在处理好AST以后,会生成一个IRModule,而IRModule中就保存每个变量跟DataNode的对应关系。

好了,现在你已经了解了如何基于我们的IR来删除公共子表达式了。接下来,我们再看看它在处理其他优化任务时是否也同样方便。我们看一下拷贝传播。

拷贝传播

实际上,基于我们的IR来处理拷贝传播,也是手到擒来,几乎不需要做什么额外的工作。

我们看一段示例代码。在这段代码中,变量x的定义是a+b。然后,我们又用了x来定义y,那你推理一下就知道,现在y也应该等于a+b。

// 拷贝传播
function copyPropagation(a:number, b:number):number{
    let x = a + b;
    let y = x;
    let z = y - x;
    return z;
}

你仍然可以用我们现在的编译器加上–dumpIR选项来生成.dot图,我把它放在下面了。

你会看到,变量x和y都引用了相同的DataNode。这里具体的实现你可以看一下IRGenerator中的visitVariableDecl()方法。在声明变量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以后,再也没有代码用到它了。

//删除死代码
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图,看看死代码在图中有什么特点。

我在图中标出了作为死代码的dc变量。你从图中可以直观地看到,这个节点有一个显著的特点,就是没有其他节点引用它,因此它不是任何其他节点的input。

你应该记得,我们在DataNode中设置了一个uses属性,指向所有使用该节点的其他节点,是一个反向的链接。那这个时候,其实dc变量对应的DataNode的uses列表是空的。所以,只要是uses为空的节点,我们就可以把它从图中去掉。而我们把Minus_6去掉以后,常量2也没有任何节点使用了,所以我们也可以把它去掉。

你看,现在我们要去除死代码的话,简单到只是查询DataNode的uses属性是否为空集合就行了。是不是太方便了?具体实现你可以看看ir.ts中的DeadCodeElimination类。

不过,需要注意的是,上面只是产生死代码的其中一个场景,还有另一个场景是出现在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语句和循环语句的情况下,能不能也像这节课这样实现公共子表达式的删除、常量传播和死代码删除?

欢迎你把这节课分享给更多感兴趣的朋友。我是宫文学,我们下节课见。

资源链接

这节课示例代码的目录在这里!