编译器-代码优化

两个维度

第一个分类维度,是机器无关的优化与机器相关的优化。

机器无关的优化与硬件特征无关,比如把常数值在编译期计算出来(常数折叠)。而机器相关的优化则需要利用某硬件特有的特征,比如 SIMD 指令可以在一条指令里完成多个数据的计算。

第二个分类维度,是优化的范围。本地优化是针对一个基本块中的代码,全局优化是针对整个函数(或过程),过程间优化则能够跨越多个函数(或过程)做优化。

本地优化是针对一个基本块中的代码,全局优化是针对整个函数(或过程),过程间优化则能够跨越多个函数(或过程)做优化。

 

 

思路 1:把常量提前计算出来

程序里的有些表达式,肯定能计算出一个常数值,那就不要等到运行时再去计算,干脆在编译期就计算出来,比如 “x=2*3”可以优化成“x=6”。这种优化方法,叫做常数折叠(Constant Folding)

而如果你一旦知道 x 的值其实是一个常量,那你就可以把所有用到 x 的地方,替换成这个常量,这叫做常数传播(Constant Propagation)。如果有“y=x*2”这样一个语句,那么就能计算出来“y=12”。所以说,常数传播会导致更多的常数折叠。

“z=a+x”,替换成“z=a+6”以后,计算速度也会更快。因为对于很多 CPU 来说,“a+x”和“a+6”对应的指令是不一样的。前者可能要生成两条指令(比如先把 a 放到寄存器上,再把 x 加上去),而后者用一条指令就行了,因为常数可以作为操作数。


a = 2
b = 3
if(a<b){  //判断语句去掉
  ...     //直接执行这个代码块
}
else{
  ...     //else分支会去掉
}

 

 

思路 2:用低代价的方法做计算

比如“x=x*0” 可以简化成“x=0”。这类利用代数运算的规则所做的简化,叫做代数简化(Algebra Simplification)。

对于很多 CPU 来说,乘法运算改成移位运算,速度会更快。比如,“x*2”等价于“x<<1”,“x*9”等价于“x<<3+x”。这种采用代价更低的运算的方法,也叫做强度折减(Strength Reduction)。

 

思路 3:消除重复的计算


x := a + b
y := x
z := 2 * y

第三行可以被替换成“z:=2*x”, 因为 y 的值就等于 x。这个时候,可能 x 的值已经在寄存器中,所以直接采用 x,运算速度会更快。这种优化叫做拷贝传播(Copy Propagation)。

 

值编号是把相同的值,在系统里给一个相同的编号,并且只计算一次即可。

值编号(Value Numbering)也能减少重复计算。


w := 3
x := 3
y := x + 4
z := w + 4

其中 w 和 x 的值是一样的,因此编号是相同的。这会进一步导致 y 和 z 的编号也是相同的。进而,它们可以简化成:


w := 3
x := w
y := w + 4
z := y

 

值编号又可以分为两种,本地值编号(在一个基本块中)和全局值编号(GVN,在一个函数范围内)。

 

 

还有一种优化方法叫做公共子表达式消除(Common Subexpression Elimination,CSE),也会减少计算次数。


x := a + b
y := a + b

 

那我们就可以让 y 等于 x,从而减少了一次对“a+b”的计算,这就是公共子表达式消除。


x := a + b
y := x

 

 

部分冗余消除(Partial Redundancy Elimination,PRE),是公共子表达式消除的一种特殊情况。

 


if (some_condition) {
   // some code that does not alter x
   y = x + 4;
 }
 else {
   // other code that does not alter x
 }
 z = x + 4;

 

优化为:


if (some_condition) {
   // some code that does not alter x
   t = x + 4;
   y = t;
 }
 else {
   // other code that does not alter x
   t = x + 4;
 }
 z = t;

 

思路 4:化零为整,向量计算

很多 CPU 支持向量运算,也就是 SIMD(Single Instruction Multiple Data)指令。这就可以在一条指令里计算多个数据。

向量优化的一个例子是超字级并行(Superword-Level Parallelism,SLP)。它是把基本块中的多个变量组成一个向量,用一个指令完成多个变量的计算。

向量优化的另一个例子是循环向量化(Loop Vectorization)。

 

 

思路 5:化整为零,各个优化

另一个思路是反着的,是化整为零。

很多语言都有结构和对象这样的复合数据类型,内部包含了多个成员变量,这种数据类型叫做聚合体(aggregates)

通常,为这些对象申请内存的时候,是一次就申请一整块,能放下里面的所有成员。但这样做,非常不利于做优化。

