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.

250 lines
15 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 40中端优化第3关一起来挑战过程间优化
你好,我是宫文学。
在前面两节课我们分析了本地优化和全局优化的场景。我们发现由于基于图IR的优点也就是**控制流和数据流之间耦合度比较低**的这个特点,我们很多优化算法的实现都变得更简单了。
那么对于过程间优化的场景我们这个基于图IR是否也会带来类似的便利呢
过程间优化Inter-procedural Optimization指的是跨越多个函数或者叫做过程对程序进行多方面的分析包括过程间的控制流分析和数据流分析从而找出可以优化的机会。
今天这节课我们就来分析两种常用的过程间优化技术也就是内联优化和全局的逃逸分析让你能了解过程间优化的思路也能明白如何基于我们的IR来实现这些优化。之后我还会给你补充另一个优化技术方面的知识点也就是规范化。
## 内联优化
内联优化是最常见到的一个过程间优化场景,说的就是当一个函数调用一个子函数时,干脆把子函数的代码拷贝到调用者中,从而减少由于函数调用导致的开销。
特别是,如果调用者是在一个循环中调用子函数,那么由很多次循环累积而导致的性能开销是很大的。内联优化的优势在这时就会得到体现。
而在面向对象编程中我们通常会写很多很简短的setter和getter方法并且在程序里频繁调用。如果编译器能自动把这些短方法做内联优化我们就可以放心大胆地写这些短方法而不用担心由此导致的性能开销了。
现在我们就举一个非常简单的、可以做内联的例子看看。在这个示例中inline函数是调用者它调用了add函数。
```plain
//内联
function inline(x:number):number{
return add(x, x+1);
}
function add(x:number, y:number):number{
return x + y;
}
```
显然在编译inline函数的时候我们没必要额外多产生一次对add函数的调用而是把add函数内联进来就行了形成下面这些优化后的代码
```plain
//内联
function inline(x:number):number{
return x + (x+1);
}
```
**那要如何基于我们的IR实现内联优化呢**
首先我们还是看看在没有优化以前inline和add两个函数的IR
![图片](https://static001.geekbang.org/resource/image/f0/f8/f0d6d718057d7be83e80b7c8eeb73cf8.png?wh=790x820)
在inline函数的IR里你能发现两个新的节点一个是Invoke节点代表函数调用的控制流另一个是CallTarget节点代表函数调用的数据流。
而内联优化就是要把这两个IR图合并形成一个大的IR。如下图所示
![图片](https://static001.geekbang.org/resource/image/52/5d/526b5acb87f4a5c2b2bf36bc2e66115d.png?wh=312x820)
具体来说,要实现上面的合并,我们需要完成两个任务:
* 首先把inline函数中的函数调用的节点替换成add函数中的加法节点
* 第二将加法节点中的x和y两个形式参数替换成inline函数里的两个实际参数。
总的来说,整个算法都是去**做节点的替换和重新连接**,思路还是很清晰的。
我们之前说过编译器在做了一种优化以后经常可以给其他优化制造机会。在这里内联优化不仅仅减少了函数调用导致的开销它还会导致一些其他优化。比如说我们在Inline函数里调用add函数的时候传入两个参数x和-x如下面的示例代码
```plain
//内联
function inline2(x:number):number{
return add(x, -x);
}
function add(x:number, y:number):number{
return x + y;
}
```
那么内联之后这里就相当于计算x+(-x)的值那也能计算出一个常量0。至于如何把x+(-x)化简成0我先留个悬念你先自己思考一下我们这节课后面会介绍到。
```plain
//内联
function inline(x:number):number{
return x + (-x); //常量0
}
```
再比如我们在主函数里调用add的时候传的参数是常量。那么内联以后我们就可以进行常量传播和常量折叠的优化在编译期就能计算出结果为5
```plain
//内联
function inline2(x:number):number{
return add(2, 3);
}
function add(x:number, y:number):number{
return x + y;
}
```
当然了,内联优化是最常见的一种过程间优化。除了内联优化之外,过程间的逃逸分析也值得拿出来单独说一下。
## 逃逸分析
对于像Java这样的面向对象语言来说逃逸分析是经常被采用的技术。我在《编译原理实战课》的[第15节](https://time.geekbang.org/column/article/257504)曾经专门讨论了Java JIT编译器中的逃逸分析技术。
简单来说,逃逸分析的目的,是**分析一个对象的生命期有没有超过函数的生存期**从而进行一些优化。比如下面这段代码中我们声明了一个Rectangle类它有width和height的属性并且有一个方法能够计算面积。然后在一个foo函数中我们创建了一个Rectangle对象并且为了调用它的area方法计算出面积。
```plain
class Rectangle{
width:number;
height:number;
constructor(width:number, height:number){
this.width=width;
this.height = height;
}
area():number{
return this.width*this.height;
}
}
function foo(width:number, height:number){
let rect = new Rectangle(width, height);
return rect.area();
}
```
分析foo的代码你会发现rect的生存期始终在foo函数之内。这个时候我们说rect对象没有逃逸。当foo函数返回时这个对象也就没有用了。因为这个特点所以我们可以做三个方面的优化
**第一个优化是栈上内存分配。**通常我们是在堆里为对象申请内存的然后在某个时机用垃圾回收程序来回收。但rect对象的生存期小于foo函数的生存期也就是说在foo函数返回以后也不会有其他的程序来访问这个对象所以这个对象所需要的内存直接在栈上申请就行了。这样当函数退出的时候rect对象的内存也就直接被回收不需要通过GC回收了内存管理的性能就更高了。
**第二个优化是标量替换。**也就是把对象的属性拆散变成width和height两个本地变量。这样它们就可以被放到寄存器里而不是非要通过内存来访问从而提高了性能。
**第三个优化是锁消除或者同步消除。**在编写并发程序的时候我们需要用锁来做线程的同步从而避免多个线程同时访问某个对象而引起数据的混乱。而且因为rect对象没有逃逸出函数体也就是说它注定只能被一个线程访问所以我们对rect对象的访问也就不需要做线程间的同步这也就消除了由于同步而引起的性能开销。
好,上面就是对逃逸分析的概要介绍。**逃逸分析可以基于单个函数或方法,也可以跨域多个函数来分析,从而做出优化。**
比如基于上面的例子我又写了两个新的函数。其中函数biggerThan能够接受两个Rectangle对象并比较它们面积的大小。在bar函数里我创建了两个Rectangle对象并调用了biggerThan函数。
```plain
function bar(w1, h1, w2, h2):booelan{
let rect1 = new Rectangle(w1, h1);
let rect2 = new Rectangle(w2, h2);
return biggerThan(rect1, rect2);
}
function biggerThan(rect1:Rectangle, rect2:Rectangle):boolean{
return rect1.area()>rect2.area;
}
```
你用肉眼就可以看出虽然rect1和rect2从bar函数传递了出去传给了biggerThan函数但它们并没有继续从biggerThan函数往外逃逸。所以如果我们把这两个函数看做一个整体那么rect1和rect2对象仍然是没有逃逸的。
所以呢,你仍然可以运用这个分析结果,来实现一些优化,比如说实现栈上内存分配和同步消除的优化。
**那了解了逃逸分析的作用以后我们如何基于当前的IR来实现逃逸分析呢**
经典的逃逸分析采用一种叫做**连接图**Connection Graph的算法。简单地说就是分析出程序中对象之间的引用关系。整个分析算法是建立在这样一种直觉认知上的基于一个连接图也就是对象之间的引用关系图如果 A 引用了 B而 A 逃逸了那么也就意味着B逃逸了。也就是说**逃逸是有传染性的**。
而基于当前的IR我们可以很方便地得到上面说的的连接图有利于我们分析对象逃逸的情况。当然单个的对象是很容易分析的。不过即使是多个对象之间存在关联也能够在数据流中体现出来。我们可以看下这个例子
```plain
let person = new Person();
person.name = "Richard";
```
在这里我们给person的name属性做了赋值。这样person对象就跟一个string对象建立了关联。如果person对象逃逸到了函数或方法之外那么该string对象也跟随着逃逸。反之那么这两个对象都没有逃逸那都可以在栈上申请内存。
表达上面两个对象之间关系的IR如下图。基于这个IR我们就能得到对象之间的关联了
![](https://static001.geekbang.org/resource/image/63/da/63d9f1873a03274000f3d66925e734da.jpg?wh=428x282)
好了到目前为止我们已经分析了基于IR在本地、全局和过程间做优化的一些场景。在实际的编译器中我们还会实现很多的场景比如把循环拉直调整内循环和外循环把用不到的分支去掉等等。但这些优化的本质都是基于控制流和数据流的分析对IR图进行修改。你把握住这个关键点就行了。
最后我再讲一个Java的JIT编译器Graal中经常见到的一种优化方法作为这三节课的结尾这个优化方法就是规范化。
## 规范化Canonicalize
在《编译原理实践课》中我曾经对Graal编译器做过剖析。如果你跟踪Graal的编译过程你会发现它的编译过程中会涉及100多个pass也就是用各种算法对一个IR图处理了一百多遍。其中很多pass都是我们已经讲过的比如死代码删除、逃逸分析等等。但还有一个使用频率很高的优化方法叫做Canonicalizer也就是规范化。
那什么是规范化呢?规范化就是**针对各种计算节点所做的化简操作**。比如对于减法运算如果减法两边的Node是同一个节点那我们就可以计算出常量0来
```plain
let y = x - x; //规范化结果为0。
```
而对于加法运算如果两个其中一个节点是另一个节点取负值那也可以化简结果为0。
```plain
let y = x + (-x); //规范化结果为0。
```
其他类似的规范化操作还包括:
* 对于a+0、a-0、0+a可以化简成a 0-a化简为-a
* 对于(a - b) + b、b + (a - b)、(a+b)-b可以化简为a(a+b)-a化简为b(a-b)-a化简为-b;
* 对于(a + 1) + 2可以化简为a+3
* a + (-b)可以化简为a-b-a+b则化简为b-a。
刚才列出的这些都属于加法和减法运算。类似的是对于其他运算我们也都可以进行化简或者变形。比如对于a_2或a_4规范化后会变成移位运算。
你也可以想象出来上述规范化的操作基于我们现在的IR实现起来都不是很复杂我们仍然只要对图中的节点进行模式的匹配和修改就行了。
总的来说上述化简操作属于算术化简和符号运算的范畴。比如把1+ 2化简成3这属于算术化简基于算术运算的规则就行。而把(a - b) + b化简成a这就属于符号运算的范畴也就是说运算的对象不再是具体的数字而是像代数里的一个个变量。
符号运算是从编译原理衍生出来的一项技术。一些数学软件包,具备基于符号进行运算的能力。比如,你输入一个复杂的公式,它能够把它化简成一个简单的公式。或者你输入一个命题,它能给你推理,并证明该命题是真还是假。
我自己特别关注的是对关系运算和逻辑运算的化简,比如(!a || !b)会被化简成!(a&&b)a || false会被化简成aa || true会被化简成ture等等。我对它们比较关注的原因是想把它们借鉴进类型运算中。比如变量a的类型声明为string|null如果再加上一个条件if(a)那么if块中a的类型就会被窄化成string
```plain
let a: string|null;
//some code
if(a){
//现在a的类型肯定是string。
}
```
之前我们也学习了类型窄化目前在我们这门课中实现的类型窄化的算法都是采用集合运算的规则进行处理的。如果我们在这里面加入符号运算那么我们再进行类型窄化就会变得更简单。我会在开源版本的PlayScript中再进行算法的迭代你感兴趣的话可以继续关注。
## 课程小结
这节课的内容就是这些了。在这节课里,我通过内联优化和过程间的逃逸分析,让你建立对过程间优化的直观认识。对于这节课的重点,我们再重新回顾一下:
首先内联优化的实质是把两个函数的IR拼接在一起把形参节点替换成实参节点。内联优化通常还会为其他优化创造出机会。
第二逃逸分析的作用在于一旦我们确定出某个对象并没有逃逸那么就可以实现栈上内存分配、标量替换和同步消除的优化。通过跨越多个函数进行分析我们可以发现出更多的对象是没有逃逸的从而可以做更多优化。逃逸分析也可以基于IR图来进行。
最后,除了过程间优化,我在这节课还补充了一个规范化方面的知识点。规范化主要是针对各种运算节点,实现符号化简和代数化简。其中的符号运算方面的技术,值得我们关注,它会帮助我们更好地进行类型的处理。
关于基于IR做中端优化我们就介绍这些。下一节课我们将介绍如何把这些IR做Lower处理并最终生成汇编代码。在这个过程中我们仍然需要用到一些优化技术。
## 思考题
在讲解中端优化的内容中我们发现受益于基于图的IR的优点实现很多优化算法都变得更简单了。但是事物往往是平衡的有一方面的优点往往就会带来另一方面的缺点。那么你能不能分析一下这种基于图的IR会让哪些操作反而变得更复杂呢
欢迎你基于对图这种数据结构的认识来发表一下观点。这种分析,会让你从更高的高度来审视对数据结构设计的取舍。
欢迎你把这节课分享给更多感兴趣的朋友。我是宫文学,我们下节课见。
## 资源链接
[这节课的示例代码目录在这里!](https://gitee.com/richard-gong/craft-a-language/tree/master/39-40)