【编译器代码优化技术】前期优化中的复制传播、常数折叠与常数传播

一、优化简介

  对于许多高级语言而言,编译器的词法分析(lexical analysis)、语法分析(syntactic analysis or parsing)、静态语义检查(static-semantic validity or semantic checking)、代码生成(code generation)4个阶段可以组合成一遍,从而构成一个快速的一遍编译器,如图1.1所示。这样的编译器对于要求不高的用户是完全可以满足的,它也可作为软件开发环境中进行增量编译的一个选择,目的是在编辑-编译-调试循环中,对程序的修改能快速地进行编译和调试。然而,我们通常不能指望这样的编译器产生非常高效的代码,因此还需要进行代码优化。
在这里插入图片描述

图1.1 一个简单编译器的高层结构
  所谓优化,实质上是对代码进行等价变换,使得变换后的代码运行结果与变换前的代码运行结果相同,而运行速度加快或占用存储空间减少,或两者都有。优化可在编译的不同阶段进行,对同一阶段,涉及的程序范围也有所不同,在同一范围内可进行多种优化。

  一般,优化工作阶段可在中间代码生成之后和(或)目标代码生成之后进行,如图1.2所示。

在这里插入图片描述

图1.2 编译的优化工作阶段

  中间代码的优化是对中间代码进行等价变换。目标代码的优化是在目标代码生成之后进行的,因为生成的目标代码对应于具体的计算机,因此,这一类优化很大程度上依赖于具体的计算机。
  由编译程序提供的对代码的各种变换必须遵循以下原则[1]:

  (1)等价原则。优化后不能改变程序的运行结果。
  (2)有效原则。使优化后产生的目标代码运行时间较短,占用的存储空间
较小。
  (3)合算原则。应尽可能以较低的代价取得较好的优化结果。
  本文讨论在编译过程的较早阶段实行的一些优化技术,包括复制传播、常数传播和常数折叠(或常数表达式求值)。复制传播、常数传播都需要进行数据流分析。常数传播中的稀有条件常数传播与其他优化不同,它们针对的是SSA形式的代码,而其他优化可以应用于几乎任何中级或低级中间代码。常数折叠不需要进行数据流分析,并且最好作为可以在优化的任何阶段根据需要来调用的子程序。在优化过程的较早阶段执行这些优化可以获得较大的好处,但它们在优化的其他阶段也几乎总是有用的。

二、优化编译器的结构

  一个能生成高效目标代码的编译器包含优化器组件。优化编译器的结构主要有两种模式,如图2.1a和b所示。在图2.1a中,源代码被转换成类似LIR的低级中间代码,所有优化都施加于这种低级形式的中间代码,我们称这为优化的低级模式(low-level model)。在图2.1b中,源代码被转换成类似MIR的中级中间代码。对中级中间代码进行的优化主要是体系结构无关的优化,然后中级代码被转换成低级代码,再进一步优化,主要是体系结构相关的优化,我们称此为优化的混合模式(mixed model)。在两种模式中,优化器的各阶段对中间代码进行分析和转换,以消除无用代码和提高任务的执行速度。例如,优化器可能确认循环中的一个计算在每一个迭代中都产生同样的结果,因此将这个计算移到循环外就可提高程序的执行速度。在混合模式中,由所谓的后遍(postpass)优化器来执行低级优化,例如利用目标机特有的指令和寻址模式,而在低级模式中这是由单一的优化器来完成的。
  混合模式的优化器有可能能更好地适应新的体系结构,且编译效率可能更高。而低级模式的优化器可能会难以移植到另一种体系结构,除非第二种体系结构与第一种非常相似,例如,第二种是第一种向上兼容的扩展。选择混合模式还是低级模式主要取决于投资和开发所关注的重点[2]。
在这里插入图片描述

图2.1 优化编译器的两种高层结构: a)低级模式,所有优化都在低级中间代码上完成; b)混合模式,优化分成两个阶段,一个阶段的优化在中级中间代码上进行,另一个阶段的优化在低级中间代码上进行

三、优化技术

3.1 复制传播

  在编译器理论中,复制传播(copy propagation)是将直接赋值目标的出现替换为其值的过程。直接赋值是一种形式为 x = y 的指令,它只是将 y 的值赋给 x。在我们考虑转换时,形如 u=v的赋值表达式被称为复制语句(copy statement),或者简称复制。
  例:为了消除图3.1a中的公共子表达式语句c=d+e,我们必须使用新的变量t来存放d+e的值。在图3.1b中,赋给变量c的是变量t的值,而不是表达式d+e的值。因为控制流可能经过对a的赋值到达语句c=b+e处,也可能经过对b的赋值到达这里,因此把 c=d+e替换为 c=a或c=b都是不正确的。