通常的优化算法都是针对标量(Scalar)的。如果经过分析,发现可以把聚合体打散,像使用单个本地变量(也就是标量)一样使用聚合体的成员变量,那就有可能带来其他优化的机会。比如,可以把聚合体的成员变量放在寄存器中进行计算,根本不需要访问内存。

这种优化叫做聚合体的标量替换(Scalar Replacement of Aggregates,SROA)。在研究 Java 的 JIT 编译器时,会有。

 

思路 6:针对循环,重点优化

在编译器中,对循环的优化从来都是重点,因为程序中最多的计算量都是被各种循环消耗掉的

对循环做优化,有很多种方法

第一种:归纳变量优化(Induction Variable Optimization)。看下面这个循环,其中的变量 j 是由循环变量派生出来的,这种变量叫做该循环的归纳变量。归纳变量的变化是很有规律的,因此可以尝试做强度折减优化。

 


int j = 0;
for (int i = 1; i < 100; i++) {
    j = 2*i;  //2*i可以替换成j+2
}
return j;

 

第二种:边界检查消除(Unnecessary Bounds-checking Elimination)。

 

当引用一个数组成员的时候,通常要检查下标是否越界。在循环里面,如果每次都要检查的话,代价就会相当高(例如做多个数组的向量运算的时候)。如果编译器能够确定,在循环中使用的数组下标(通常是循环变量或者基于循环变量的归纳变量)不会越界,那就可以消除掉边界检查的代码,从而大大提高性能。

 

 

第三种:循环展开(Loop Unrolling)

把循环次数减少,但在每一次循环里,完成原来多次循环的工作量。


for (int i = 0; i< 100; i++){
  sum = sum + i;
}

优化后:


for (int i = 0; i< 100; i+=5){
  sum = sum + i;
  sum = sum + i + 1;
  sum = sum + i + 2;
  sum = sum + i + 3;
  sum = sum + i + 4;
}

 

sum = sum + i*5 + 10

减少循环次数,本身就能减少循环条件的执行次数。同时,它还会增加一个基本块中的指令数量,从而为指令排序的优化算法创造机会。

 

第四种:循环向量化(Loop Vectorization)。

在循环展开的基础上,我们有机会把多次计算优化成一个向量计算

第五种:重组(Reassociation)。


for (i = 0; i< M; i++){
  for (j = 0; j<N; j++){
    a[i,j] = b + a[i,j];
  }
}

 

优化后:
for (i = 0; i< M; i++){
  t=a+i*N;
  for (j = 0; j<N; j++){
    *(t+j) = b + *(t+j);
  }
}

 

第六种:循环不变代码外提(Loop-Invariant Code Motion,LICM)。

第七种:代码提升(Code Hoisting,或 Expression Hoisting)。


  if (x > y)
    ...
    z = x + y
    ...
  }
  else{
    z = x + y
    ...
  }

f 结构中,then 块和 else 块都有“z=x+y”这个语句,它可以提到 if 语句的外面。

 

思路 7:减少过程调用的开销

当程序调用一个函数的时候,开销是很大的,比如保存原来的栈指针、保存某些寄存器的值、保存返回地址、设置参数,等等。其中很多都是内存读写操作,速度比较慢。所以,如果能做一些优化,减少这些开销,那么带来的优化效果会是很显著的,具体的优化方法主要有下面几种。

 

第一种:尾调用优化(Tail-call Optimization)和尾递归优化(Tail-recursion Elimination)。

 

尾调用就是一个函数的最后一句,是对另一个函数的调用


f(){
  ...
  return g(a,b);
}

而如果 g() 本身就是 f() 的最后一行代码,那么 f() 的栈帧已经没有什么用了,可以撤销掉了(修改栈顶指针的值),然后直接跳转到 g() 的代码去执行,就像 f() 和 g() 是同一个函数一样。这样可以让 g() 复用 f() 的栈空间,减少内存消耗,也减少一些内存读写操作(比如,保护寄存器、写入返回地址等)。

如果 f() 和 g() 是同一个函数,这就叫做尾递归

尾递归是可以转化为一个循环的。

左递归文法为右递归文法的时候,就曾经用循环代替了递归调用。尾递归转化为循环,不但可以节省栈帧的开销,还可以进一步导致针对循环的各种优化。

 

第二种:内联(inlining)。

内联也叫做过程集成(Procedure Integration),就是把被调用函数的代码拷贝到调用者中,从而避免函数调用。

我们现在使用的面向对象的语言来说,有很多短方法,比如 getter、settter 方法。这些方法内联以后,不仅仅可以减少函数调用的开销,还可以带来其他的优化机会。

 

第三种:内联扩展(In-Line Expansion)。

