想象一家大型中央厨房:客人点的是“糖醋排骨”,但后厨真正执行的是一张张动作卡片——“切块”“腌制”“油炸”“裹汁”。编译器也一样,它不会直接让 CPU 去做“糖醋排骨”,而是先把源码翻译成一张张只含一个动作的卡片,这就是三地址码。后来厨房搞了场改革:每只装食材的容器只能用一次,新食材必须换新容器,这就是 SSA 的单赋值。遇到两条流水线汇合,就用一个 φ 分拣闸决定选用哪边的食材。本文会用这座厨房带你走完从三地址码到 SSA 构造、再到回归普通代码的完整旅程。

岗位关联

理解三地址码与 SSA,是看懂 LLVM、TVM 等现代编译/推理框架优化流程的钥匙;对 AI 编译器工程师来说,这决定了你能否把模型高效地“炒熟”成可运行的机器码。

目录
  1. 可视化演示
  2. 三地址码基础
  3. 控制流图CFG
  4. 单赋值与phi
  5. 支配者关系
  6. SSA构造算法
  7. SSA析构与LLVM
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示三地址码指令格式、SSA构造与phi函数插入及支配者树与支配边界,支持变量版本调节。

本节配有交互动效(进入视口后自动加载,约 3KB)。

三地址码指令格式 紫色赋值指令x=y op z,每条指令最多三个操作数 黄色控制流指令if x relop y goto L实现条件跳转 x = y op z | x = op y | goto L 三地址码简洁统一,易于生成和优化

三地址码基础

想象中央厨房接到“糖醋排骨”的订单。大厨不会把整道菜丢给一个人,而是拆成“切块、腌制、挂糊、油炸、裹汁、装盘”一张张动作卡片。编译器也一样:前端解析完源码后,先产生一种扁平的过渡语言——三地址码,让每一张卡片只记录一个最小动作。

三地址码的指令形式很固定:x = y op z。其中 op 可以是加减乘除、比较、跳转、函数调用等;y 和 z 是源操作数,x 是放结果的新位置。这里的“地址”不一定是内存地址,可以理解成一个个编号的食材容器(变量或临时变量)。每条指令最多涉及三个地址,所以叫三地址码。

四元组表示把一条三地址码写成四个字段:(op, y, z, x)。例如 t1 = a + b 写作 (+, a, b, t1)。临时变量 t1、t2 就像厨房里编号的一次性餐盒,每个中间结果都有自己的名字,避免把一锅菜反复倒进同一个碗里。

AST 是一棵大树,三地址码则像被压平的卡片。每张卡片动作单一、字段清晰,后续优化和代码生成都像厨师按卡片顺序接力,不用再爬一遍语法树。

Quad=(op, y, z, x)\mathrm{Quad} = (\mathrm{op},\ y,\ z,\ x)
四元组是一种表格化的三地址码,四个槽位分别记录“动作、原料一、原料二、成品”。
  1. 操作码 op\mathrm{op} 代表动作,例如 +、-、*、跳转。
  2. 第一源操作数 y第一个参与动作的原料/地址。
  3. 第二源操作数 z第二个原料/地址;一元操作时通常为空。
  4. 目标 x把动作结果写到 x 指向的容器,通常是一个全新编号的临时变量。

控制流图CFG

卡片一多,厨房就需要一张工序流程图。控制流图(CFG)把连续执行、中间没有分支的卡片合成“基本块”,块与块之间用有向边连接。边代表“做完这一堆卡片后,下一步可能走到哪一堆卡片”。

基本块的划分规则很简单:程序入口、跳转目标、跳转指令的下一条指令都是“带头卡片”。从带头卡片开始,不断加入后续语句,直到遇到跳转指令或下一个带头卡片为止。基本块内部是直线执行,不能从中间进入,也不能从中间离开。

CFG 是数据流分析的地图。每个基本块的入口和出口都是“程序点”,SSA 的 φ 函数就插在块入口这个程序点上。跳转连接则把不同卡片堆串成一条条可走的路径,编译器在后端算法中频繁在这些路径上做推理。

有了 CFG,后续的支配关系和 SSA 构造就有了统一地图。厨房里的每一条“出菜路线”都在这张图上,哪里可能汇合、哪里必须设分拣闸,一目了然。

