《Go 语言运行时原理》8.1 从源码到 SSA:编译阶段与 dump

用 GOSSAFUNC=sum 生成 ssa.html,实测一个简单函数要经过 58 个 SSA pass(regalloc 21.0 微秒、prove 12.6 微秒),再把编译阶段定位到 src/cmd/compile/internal/gc/main.go:Main 与 src/cmd/compile/internal/ssa/compile.go:passes,并给出 dump 时机。

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

读这张表有三个观察:

  1. pass 数量 58 个,从 start 到 genssa。start 是刚建好的初始 SSA(还带着未消解的 phi),genssa 是最终生成的机器码表示。
  2. 最贵的两个 pass 是 regalloc(本次 21000 ns)和 prove(12583 ns)——寄存器分配要做活跃区间分析,prove 要做值域推导,都不便宜。这也是为什么小函数的内联收益有限:pass 数量不随函数大小线性缩小。
  3. 耗时带纳秒精度,说明它统计的是编译器自身的 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/walkrange、defer、闭包等语法糖展开
内联src/cmd/compile/internal/inline内联决策与替换(见 8.3)
逃逸分析src/cmd/compile/internal/escape决定对象放栈还是堆(见 4.2)
SSAsrc/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 与优化实测 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练