“经典SSA构造分成不可互换的两阶段。第一阶段运行φ 节点放置算法,为每个变量在所需汇合块插入未完全命名的 φ。第二阶段取得由支配树算法计算的树,从入口深度优先遍历,并为每个原变量 $v$ 维…”
形式陈述
在约定的三地址码指令集上,静态单赋值形式(static single-assignment, SSA)要求每个标量名字恰有一个静态定义,并且该定义按支配关系先于它的每个普通使用。对控制流图中的使用点
若 CFG 节点是整块,同块内还必须检查指令顺序:普通定义在使用之前,块首 φ 的结果在普通指令之前可用;块级的自反支配本身不能为“先读后定义”开绿灯。“静态一次”按程序文本计数,不按运行次数计数。循环头的一条定义可以在不同迭代反复执行;它仍是一个静态定义点。入口参数和全局初值通常视作位于人工入口的定义,因此参数的首次使用并非“无定义”。块参数式 SSA 把合流值写成后继块形参;经典 SSA 则使用φ 节点,两者都必须按前驱边解释值的来源。
块参数与 φ 的对应需要连同边一起给出:
这项不变量主要针对可版本化的标量临时量。可变内存若可能由别名写入,不能仅把指针名字改成
稀疏条件常量传播沿本页的定义—使用与φ入边接口传播值,同时增长可能执行的控制边。它只合流已激活的φ槽;本页的SSA构造、消解与逐边复制证明仍保持原有责任。
直觉
普通程序反复给变量
SSA 没有消除控制流。名字唯一并不意味着值唯一:不同执行可以使同一个定义产生不同运行时值,例如循环中的
例子与边界
考虑菱形程序:入口给
沿真边手算得到
循环展示“静态”一词的边界。初始化
在同一张图上构造与消解
下面固定纯标量、精确自然数运算;没有内存、异常或外部事件。输入是
E: a := 1; b := 2; k := 0; goto H
H: if k < n then goto B else goto X
B: t := a; a := b; b := t; k := k + 1
if s then goto X else goto H
X: return (a,b,k)
| 块 | 支配集 | 立即支配点 | DF |
|---|---|---|---|
| — | |||
例如
放置 φ 与栈式改名
按φ 节点放置算法,对变量
本例
采用SSA 构造的栈式改名,按支配树深度优先遍历,并给每个原变量维护名字栈。规则是:
- 进入块时,先为各 φ 的结果生成新名字并压栈,暂不读取其参数。
- 顺序处理普通指令:右侧和分支读取当前栈顶;左侧生成新名字后压栈。因此
k:=k+1先读旧版本再产生新版本。 - 对每条出边,为后继 φ 的这一条边填入当前栈顶。
- 递归处理支配树孩子,返回时弹出本块压入的名字,恢复父层环境。
这与经典栈式算法的参数填充时机一致。[1] 依次访问
在
E: a0 := 1; b0 := 2; k0 := 0; goto H
H: a1 := phi(E:a0, B:a2)
b1 := phi(E:b0, B:b2)
k1 := phi(E:k0, B:k2)
if k1 < n then goto B else goto X
B: t1 := a1; a2 := b1; b2 := t1; k2 := k1 + 1
if s then goto X else goto H
X: a3 := phi(H:a1, B:a2)
b3 := phi(H:b1, B:b2)
k3 := phi(H:k1, B:k2)
return (a3,b3,k3)
输入
逐条边证明改名保持原值
令原环境为
在
:两边初始化相同, 同时取三个 0 版本,关系成立。 :比较读取相同 ,所以选择同一条边。若进入 ,顺序赋值都把 变成 ; 保存旧 ,得到块末关系。 : 取 ,恰为原环境。 :两边依据同一输入 选边; 取块末的 ,恢复头部关系。 : 同样取块末三个版本,返回三元组相同。
这五种情况覆盖所有边。沿任意有限执行前缀归纳,就覆盖任意次数回边;随后相同返回指令给出相同观察。本例还必定终止:
同一程序中的关键边与复制环
先显式进行复制传播
上面的原始 SSA 中,
H: a1 := phi(E:a0, B:b1)
b1 := phi(E:b0, B:a1)
k1 := phi(E:k0, B:k2)
if k1 < n then goto B else goto X
B: k2 := k1 + 1
if s then goto X else goto H
X: a3 := phi(H:a1, B:b1)
b3 := phi(H:b1, B:a1)
k3 := phi(H:k1, B:k2)
return (a3,b3,k3)
复制只应发生在被选中的边上
进行SSA 消解时,对每条入边收集后继块的 φ 所要求的并行复制。优化后五条边对应
| 边 | 该边应执行的并行赋值 |
|---|---|
| 无 | |
关键边的源块有多个后继,目标块有多个前驱;这里是
为什么不能把回边复制提前塞到
保存一个旧值,消除回边复制环
a1:=b1; b1:=a1,否则旧
u := a1
a1 := b1
b1 := u
k1 := k2
四条指令依次执行。若旧值为
一般并行复制也有直接算法:先删去 d:=d;若某目标 d:=s 并删去它。若无这种目标,剩余有限依赖图中有环;选一个目标
这两种动作都保持不变量:“已输出的顺序指令执行后,再同时执行剩余复制,与原并行复制在所有非临时位置上的最终值相同。”安全删除不会覆盖剩余源;断环只把旧源换成其保存值。归纳直到剩余为空,就得到顺序化的正确性。这里不声称最少临时量;空间更紧的算法及其机械化证明见文献。[2]
把边拆分和复制顺序化合在一起,每次原边转移都由一段有限、无分支的新路径匹配;路径末的非临时状态等于原 φ 的同时更新。因此逐边关系继续成立。新增块不会自行循环,返回值不变;对一般可能发散的程序,这种每边有界有限展开也不会把有限执行变成发散或吞掉无限执行。
| 输入 | 原程序、SSA、消解后共同返回值 |
|---|---|
内存与效果边界
内存反例也很具体:令
推论与应用
唯一定义使常量传播、复制传播、死代码删除和值编号拥有天然的稀疏工作表。若
支配作用域值编号把这项优化出口具体化:按操作数的值代表建立表达式键,沿支配树保存并撤回可用载体,同块重复φ仅在输入编号已知时合并。它保留未解回边,不能把静态定义唯一误读为循环中的运行值不变。若改在无φ标量形式上消除只在部分来路重复的运算,边局部部分冗余消除另行建立新鲜载体并在缺失入边补算;两项算法都不能替代本页的构造与消解证明。
SSA 不是目标机器通常直接执行的格式。进入寄存器分配前,编译器往往消解 φ、拆分关键边并安排并行复制。构造与消解必须各自保持语义;仅证明输入和输出都满足某种名字格式,不足以证明它们运行结果相同。
memory SSA 可为每次可能写内存的操作产生新内存版本,并让 load 使用支配它的版本;分支汇合时也需要内存 φ。它能把别名分析结果编码为稀疏依赖,却不会自己证明两个地址不别名。若外部调用的 mod/ref 摘要漏掉一次写入,内存版本链会显得合法但 load 仍可能读错值。
参考资料
-
[1] Ron Cytron et al., “Efficiently Computing Static Single Assignment Form and the Control Dependence Graph”, ACM TOPLAS 13(4), 1991, §§4–5,特别是 Figures 11–12 与 Lemmas 9–10:支配边界构造、栈式改名与沿执行路径的值保持。
-
Andrew W. Appel, Modern Compiler Implementation in ML, Cambridge University Press, 1998, Chapter 19.
-
[3] Fabrice Rastello, ed., SSA-based Compiler Design;在线预稿 §§2.4、3.2–3.3:pruned SSA、关键边与复制传播后的消解。此处节页对应预稿(pp. 20, 36–39),不把它当作2022年最终版逐页引文。
-
[2] Laurence Rideau, Bernard Paul Serpette, Xavier Leroy, “Tilting at Windmills with Coq: Formal Verification of a Compilation Algorithm for Parallel Moves”, Journal of Automated Reasoning 40, 2008, pp. 307–326,§§2–4:并行赋值语义、临时位置与顺序化不变量。