G=(V,E),V={B1,…,Bn},E={(Bi,Bj)∣next(Bi)=Bj or jump(Bi)=Bj}G = (V, E), \quad V = \{B_1,\ldots,B_n\}, \quad E = \{(B_i, B_j) \mid \mathrm{next}(B_i) = B_j \ \mathrm{or}\ \mathrm{jump}(B_i) = B_j\}
CFG 是一个有向图:节点是基本块,边是可能的执行顺序。
  1. V 基本块集合\(V = \{B_1,\ldots,B_n\}\) 是所有卡片堆的集合。
  2. E 有向边集合\(E\) 中的每条边 \( (B_i,B_j) \) 表示块 \(B_i\) 执行后可能进入块 \(B_j\)。
  3. next(Bi)=Bj表示块 \(B_i\) 末尾没有跳转,自然落入 \(B_j\)。
  4. jump(Bi)=Bj表示块 \(B_i\) 末尾的跳转指令把控制流交给 \(B_j\)。

单赋值与phi

SSA 是一场“容器只用一次”的改革。普通代码里,变量像抽屉,可以反复拉开换内容;SSA 中每个变量名只能被赋值一次。如果同一逻辑变量要多次赋值,就生成 x1、x2、x3 这样带编号的新名字。每个新名字只有一个定义点,数据流动路径极其清晰。

问题来了:if 的两个分支分别给 x 赋了不同的值,汇合之后到底用哪个?传统代码用同一个抽屉覆盖,SSA 不能覆盖,所以需要 φ 函数。φ 是编译中间表示里的一种特殊“分拣闸”:它根据控制流实际来自哪个前驱块,选择对应编号的值。

单赋值让每个名字只有一个定义点,所以常量传播、死代码消除等优化变得非常简单:想知道某个值从哪来,只要找到这个名字的唯一定义即可。厨房里每份食材都有唯一“溯源码”,出了质量问题能立刻定位是哪条线、哪只容器。

x3=ϕ(x1, x2)x_3 = \phi(x_1,\ x_2)
φ 的语义是:如果从第一个分支过来,x3 等于 x1;如果从第二个分支过来,x3 等于 x2。
  1. 目标 x3x_3 是汇合后使用的新版本变量,由 φ 选出。
  2. φ 标记\phi 不是普通函数调用,而是表示“按前驱选择”。
  3. x1 来源一x_1 是第一个前驱块里对 x 的赋值。
  4. x2 来源二x_2 是第二个前驱块里对 x 的赋值。

支配者关系

SSA 的 φ 不是随便插的,它需要插在“必经之路”的边界上,所以必须先算支配关系。在 CFG 中,如果从入口到某个基本块 n 的所有路径都经过 d,就说 d 支配 n。这就像厨房送餐:不管客人走哪条走廊,都要经过检查站 d,d 才能支配终点 n。

在支配关系之上可以建立支配者树:每个节点只保留离自己最近的支配者(直接支配者)作为父节点。这棵树浓缩了 CFG 的最重要控制骨架,编译器可以在树上快速判断“谁管着谁”。

支配边界则回答“某个检查站的控制力到哪里结束”。如果 d 支配 m 的某个前驱,但 d 并不严格支配 m,那么 m 就在 d 的支配边界内。这里的“严格支配”指 d 支配 m 且 d ≠ m。φ 函数正是要插在这些支配边界对应的基本块里。

d dom n  ⟺  ∀π∈Paths(entry,n): d∈πd\ \mathrm{dom}\ n \iff \forall \pi \in \mathrm{Paths}(\mathrm{entry}, n):\ d \in \pi
支配边界是“支配力首次失效”的位置,而不是普通意义上所有汇合点的集合。
  1. Pred(m)\mathrm{Pred}(m) 是 m 的所有直接前驱块集合。
  2. ∃ p至少存在一个前驱 p 满足条件。
  3. n dom pn 支配前驱 p,说明 n 的影响力已经覆盖到 m 门口。
  4. ¬(n sdom m)n 不严格支配 m,说明这种影响力在 m 处第一次没能“说了算”,所以 m 需要分拣闸。

SSA构造算法

SSA 构造像在厨房里全面安装“溯源码系统”,分三步:先算出每个节点的支配边界,再在需要汇合的地方插入 φ,最后给所有变量做深度优先重命名。第一步确定分拣闸装在哪,第二步装闸,第三步给每份食材贴上唯一编号。

插入 φ 的位置可以这样算:对变量 v,收集所有定义 v 的基本块,把这些块各自的支配边界全部取并集,得到的节点就是要插入 φ 的地方。注意这不是一次就能完成的,因为新插入的 φ 本身也是一个“定义”,它又可能在新的支配边界产生更多 φ,所以要反复迭代到不动点。

