Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 49 additions & 43 deletions 30-optimize.tex
Original file line number Diff line number Diff line change
Expand Up @@ -351,8 +351,10 @@ \subsubsection{求支配集的其他方法}
以上的方法是通过迭代的方式求解支配集的。在控制流图节点数量较少的情况下,其时间复杂度是可以接受的。
然而,在某些极端的情况下,这种方法的时间复杂度是 $O(n^2)$ 的,可能会导致性能问题。以下给出两个求支配集的更高效的方法:

1. bitset 优化集合操作:注意到在迭代过程中,我们只需要对支配集进行交集、并集等操作,因此可以使用 bitset 位运算来加速这些操作。
2. Lengauer-Tarjan 算法:该算法可以在接近线性的时间复杂度内求解支配集。
\begin{itemize}
\item bitset 优化集合操作:注意到在迭代过程中,我们只需要对支配集 w 进行交集、并集等操作,因此可以使用 bitset 位运算来加速这些操作。
\item Lengauer-Tarjan 算法:该算法可以在接近线性的时间复杂度内求解支配集。
\end{itemize}

以上两种做法,均在 OI-wiki \url{https://oi-wiki.org/graph/dominator-tree/} 中有详细介绍,感兴趣的读者可以参考。

Expand Down Expand Up @@ -793,50 +795,54 @@ \section{SCCP: Sparse Conditional Constant Propagation 常量传播}

\subsection{基础 —— SCP}

首先,我们讨论不含条件分支的稀疏常量传播(Sparse Constant Propagation, SCP)。通过迭代的方式逐步确定程序中的常量。
首先,我们介绍不含条件分支的稀疏常量传播(Sparse Constant Propagation, SCP),通过迭代逐步确定程序中的常量。

初始时,假设所有值都有可能成为常量,并将每条语句的结果标记为“未定义”,而函数入参则被标记为“非常量”。
然后,将所有基本块内的语句加入队列,反复从中取出语句并尝试更新其标记。更新规则如下:

\begin{itemize}
\item 如果语句的结果已标记为“非常量”,则不进行处理;
\item 否则,将语句中的所有使用(use)替换为当前标记,并尝试对结果进行估值。
\end{itemize}

若无法估值(如 \texttt{load} 指令,其值取决于内存),则标记为“非常量”;
若根据算术规则可以确定结果(如 $0$ 乘任何数为 $0$,或 \texttt{add} 的两个操作数都是常量),则更新结果标记。
如果结果原标记为“未定义”,更新为“常量”;若已标记为“常量”,则比较新旧值,若不同则更新为“非常量”。

初始时,我们假设所有值都有可能成为常量,并为每个表达式的结果标记为“未定义”。接着从入口块开始,依次遍历表达式。
对于纯函数表达式(没有外部状态依赖的表达式,如算术运算、比较以及无副作用的函数),
如果所有操作数都是常量或通过某些规则可确定结果(如 $0$ 乘任何数都为 $0$),
则更新表达式结果为“常量”。如果先前标记为“常量”的结果发生变化,则更新为“非常量”。若无法确定结果,则直接标记为“非常量”。
特别地,$\phi$ 函数仅在所有入口块传入相同常量时才标记为“常量”。

需要特别注意的是,$\phi$ 函数仅当所有入口块传入的值都是相同的常量时,才会被标记为“常量”。
每当语句的结果标记更新后,将使用此结果的语句加入队列。由于程序基于 SSA 形式,变化可沿着 def-use 链传播。
每条语句最多遍历 $3 * (\text{操作数} + 1)$ 次,因状态只能从“未定义”转变为“常量”,再转变为“非常量”。
因此,SCP 算法的时间复杂度为线性。在算法结束时,所有标记为常量的值将会被替换为相应的常量。

每次更新表达式的标记后,我们都会更新使用该表达式的所有地方。由于程序是基于 SSA 形式的,因此我们可以沿着 def-use 链传播变化。
该过程中的每个表达式最多遍历 $3 * (\text{操作数} + 1)$ 次,因为状态转换只可能是“未定义 -> 常量 -> 非常量”
因此,SCP 算法的时间复杂度是线性的。在算法结束后,所有被标记为常量的表达式都将被替换为对应的常量
笔者注: 请特别注意区分“未定义”和“非常量”两种状态。“未定义” 是一种最强的状态,其类似未初始化的未定义行为,
因此程序可以假定其为任何使程序合法的值。而“非常量”则是一种最弱的状态,其表示无法分析得到其值,可能取决于
程序运行时的状态(比如外部输入、内存读取等)

\subsection{改进 —— SCCP}

注意到以上 SCP 算法对于分支是无能为力的,必须遍历所有可能的分支。而对于 \text{\%phi} 函数,我们要求所有的入口都是相同的常量,
这显然过于苛刻。事实上,在我们给出的例子里面,由于只有一个分支可达,所以这个 \text{\%phi} 函数的结果是可以确定的。

一个暴力的解决方案是,在跑完 SCP 后,我们对于所有条件为常量的的分支语句特判,转化为跳转指令。在全部替换结束后,
再把所有由入口块不可到达的块删除,并且删除由这些块引出的 \text{\%phi} 函数。

(笔者注:这其实可以是单独的一个优化步骤,把所有不可达的分支删除,并且简化控制流图。
常用于某些未定义行为的优化。编译器总是假定程序是没有未定义行为的,因此如果一个分支有未定义行为,我们可以假定这个分支是不可达的。
同时,一个函数必须要有返回语句 (void 类型也有 ret void),否则也是未定义行为。
因此,我们可以分别从入口块和返回块开始,分别正向和反向遍历,标记所有可达的块。
只有那些可以从入口块到达的块,以及可以到达返回块的块,才被保留。其他的块都将会被删除。请注意,在删除块的时候,需要检查其后继块,
删除对应的 \text{\%phi} 函数的对应入口。)

当然,该方法在最坏情况下需要额外执行分支数量的次数。我们可以通过改进 SCP 算法来减少这个数量。我们可以在 SCP 算法中加入对分支的处理,
即为 Sparse Conditional Constant Propagation (SCCP)。

首先,我们需要维护两个队列:一个是表达式队列,另一个是控制流边队列。一个小 trick 是加入一个虚拟块作为入口点的前驱块,
初始化控制流队列为从虚拟块到入口块的边,而表达式队列最初为空。

在每一轮迭代中,我们尝试从两个dui'lie中取出一个元素。如果是控制流队列的元素(即一条边,从块 $A$ 到 $B$),
我们首先标记 $B$ 从 $A$ 来过。如果之前 $B$ 从 $A$ 来过,那么直接跳过本次操作。否则,我们会遍历 $B$ 块的所有
\text{\%phi} 函数,以及控制流语句,按照新的规则更新 (见下文)。特别地,如果在此之前块 $B$ 从没有被访问过 (即第一次访问),
那么我们需要把 $B$ 中的其他语句也顺序访问一遍,以确保我们不会遗漏任何一个表达式。所以单个块内的遍历顺序是:
\text{\%phi} 函数 -> (如果是第一次,其他语句) -> 控制流语句。

如果是表达式队列的元素,我们基本按照 SCP 的规则更新,但是细节上有所不同。
对于 \text{\%phi} 函数,我们只需检查其当前所有的来过的入口的值是否都是常量,以此来更新。
比如块 $C$ 当前仅仅从 $A$ 和 $B$ 来过,那么只需检查每个 \text{\%phi} 函数的来自 $A$ 和 $B$ 的值是否相同即可。
对于无条件跳转 \text{br} 语句,我们需要把 “当前块” 到 “跳往块” 的边加入控制流队列。对于条件跳转 \text{br} 语句,
如果条件被标记为 “未定义”,则不操作。如果被标记为 “常量”,则根据条件跳转的结果,
把 “当前块” 到 “跳往块” 的那条边加入控制流队列。否则,需要将两条边都加入控制流队列。
特别地,当一个表达式的标记更新的时候,我们需要把所有使用这个表达式的地方加入表达式队列。
SCP 对于条件分支无能为力,必须遍历所有可能的分支。此外,SCP 对 \texttt{phi} 函数要求过于严格,
即所有入口值必须是相同常量,而在某些情况下仅部分分支可达时,可能不需要这么严格的要求。

一种暴力解决方案是,先运行 SCP,然后将所有条件为常量的分支转化为跳转指令。
替换完成后,删除不可达块及其相关的 \texttt{phi} 函数,再次运行 SCP,直至没有变化。

然而,若函数中有 $b$ 个分支语句,最坏情况下此方法需执行 $b+1$ 次 SCP。
为减少此次数,可以通过引入条件分支处理来改进 SCP,即稀疏条件常量传播(Sparse Conditional Constant Propagation, SCCP)。

SCCP 需要维护两个队列:语句队列和控制流队列。一个小 trick 是引入一个虚拟块作为入口点的前驱,
初始化控制流队列为从虚拟块到入口块的边,语句队列则最初为空。

每轮迭代中,尝试从两个队列中取出元素进行处理:

\begin{itemize}
\item 若取出的是控制流队列的元素(即从块 $A$ 到 $B$ 的边),首先标记 $B$ 从 $A$ 来过。
若 $B$ 已从 $A$ 来过,则跳过此操作。否则,遍历 $B$ 中的 \texttt{phi} 函数和控制流语句,并按语句队列的规则更新。
若 $B$ 是首次访问,还需遍历块中的其他语句,确保不遗漏任何内容。
遍历顺序为:\texttt{phi} 函数 -> (仅首次访问时,其他语句) -> 控制流语句。
\item 若取出的是语句队列的元素,则大致按 SCP 的规则更新,细节略有不同:
对于 \texttt{phi} 函数,仅需检查当前所有已访问过的入口的值是否相同;
对于无条件跳转 \texttt{br} 语句,将“当前块”到“目标块”的边加入控制流队列;
对于条件跳转 \texttt{br} 语句,若条件为“未定义”,则不处理,若为“常量”,则根据条件结果加入相应边;若条件为“非常量”,则将两条边都加入控制流队列。
当语句结果标记更新时,将使用此结果的语句加入语句队列,这一点和 SCP 一致。
\end{itemize}
Loading