文章目录
- 一、简介
- 二、消除无用和不可达代码
- 2.1 消除无用代码
- 2.2 消除无用控制流
- 2.3 消除不可达代码
- 三、代码移动
- 3.1 缓式代码移动
- 3.2 代码提升
- 四、特化
- 4.1 尾调用优化
- 4.2 叶调用优化
- 4.3 参数提升
- 五、冗余消除
- 5.1 值相同与名字相同
- 5.2 基于支配者的值编号算法
- 六、为其他变换制造时机
- 6.1 超级块复制
- 6.2 过程复制
- 6.3 循环外提
- 6.4 重命名
- 七、联合优化
- 7.1 合并优化
- 7.2 强度削减
- 7.3 选择一种优化序列
一、简介
所谓的标量优化,指的是单个控制线程下代码的优化。
大多数优化器都被构建为一系列处理趟(pass),如下图所示。每趟以IR形式的代码作为其输入,以重写后的IR代码版本作出其输出。这种结构将实现划分为若干小片段,从而避免了大型单块程序引起的部分复杂性。这允许独立地构建并测试各个处理趟,简化了开发、测试和维护。这建立的方法颇为自然,使编译器能够提供不同的优化级别,每个级别规定了一组需要运行的处理趟。趟结构使编译器编写者能够多次运行某些趟(如果需要的话)。实际上,某些趟只应该运行一次,而其他趟可能需要在优化过程中的不同时机多次运行。
在优化器的设计中,变换的选择和变换顺序的确定,对于优化器的整体有效性具有非常关键的作用。变换的选择决定了优化器能够发现IR程序中的哪些低效性,以及优化器如何重写代码以消除这些低效性。编译器应用变换的顺序则决定了各趟处理之间的交互方式,如前一趟的优化结果可能会影响后一趟的优化。
二、消除无用和不可达代码
操作可能是无用的,意即其结果没有外部可见的效应;操作可能是不可达的,意即它不可能执行。这两类代码有个术语叫“死代码”,也有书只将无用代码称为“死代码”。
删除无用或不可达代码可以缩减代码的IR形式,这会导致可执行程序更小、编译更快、通常执行也更快。同时,它还可以增强编译器改进代码的能力。
因为消除无用和不可达代码的算法会修改程序的控制流图(CFG),我们将审慎地区分术语分支(br)和跳转(jump),分支指令会根据比较的结果决定是否跳转至目标地址,跳转指令则会直接跳转至目标地址。
2.1 消除无用代码
后向支配性(postdominance ):在CFG中,如果从i
到CFG出口结点的每条路径 都经过j
,则结点j
后向支配结点i
。可参见支配性的定义。
上CFG图各个结点的后向支配者集合如下表:
B0 | B1 | B2 | B3 | B4 | B5 | B6 | B7 | B8 |
---|---|---|---|---|---|---|---|---|
0,1,3,4 | 1,3,4 | 2,3,4 | 3,4 | 4 | 5,7,3,4 | 6,7,3,4 | 7,3,4 | 8,7,3,4 |
控制依赖性(control dependence):在一个CFG中,结点j
在控制上依赖于结点i
,当且仅当以下条件成立。
- 存在一条 从
i
到j
的非空路径,使得j
后向支配路径上i
之后的每个结点。一旦执行从该代码路径开始,那么要到达CFG的出口,控制流必定要经过j
(根据后向支配性的定义)。 j
并不严格后向支配i
。有另一条离开i
的边,控制流可能由某条路径流向另一个不在i
到j
的路径上的结点。必定有一条从该边开始的路径通向CFG的出口结点,且不经过j
。
换言之,有两条或更多条边离开程序块i
,如上CFG图中的B1、B5、B3。从i
离开到CFG出口的所有路径上,有一条或多条边通向j
,也有一条或多条边并不通向j
。因而,在程序块i
结束处的分支指令处所作的决策将会确定是否执行j
。如果j
中的一个操作是有用的,那么结束i
的分支指令也是有用的。从概念上可知,结点j
控制上依赖结点i
,其实也就是结点i
是结点j
的反向支配边界(Reverse Dominance Frontier,记作RDF(j)),可参见支配边界。
上CFG图各个结点的支配边界如下表:
B0 | B1 | B2 | B3 | B4 | B5 | B6 | B7 | B8 |
---|---|---|---|---|---|---|---|---|
∅ \empty ∅ | 3 | 1 | 3 | ∅ \empty ∅ | 1 | 5 | 1 | 5 |
下图是消除无用代码的算法,称为为Dead,算法的输入为SSA IR。Dead由两趟组成:第一趟Mark,依赖于反向支配边界,发现有用操作的集合并对其做标记;第二趟Sweep,负责删除没有标记为有用的操作。
第一趟。Mark会遍历程序中的每个操作i:清除掉i的mark字段;若i是关键(critical)操作,则将其标记为有用的,并将i添加至WorkList。如果一个操作跟过程的返回结果有关(如对引用调用参数或全局变量赋值赋值、或通过具有歧义的指针赋值、或作为return语句的返回值),或者操作是输入输出语句,或者操作可以影响到当前过程外的可以访问的某个内存中的值,则称这个操作为关键操作。关键操作的例子包括过程的起始代码序列和收尾代码序列,以及调用位置处的调用前代码序列和返回后代码序列。
接下来,算法遍历WorkList,从中取出每个操作i(假设i形如x <- y op z
):如果y的定义def(y)没有被标记为有用的,则对其标记并添加至WorkList;z也重复y的操作;接着遍历操作i所处块的反向支配边界集,将每个反向支配边界块结束时的分支指令标记为有用的。
第二趟。Sweep会将任何未标记的分支指令替换为一个跳转指令,跳转到该程序块的第一个包含有用指令的后向支配者(post dominator)。如果分支指令未被标记,那么从其后继结点一直到其直接的后向支配者结点,都不包含有用操作。(否则,如果其中有某些操作被标记,那么该分支指令也将被标记。)如果其直接后向支配者不包含被标记的操作,也有同样的论点成立。为找到最接近的有用后向支配者,该算法可以向上遍历后向支配者树,直至找到包含有用操作的程序块。因为根据定义,出口程序块是有用的,所以该查找必定会停止。
2.2 消除无用控制流
消除无用代码的优化带有一种副作用,即可能会产生无用控制流。所以Mark、Sweep之后会再执行一趟叫做为Clean的算法,消除无用控制流来简化CFG。Clean直接运行在过程的CFG上,它使用以下4个变换。
- 合并冗余分支指令:如果Clean发现一个程序块Bi以分支指令结束,但该分支指令的两个目标是同一个程序块Bj,则将其替换为到目标程序块的跳转指令,并调整程序块Bi的后继结点列表和Bj的前驱结点列表。
- 删除空程序块:如果Clean发现一个程序块只包含一条跳转指令,再无其他指令,则可以将该程序块合并到其后继结点。当有其他趟从程序块Bi中删除了所有操作时,就会出现这种情况。
- 合并程序块:如果Clean发现一个程序块Bi结束于到Bj的跳转指令,且Bj只有一个前趋结点,那么就可以合并这两个程序块。
- 提升分支指令:如果Clean发现程序块Bi结束于到空程序块Bj的跳转指令,且Bj以分支指令结束,那么Clean可以将Bi程序块末尾的跳转指令替换为Bj中分支指令的副本。
Clean按后根次序遍历(postOrder)流图,因此Bi的后继结点会在Bi之前被简化,除非其后继结点在后根次序编号下位于某条后向边上。在这种情况下,Clean将先访问前趋结点,而后访问后继结点,在有环图中这是不可避免的。在前趋结点之前简化后继结点可以减少实现必须移动某些边的次数。Clean算法如下图所示。
Clean本身不能消除空循环。假设下图B2为空,则B2只有结束处的一条分支指令,且两个目标地址不同。从上文4个变换条件可知,Clean无法将其消除,但通过Clean和Dead(见上文2.1)之间的协作可以消除这个空循环。
Dead使用控制依赖性来标记有用的分支指令。如果B1和B3包含有用的操作,而B2没有有用操作,那么Dead中的Mark处理趟将判断B2结束处的分支指令不是有用的,因为B2
∈
\isin
∈ RDF(B3)。由于该分支指令是无用的,计算分支条件的代码也是无用的。
因而,Dead可以消除B2中所有的操作,并将其结束处的分支指令转换为一个跳转指令,跳转到其最接近且有用的后向支配者B3。这消除了原来的循环,并产生了下图中的CFG。
在这种形式下,Clean会将B2合并到B1中,从而产生了下图中的CFG。
该操作也使得B1末尾处的分支指令变成冗余的。Clean会将其重写为跳转指令,从而产生了下图中的CFG。此时,如果B1是B3的唯一剩余前趋结点,则Clean会将这两个程序块合并为一个程序块。
与向Clean添加一个处理空循环的变换相比,这种协作更简单且更有效。
2.3 消除不可达代码
有时候CFG中包含了不可达的代码。编译器应该找到不可达程序块并删除它们。程序块可能因两个不同的原因而变为不可达代码:可能没有穿越CFG的代码路径到达该程序块,或者到达该程序块的代码路径是不可能执行的,比如受控于一个条件判断的代码,如果条件表达式的值总是false,则该代码是不可能执行的。
第一种情况可以参考上文2.1 中的算法,在CFG上执行标记-清除风格的可达性分析。第二种情况处理起来比较复杂,需要编译器推断控制分支指令表达式的值,7.1 合并优化 中的算法可以做到。
三、代码移动
3.1 缓式代码移动
缓式代码移动(Lazy Code Motion, LCM)专注于将不变的表达式从循环中移出,从而减少循环时执行无效的操作。该变换插入代码以使这些操作在所有代码路径上都变成冗余的,并删除这些新的冗余表达式。
冗余的概念在局部值编号和超局部值编号中介绍过。
- 冗余:如果在能够到达位置p的每条路径上表达式e都巳经进行过求值,那么表达式 e在位置p处是冗余的。
- 部分冗余:如果表达式e在到达位置p的部分(而非全部)代码路径上是冗余的,则表达式e在位置p处是部分冗余的。
在下图a中,a <- b * c
存在于通向汇合点的一条路径上,但在另一条路径上不存在。为使第二个计算成为冗余的,LCM向另一条代码路径插入了对a <- b * c
的求值,如下图b所示。在下图c中,沿循环的后向边a <- b * c
是冗余的,但在进入循环的边上却并非如此。在循环之前插入一个对a <- b * c
的求值,使得该表达式在循环内部变为冗余的,如下图d所示。通过使循环中不变的计算成为冗余并消除,LCM将其从循环移出,这种优化在独立进行时被称为循环不变量代码移动(loop-invariant code motion)。
LCM算法运行在程序的IR形式及其CFG上,而非在SSA形式上。它使用3组不同的数据流方程,并从其结果 推导出额外的集合。算法为CFG中的每条边都生成了一个表达式集合,其中包含了沿该边应该求值的所有表达式,并为CFG中的每个结点都生成了一个表达式集合,其中的表达式在对应程序块中的向上展现求值应该删除。使用一个简单的重写策略即可解释这些集合并修改代码。
LCM同时计算了可用表达式和可预测表达式,使用这些分析的结果,用集合Earliest(i, j)来标注CFG中的每条边<i, j>
,这个集合包含了以该边为最早合法置放位置(earliest legal placement)的所有表达式。LCM接下来求解第三个数据流问题,以发现延迟置放(later placement)情况,即将表达式求值延迟至最早置放位置之后、但仍然具有同样效果的情形。延迟置放是颇为理想的清况,因为它们可以缩短由插入的求值定义的值的生命周期。最终,LCM计算其最终产物,集合Insert和Delete,这两个集合用于指引其代码重写步骤。
1) 可用表达式
在之前的文章《数据流分析2.4.1》中对可用表达式及其方程做过介绍,用一个集合AvailIn(n)来表示,过程中从程序入口处到结点n的所有可用表达式的名字。因为LCM需要程序块末尾处的可用表达式信息,所以这里使用的集合是AvailOut(AvailIn表示的是入口处),与AvailIn方程只有微小的区别。
对于表达式e和程序块b来说,如果在从程序入口块n0到b的每条代码路径上,e都已经求值且其参数在求值后均未重新定义过,则表达式e在程序块b的出口处是可用的,用AvailOut(b)表示。定义AvailOut(b)的方程如下:
A v a i l O u t ( n ) = ⋂ m ∈ p r e d s ( n ) ( U E E x p r ( m ) ∪ ( A v a i l O u t ( m ) ∩ E x p r K i l l ( m ) ‾ ) ) AvailOut(n)= \bigcap_{m \isin preds(n)} (UEExpr(m) \cup (AvailOut(m) \cap \overline{ExprKill(m)})) AvailOut(n)=⋂m∈preds(n)(UEExpr(m)∪(AvailOut(m)∩ExprKill(m)))
方程的初始条件为: A v a i l O u t ( n 0 ) = ∅ AvailOut(n_0) = \emptyset AvailOut(n0)=∅, A v a i l O u t ( i ) = { a l l AvailOut(i)= \{ all AvailOut(i)={all e x p r e s s i o n } expression \} expression}, ∀ n ≠ n 0 \forall n \ne n_0 ∀n=n0
如果表达式e ∈ \isin ∈ AvailOut(b),则编译器可以将e的一个求值放置在程序块b末尾,并获取从n0到b的任一控制流路径上最近一次求值产生的结果。如果e ∉ \notin ∈/ AvailOut(b),那么从e最近一次求值以来它的某个子表达式已经修改过,因而程序块b末尾处对e的求值很可能生成一个不同的值。
LCM使用AvailOut集合来帮助判断表达式在CFG中可能的放置位置,即编译器可以判断它能够将表达式e的求值在CFG中(忽略对e的使用)向前移动多远。
2) 可预测表达式
在之前的文章《数据流分析2.4.3》中对可预测表达式及其方程做过介绍。
由于LCM在每个程序块的开始和结束处都需要有关可预测表达式的信息,因而我们重构了可预测表达式的方程,引入了集合AntIn(n),以表示在对应于CFG中结点n的程序块入口处可预测表达式的集合。定义AntIn(n)集合的方程如下:
A n t I n ( m ) = U E E x p r ( m ) ∪ ( A n t O u t ( m ) ∩ E x p r K i l l ( m ) ‾ ) AntIn(m)= UEExpr(m) \cup (AntOut(m) \cap \overline{ExprKill(m)}) AntIn(m)=UEExpr(m)∪(AntOut(m)∩ExprKill(m))
A n t O u t ( n ) = ⋂ m ∈ s u c c ( n ) A n t I n ( m ) , n ≠ n f AntOut(n)= \bigcap_{m \isin succ(n)} AntIn(m), n \ne n_f AntOut(n)=⋂m∈succ(n)AntIn(m),n=nf
方程的初始条件为: A n t O u t ( n f ) = ∅ AntOut(n_f) = \emptyset AntOut(nf)=∅, A n t O u t ( n ) = { a l l AntOut(n)= \{ all AntOut(n)={all e x p r e s s i o n } expression \} expression}, ∀ n ≠ n f \forall n \ne n_f ∀n=nf
AntOut提供了将一个求值提升到当前程序块开始或结束处的安全性方面的信息。如果x ∈ \isin ∈ AntOut(b),那么编译器可以将x的一个求值放置在b的末尾,这有两点保证。首先,b末尾处的求值结果,将与x沿过程中任一执行路径的下一次求值结果相同。其次,沿离开程序块b的任一执行路径,在重定义x的任一参数之前,程序会对x进行求值。
3) 最早置放
如果给出了可用性和可预测性的解,对于每个表达式,编译器都可以判断在程序中最早于何处可以对该表达式进行求值。为简化各个方程,LCM假定它会将求值放置在CFG的某条边上,而非某个特定程序块的起始或结束处。计算出一条边供放置表达式的求值操作之用,使得编译器可以推迟具体的放置决策:到底是将求值操作放置在边的源结点末尾处、边的目标结点的起始处,还是在边的中间插入一个新的程序块来放置表达式的求值操作。(参见数据流分析3.5 中对关键边的讨论。)
用集合Earliest(i, j)来标注CFG中的每条边<i,j>
。对于CFG中一条边<i,j>
和表达式e来说,e属于Earliest(i, j),当且仅当:编译器可以合法地将e移动到<i,j>
,且无法将其移动到CFG中更早的边上。Earliest方程为处理该条件,将其编码为三个条件的交集:
E a r l i e s t ( i , j ) = A n t I n ( j ) ∩ A v a i l O u t ( i ) ‾ ∩ ( E x p r K i l l ( i ) ∩ A n t O u t ( i ) ‾ ) Earliest(i, j) = AntIn(j) \cap \overline{AvailOut(i)} \cap (ExprKill(i) \cap \overline{AntOut(i)} ) Earliest(i,j)=AntIn(j)∩AvailOut(i)∩(ExprKill(i)∩AntOut(i))
这些条件如下定义了e的一个最早置放:
- e
∈
\isin
∈ AntIn(j) 意味着编译器可以安全地将e移动到
j
的起始处。可预测性方程确保:e在j
的起始处,将与其在离开j
的任一代码路径上产生相同的值,且这些代码路径都对e进行了求值。 - e
∉
\notin
∈/ AvailOut(i) 说明,此前对e的求值计算结果在
i
的出口处均不可用。如果e ∈ \isin ∈ AvailOut(i),那么在边(i,j)
插入e将是冗余的。 - 第三个条件包含两种情况。如果e
∈
\isin
∈ ExprKill(i),则编译器无法穿过程序块
i
移动e,因为有一个定义在i
中。如果e ∉ \notin ∈/ AntOut(i),则由于对某些边<i,k>
,e ∉ \notin ∈/ AntIn(k),编译器无法将e移动到i
中。如果二者之一成立,那么在CFG中不能将e移动到比<i,j>
更早的位置上。
CFG的入口结点n0带来了一个特例。LCM无法将表达式移动到早于n0的位置上,因此对任何k,LCM都会忽略掉Earliest(n0, k)中的第三个条件。
4) 延迟置放
LCM中最后一个数据流问题用于判断何时一个最早置放能被推迟到CFG中稍后的一个位置,而仍然能够实现相同的效果。
延迟分析表述为CFG上的一个前向数据流问题,其目标在于为每个结点n关联一个集合LaterIn(n),为每条边<i,j>
关联一个集合Later(i, j)。LCM初始化LaterIn集合如下:
L a t e r I n ( j ) = ⋂ i ∈ p r e d ( j ) L a t e r ( i , j ) , j ≠ n 0 LaterIn(j)= \bigcap_{i \isin pred(j)} Later(i, j),j \ne n_0 LaterIn(j)=⋂i∈pred(j)Later(i,j),j=n0
L a t e r ( i , j ) = E a r l i e s t ( i , j ) ∪ ( L a t e r I n ( i ) ∩ U E E x p r ( i ) ‾ ) , i ∈ p r e d ( j ) Later(i, j)= Earliest(i, j) \cup (LaterIn(i) \cap \overline{UEExpr(i)} ),i \isin pred(j) Later(i,j)=Earliest(i,j)∪(LaterIn(i)∩UEExpr(i)),i∈pred(j)
方程的初始条件为: A n t O u t ( n f ) = ∅ AntOut(n_f) = \emptyset AntOut(nf)=∅, A n t O u t ( n ) = { a l l AntOut(n)= \{ all AntOut(n)={all e x p r e s s i o n } expression \} expression}, ∀ n ≠ n f \forall n \ne n_f ∀n=nf
表达式e
∈
\isin
∈ LaterIn(k),当且仅当:到达k的每条代码路径都包含一条边<p,q>
使得e
∈
\isin
∈ Earliest(p,q),且从q到k的路径既未重定义e的操作数,也不包含e的最早置放能够预期的对e的求值。Later方程中的Earliest条件确保了Later(i, j)包含Earliest(i, j)。在e能够从向前(沿控制流方向)移动(即e
∈
\isin
∈ LaterIn(i)),且e置放在i
入口处却无法预期到i
中对e的使用(e
∉
\notin
∈/ UEExpr(i))的情况下,Later方程的其余部分会将e置于Later(i, j)中。
给定Later和LaterIn集合,e
∈
\isin
∈ LaterIn(i)意味着编译器能够将e的求值穿过i
向前移动而不会损失任何利益,即表达式e在程序块i
中的求值(如果有的话)是无法通过早期求值来预测的,而e
∈
\isin
∈ Later(i, j)意味着编译器能够将e在i
的求值移动到j
中。
5) 重写代码
执行LCM算法的最后一步是利用从数据流计算推导出的知识重写代码。为驱动重写过程,LCM会计算两个额外的集合Insert和Delete。
Insert集合对每条边规定了LCM应该在该边插入的计算。
I n s e r t ( i , j ) = L a t e r ( i , j ) ∩ L a t e r I n ( i ) ‾ Insert(i,j) = Later(i,j) \cap \overline{LaterIn(i)} Insert(i,j)=Later(i,j)∩LaterIn(i)
如果i
有一个后继结点,则LCM可以将计算插入到i
末尾。如果j
只有一个前趋结点,则算法可以将计算插入至j
的入口处。如果上述两个条件均不成立,那么边<i,j>
是一条关键边,而且编译器应该在该边中间插入一个程序块来拆分该边,并将Insert(i, j)中表达式的求值放入该程序块。
Delete集合对每个程序块规定了LCM应该从该程序块删除的计算。
D e l e t e ( i ) = U E E x p r ( i ) ∩ L a t e r I n ( i ) ‾ , i ≠ n 0 Delete(i) = UEExpr(i) \cap \overline{LaterIn(i)}, i \ne n_0 Delete(i)=UEExpr(i)∩LaterIn(i),i=n0
当然,Delete(n0)为空,因为n0之前没有其他程序块。如果e
∈
\isin
∈ Delete(i),那么在所有的插入已经进行之后,e在i
中的第一次计算将变为冗余的。而e在i
中的后续各次求值中,凡是具有向上展现使用的都是可以删除的,这里向上展现使用是指某个操作数并不是在i
的起始位置与该次求值之间定义的。因为e的所有求值均定义了同一个名字,所以编译器无需重写对被删除求值的后续引用。这些引用仍然指向LCM已经证明了的能够产生同样结果的早期求值。
3.2 代码提升
代码提升(code hoisting)使用了可预测分析的结果,来减少指令的重复,从而减少代码长度。该变换试图发现下述情形:插入某个操作使得其他几个同样的操作变为冗余,且不改变程序计算的值。
如果对某个程序块b,表达式e ∈ \isin ∈ AntOut(b),这意味着e沿离开b的每条代码路径都进行了求值,且e在b末尾处的求值使得离开b的各条路径上的第一次求值变为冗余。为减小代码长度,编译器可以在b的末尾插入对e的一个求值,并将离开b的各条路径上对e的第一次求值替换为对此前计算值的引用。这种变换的效果是,将对e的求值操作的多个副本替换为一个副本,从而减少了编译后代码中操作的总数。
为直接替换这些表达式,编译器需要定位它们。当然,编译器可以插入对e的求值,然后求解另一个数据流问题,证明在b的末尾到e的某次求值之间没有重新定义e的任何操作数。另外,编译器还可以遍历离开b的每条代码路径,通过考察代码路径上各个程序块的UEExpr集合,来找到定义e的第一个程序块。这两种方法看起来都比较复杂。
一种更简单的方法是,编译器访问每个程序块b,然后在b的末尾处对每一个表达式e ∈ \isin ∈ AntOut(b)插入一个对e的求值。如果编译器使用了一种统一的命名规范,正如LCM的讨论表明的那样,那么每个求值都将定义对应的适当名字。而对LCM或超局部值编号算法的后续应用,将会删除新的冗余表达式。
四、特化
用于执行特化的技术在之前文章已经介绍过。局部值编号、超局部值编号、稀疏简单常量传播、过程间常量传播,运算符强度消减(本节7.2)、以及窥孔优化(下一篇文章介绍)。
4.1 尾调用优化
如果一个调用是过程的最后一个操作,我们就称该调用为尾调用(tail call)。编译器可以针对上下文而特化尾调用,从而消除过程链接代码的大部分开销。
// case 1, not a tail call
f(x) {
y = g(x);
return y;
}
// case 2, not a tail call
f(x) {
return g(x) + 1;
}
// case 3, not a tail call
f(x) {
g(x);
}
// case 4, tail call
f() {
x = 2;
return g(x);
}
我们在过程抽象中已经介绍过运行时结构,这里会以此内容作为基础,且这块内容不再重复解释。
假设o调用p、p又调用q。如果p中对q的调用是尾调用,那么在调用q之后的返回后代码序列和p本身的收尾代码序列之间,不存在有用计算。所以,任何用于保留和恢复p的状态的代码,只要超出了从p返回到o的所需,就都是无用的。
在p中对q的调用位置处,最小的调用前代码序列必须对从p传递给q的实参求值,并根据需要调整存取链或display数组。
- 它完全没有必要保存任何由调用者保存的寄存器(caller寄存器),因为它们不可能是活跃的;
- 它也不必分配一个新的AR,因为q可以使用p的AR。
- 它决不能改动返回到o所需的上下文,即o传递到p的返回地址和调用者的ARP,以及p保存到AR中的由被调用者保存的寄存器(callee寄存器)。
这一上下文的存在,将使得q中的收尾代码序列直接将控制返回到o。然后,q必须执行一个定制的起始代码序列,以便与p中的最小调用前代码序列匹配,且p中的最小调用前代码序列必须跳转到q的起始代码序列。q的起始代码序列只保存p的状态中对返回到o有用的那部分,并不保存由被调用者保存的寄存器,这是出于两个原因。
- 其一,p在相应寄存器中的值不再是活跃的。
- 其二,p留在AR的寄存器保存区中的那些值是返回到o所需要的。
因而,q中的起始代码序列应该初始化q需要的局部变量和值,接下来它应该分支到q的实际代码执行。
在对p中的调用前代码序列和q中的起始代码序列进行上述修改后,尾调用避免了保存和恢复p的状态并消除了该调用的大部分开销。当然,一旦p中的调用前代码序列已经被用这种方法裁剪过,那么p中的返回后代码序列和p的收尾代码序列将变为不可达代码,死代码消除技术可以将其优化掉。
如果尾调用是一个自递归调用(即p和q是同一个过程),那么尾调用优化可以产生特别高效的代码。在尾递归中,整个调用前代码序列都退化为参数求值和一个返回例程顶部的分支指令。最终跳出递归的返回只需要一条分支指令,而非为每次递归调用都使用一条分支指令。结果产生的代码在效率上可以与传统的循环相匹敌。
4.2 叶调用优化
叶过程:不进行过程调用的过程称。
非叶过程中,过程起始代码序列可能会将寄存器中的返回地址保存到AR中的一个槽位里。但如果是叶过程,这个操作将是不必要的。如果因其他用途而需要保存返回地址的寄存器,那么寄存器分配器可以溢出该值。
在叶过程中,寄存器分配器应该设法优先使用由调用者保存的寄存器,而后才轮到由被调用者保存的寄存器。至于为什么这么做,参见子程序调用时寄存器的保存方式。
此外,编译器还可以避免为叶过程分配活动记录的运行时开销。在使用堆分配AR的实现中,分配AR的代价可能会非常高。在只有单个控制线程的应用程序中,编译器可以静态分配任一叶过程的AR。具有更激进分配策略的编译器,可能会分配一个足够大的静态AR供任一叶过程使用,并让所有的叶过程共享这一AR。
如果编译器能够访问到叶过程及其调用者的代码,则它可以在(叶过程的)每个调用者的AR中为叶过程的AR分配空间。这种方案将AR分配的代价均摊到至少两个调用上,即对调用者的调用和对叶过程的调用。如果调用者多次调用叶过程,那么这样所带来的节省将以乘法效应倍增。
4.3 参数提升
提升:一类将歧义值移动到局部标量名中以将其暴露给寄存器分配的变换
考虑为一个具有歧义的引用调用参数生成代码,代码可能用两个不同的参数槽位传递同一个实参,或将一个全局变量作为实参传递。除非编译器进行过程间分析来排除这些可能性,否则它必须将所有引用参数都视为可能具有歧义的引用。所以,每次使用这种参数时,都要求发出一条load
指令,而每次定义其值时,都要求发出一条store
指令。
如果编译器能够证明与该形参对应的实参在被调用者中必定是无歧义的,那么它可将该参数的值提升到一个局部标量值中,使被调用者能够将其保持在寄存器中。如果被调用者并不修改该实参,则提升后的参数可以直接传值。如果被调用者修改该实参且其结果在调用者中是活跃的,那么编译器必须使用传值兼传结果语义来传递提升后的参数。
为对某个过程p应用这种变换,优化器必须识别所有可以调用p的调用位置。它或者证明该变换对所有这些调用位置都是适用的,或者创建p的一个副本来处理提升后的值(参见6.2 过程复制)。