8.1 从源码到 SSA:编译阶段与 dump
前面七章都在运行时(调度、内存、GC)里打转,从这一章起换到编译器:很多「运行时行为」其实在编译期就已经定下来了——逃逸、内联、边界检查、接口调用,都是编译器替你做的决策。
本节要回答的问题是:一段 Go 源码要经过哪些阶段才变成机器码,中间表示怎么 dump 出来? 结论是:前端(parse → typecheck → walk → inline → escape)之后进入后端 SSA,一个函数要跑 58 个 pass,其中
regalloc最贵(本次 21.0 微秒)、prove次之(12.6 微秒)。本节的增量是编译阶段的完整链条与 dump 方法;「怎么手写汇编」属于卷三《Go 语言高级编程》8.1/8.2,本节不涉及。
8.1.1 实验:生成并读懂 ssa.html
复现基线
- Go 版本:
go version go1.27.0 darwin/arm64(GOTOOLCHAIN=go1.27.0) - 机器:Apple M1 Pro,10 核,32 GiB 内存
- 被测文件:一个 37 行的小程序,含一个循环函数
sum与一个小函数pick - 工具:
GOSSAFUNC=<函数名> go build,在当前目录生成ssa.html(实测 141.2 KB)
被测程序:
package main
import "fmt"
// sum 是一个容易被 SSA pass 优化的函数:
// 循环里 s += i*2 会被强度削减(i*2 -> i<<1)并做边界检查消除。
func sum(n int) int {
s := 0
for i := 0; i < n; i++ {
s += i * 2
}
return s
}
// pick 用来观察内联:小函数会被内联进调用者。
func pick(a, b int) int {
if a > b {
return a
}
return b
}
func main() {
fmt.Println(sum(10), pick(3, 7))
}
生成 dump:
cd /tmp/gbrt3/ssa
GOSSAFUNC=sum GOTOOLCHAIN=go1.27.0 go build -o /dev/null .
ls -la ssa.html
Go build: Success
644 ssa.html 141.2K
ssa.html 是一张宽表:每一列是一个 pass,每一行是函数的中间表示。表头的 <h2> 就带着该 pass 的名字与耗时。把 sum 的 pass 列表和耗时抽出来:
start
number lines [1959 ns]
early phielim and copyelim [833 ns]
early deadcode [2417 ns]
short circuit [458 ns]
decompose user [125 ns]
pre-opt deadcode [1292 ns]
opt [2250 ns]
zero arg cse [1250 ns]
opt deadcode [1000 ns]
generic cse [4417 ns]
phiopt [208 ns]
gcse deadcode [958 ns]
nilcheckelim [3125 ns]
prove [12583 ns]
divisible [750 ns]
divmod [458 ns]
middle opt [2209 ns]
known bits [1583 ns]
early fuse [167 ns]
expand calls [3833 ns]
decompose builtin [1833 ns]
softfloat [83 ns]
branchelim [334 ns]
late opt [1958 ns]
dead auto elim [667 ns]
sccp [6750 ns]
generic deadcode [1583 ns]
late fuse [1333 ns]
check bce [42 ns]
dse [1000 ns]
memcombine [417 ns]
writebarrier [750 ns]
lower [4792 ns]
addressing modes [41 ns]
late lower [1083 ns]
pair [916 ns]
lowered deadcode for cse [1083 ns]
lowered cse [1250 ns]
elim unread autos [166 ns]
tighten tuple selectors [208 ns]
lowered deadcode [792 ns]
checkLower [167 ns]
loop invariant [3000 ns]
late phielim and copyelim [375 ns]
tighten [2542 ns]
late deadcode [1208 ns]
critical [333 ns]
phi tighten [125 ns]
likelyadjust [417 ns]
layout [667 ns]
schedule [2250 ns]
late nilcheck [792 ns]
flagalloc [1250 ns]
regalloc [21000 ns]
loop rotate [958 ns]
trim [125 ns]
genssa
读这张表有三个观察:
- pass 数量 58 个,从
start到genssa。start是刚建好的初始 SSA(还带着未消解的 phi),genssa是最终生成的机器码表示。 - 最贵的两个 pass 是
regalloc(本次 21000 ns)和prove(12583 ns)——寄存器分配要做活跃区间分析,prove 要做值域推导,都不便宜。这也是为什么小函数的内联收益有限:pass 数量不随函数大小线性缩小。 - 耗时带纳秒精度,说明它统计的是编译器自身的 CPU 时间(
base.Timer),不是墙钟;同一台机器上多次构建的数值会小幅波动,看相对量级即可。
8.1.2 源码:编译阶段与 SSA 入口
整个编译流程的编排在 src/cmd/compile/internal/gc/main.go 的 Main 里,按时间顺序读关键调用即可还原阶段链条:
// src/cmd/compile/internal/gc/main.go:Main(节选)
func Main(archInit func(*ssagen.ArchInfo)) {
base.Timer.Start("fe", "init")
...
// Interleaved devirtualization and inlining.
base.Timer.Start("fe", "devirtualize-and-inline")
interleaved.DevirtualizeAndInlinePackage(typecheck.Target, profile)
...
// Escape analysis.
base.Timer.Start("fe", "escapes")
escape.Funcs(typecheck.Target.Funcs)
...
base.Timer.Start("be", "compilefuncs")
...
}
阶段顺序是:解析 → 类型检查 → walk(降语法糖)→ 内联(interleaved)→ 逃逸分析(escape.Funcs)→ 后端编译(compilefuncs)。注意 base.Timer.Start("fe", ...) 的 fe 是 frontend、be 是 backend,ssa.html 里的耗时统计就挂在同一套计时器上。
进入 SSA 的入口在 src/cmd/compile/internal/ssagen/ssa.go:buildssa 把 AST 翻译成初始 SSA,然后调用 ssa.Compile:
// src/cmd/compile/internal/ssagen/ssa.go:buildssa(节选,约 294 行)
func buildssa(fn *ir.Func, worker int, isPgoHot bool) *ssa.Func {
...
ssa.Compile(s.f)
...
}
ssa.Compile 就是那条 pass 流水线的执行者,它在 src/cmd/compile/internal/ssa/compile.go:
// src/cmd/compile/internal/ssa/compile.go:Compile(节选,约 30 行)
func Compile(f *Func) {
...
f.HTMLWriter.WritePhase("start", "start")
...
for _, p := range passes {
...
}
}
WritePhase("start", "start") 写的就是 ssa.html 里第一列那个 start;随后 for _, p := range passes 逐个执行,每个 pass 也会写一列。pass 表本身是一个静态数组:
// src/cmd/compile/internal/ssa/compile.go:passes(节选,约 457 行)
var passes = [...]pass{
{name: "number lines", fn: numberLines, required: true},
{name: "early phielim and copyelim", fn: copyelim},
{name: "early deadcode", fn: deadcode},
{name: "short circuit", fn: shortcircuit},
{name: "decompose user", fn: decomposeUser, required: true},
{name: "pre-opt deadcode", fn: deadcode},
{name: "opt", fn: opt, required: true},
...
{name: "prove", fn: prove},
...
{name: "lower", fn: lower, required: true},
...
{name: "regalloc", fn: regalloc},
...
}
字段 required: true 表示该 pass 即使在 -N(关闭优化)下也必须运行,否则 SSA 无法正确降级;没有 required 的 pass 会被 -N 跳过。想验证这一点,用 -gcflags='-N' 再生成一次 ssa.html,列数会明显减少。
前端的几个阶段也各有对应的包,理解「一个现象该去哪个目录找」很有用:
| 阶段 | 包路径 | 典型职责 |
|---|---|---|
| 解析 | src/cmd/compile/internal/syntax | 词法/语法分析,产出 AST |
| 类型检查 | src/cmd/compile/internal/typecheck | 类型推导、方法集、常量折叠 |
| walk(降糖) | src/cmd/compile/internal/walk | range、defer、闭包等语法糖展开 |
| 内联 | src/cmd/compile/internal/inline | 内联决策与替换(见 8.3) |
| 逃逸分析 | src/cmd/compile/internal/escape | 决定对象放栈还是堆(见 4.2) |
| SSA | src/cmd/compile/internal/ssa | 本节的主角,pass 流水线 |
也就是说,当一个变量「为什么跑到堆上了」、一个函数「为什么没内联」时,答案分别落在 escape 与 inline 两个目录里,而不是 SSA 目录——SSA 只负责把已经定好的决策翻译成机器码。
8.1.3 决策:什么时候该 dump
ssa.html 不是日常工具,它信息量大但读起来慢。按下面的判据决定要不要打开它:
| 场景 | 该用什么 | 理由 |
|---|---|---|
| 只想确认某个函数有没有被内联 | -gcflags='-m=2' | 一行一条结论,比读 SSA 快得多(见 8.3) |
| 想确认边界检查是否消除 | -gcflags='-d=ssa/check_bce/debug=1' | 直接报 Found IsInBounds 的行号 |
| 想确认某个优化有没有发生 | GOSSAFUNC=<fn> go build | 逐列对比 pass 前后的 IR |
| 想看最终机器码 | -gcflags='-S' | SSA 是中间态,-S 才是落地的汇编 |
| 想定位编译器慢在哪 | ssa.html 的 pass 耗时列 | 直接看到 regalloc 等热点 |
三条纪律:
GOSSAFUNC会在当前目录写ssa.html:跑完必须清理,不要把它提交进仓库(本节的ssa.html只存在于/tmp/gbrt3/ssa/)。- 先看
-m再看 SSA:能用一个标志回答的问题不要开 dump,ssa.html留给「标志回答不了」的情况。 - 对照
-N与默认构建:同一函数的列数差异,直接反映优化开关关掉了哪些 pass,这是理解 pass 职责最快的路径。
阅读导航:上一节:7.3 减少分配与 GC 压力 · 下一节:8.2 SSA pass 与优化实测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。