在这里插入图片描述

图3.1 在公共子表达式消除过程中引入的复制语句

  隐藏在复制传播转换之后的基本思想是在复制语句 u=v 之后尽可能地用v来替代u。比如,图3.2a的基本块B中的赋值语句x=t3是一个复制语句。把复制传播应用于B会生成图3.2b中的代码。这个改变看起来可能不像是一个改进,但是它给了我们消除对x赋值的语句的机会[3]。
在这里插入图片描述

图3.2 进行复制传播转换前(a)后(b)的基本块B

  在计算哪些目标可以被安全替换时,复制传播通常使用到达-定值分析(Reaching-Definition Analysis)、引用-定值链(Use-Definition Chains, UD Chains)和定值-引用链(Definition-Use Chains, DU Chains)。如果可以安全地修改目标的所有upwards exposed uses,那么赋值操作就可以被取消了。
  复制传播是一种有用的“清理”优化,经常在其他编译器程序运行后使用。一些优化——比如消除普通子表达式需要在之后进行复制传播以提高效率。复制传播可以分为局部遍和全局遍来实现,前者在各个基本块之内操作,后者跨整个流图操作;也可以只用单独一个全局遍来实现。

3.2 常数折叠

  常数表达式计算(constant-expression evaluation),或称常数折叠(constant folding),指的是在编译时计算其操作数已知是常数的表达式。在多数情况下这是一种相对容易的转换。在它的最简单形式中,常数表达式计算包括判别表达式的所有操作数是否为常数值,在编译时计算此表达式,以及用计算结果替代该表达式。对于布尔值,总是可以应用这种优化。
  以如下表达式为例:

i = 320 * 200 * 32;

  大多数编译器实际上不会为此语句生成两个乘法指令和一个存储。相反,它们会识别类似这样的结构,并在编译时替代计算值(在本例中是2,048,000)。 常数折叠可以利用算术特性。如果x是数字,即使编译器不知道x的值,0*x的值也为零(请注意,这对IEEE浮点数无效,因为x可能是 Infinity 或 NotANumber。不过,一些偏重性能的语言,例如GLSL允许对常量这样做,这偶尔会导致错误)。
  对于整型常数表达式,大多数情形下都可应用这种优化——但有些情形例外,即当执行这种常数表达式会导致运行时出现异常时,例如,零做除数,以及用其语义要求检测溢出的语言书写的常数表达式可能出现溢出时。在编译时对这种情形进行常数折叠需要判断对于程序可能有的输入,这些表达式在运行时是否实际会执行。如果会执行,可以用产生适当报错信息的代码来替代它们,或者(更可取的)在编译时产生警告信息指出可能发生的错误,或者同时产生这两种信息。 对于地址表达式的特殊情形,常数折叠计算总是值得做并且是安全的——溢出与它们无关。
  对干浮点常数表达式,情况要复杂一些。首先,我们必须保证编译时的浮点运算与目标机的一致,或者,如果不一致的话,编译器要提供适当的模拟器来模拟执行目标机的浮点运算。否则,编译时执行的浮点运算结果可能与运行时执行得到的结果不相同。其次,还存在浮点算术出现异常的问题,并且可能比整数的情况更为严重,因为ANSI/IEEE-754标准规定的异常和异常值类型多于已经实现的任何整数算术运算模式。可能的情形包括无穷值、NaN(非数值值)、非规格化的值,以及可能出现的(需要考虑的)各种异常。任何考虑在优化器中实现浮点常数表达式计算的人都应当阅读ANSI/IEEE-754 1985标准和Goldberg对这个标准非常严谨的解释。
同其他所有与数据流无关的优化一样,常数表达式计算的效果可以通过将它与数据流有关的优化(尤其是常数传播)结合在一起而得到增强。
  常数折叠不仅仅适用于数字,字符串和常量字符串的连接也可以进行常数折叠。像“abc”+“def”这样的代码可以被替换为“abcdef”。
图3.3给出了一个执行常数表达式计算的算法。如果函数Constant(v)的参数是常数,返回true,否则返回false。当opr是二元运算符时,函数Perform_Bin(opr opd1,opd2)计算表达式opd1 opr opd2;当opr是一元运算符时、函数Perform_Un(opr opd)计算表达式opr opd;这两个函数的返回结果都是类型为kind const的MIR操作数。计算是在与目标机的行为完全相同的环境中进行的,即,计算结果必须与运行时执行得出的结果完全一致。常数折叠最好构造成可以随优化需要而调用的子程序[2]。
在这里插入图片描述