插完 φ 后,还要重命名变量:深度优先遍历支配者树,维护每个变量当前版本号。遇到一个定义就生成新名字 v_i,遇到一个使用就替换成当前栈顶版本。这样,每个变量名都只有一个定义点,SSA 正式建成。LLVM 的 mem2reg pass 做的核心工作之一,就是把普通内存读写提升成这种 SSA 寄存器形式。

Places(v)=⋃b∈Defs(v)DF(b)\mathrm{Places}(v) = \bigcup_{b \in \mathrm{Defs}(v)} \mathrm{DF}(b)
变量 v 需要插 φ 的位置,等于它所有定义点支配边界的并集。
  1. Defs(v)\mathrm{Defs}(v) 是所有给变量 v 赋值的块集合。
  2. DF(b)\mathrm{DF}(b) 是块 b 的支配边界。
  3. ⋃ 并集把所有边界点合并,得到首批 φ 候选位置。
  4. 迭代语义新插入的 φ 会继续参与并集计算,直到不再产生新 φ。

SSA析构与LLVM

优化做完之后,CPU 并不认识 φ。SSA 析构就是把 φ 拆掉,回到普通三地址码。这相当于厨房优化完流程后,把分拣闸撤掉,改成在每个前驱工作台提前把对应食材送到汇合口。

基本做法是:对每个 φ 指令 x = φ(x1, x2),在第一个前驱块末尾插入 x = x1,在第二个前驱块末尾插入 x = x2,然后删除 φ。多个 φ 同时存在时,它们可能构成“并行拷贝”——像同时交换两杯水,必须借助临时容器拆成顺序拷贝,否则会互相覆盖。

LLVM 的 IR 正是这种工业级三地址码风格:% 开头的虚拟寄存器、phi 指令、基本块标签一应俱全。优化 pass 在 SSA 上做常量传播、循环不变式外提、死代码消除,后端再通过 SSA 析构和寄存器分配把逻辑变成机器码。可以说,LLVM 的优化能力一半来自这套类似“中央厨房改革”的 SSA 骨架。

in B1: x=x1;in B2: x=x2;remove x=ϕ(x1,x2)\mathrm{in}\ B_1:\ x = x_1; \quad \mathrm{in}\ B_2:\ x = x_2; \quad \mathrm{remove}\ x = \phi(x_1, x_2)
SSA 析构不是“删掉 φ”三个字,而是把选择语义下沉到每个前驱里显式赋值。
  1. B1 前驱拷贝在 \(B_1\) 块末尾写入 x = x1,模拟从第一个分支来的情况。
  2. B2 前驱拷贝在 \(B_2\) 块末尾写入 x = x2,模拟从第二个分支来的情况。
  3. remove φ原来的 x = φ(x1,x2) 使命完成,可以删除。
  4. 并行拷贝多个 φ 同时存在时,需要临时变量做并行交换。

AI 应用场景

深度学习编译器中的算子融合

阶段:模型编译优化阶段

解决的问题:传统推理引擎按图逐个执行算子,中间张量不断写回显存再读出来,访存开销巨大。AI 编译器先把算子图翻译成三地址码风格的 IR,再用 SSA 单赋值让每个中间张量只有一个定义点,后端就能安全判断哪些算子可以融合、哪些中间结果可以复用内存。

具体案例:TVM/MLIR 把 ONNX 或 PyTorch 图翻译成 MLIR/TIR,优化 pass 在 SSA 信息指导下做算子融合和 buffer 复用,最后交给 LLVM 处理寄存器级优化。

大模型推理引擎的常量折叠

阶段:图优化与预计算阶段

解决的问题:大模型推理里有些 shape 推导、mask 生成、位置编码结果在运行时完全不变,但每次请求都重复计算。SSA 的“单赋值”让每个值只有一个来源,常量传播 pass 可以沿着定义链一路折叠,把死代码直接删掉,减少推理延迟。

具体案例:TensorRT 和 FasterTransformer 在引擎构建期做常量折叠与死代码消除,批量推理时省掉大量冗余 kernel launch。

静态程序分析与安全审计

阶段:代码数据流分析阶段

解决的问题:安全工具需要追踪用户输入是否流到了危险函数,也就是“污点传播”。SSA 中每个变量只有一个定义点,数据流路径天然显式,分析器可以像顺着厨房的溯源码追查食材来源一样,快速定位污染源。

具体案例:LLVM 的 DataFlowSanitizer 和许多静态分析器都基于 SSA 形式做污点分析,辅助发现 SQL 注入、内存越界等漏洞。

Python 演示

