编译器优化–5--消除冗余
关于消除冗余,我们着这里只讨论在局部范围内(单个程序块)上的消除冗余。做这种优化一般常用两种方法:值编号(value numbering)
,树高平衡(tree-height balancing)
。我这这里详细讨论关于值编号(value numbering)
的技术细节。
局部值编号(Local Value Numbering,LVN)
定义:对于基本程序块B中的一个表达式,当且仅当它在B中此前已经计算过,且在此之间并无其他运算重新定义组成表达式的各个参数值时,则称该表达式是冗余的。
例子1
基本程序块如下:
a
←
b
+
c
b
←
a
−
d
c
←
b
+
c
d
←
a
−
d
\begin{aligned}a &\leftarrow b + c\\b &\leftarrow a - d\\c &\leftarrow b + c\\d &\leftarrow a - d\\\end{aligned}
abcd←b+c←a−d←b+c←a−d
在上面例子中第3个运算中出现
b
+
c
b + c
b+c不是冗余的,因为第2个运算重新定义了b。第4个运算中出现的
a
−
d
a-d
a−d是冗余的,因为在第2个和第4个运算之间没有重新定义表达式中的参数a或d。
编译器在优化时会重写该基本程序块,使得只对
a
−
d
a-d
a−d仅运算一次。如下图所示
a
←
b
+
c
b
←
a
−
d
c
←
b
+
c
d
←
b
\begin{aligned}a &\leftarrow b + c\\b &\leftarrow a - d\\c &\leftarrow b + c\\d &\leftarrow b\\\end{aligned}
abcd←b+c←a−d←b+c←b
例子2
前一个例子中,冗余表达式的文本与先前计算过的表达式是相同的。另外还有其他情况,比如:假设已经分析出
d
d
d右侧的表达式是冗余的,可以使用
b
b
b的值直接替换它,使得
d
←
b
d \leftarrow b
d←b。整个基本程序块如下所示:
a
←
b
+
c
b
←
a
−
d
c
←
b
+
c
d
←
b
e
←
d
+
c
\begin{aligned}a &\leftarrow b + c\\b &\leftarrow a - d\\c &\leftarrow b + c\\d &\leftarrow b\\e &\leftarrow d + c\\\end{aligned}
abcde←b+c←a−d←b+c←b←d+c
对于上面程序块B中的第3条语句和第5条语句中
b
+
c
b + c
b+c和
d
+
c
d + c
d+c表达式的值是相同的。为了识别这种情形,编译器必须跟踪值通过名字发生的流动。这种情况,如果仅依赖基于比较文本是否相同的技术是无法检测出来的。
算法
程序员可能会说,如上面两个例子,他们是不会编写出包含这种冗余表达式的代码。实际上,从源码到IR的转换会细化许多细节(如地址计算)并引入冗余表达式,冗余消除
是可以找到许多优化时机的。
人们已经开发出了许多用于发现并消除冗余的技术。局部值编号(Local Value Numbering)
是这些变换中最古老也最强大的技术之一。它可以发现基本程序块内部的冗余,并重写该程序块避免冗余。它为其他局部优化(如常量合并和使用代数恒等式进行简化)提供了一套简单且高效的框架。
值编号背后的思想很简单。算法遍历基本程序块,并为程序块计算的每个值分配一个不同的编号。该算法会为值选择编号,使得给定两个表达式 e i e_i ei和 e j e_j ej,当且仅当对表达式的所有可能的运算对象,都可以验证 e i e_i ei和 e j e_j ej具有相等的值时,两者具有相同的值编号。
下面给出了基本的LVN算法的伪代码。
for i in range(0, n-1), where the block has n operations "Ti = li Opi Ri"
1. get the value numbers for Li and Ri
2. construct a hash key from Opi and the value numbers for Li and Ri
3. if the hash key is already present in the table then
replace operation i with a copy of the value into Ti and associate the value number with Ti
else
insert a new value number into the table at the hash key location record that new value number for Ti
LVN的输入是一个具有n个二元运算的基本程序块,每个运算符形如 T i = L i O p i R i T_i = L_i\ Op_i\ R_i Ti=Li Opi Ri。LVN算法会按照顺序考擦每个运算。它使用一个散列表来将名字、常数和表达式映射到不同的值编号。该散列表最初是空的。
为处理第 i i i个运算,LVN在散列表中查找 L i L_i Li和 R i R_i Ri,获取与二者对应的值编号。如果算法找到对应的项,LVN将使用该项包含的值编号;否则,算法将创建一个表项并分配一个新的值编号。
给出 L i L_i Li和 R i R_i Ri的值编号,分别记作 V N ( L i ) VN(L_i) VN(Li)和 V N ( R i ) VN(R_i) VN(Ri),LVN算法会基于 ( V N ( L i ) , O p i , V N ( R i ) ) (VN(L_i), Op_i, VN(R_i)) (VN(Li),Opi,VN(Ri))构造一个散列表的键,并在表中查找该键。如果存在对应的表项,那么该表达式是冗余的,可以将其替换为对此前计算值的引用;否则,运算 i i i是该程序块中对此表达式的第一次计算,随后LVN会对应的散列键创建一个散列表项,并为该表项分配一个新的值编号。算法还将散列键的值编号(新的或现存的)分配给对应的 T i T_i Ti的表项。因为LVN使用值编号而非名字来构造表达式的散列键,它实际上可以通过复制和赋值操作来跟踪值的流动,它可以解决如前面第2个例子的情形。将LVN扩展到任意元表达式是很简单的。
对于之前给出的LVN算法伪代码还有很多需要完善可考虑的地方如:
- 将LVN算法扩展到任意元表达式;
交换运算
的问题,如( a ∗ b a * b a∗b和 b ∗ a b * a b∗a),两者应该分配同样的值编号;- 常量的求值及合并;
- 代数恒等式,如($ x + 0 \equiv x$)等;
- 命名的影响;
- 间接赋值的影响;
想要完整的实现LVN的算法,需要考虑以上6点甚至更多。在此我就不在深入阐述关于LVN的更多细节
有兴趣对LVN深挖的读者可以参考《Engineering a Compiler》的第8.4章节。
参考
《编译原理》
《Engineering a Compiler》