SSA 中间表示与优化 Pass
一、为什么需要 SSA
类型检查后的 AST + IR 已经足够"理解"你的代码了,但还不够"适合优化"。问题出在变量的多次赋值上。
考虑这段代码:
go
x := 10
if cond {
x = 20
}
y := x + 1
在普通 IR 里,x 被赋值了两次(x = 10 和 x = 20),而 y := x + 1 里的 x 到底是 10 还是 20?需要做控制流分析才能确定------这是一个需要迭代求解的数据流问题。
SSA(Static Single Assignment,静态单赋值) 通过一个简单的规则解决了这个问题:每个变量只被赋值一次。 如果逻辑上需要多次赋值,就给变量加版本号:
原始 IR: SSA 形式:
x = 10 x1 = 10
if cond { if cond goto B1 else B2
x = 20 B1: x2 = 20; goto B3
} B2: goto B3
y = x + 1 B3: x3 = phi(B1: x2, B2: x1)
y1 = x3 + 1
phi(φ)函数是 SSA 的核心:它在控制流汇合点"选择"来自不同路径的值。phi(B1: x2, B2: x1) 的意思是------如果从 B1 来用 x2,从 B2 来用 x1。
SSA 的好处:每个值只有一个定义点,数据流分析变成了一次遍历就能完成的 DAG(有向无环图)问题,无需迭代。
二、Go 编译器中的 SSA
Go 编译器从 1.7 版本开始引入 SSA 后端。SSA 代码位于 cmd/compile/internal/ssa 目录,包含 50+ 个优化 Pass。
SSA 的生命周期
IR(AST 编译后)
│
▼
┌─────────────┐
│ SSA 生成 │ IR → SSA 初始形式(gen)
└─────────────┘
│
▼
┌─────────────┐
│ SSA 优化 │ 50+ 个 Pass 依次执行
│ (多轮) │ 每个 Pass 做一种优化
└─────────────┘
│
▼
┌─────────────┐
│ SSA 降级 │ SSA → 目标架构指令(lower)
└─────────────┘
│
▼
┌─────────────┐
│ 机器码生成 │ 指令编码 → .a 目标文件
└─────────────┘
SSA Pass 的工作方式
每个 Pass 是一个函数,接收 SSA 图(函数的 CFG + 数据流),返回优化后的 SSA 图。Pass 之间是流水线关系------前一个 Pass 的输出是后一个 Pass 的输入。
go
// 简化示意:一个 SSA Pass 的结构
func passDeadCode(f *Func) *Func {
// 遍历所有基本块
for _, b := range f.Blocks {
// 遍历块中的所有指令
for _, v := range b.Values {
// 如果这个值没有任何使用者,且没有副作用,删除它
if v.Uses == 0 && !v.HasSideEffects() {
v.Op = OpInvalid
}
}
}
return f
}
三、核心 SSA 优化 Pass 详解
1. 常量折叠(Const Fold)
编译期就能计算的表达式直接求值,运行时零开销。
go
// 源码
x := 1 + 2 * 3
// SSA 生成时(未优化)
t1 = Const <int> [1]
t2 = Const <int> [2]
t3 = Const <int> [3]
t4 = Mul <int> t2 t3
t5 = Add <int> t1 t4
// 常量折叠后
t5 = Const <int> [7] // 直接算出结果
// t1~t4 被死代码消除
2. 死代码消除(Dead Code Elimination)
删除永远不会执行的代码和对结果无影响的计算。
go
// 源码
func f(x int) int {
y := x * 0 // y 永远是 0
z := 100
return z // y 从未被使用
}
// DCE 后:y := x * 0 被完全删除
// 如果 x 有副作用才保留(但 x*0 没有)
3. 边界检查消除(Bounds Check Elimination)
Go 的切片/数组访问默认有运行时边界检查(防止越界 panic)。SSA 会尝试证明某些访问一定安全,从而消除检查。
go
// 源码
func sum(s []int) int {
total := 0
for i := 0; i < len(s); i++ {
total += s[i] // 每次访问都要检查 i < len(s)
}
return total
}
// BCE 后:循环条件已经保证 i < len(s)
// 所以 s[i] 的边界检查被消除
// 用 go build -gcflags="-d=ssa/check_bce/debug=1" 可以看到
Go 编译器在边界检查消除方面做了大量工作。以下是已知能消除检查的模式:
go
// 模式 1:for-range 循环
for i := range s {
_ = s[i] // 检查被消除:range 保证 i 在范围内
}
// 模式 2:显式 len 检查
if i < len(s) {
_ = s[i] // 检查被消除:if 已保证
}
// 模式 3:常量索引
_ = s[0] // 如果 len(s) >= 1 能被证明,检查被消除
// 模式 4:连续访问(前一个检查后,后续偏移已知安全)
_ = s[0] // 检查
_ = s[1] // 可能被消除(如果证明 len(s) >= 2)
4. 内联(Inlining)
把小函数的函数体直接"粘贴"到调用处,消除函数调用开销(栈帧创建、参数传递、返回跳转)。
go
// 源码
func double(x int) int { return x * 2 }
result := double(42)
// 内联后
result := 42 * 2
// 常量折叠后
result := 84
内联决策基于函数的"开销预算"------函数体太大(AST 节点数超过阈值)就不内联。Go 1.17+ 改进了内联策略,支持部分内联(只内联函数的快速路径)。
用 //go:noinline 指令可以禁止内联(通常用于 benchmark 精度控制):
go
//go:noinline
func expensiveOp(x int) int {
return x * x
}
5. 逃逸分析(Escape Analysis)
判断变量应该分配在栈上还是堆上。栈分配零 GC 开销,堆分配需要 GC 回收。
go
func example() *int {
x := 42 // x 的地址被返回 → 逃逸到堆
return &x
}
func example2() int {
x := 42 // x 不逃逸 → 栈分配
return x
}
用 go build -gcflags="-m" 查看逃逸决策:
./main.go:3:2: moved to heap: x
./main.go:8:2: x does not escape
四、GOSSAFUNC:观察 SSA 编译全过程
GOSSAFUNC 是最有价值的 SSA 调试工具。设置环境变量后,编译器会生成一个交互式 HTML 页面,展示函数从源码到机器码经历的每一个 SSA Pass。
bash
# 生成 main 函数的 SSA HTML
GOSSAFUNC=main go build main.go
# 也可以指定包内函数
GOSSAFUNC='(*Buffer).Write' go build ./...
生成的 ssa.html 包含:
- 源码:原始 Go 代码
- IR:AST 翻译后的编译器 IR
- SSA 各阶段:每个 Pass 的 SSA 图,可以前后对比
- 汇编:最终生成的目标架构汇编
每个 SSA 图是一个可视化的控制流图------基本块(Block)用方框表示,值(Value)用箭头连接,phi 函数用特殊标记。
SSA HTML 的阅读方法
- 先看源码:理解函数在做什么
- 看 start 阶段:IR 翻译成 SSA 的初始形式
- 逐步往后看:每个 Pass 做了什么改动
- 重点看 opt 阶段:主要优化在这里发生
- 最后看 lower 阶段:SSA 降级为机器指令
- 看汇编:最终的机器码
五、Phi 函数深入
Phi(φ)函数是 SSA 的灵魂。它出现在控制流汇合点------当一个变量从不同的路径到达同一个点,且每条路径上的值不同,就需要 phi 来"选择"。
┌─────────────┐
│ x1 = 10 │ (路径 A)
└──────┬──────┘
│
▼
┌─────────────┐
│ x3 = phi │ ← 汇合点
│ (A: x1, │ 如果从 A 来,x3 = x1 = 10
│ B: x2) │ 如果从 B 来,x3 = x2 = 20
└─────────────┘
▲
│
┌──────┴──────┐
│ x2 = 20 │ (路径 B)
└─────────────┘
Phi 函数不是一条真正的机器指令------在代码生成阶段,phi 会被"消除"(phi elimination),翻译成在分支前插入的赋值指令(或用寄存器分配优化掉)。
六、SSA 与日常编程
了解 SSA 后,你能写出"编译器更容易优化"的代码:
| 原则 | 解释 |
|---|---|
| 写清晰的代码 | 编译器比你更懂微观优化,清晰代码更容易被优化 |
| 避免不必要的间接 | 接口调用比直接调用慢,除非编译器能去虚拟化 |
| for-range 优于 for-index | range 的边界检查更容易被消除 |
| 小函数放心用 | 内联让小函数零开销,不用手动展开 |
| 常量表达式放心写 | 常量折叠让 1<<20 和 1048576 完全等价 |
| 注意逃逸 | 返回局部变量地址会逃逸到堆,性能敏感场景避免 |
七、SSA 优化 Pass 一览
Go 编译器有 50+ 个 SSA Pass,按阶段分为:
早期优化(rewrite phase)
| Pass | 作用 |
|---|---|
deadcode |
死代码消除 |
opt |
通用优化(常量折叠、简化) |
nilcheck |
nil 检查优化 |
prove |
不等式证明(用于 BCE) |
中期优化(optimize phase)
| Pass | 作用 |
|---|---|
copyelim |
拷贝消除 |
dse |
死存储消除 |
elimunread authtmp |
消除未使用的临时变量 |
shortcircuit |
短路求值优化 |
cse |
公共子表达式消除 |
phielim |
phi 函数消除 |
phiopt |
phi 函数优化 |
后期优化(lower phase)
| Pass | 作用 |
|---|---|
lower |
SSA 操作降级为机器指令 |
closure |
闭包调用优化 |
regalloc |
寄存器分配 |
stackframe |
栈帧布局 |
八、本章要点
| 要点 | 说明 |
|---|---|
| SSA = 每变量只赋值一次 | 版本号区分多次赋值,phi 函数处理控制流汇合 |
| phi 函数 | 在基本块汇合点选择来自不同路径的值 |
| 50+ Pass | 常量折叠、DCE、BCE、内联、逃逸分析、CSE、寄存器分配... |
| GOSSAFUNC | 生成 HTML 展示函数从 SSA 到机器码的完整过程 |
| 边界检查消除 | for-range / 显式 len 检查 / 常量索引等模式可消除检查 |
| 逃逸分析 | 决定栈/堆分配,-gcflags="-m" 查看决策 |
一句话总结:SSA 是 Go 编译器的优化引擎------它让"写清晰的代码"和"写高效的代码"不再是矛盾的,因为编译器会在 SSA 上做几十轮优化,替你把清晰代码变成高效机器码。