编译原理
为寄存器分配做准备:控制流图、定义/使用集合、活跃变量数据流方程,以及干涉图(interference graph)的构建。
Chapter 9 把 canonical IR 翻成了 abstract assembly——指令选好了,但用的还是无穷多个 temporary(t1, t2, t3, ...)。真实机器只有几十个寄存器,根本装不下。Chapter 10 接着问:
怎么知道哪些 temporary "正在使用",从而把它们挤进有限的寄存器里?
答案是 liveness analysis——一个典型的 dataflow analysis:
Chapter 10 的三个主题:
┌──────────────────────┐
│ Abstract Syntax Tree │
└──────────┬───────────┘
↓ Chapter 5–7
┌──────────────────────┐
│ IR Tree │
└──────────┬───────────┘
↓ Chapter 8: Canonical IR
┌──────────────────────┐
│ Canonical IR list │
└──────────┬───────────┘
↓ Chapter 9: Instruction Selection
┌──────────────────────┐
│ Abstract Assembly │ ← 用无穷 temporary
└──────────┬───────────┘
↓ Chapter 10: Liveness Analysis ← 你在这里
┌──────────────────────┐
│ Interference Graph │
└──────────┬───────────┘
↓ Chapter 11: Register Allocation
┌──────────────────────┐
│ Real Assembly │ ← 用真实寄存器
└──────────────────────┘
| IR / abstract assembly | 可以用无穷多个 temporary |
| 真实机器 | 只有有限多个 register |
要把多个 temporary 塞进同一个 register,必须先回答一个问题:
哪些 temporary 是"同时活着的"?
a := b + 1;
return a;
这里 b 在 a := b + 1 之后就再也不用了,而 a 是这之后才需要的——a 和 b 不会同时被需要,完全可以塞同一个 register。
只要能把所有这种"不重叠"的 temporary 配对挤压,就能用少量 register 装下大量 temporary。装不下的那些,溢出到内存(spill)。
一个变量在某个程序点 live,当且仅当它当前持有的值在未来可能被用到。
注意是"持有的值"——如果以后会再赋值再读,那只是同名变量的不同生命周期。
要回答"变量 x 在 statement n 之后还会被用到吗?",得问两件事:
由于"未来要不要用"这种问题天然是从后往前推:
Liveness 是 backward analysis——信息从程序的尾部沿 control flow 反向流动。
这跟 reaching definitions、available expressions 等"forward"问题正好相反。
CFG 是 dataflow analysis 的舞台:
m → n 表示 m 之后可能执行 n a ← 0
L1: b ← a + 1 → 1: a := 0
c ← c + b 2: b := a+1 ←┐
a ← b * 2 3: c := c+b │
if a < N goto L1 4: a := b*2 │
return c 5: a < N ─────┘ (true → 2)
6: return c (false from 5)
| 术语 | 含义 |
|---|---|
| out-edges of n | 离开 n 的边 |
| in-edges of n | 进入 n 的边 |
| succ[n] | n 的后继节点集合 |
| pred[n] | n 的前驱节点集合 |
上面例子中
succ[5] = {2, 6}、pred[2] = {1, 5}。
| def(节点) | 这条指令赋值给的变量集合(LHS) |
| use(节点) | 这条指令读取的变量集合(RHS / 表达式中) |
也可反过来定义:
3: c := c + b → def(3) = {c}, use(3) = {b, c}
4: a := b * 2 → def(4) = {a}, use(4) = {b}
注意 statement 3 既 use 又 def 了 c——RHS 里的 c 是旧值的 use,赋值是新值的 def。
A variable is live on an edge if there is a directed path from that edge to a use of the variable that does not go through any def.
通俗讲:沿控制流往下走,先撞到 use 而不是 def——它才活着。如果先被重新赋值,旧值就死了。
| 含义 | |
|---|---|
| in[n] | 在 n 的入口边上活着的变量(进入 n 之前就需要) |
| out[n] | 在 n 的出口边上活着的变量(n 执行完之后还需要) |
变量 a 在某条边上活着 ⟺
● v
│ no def(v)
▼
● use(v) ← v live on the edge above
从直觉一步步推到方程:
out[n] 来自后继的 in[m] n: ...
/ \
m1 m2
in[m1] in[m2]
如果 a 在某个 successor m 的入口活着,那它在 n 的出口当然也活着——因为执行完 n 就要进 m。所以:
如果 n use 了 a,那 a 进入 n 时就得有值——不然 n 没法运行。所以:
如果 a 在 n 的出口活着(后面要用),而 n 没有 def(a),那 a 是从 n 入口"穿过"来的——n 入口必须也有 a:
反过来,如果 n 重新 def 了 a,那入口的 a 是死的(被 n 覆盖了)。
把 Rule 2 + Rule 3 合并:
这两条方程是 Chapter 10 的灵魂。所有后续算法、定理都围绕它们展开。
in[n] = use[n] ← 自己要用的
∪ (out[n] - def[n]) ← 出口活着且自己没杀掉的
n 入口活着的变量 = "我自己 n 要用的" + "出来后还活着、且 n 没 def 掉的"。
方程是循环的——in[n] 依赖 out[n],out[n] 依赖 in[succ[n]],而 succ 可能绕回 n(循环)。所以得迭代到不动点(fixed point)。
for each n:
in[n] ← {}; out[n] ← {}
repeat
for each n:
in'[n] ← in[n]; out'[n] ← out[n]
in[n] ← use[n] ∪ (out[n] - def[n])
out[n] ← ⋃_{s∈succ[n]} in[s]
until in'[n] = in[n] AND out'[n] = out[n] for all n
use[n] 和 def[n] 是程序静态属性,已知且固定。in[n] / out[n] 只会增加(单调),因为 use[n] 始终包含、out 来自 in[s] 的并集。out[n] 依赖 in[s](s 是后继),而 in[n] 依赖 out[n]。所以:
Liveness flows backward along control-flow edges, and from out to in, so the computation should follow the same direction.
也就是按节点编号从大到小(从尾到头)、每个节点先算 out 再算 in,信息一次传播一长段。反过来按 1→6 算的话,要好多轮才传得回去。
| 迭代顺序 | 例子收敛轮数 |
|---|---|
| forward (1→6, in→out) | 约 7 轮 |
| backward (6→1, out→in) | 3 轮 |
这是一个通用经验:backward problem ⇒ backward iteration;forward problem ⇒ forward iteration。顺着信息流,效率最高。
只有一个前驱、一个后继的节点没意思——把这种节点跟邻居合并成 basic block,CFG 节点数大幅缩小,迭代轮数也变少。
块内 use/def 可以提前算好(只关心"块整体的 use/def"),块间再做 dataflow。
不是每次都需要全套 in/out。某些场景下只需要追踪某个变量是否活着——按需计算。许多 temporary 的 live range 很短,这种"按需"很省。
| 适用 | 集合操作复杂度 | |
|---|---|---|
| Bit Array | dense set(变量很多、活的也多) | union = N/K(K = word size) |
| Sorted List | sparse set(每点只活几个变量) | union = 合并两个有序表 |
平均每个集合元素数 < N/K 时,sorted list 更快。实际编译器多用 bit array,因为常量因子小、SIMD 友好。
设程序大小 N(节点数 ≤ N,变量数 ≤ N):
最坏:O(N⁴)。实际:O(N) ~ O(N²)(选合适的迭代顺序)。
方程可能有多个解。考虑下面的表(教材 Table 10.7):
| 解 X | 解 Y | "解" Z | |
|---|---|---|---|
| 1 in/out | c / ac | cd / acd | c / ac |
| 2 in/out | ac / bc | acd / bcd | ac / b |
| 3 in/out | bc / bc | bcd / bcd | b / b |
| 4 in/out | bc / ac | bcd / acd | b / ac |
| 5 in/out | ac / ac | acd / acd | ac / ac |
| 6 in/out | c / | c / | c / |
满足方程的解称为 fixed point。其中最小的(变量最少的)那个叫 least fixed point。
Theorem. 迭代算法从空集出发,每次只增不减,最后收敛到的就是 least fixed point。
任意 fixed point 都是 liveness 的保守上估:
| 含义 | |
|---|---|
| ✅ 真活的一定在算出的 live 集里 | dynamic live ⇒ static live |
| ❌ 算出在 live 集里的不一定真活 | static live ⇏ dynamic live |
也就是说,我们可能多说几个变量活着(寄存器多用一些),但绝不会漏说。
保守 = 安全:误判活着 ⇒ 多用 register,代码不优但正确;误判死了 ⇒ 寄存器复用导致值被覆盖,程序错。
1: a := b * b ← a >= 0
2: c := a + b ← c >= b
3: if c >= b ← always true!
4: return a ← dead branch
5: return c
例子里 node 4 在动态执行中永远到不了,但 static analysis 看到 CFG 边就认为它能到——所以认为 a 在 node 1 之后是 live 的。
Static 总是 ⊇ Dynamic,所以 static liveness 是 dynamic liveness 的保守近似。
Theorem(Halting Problem). 不存在程序 H,对任意程序 P 和输入 X,(在不无限循环的前提下)能判定 P(X) 是否停机。
Corollary. 不存在程序 H'(P, L),能判定程序 P 中的标号 L 在某次执行中是否会被到达。
证明草图:若 H' 存在,把程序结尾加一个
halt → goto L,就能用 H' 判定 P 是否停机——矛盾。
推论:编译器根本不可能精确知道一个变量是否真活,只能用静态、保守的近似。
设计原则:dataflow equation 应当设计成"任何解都是保守的"。这样优化结果至多 suboptimal,但绝不 incorrect。
Interference: 阻止两个 temporary 共享同一个 register 的条件。
两类:
a b c
a x
b x
c x x
b
│
●
│
a ─── c
这就是 interference graph——下一章 register allocation 把它当成图染色问题:用 K 种颜色(寄存器)染图,相邻节点不同色。
在 def 一个变量 a 的指令 n 处,a 跟
out[n]里所有其他变量都 interfere。
直觉:n 执行完后,a 占了一个 register,而 out[n] 里的变量也都占着 register,显然不能撞车。
算法:
for each n that defines variable a:
for each b in out[n], b ≠ a:
add edge (a, b) to interference graph
考虑 t := s(纯 copy,不是 t := s + 1):
t := s ← 此后 t 和 s 值相同
...
x := ... s ... ← use s
...
y := ... t ... ← use t
执行完 t := s 后,out[n] 里既有 t 又有 s——按基本规则会加 (s, t) 边。
但其实没必要!s 和 t 此时值相等,完全可以共用一个寄存器(把 MOVE 退化掉,直接复用)。
At a MOVE instruction
a := c,whereout[n] = {b1, ..., bk},add edges (a, bi) only forbi ≠ c.
也就是:MOVE 的 src 和 dst 之间不加 interference 边。
如果以后真的有不同时活的需要,会被其他指令的 def 加上边(比如后面
t := ...用到 t 时,t 跟当时活着的 s 自然产生冲突)。
如果某指令 def 了 a,但 a 之后再也没用——a 的 live range 长度为 0。
会不会造成问题?
a := b + c),不用 a 干脆别生成。结论:zero-length live range 仍然 interfere 它所重叠的活变量。这是 interference graph 构造时容易遗漏的边界。
(本节理论性较弱,只列接口,不展开)
1. Assem program → Control-flow graph (FlowGraph 模块)
2. Control-flow graph → Liveness 分析 (Liveness 模块)
3. 副产物:Interference graph + MOVE pairs
FG_def(n) / FG_use(n) / FG_isMove(n) 提供节点的 use/def 信息;
Live_liveness(flow) 返回 interference graph + 应当合并的 MOVE 对。
寄存器有限,IR 用无穷 temporary。要把多个 temporary 塞同一寄存器,就得知道哪些不会同时活着。
"未来会用到"天然反向流动。CFG 是舞台,sweet equations 描述如何在节点之间传播。
单调 + 有上界 ⇒ 必收敛。从空集出发,得到 least fixed point。
backward problem 用 backward iteration,从尾节点起、out 先于 in,信息一波到位。
Halting problem 决定了没有"完美"liveness 算法。任何静态分析都是 dynamic 行为的上估——宁多勿少,保证正确。
静态认为活着 ≠ 实际执行真用到。多估一个变量 ⇒ 寄存器分配不优;漏估一个变量 ⇒ 程序错。所以方程要"任何解都保守"。
把 liveness 落到图上:def 节点的变量 ↔ out[n] 中其他变量 连边。MOVE 的 src/dst 不连(可以共享寄存器)。Zero-length live range 仍要算 interference。
| Chapter | 问 | 答 |
|---|---|---|
| 7 | typed AST → IR? | IR Tree + Translate |
| 8 | IR 怎么整理? | Canonical → Basic Blocks → Traces |
| 9 | IR → 机器指令? | Instruction selection (maximal munch) |
| 10 | temp 谁跟谁能共享寄存器? | Liveness analysis + Interference graph |
| 11 | 怎么把 temp 真的塞进寄存器? | Graph coloring / 寄存器分配 |
到这里整个编译器已经能算出"哪些 temporary 互相冲突"。下一章 Chapter 11 用 图染色 把 interference graph 涂成 ≤K 色(K = 寄存器数),涂不开就 spill 到内存。
abstract assembly + flow graph
→ liveness (in/out)
→ interference graph ← 你在这里
→ graph coloring → real assembly
按以下顺序推进,效率最高:
c := c + b 这种自更新)in[n] = use[n] ∪ (out[n] - def[n])out[n] = ⋃ in[s]c := c + b:c 既 use 又 def,顺序是先 use 再 def(use[n] = {b, c}、def[n] = {c})in[n] 是 n 入口边集合的 union,out[n] 是出口边集合的 unionLiveness 是连接 IR 和真实机器寄存器的桥梁:没有它,寄存器分配就是空中楼阁。
Written by
Comments