图3.3 执行常数表达式计算的算法

3.3 常数传播

  常数传播(constant propagation)是一种转换(或称常量传播),对于给定的关于某个变量x和一个常数c的赋值x←c,这种转换用c来替代以后出现的x的引用,只要在这期间没有出现另外改变x值的赋值。常数传播通常应用于高级中间表示(IR),它解决了在运行时静态检测表达式是否总是求值为唯一常数的问题,如果在调用过程时知道哪些变量将具有常量值,以及这些值将是什么,则编译器可以在编译时期简化常数。
  例如,在图3.4a基本块B1中的赋值b←3将常数3赋给b,并且流图中没有其他对b的赋值。常数传播将此流图转换为图3.4b所示的情形。注意,b的所有出现都已被3替换,但都没有对结果得到的常数表达式进行计算。这是常数表达式计算(见3.2常数折叠)的工作。
在这里插入图片描述

图3.4 a)要传播的常数赋值的例子,即B1中的b←3,b)对它做常数传播后的结果

  对于RISC体系结构,常数传播尤其重要,因为它将小整数移到使用它们的地方。所有RISC机器都提供使用小整数作为操作数的指令(“小”的定义随体系结构不同而变化)。如果知道一个操作数是这种小整数,就可以生成更有效的代码。此外,有些RISC机器(如MIPS)有使用一个寄存器和一个小常数之和的寻址方式,但没有使用两个寄存器之和的寻址方式;将常数值传播到这种地址结构既节省了寄存器,也节省了指令。更一般的是,常数传播减少了过程需要的寄存器个数、并增加了其他若干优化的效果,这些优化包括常数表达式计算、归纳变量优化,以及基于依赖关系分析的那些转换。
  常量传播在优化中的几种用途:
  (1)能在编译时求值的表达式不需要在执行时才求值。如果这样的表达式在循环内,则只需要在编译时进行一次求值而节省执行时间。
  (2)通过用常量值替换常量变量来修改源程序,这样可以识别然后消除程序的无效代码部分,例如由始终为假的表达式那部分无效代码,从而提高程序的整体效率。
  (3)过程的部分参数是常量,减少涉及状态向量的大小可以避免代码的扩展,对于控制状态,我们仅存储非常数变量的值。常数值不需要存储,可以始终通过查看控制状态来检索。
  (4)对从未到达的路径的检测简化了项目的控制流程。简化的控制结构可以帮助将程序转换为适合向量化处理的形式或并行处理的形式。

3.3.1 常量传播四种算法概述

  常量传播算法通常有四种:
  第一种算法由Kildall最早设计出,称为简单常量传播(Simple Constant Propagation)。
  简单常量传播示例如下:

int x = 14;
int y = 7 - x / 2;
return y * (28 / x + 2);

  传播x变量将会变成:

int x = 14;
int y = 7 - 14 / 2;
return y * (28 / 14 + 2);

  持续传播,则会变成:(还可以再进一步的消除无用代码x及y来进行最佳化)

int x = 14;
int y = 0;
return 0;

  第二种算法由Reif和Lewis提出,称为稀疏简单常量传播(Sparse Simple Constant Propagation),该算法基于SSA图,由于与SSA图的大小呈线性变化,因此一直未得到广泛使用。
  第三种算法是Wegbreit算法的一种变体,称为条件常量传播(Conditional Constant Propagation),可以发现所有常量,这些常量可以通过使用所有常量操作数计算所有条件分支来找到,但是它使用相同的输入数据结构,并且渐近于简单常量传播。该算法不能进行死代码消除。
  第四种算法可以更精确地传播常数及移除无用代码,称之为稀疏条件常量传播(Sparse Conditional Constant Propagation),它可以检测程序中始终会计算为固定值的变量和表达式,并在编译时而不是在运行时计算其值。它与传统的常量传播不同,它依靠静态单一分配(SSA)形式来提高分析稀疏的效率,并且具有检测由于恒定分支条件而永远不会执行的控制流边缘的能力。
  四种常量传播之间关系如下图所示[4]:
在这里插入图片描述

图3.5 四种常量传播之间的关系

  一些编译器在基本块内执行常量传播或在更复杂的控制流中执行恒定传播,很少有编译器通过位域分配执行常量传播或通过指针分配对地址常量执行常量传播。