python56 行
# 简易 SSA 构造 demo: 给定 CFG 前驱图, 计算支配关系与支配边界, 并插入 phifrom collections import defaultdict preds = {    0: [],    1: [0],    2: [0],    3: [1, 2],    4: [3, 0],} def build_dom(preds, entry=0):    nodes = set(preds)    dom = {n: set(nodes) for n in nodes}    dom[entry] = {entry}    changed = True    while changed:        changed = False        for n in nodes:            if n == entry:                continue            new = set.intersection(*(dom[p] for p in preds[n])) | {n}            if new != dom[n]:                dom[n] = new                changed = True    return dom def build_df(preds, dom):    df = defaultdict(set)    nodes = set(preds)    for b in nodes:        for p in preds[b]:            # 若 d 支配前驱 p, 且 d 不严格支配 b, 则 b 在 DF(d) 中            for d in dom[p]:                if d == b or d not in dom[b]:                    df[d].add(b)    return df def insert_phi(def_blocks, df):    phi_blocks = set()    work = set(def_blocks)    while work:        n = work.pop()        for b in df[n]:            if b not in phi_blocks:                phi_blocks.add(b)                work.add(b)    return phi_blocks if __name__ == "__main__":    dom = build_dom(preds)    df = build_df(preds, dom)    phi = insert_phi([1, 2], df)    for node in sorted(preds):        print("block", node, "dom", sorted(dom[node]), "df", sorted(df[node]))    print("phi blocks:", sorted(phi))
目的:用一个小 CFG 演示 SSA 构造前两步:求支配者集合、求支配边界、迭代插入 phi。
  1. new = set.intersection(*(dom[p] for p in preds[n])) | {n}一个块的新支配者是所有前驱支配者的交集再加上自己。
  2. if d == b or d not in dom[b]:如果 d 支配某个前驱但没有严格支配 b,b 就是 d 的支配边界。
  3. work.add(b)新插入的 phi 本身也是一个“定义”,要把 b 继续放进工作列表传播 phi。

常见误区

误区三地址码和汇编都是“一条条指令”,那它俩应该差不多,反正最后都要变成机器码。

正解三地址码是机器无关的中间表示,变量和临时变量由编译器管理,不绑定具体寄存器或内存寻址方式;汇编则是针对目标 CPU 的文本形式,指令集和寄存器都固定。厨房里的“切块”卡片是通用动作,具体用哪把刀、哪块砧板是后端的事。

为何常见:两者都是线性指令序列,外形相似,初学者很容易把“中间表示”和“目标汇编”混为一谈。

误区SSA 要求每个变量只能赋值一次,所以写代码时不能出现 `x = x + 1` 这种语句。

正解SSA 确实要求每个变量名只有一个定义点,但这是靠重命名实现的。源码里的 x 第二次赋值会变成 x1,第三次变成 x2,汇合时再用 φ(x1,x2) 合并。容器每个只能用一次,但仓库里有源源不断的新容器。

为何常见:“单赋值”这个名称太有误导性,让人以为是在限制程序员写代码,其实它是中间表示内部的重命名规则。

误区φ 带了函数名,肯定会在运行时被调用,会有栈帧和调用开销。

正解φ 是编译期/IR 层的特殊指令,它表示“按前驱块选择值”,不产生函数调用,也不需要栈帧。SSA 析构阶段会把它转成普通拷贝或直接消除。分拣闸是静态布置的,不是每个包裹现场打电话问路。

为何常见:名字里带“函数”两个字,加上很多资料直接用 phi(x,y) 形式书写,很容易误解成运行时函数。

误区只要两个分支汇合了,就到了支配边界,就得插 φ。

正解支配边界 DF(d) 是 d 的控制力边界,要求 d 支配某个前驱但不严格支配目标块。不是所有汇合点都在 d 的支配边界里。每个检查站只在自己的势力范围边缘设卡,而不是在整栋楼每个门口都设卡。

为何常见:φ 经常出现在汇合点,初学者容易把“需要 φ 的位置”直接等同于“所有汇合点”。

面试题

Q1. 三地址码最典型的指令形式是?

单选题 · easy

  • A.x = y op z← 正确答案
  • B.mov eax, [x]
  • C.int f() { return 1; }
  • D.#define

答案:A。三地址码是机器无关的中间表示,核心形式就是 x = y op z,一条指令最多三个地址。mov eax 是目标汇编,函数源码和宏定义都是更高层的源码形式。

Q2. 四元组 (op, y, z, x) 中,哪个字段是结果?

