编译优化之 - 常量传播入门

1. 介绍

  常量传播是现代的编译器中使用最广泛的优化方法之一,它通常应用于高级中间表示(IR)。它解决了在运行时静态检测表达式是否总是求值为唯一常数的问题,如果在调用过程时知道哪些变量将具有常量值,以及这些值将是什么,则编译器可以在编译时期简化常数。
常量传播在优化中的几种用途:

  • 能在编译时求值的表达式不需要在执行时才求值。如果这样的表达式在循环内,则只需要在编译时进行一次求值而节省执行时间。
  • 通过用常量值替换常量变量来修改源程序,这样可以识别然后消除程序的无效代码部分,例如由始终为假的表达式那部分无效代码,从而提高程序的整体效率。
  • 过程的部分参数是常量,减少涉及状态向量的大小可以避免代码的扩展,对于控制状态,我们仅存储非常数变量的值。常数值不需要存储,可以始终通过查看控制状态来检索。
  • 对从未到达的路径的检测简化了项目的控制流程。简化的控制结构可以帮助将程序转换为适合向量化处理的形式或并行处理的形式。

常量传播算法通常有四种:

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

四种常量传播之间关系如下图所示:
0

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

2. 示例

  1. 简单常量传播示例如下:
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;
  1. 按位常数传播示例如下:
int foo(int x, int y) {
    if (y & 4) {
        cout << "11111111111" << endl;
        return x & (y & 1);
    } else {
        cout<<"22222222222"<<endl;
        return x & (y & 2);
    }
}

int fun(int m, int n) {
    return foo(m, 9) | foo(n, 3);
}

优化之后:

int foo(int x, int y) {
    cout << "22222222222" << endl;
    return x & (y & 2);
}

int fun(int m, int n) {
    return foo(m, 9) | foo(n, 3);
}
  1. 稀疏条件常量传播示例如下:
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等其他优化可能会为条件常量传播操作产生机会。而常量折叠是指将具有已知常量值的运算符表达式简化为操作数。

  在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);  
}

3. 控制流

  在构造出basic block并生成CFG后,执行稀疏条件常量传播的第一步是将程序转换为SSA形式,这时每个赋值都是一个唯一变量。经过常量传播替换和dce优化之后,能减少SSA变量的有效范围,这样来自同一原始变量的SSA变量的有效范围将永远不会相互干扰。
  由于采用SSA形式,即分析可以从控制流分支中获取信息,完成分析后,下一步是使用其结果通过用常量替换计算并删除无效代码来实际修改代码。任何无法访问的块都可以简单地从CFG中删除。实际上,如果在别处定义的任何变量仅在已删除的块中使用,则这样做可能会创建更多要删除的无效代码,下一步是用常数值本身替换已知为常数的变量定义中的表达式。同样这也会创建更多的无效代码。因此,最后一步是删除无用的变量定义。此后,程序将转换回SSA格式,然后将CFG展平为单个指令列表。

对于之前使用的这段简单的带控制流代码:

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);

条件常量传播可以确定如上else分支不可访问。

  常量传播算法的输出是lattice values分配给程序中每个节点上的变量。让一个赋值或条件节点中定义或使用的所有变量都由一个lattice element来表示,该lattice element表示算法在执行期间此类变量值的编译时信息。
1
  T是一个尚未确定的值(在程序开始执行的时候没有定值),v是已知常量值的变量,Z表示非常量值,当一个变量的值不是常量时该变量就被映射到NAC(not-a-constant)。程序的每个节点都有称为LatticeCellsy的单元格来于保存lattice element。随着算法的进行,存储在这些元素中的值会发生变化。LatticeCells与表达式的结果和操作数相关联,关联的细节取决于特定的算法。
详细介绍请见:《编译原理》(第二版)405页关于常量传播的数据流值分析。

  其背后的思想是,最初先将条件之外的所有边标记为不可到达;从b0开始,仅沿着被认为可到达的边缘传播常数信息;当布尔表达式b(v1,v2,…)控制条件分支时,使用t(v)映射来评估b(v1,v2,…),该映射标识变量的恒定状态;如果对任意vi,t(vi)=T,则认为所有边缘都暂时无法到达,否则使用t(v)来评估b(v1,v2,…),得到true、false或者Z,但是对于某些vi,t(vi)=Z,它的布尔运算的短路属性也可能会得出true或false;如果b(v1,v2,…)是true或false,则只标记出一条可达的边,否则如果b(v1,v2,…)等于Z,则把所有的边都标记为可达。常量传播只沿着可达的边进行。

如下示例代码,其传播示意如图所示:

i = 1;
done = 0;
while ( i > 0 && ! done) {
  if (i == 1)
  done = 1;
  else i = i + 1; }

2
3
4

4. SSA形式的过程调用

  过程调用有两个好处,一个是将控制权转移到一个过程,然后返回,另一个是创建实例并更改变量的名称。SSA形式很容易对过程调用的控制流进行建模,但是可能需要进行更改以对参数传递的过程进行建模。
  从函数调用返回的值在被调用过程中用作正则表达式值。每个按值返回的调用参数均通过两个赋值语句以SSA形式建模,第一个将实参数分配给形参,第二个将形参分配回实参。每个按引用调用参数都被建模为按值返回参数。

  可以将过程集成(Procedure integration)与常量传播相结合,以实现比单独使用更好的结果,可以节省时间和空间。prepass可以为每个过程创建SSA图,我们只能基于过程中SSA图进行的常量传播来集成那些可执行的语句。


References:
  • https://en.wikipedia.org/wiki/Constant_folding
  • https://www.cs.cornell.edu/courses/cs6120/2019fa/blog/sccp/
  • https://www.sciencedirect.com/topics/computer-science/constant-propagation
  • Constant propagation with conditional branches
  • http://pages.cs.wisc.edu/~fischer/cs701.f14/lectures/L7.pdf
  • 8
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值