公共表达式提取

背景

一般而言程序员不会有意地编写重复的表达式,但是中间代码生成的过程中,因为是直接把源语言的各种高层特性翻译成中间代码,所以中间代码中可能会产生一些冗余,例如在decaf中,如果连续多次在一个对象上调用虚函数:

  obj.foo();
  obj.bar();

大致的中间代码可能为(只是大概表示一下意思):

  a = *(obj + 0)
  b = *(a + offset_foo)
  parm obj
  call b
  c = *(obj + 0) # 公共表达式!
  d = *(b + offset_bar)
  parm obj
  call d

这样的公共表达式并非程序员主动写出来的,程序员也不可能通过目前的任何decaf语言特性来消除这样的重复计算,这样的优化只适合交给编译器来做。

公共表达式提取的优化目标是减少这样重复的计算,对于某个计算的右端项,如果它已经在前面出现过,那么就可能不用再计算一次,可以直接复用之前的结果。

值得注意的是,有一种优化手段叫做部分冗余消除(pre, partial redundancy elimination),公共表达式提取可以看作部分冗余消除的一种特殊情况。在实际的编译器中,往往直接实现了部分冗余消除,而不会有专门的公共表达式提取的优化。然而部分冗余消除的实现也更复杂,总共涉及四种数据流分析,感兴趣的同学可以自行阅读龙书的相关章节,我们这个简单的编译器就不去做这么复杂的优化了。

算法

公共表达式提取中的"前面"如何定义?从流图的角度来说就是从起始语句到这条语句中,任意一条路径都会做这个计算。为什么"前面"有了计算结果后还只是"可能"不用再计算一次?因为这个计算的operand可能在中途被改变了,从而导致这里的计算会产生和前面的计算不同的结果。

还有一点需要考虑的是,即使看起来operand没有改变,也不能证明之前的结果就仍然有效,这主要与访存指令相关:两条load指令即使基址和偏移都一样,也很难保证中途这个地址没有被别人修改过。幸运的是decaf不允许对于任意变量取地址,所以store指令并不会影响任何一条对虚拟寄存器进行的"纯运算"。

除此之外,框架还对decaf的类型系统做了很简单的利用来尝试减小store指令能够杀死的公共表达式。大家在pa3中应该已经注意到了MemHintCallHint的存在:

pub enum MemHint { Immutable, Obj, Arr }

pub struct CallHint {
  pub arg_obj: bool,
  pub arg_arr: bool,
}

CallHint表示函数的参数中有没有数组或者对象,只要有就保守地认为它会被写。MemHint非常简单地把访存划分成了对于对象和数组的访存,这两种访存是不可能相互干扰的。还有一类访存是"不可变"的,例如对于对象的虚函数表的读取和对虚函数表内容的读取,还有对数组长度的读取,它们不会受到其它store指令的影响。事实上还有更多可以利用的信息:例如对于两种没有继承关系的对象的访存一定不会相互影响,对于任何对象的偏移量不同的访存一定不会相互影响(当然,这些规则在c/c++中不一定成立)...再往下深入涉及到了别名分析的知识,这超出了这门课的要求,所以我们对于访存的优化也就到此为止了。

正如之前的文档中说的,这的确很幼稚,也不可能期望它达到太好的优化效果,大家就把它当成是我的一点小小的尝试就好了,不用太认真

回到公共表达式提取上来,它基于可用表达式(available expression)分析。可用表达式的定义基本上就与我们上面描述的一样:从起点处任何一条路径都会计算且不会被杀死。可用表达式分析是一种前向数据流分析,它的值集是我们关心的所有右端项,例如在实验框架中包括:

enum TacRhs {
  Bin(BinOp, [Operand; 2]),
  Un(UnOp, [Operand; 1]),
  Load([Operand; 1], i32),
}

列举一下一些语句的GenGenKillKill集合,更完整的表可以参考龙书:

语句

GenGen

KillKill

a = b op c

{ b op c } - KillKill

包含a的右端项

a = *(b + imm)

{ *(b + imm) } - KillKill

包含a的右端项

*(b + imm) = a

\emptyset

所有形如*(b + imm)的右端项

call a

\emptyset

所有形如*(b + imm)的右端项

其中"所有形如*(b + imm)的右端项"是最简单的实现方法,是没有把CallHintMemHint考虑在内的。如果把它们考虑在内,则应该只是一部分形如*(b + imm)的右端项。

关于表格中的"Kill- Kill",其意义是类似于x = x + y之类的语句不能生成x + y,注意是不能生成,而不是既生成又杀死,大家应该能理解这个区别。

有了语句级别的GenGenKillKill集合后,容易计算出算出基本块级别的GenGenKillKill集合,然后就可以进行方程求解了,但是还有两个地方需要注意:一是使用的交汇运算符是And而不是Or,这体现了所有路径都有这个计算;二是outout集合的初值应该设置成:

out(Entry)=out(B)=U(BEntry)out(Entry) = \emptyset\\ out(B) = U(B \ne Entry)\\

其中EntryEntry是一个虚拟的起始节点,我们的实现中bb数组里并没有它,不过在数据流分析的时候可以假装有它存在,具体细节可以参考代码。赋的初值看起来和其它数据流分析不太一样,其它数据流一般是以空集作为初值,而这里是以全集UU(一般对于虚拟的起始节点/终止节点的赋值不称为初值,而称为边界条件,这是因为这个值并不会在迭代中发生改变)。本质上这个初值是半格的顶元素,迭代的过程就是值在半格中不断下降的过程,而\cap的顶元素是全集,所以这里以全集为初值。

如果暂时不能理解这个初值的话也不用深究,可以大致理解成:在这个初值下我们最初假定所有表达式都是可用表达式,所以赋值为全集UU,然后一步步剔除不是可用表达式的部分,这样就不会漏过任何可用表达式,错过优化机会(相反,在活跃变量分析中我们最初假定所有变量都不活跃,然后一步步增加活跃变量,这样就不会误认为某些变量活跃而错过优化机会)。

得到基本块级别的可用表达式后,再计算每个语句的可用表达式。如果在一个语句处,它的右端项是可用表达式,那么一定可以把这次计算消除,用前面的计算结果来代替,具体做法是:向前搜索,找到最近的所有可用表达式,然后对每一个都做如下修改:

  c = a + b       tmp = a + b
  ...       ----> c = tmp
  d = a + b       ...
                  d = tmp

其中tmp是一个新的虚拟寄存器,对于"最近的所有"可用表达式所做的修改用的都是同一个tmp。"向前搜索"的含义是,从本条语句前一条开始逆向搜索本基本块,然后反向沿着流图继续递归搜索别的基本块。"最近的所有"的含义是,在向前搜索某个基本块的过程中,一旦遇到相同的表达式就停下来,这个基本块的搜索宣告结束,也不再递归搜索它的前驱;否则,如果这个基本块内没有找到,则需要递归搜索它的前驱。

Last updated