3.3.2 稀疏条件常量传播

  我们在这里描述稀疏条件常量传播(Sparse Conditional Constant Propagation),因为它是更有效的一种。这种常数传播方法相对传统方法有两个主要的优点:它可以由条件推导出有关信息,并且也更为有效。
  先看一个简单示例:

int a = 30;
int b = 9 - a / 5;
int c;
c = b * 4;
if (c > 10) {
    c = c - 10;
}else{
    c = 100;
}
return c * (60 / a);

  传播之后:

int a = 30;
int b = 3;
int c;
c = 12;
if (true) {
    c = 2;
}else{
    c = 100;
}
return c * 2;

  编译器对a和b执行死代码消除之后:

int c;
if (true) {
    c = 2;
}else{
    c = 100;
}
return c * 2;

  编译器再次执行传播和消除操作:

return 4;

  常量传播除了以上示例中能对常数折叠之外,还能对代码中的相同函数进行折叠,以减小代码大小。其行为类似于Gold Linker的ICF优化。启用链接时优化后,该优化将更有效地工作。
  由于常量传播是一个全局的流分析问题(前向数据流),它的传播具有单调性。如果函数变量是常量,则inlining等其他优化可能会为条件常量传播操作产生机会。而常数折叠是指将具有已知常量值的运算符表达式简化为操作数。
  下面具体描述稀有条件常数传播的执行过程。为了执行稀有条件常数传播,我们必须首先将流图转换为SSA形式,但有一个额外的附带条件,即每一个结点只含有一种运算或四函数。我们使用迭代的必经边界方法将流图转换为最小SSA形式,并划分基本块为每个结点一条指令,然后对每一个变量引入一条将它的惟一定义连接到它的每一个使用的SSA边。这些工作使得信息的传播可以与程序的控制流无关。
  然后,我们利用流图的边和SSA边来传递信息实现程序的符号执行。在处理过程中,仅当结点的执行条件满足时,我们才标志它们是可执行的,并且在每一步我们只处理那些可执行的结点,以及那些其SSA前驱已经被处理过的结点——这就是该方法为什么是符号执行,而不是数据流分析的原因。我们使用图3.6画出的格,其中每一个Ci是一个可能的常数值,包含true和false是为了提供关于条件表达式结果的格值。若ValType表示集合{false,…,C-2,C-1,C0,C1,C2,…,true),则这个格叫做ConstLat。对于程序中的每一个变量,我们在流图中定义这个变量的惟一结点的出口处给它相连一个格值。给一个变量赋予值┬意味着它有一个还未确定的常数值,而┴则意味着不是常数,或不能确定是常数。我们用┬初始化所有的变量。
在这里插入图片描述

图3.6 常数传播格ConstLat

  为了包含函数,我们用ICAN扩充MIR的指令表示,如下所示∶

V a r N a m e 0 ← ∅ ( V a r N a m e 1 , ⋅ ⋅ ⋅ , V a r N a m e n ) < k i n d : p h i a s g n , l e f t : V a r N a m e 0 , v a r s : [ V a r N a m e 1 , ⋅ ⋅ ⋅ , V a r N a m e n ] > VarName_0\leftarrow\varnothing(VarName_1,···,VarName_n) \\<kind:phiasgn,left:VarName_0,vars:[VarName_1,···,VarName_n]> VarName0(VarName1,,VarNamen)<kind:phiasgn,left:VarName0,vars:[VarName1,,VarNamen]>

并定义Exp_Kind(phiasgn)=listexp和Has_Left(phiasgn)=true。
  我们使用两个函数Visit_Phi()和Visit_Inst()来处理流图的结点。其中第一个函数以一种有效的方式执行格值上Φ的函数,后一个函数对原来的语句做同样的事。
  执行稀有条件常数传播的代码是图3.7给出的Sparse_Cond_Const()。这个算法使用两个工作表FlowWL和SSAWL,FlowWL存放需要处理的流图边,SSAWL存放需要处理的SSA 边。数据结构ExecFlag(a,b)记录流图边a → b是否是可执行的。对于每一个SSA形式的变量v,存在着一个格点LatCell(v),它记录在定义变量v的结点的出口处与这个变量相连的格元素。函数SSASucc(n)记录结点n的SSA后继边,即从结点n出发的SSA边。
在这里插入图片描述

图3.7基于SSA的稀有条件常数传播算法

  稀有条件常数传播的时间复杂度与流图的边数和SSA边数有关,因为每一个变量的值在这种格中只能降低两次,因此,计算时间复杂度是 ,其中 是SSA边集合,在最坏的情况下,这个值是结点个数的平方,但在实际中它几乎总是线性的。