内联扩展跟普通内联类似,也是在调用的地方展开代码。不过内联扩展被展开的代码,通常是手写的、高度优化的汇编代码。

第四种:叶子程序优化(Leaf-Routine Optimization)。

叶子程序,是指不会再调用其他程序的函数(或过程)。因此,它也可以对栈的使用做一些优化。比如,你甚至可以不用生成栈帧,因为根据某些调用约定,程序可以访问栈顶之外一定大小的内存。这样就省去了保存原来栈顶、修改栈顶指针等一系列操作。

 

思路 8:对控制流做优化

 

对程序的控制流分析,我们可以发现很多优化的机会。这就好比在做公司管理,优化业务流程,就会提升经营效率。

 

第一种:不可达代码消除(Unreacheable-code Elimination)。根据控制流的分析,发现有些代码是不可能到达的,可以直接删掉,比如 return 语句后面的代码。

第二种:死代码删除(Dead-code Elimination)。通过对流程的分析,发现某个变量赋值了以后,后面根本没有再用到这个变量。这样的代码就是死代码,就可以删除。

第三种:If 简化(If Simplification)。在讲常量传播时我们就见到过,如果有可能 if 条件肯定为真或者假,那么就可以消除掉 if 结构中的 then 块、else 块,甚至整个消除 if 结构。

第四种:循环简化(Loop Simplification)。也就是把空循环或者简单的循环,变成直线代码,从而增加了其他优化的机会,比如指令的流水线化。

第五种:循环反转(Loop Inversion)。这是对循环语句常做的一种优化,就是把一个 while 循环改成一个 repeat…until 循环(或者 do…while 循环)。这样会使基本块的结构更简化,从而更有利于其他优化。

第六种:拉直(Straightening)。如果发现两个基本块是线性连接的,那可以把它们合并,从而增加优化机会。

第七种:反分支(Unswitching)。也就是减少程序分支,因为分支会导致程序从一个基本块跳到另一个基本块,这样就不容易做优化。比如,把循环内部的 if 分支挪到循环外面去,先做 if 判断,然后再执行循环,这样总的执行 if 判断的次数就会减少,并且循环体里面的基本块不那么零碎,就更加容易优化。

 

七种优化方法,都是对控制流的优化,有的减少了基本块,有的减少了分支,有的直接删除了无用的代码。

 

 

控制流分析(Control-Flow Analysis,CFA)。控制流分析是帮助我们建立对程序执行过程的理解,比如哪里是程序入口,哪里是出口,哪些语句构成了一个基本块,基本块之间跳转关系,哪个结构是一个循环结构(从而去做循环优化),等等。

 

数据流分析(Data-Flow Analysis,DFA)数据流分析,能够帮助我们理解程序中的数据变化情况。

做变量活跃性分析以外,数据流分析方法还可以做很多有用的分析。比如,可达定义分析(Reaching Definitions Analysis)、可用表达式分析(Available Expressions Analysis)、向上暴露使用分析(Upward Exposed Uses Analysis)、拷贝传播分析(Copy-Propagation Analysis)、常量传播分析(Constant-Propagation Analysis)、局部冗余分析(Partial-Redundancy Analysis)等。

 

依赖分析(Dependency Analysis)。依赖分析,就是分析出程序代码的控制依赖(Control Dependency)和数据依赖(Data Dependency)关系。这对指令排序和缓存优化很重要。

 

指令排序能通过调整指令之间的顺序来提升执行效率。但指令排序不能打破指令间的依赖关系,否则程序的执行就不正确。

 

别名分析(Alias Analysis)。在 C、C++ 等可以使用指针的语言中,同一个内存地址可能会有多个别名,因为不同的指针都可能指向同一个地址。编译器需要知道不同变量是否是别名关系,以便决定能否做某些优化。

 

有些优化,比如对循环的优化,对每门语言都很重要,因为循环优化的收益很大。而有些优化,对于特定的语言更加重要。在课程后面分析像 Java、JavaScript 这样的面向对象的现代语言时,你会看到,内联优化和逃逸分析的收益就比较大。而对于某些频繁使用尾递归的函数式编程语言来说,尾递归的优化就必不可少,否则性能损失太大。

 

 

至于优化的顺序,有的优化适合在早期做(基于 HIR 和 MIR),有的优化适合在后期做(基于 LIR 和机器代码)。并且,你通过前面的例子也可以看到,一般做完某个优化以后,会给别的优化带来机会,所以经常会在执行某个优化算法的时候,调用了另一个优化算法,而同样的优化算法也可能会运行好几遍。

 

 

 

 

 

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值