单选题 · easy

  • A.op
  • B.y
  • C.z
  • D.x← 正确答案

答案:D。四元组的惯例是“操作码、源操作数1、源操作数2、目标结果”。x 是结果字段,y 和 z 是参与运算的源操作数。

Q3. 在 CFG 中,基本块是指?

单选题 · easy

  • A.只有一个跳转指令的块
  • B.只能从第一条进入、最后一条离开,中间没有跳转的直线语句序列← 正确答案
  • C.不包含任何变量的语句集合
  • D.所有跳转目标的集合

答案:B。基本块是直线执行的语句序列,入口不能从中间进入,出口不能从中间离开,块内没有分支或跳转目标。它像一叠连续动作的工序卡片,整叠执行。

Q4. SSA 的“单赋值”核心含义是?

单选题 · mid

  • A.每个变量名只有一个定义点← 正确答案
  • B.每个基本块只能有一条赋值指令
  • C.所有赋值只能放在入口块
  • D.每个 phi 只能有一个参数

答案:A。SSA 不是说整个程序只能赋一次值,而是通过重命名让每个变量名只有一个定义点。同一逻辑变量多次赋值会变成 x1、x2,再用 φ 合并。

Q5. x3 = phi(x1, x2) 出现在基本块入口,其语义是?

单选题 · mid

  • A.取 x1 和 x2 中较大的一个
  • B.运行时调用函数 phi 求值
  • C.根据实际到达的前驱块选择 x1 或 x2 作为 x3← 正确答案
  • D.无条件把 x1 赋给 x3

答案:C。φ 是分拣闸,它的选择依据是控制流从前驱块中的哪一个到达汇合点。不是大小比较,也不是运行时函数调用。

Q6. 关于支配关系,哪些说法正确?

多选题 · mid

  • A.入口支配所有入口可达的基本块← 正确答案
  • B.若 d 支配 n 且 d ≠ n,则 d 严格支配 n← 正确答案
  • C.每个基本块可以有多个直接支配者
  • D.支配关系与 CFG 的边无关

答案:AB。入口到任何可达块的所有路径都必经过入口,所以入口支配所有可达块。严格支配就是支配且不等于自身。直接支配者只有一个,支配关系依赖 CFG 的路径。

Q7. 下面哪些情况会触发布块 b 为变量 x 插入 phi?

多选题 · hard

  • A.b 在 x 某个定义点的支配边界中← 正确答案
  • B.x 在 b 的多个前驱块中均有定值且都能到达 b← 正确答案
  • C.x 只在 b 内定值并被使用
  • D.b 是入口基本块

答案:AB。φ 的本质是多个定义在汇合点合并。定义点的支配边界覆盖了这些汇合位置。如果变量只在块内自产自用,没有多前驱汇合,也不需要 φ。

Q8. 关于支配边界 DF(d),正确的是?

多选题 · hard

  • A.若 m ∈ DF(d),则存在 m 的前驱 p 被 d 支配← 正确答案
  • B.若 m ∈ DF(d),则 d 不严格支配 m← 正确答案
  • C.若 m ∈ DF(d),则 m 必被 d 支配
  • D.DF(d) 可能为空← 正确答案

答案:ABD。支配边界是“支配力失效”的位置,所以它要求支配某个前驱但不严格支配目标。DF(d) 完全可能为空,比如 d 一直支配到整个图的出口。DF 中的 m 通常不被 d 支配。

Q9. SSA 析构时,为什么不能只删掉 phi?

多选题 · hard

  • A.φ 不是机器可执行指令← 正确答案
  • B.φ 表示按前驱选择值,必须转成各前驱里的拷贝← 正确答案
  • C.φ 会消耗额外寄存器,删除能提高性能
  • D.多个 φ 可能形成并行拷贝,需要临时变量处理覆盖← 正确答案

答案:ABD。φ 是 IR 层的选择指令,CPU 不认识,必须把选择语义下沉到每个前驱块的拷贝里。多个 φ 同时存在时可能互相覆盖,需要临时变量拆成顺序拷贝。

Q10. LLVM 的 mem2reg pass 核心作用是什么?

单选题 · mid

  • A.将 alloca/load/store 提升为 SSA 虚拟寄存器← 正确答案
  • B.把 SSA 虚拟寄存器降回内存操作
  • C.删除所有 phi 函数
  • D.将 IR 直接翻译成目标机器码

答案:A。mem2reg 是 LLVM 中最典型的 SSA 构造 pass,它把栈上内存读写提升为虚拟寄存器,并插入必要的 φ,让后续优化在 SSA 上进行。