3.3.3 GCC关于常量传播的优化操作

  在GCC中关于常量传播的优化操作通常是由:-fdevirtualize、-fipa-cp、-fipa-cp-clone、-fipa-bit-cp、-fipa-vrp、-ftree-bit-ccp、-ftree-ccp、-ftree-dominator-opts、-ftree-vrp这些选项控制,他们都是默认情况下在-OS,-O2或-O3中启用。
  一般只有少数函数调用会将常量作为参数传递,或者出现常量相互冲突的情况,因此无法将其传播到被调用的函数中。在GCC中通过克隆被调用函数来处理此问题,以使每个冲突的调用都能获得自己的版本。但是由于此优化可以创建多个函数副本,因此可能会大大增加代码大小。
  GCC函数克隆示例如下:

int fun(int x, int y){
  if (y > 10)
    return x + y;
  else
    return x * y;
}
void bar(int m, int n){
   fun(m, 100) + fun(m, n);  
}

  经过函数克隆操作之后:

int fun(int x, int y){
  if (y > 10)
    return x + y;
  else
    return x * y;
}
int fun_clone(int x){
  return x + 100;
}
void bar(int m, int n){
   fun_clone(m) + foo(m, n);  
}

总结

  在计算机领域,优化编译器是一种试图使可执行计算机程序的某些属性最小化或最大化的编译器。常见的要求是最大限度地减少程序的执行时间、内存占用、存储大小和功耗(后三项在便携式计算机中很流行)。
  编译器优化通常使用一系列优化转换来实现,这些算法将一个程序进行转换,以产生一个语义等同的输出程序,使得该程序消耗更少的资源或执行得更快。我们已经证明,一些代码优化问题是NP完全问题,甚至是不可判定的。在实践中,程序员是否愿意等待编译器完成其任务等因素对编译器可能提供的优化设置了上限。优化通常是一个非常占用CPU和内存的过程。过去,计算机内存限制也是限制能够执行哪些优化的主要因素。
由于这些因素,优化很少会产生任何意义上的“最佳”输出。事实上,“优化”在某些情况下可能会阻碍性能。相反,它们是改进典型程序中资源使用的启发式方法。
  20世纪60年代的早期编译器通常主要关注的是正确或有效地简单地编译代码,因此编译时间是一个主要关注点。到20世纪80年代末,优化的编译器已经足够有效,以至于用汇编语言编程的情况减少。这与RISC芯片的发展和先进的处理器功能(如指令调度和推测执行)共同发展,这些功能被设计成由优化编译器而不是由人类编写的汇编代码来实现。
  现代编译器仍然使用几十年前存在的技术构建。这些包括用于词法分析、解析、数据流分析、数据依赖性分析、向量化、寄存器分配、指令选择和指令调度的基本算法和技术。目前,有研究论文将优化技术与神经网络相结合,对编译技术进行现代化改造。例如,Saman Amarasinghe提出的SLP向量化改进算法[5],实现了比 LLVM 的 SLP 实现7.58%的几何平均性能提升。
  虽然编译领域的工作已经使计算科学发生了沧海桑田般的变化,但我们依然面临为数众多的难题,现在又出现了许多新的挑战。这些未解决的编译器挑战(例如怎样提高并行编程的抽象层次,开发安全而健壮的软件,以及验证整个软件栈)在实际应用中占据着非常重要的地位,同时也是当今计算机科学中最具挑战性的难题。为了解决这些问题,编译领域必须开发新的技术,使得编译器得到进一步发展。

参考文献:
[1] 姜淑娟,张辰,刘兵编著.编译原理及实现[M].北京:清华大学出版社.2016.[Z].
[2] MUCHNICK SS. Advanced compiler design and implementation[M]. San Francisco, Calif: Morgan Kaufmann Publishers, 1997.
[3] AHO A V, SETHI R, ULLMANJ D. Compilers, principles, techniques, and tools[M]. Reading, Mass: Addison-Wesley Pub. Co, 1986.
[4] WEGMAN N, KENNETH F, WATSON I T J.Constant Propagation with Conditional Branches[J]: 30. .
[5] AMARASINGHE S. Compiler 2.0: Using Machine Learning to Modernize Compiler Technology[C/OL]//The 21st ACM SIGPLAN/SIGBED Conference on Languages, Compilers, and Tools for Embedded Systems. London United Kingdom: ACM, 2020: 1–2[2021–11–29].
[6] https://blog.csdn.net/qq_36287943/article/details/104974597

  • 3
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值