第三章:高级语言的 SSA 构建(二)
在《第三章:高级语言的 SSA 构建(一)》中,我们讨论了 SSA 的基本概念和基本编译技术,并且详细分析和讨论了分支的 SSA 构建,接下来,我们将会继续未竟的话题,讨论循环的 SSA 构建和函数构建。
SSA 构建流程控制:循环
在开始了解 SSA 构建循环之前,我们需要先了解循环本身的基本概念:
规约循环与非规约循环
规约循环(Reducible Loop):规约循环是一个非常好的循环,他的特点是:
- 入口唯一
- 所有回边都指向入口(循环头)
- 循环头支配所有循环体的节点
规约循环是最常见的循环形式,大多数高级语言不使用 Goto 构成的循环结构(如 for、while)都会生成规约循环。
与之相对的概念是非规约循环(Irreducible Loop),非规约循环的特点是:
- 入口点可能有多 个;
- 回边可以指向循环内任意节点;
- 循环头有可能无法支配所有循环体节点;
我们可以举出很多个非规约循环的案例:
多入口循环
这个循环是非规约的,原因在于:
- 存在两个入口点(Entry 1 和 Entry 2)可以进入循环
- 循环没有唯一的循环头(B 和 D 都可以作为入口点)
- 既不是 B 支配 D,也不是 D 支配 B
交叉循环
这个例子展示了两个相互交叉的循环:
- B-C-D 形成一个循环
- C-E 形成另一个循环
- 循环 C-E 和循环 B-C-D 共享节点 C
- 没有唯一的循环头,因为 C 既属于一个循环又属于另一个循环
我们现在讨论的循环,都是规约循环。
在后面的内容,会尽量少的讨论非规约循环,因为非规约循环的构建一般需要用到一些 GOTO 之类的 控制指令,或者在更低级语言中无条件跳转,过早地接触这些内容会让读者心生恐惧。
非规约循环的处理一般会想办法转移到现有的规约循环中,或者直接使用 GOTO + Label 来组合,优化也会变得更加复杂。
三段式循环
三段式循环(Three-Address Loop)是一种常见的循环结构模式,我们在这里深入讨论这种经典的循环结构的 SSA 构建。
在读者阅读本节之后,可以尝试阅读 LLVM 的 SSA 构建文档 以获得更多信息。
也可以深入思考对于其他的循环结构应该如何构建 SSA。
C 语言风格的循环一般由三部分组成:初始化、循环体、更新。 考虑如下代码:
package main;
class Main {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
System.out.println("Hello, World!");
}
}
}
上述代码编译后核心 SSA 结构如下:
Main_main_e5a8 <string> (11) args
type: () -> null
entry-0:
jump -> loop.header-1
loop.header-1: <- entry-0
jump -> loop.condition-2
loop.condition-2: <- loop.header-1 loop.latch-4
<any> t33 = phi [<number> 0, loop.header-1] [<number> t31, loop.latch-4]
<boolean> t19 = <any> t33 lt <number> 10
Loop [<nil>; <boolean> t19; <nil>] body -> loop.body-3, exit -> loop.exit-5
loop.body-3: <- loop.condition-2
<#24.println> t26 = undefined-System.out.println(from:24)
<any> t24 = undefined-System.out(valid)(from:22)
<any> t22 = undefined-System
<null> t28 = call <#24.println> t26 (<any> t24, <string> Hello, World!) binding[] member[]
jump -> loop.latch-4
loop.latch-4: <- loop.body-3
<number> t31 = <any> t33 add <number> 1
jump -> loop.condition-2
loop.exit-5: <- loop.condition-2
extern type:
三段式循环的基本块分析
在这个例子中,循环被分解为以下几个基本块:
entry-0
: 程序入口块loop.header-1
: 循环头部块loop.condition-2
: 循环条件判断块loop.body-3
: 循环体块loop.latch-4
: 循环更新块loop.exit-5
: 循环出口块
让我们用 mermaid 来可视化这个控制流图:
循环中的 Phi 生成
根据我们在 "第二章" 中基本块和 Phi 生成的讨论,我们知道 Phi 节点是用来处理多入口基本块的。我们在这里发现,loop
的 loop.condition-2
中包含了两个前 驱块,所以这儿可能会生成 Phi。
那么,我们把上述图再细化一下,得到如下内容:
我们会得到一个 phi 节点 t33
,这个 phi 节点会根据前驱块的不同,选择不同的值。可能的选项为:
- 如果前驱块是
loop.header-1
,则t33
的值为 0 - 如果前驱块是
loop.latch-4
,则t33
的值为t31
这个情况是一个典型的 For 循环中的 Phi 节点生成的案例。
仔细观察一下,我们发现 t33 依赖(被支配)了 t31,而 t31 又是由 t33 + 1
得到的(支配),这陷入了一个循环。我们把这个关系表述成一个更精炼的图
虽然从 “空间” 上看,这是一个不健康的支配依赖,但是在 SSA 中,这是完全合法的:
- 时序关系:
t33
在t31
之前被定义,每次都是先使用 t33 的值,才需要计算新的 t31. - Phi 节点在过程中选择不同路径,导致不同的值,这是执行的时候控制的,并不需要我们来解决。
图灵完备(Turing Complete)指的是一个计算系统能够模拟通用图灵机的计算能力,换句话说,它能够计算任何可计算的函数。一个系统要达到图灵完备,需要具备基本的控制流(如条件判断和循环)以及任意数量的存储空间。现代的编程语言,包括我们的 SSA 中间表示,都是图灵完备的。
停机问题(Halting Problem)是指:给定一个程序和它的输入,判断这个程序是否会在有限时间内终止运行。这个看似简单的问题,实际上是不可判定的。换句话说,不存在一个通用的算法能够准确判断任意程序是否会停机。
这是因为存在一个根本性的矛盾:
- 假设存在一个函数
willHalt(program, input)
,它能判断任意程序是否会停机