《Go 语言运行时原理》8.2 SSA pass 与优化实测

用 GOSSAFUNC 逐列追踪 OpIsInBounds 节点,实测它在 prove pass 之后消失(边界检查被消除),并用 -S 证明 i*2 被强度削减折叠成 ADD R1<<1;把 prove、checkbce、opt 三个 pass 定位到 src/cmd/compile/internal/ssa/ 下的具体函数。

8.2 SSA pass 与优化实测

8.1 让我们看到了 pass 列表的全貌,这一节挑两个具体 pass,验证「它到底改了什么」——不是读文档,而是对比 pass 前后的中间表示。

本节要回答的问题是:怎么确认某个 SSA pass 真的生效了? 结论是:prove pass 通过值域推导判定边界检查冗余,OpIsInBounds 节点在它之后从 IR 里消失;opt 把 i*2 折叠进寻址模式,最终汇编里只剩 ADD R1<<1, R2, R2。本节的增量是逐 pass 的对照方法与两个可复现的优化实证;SSA 的概念介绍在 /posts/golang/ 已有专题文章,本节只讲「怎么观察」与「结果长什么样」。

8.2.1 实验:追踪一个节点在 pass 间的生死

复现基线

  • Go 版本:go version go1.27.0 darwin/arm64(GOTOOLCHAIN=go1.27.0)
  • 机器:Apple M1 Pro,10 核,32 GiB 内存
  • 被测程序:含一个「显式守卫 + 访问数组」的函数 guarded 与一个「无守卫」的 unguarded
  • 工具:GOSSAFUNC=guarded go build(生成 ssa.html,137.1 KB)+ -gcflags='-S'

被测程序:

package main

import "fmt"

var a = [8]int{0, 1, 2, 3, 4, 5, 6, 7}

// guarded 显式做区间判断,SSA 的 prove pass 能据此消除 a[i] 的边界检查。
func guarded(i int) int {
	if uint(i) >= uint(len(a)) {
		return -1
	}
	return a[i]
}

// unguarded 没有任何前置判断,边界检查必须保留。
func unguarded(i int) int {
	return a[i]
}

func main() {
	fmt.Println(guarded(3), unguarded(3))
}

生成 dump,然后统计每个 pass 列里 OpIsInBounds 出现的次数:

cd /tmp/gbrt3/bce
GOSSAFUNC=guarded GOTOOLCHAIN=go1.27.0 go build -o /dev/null .
python3 - <<'PY'
import re
h = open('ssa.html', encoding='utf-8').read()
parts = re.split(r'(<h2[^>]*>.*?</h2>)', h)
cur = None
for p in parts:
    m = re.match(r'<h2[^>]*>(.*?)</h2>', p)
    if m:
        cur = re.sub(r'<[^>]+>', '', m.group(1)).split('[')[0].strip()
    elif cur and cur in ('opt', 'prove', 'divisible', 'middle opt', 'check bce', 'lower'):
        print(f'{cur}: IsInBounds x{p.count("IsInBounds")}')
PY
opt: IsInBounds x1
prove: IsInBounds x1
divisible: IsInBounds x0
middle opt: IsInBounds x0
check bce: IsInBounds x0
lower: IsInBounds x0

IsInBounds 节点一路活到 prove(此刻还在),到下一个 pass divisible 时就变成了 0——边界检查被 prove 判定为冗余后删除。作为对照,unguarded 没有守卫,它的 IsInBounds 会一直活到 lower,最终变成汇编里的 runtime.panicBounds 调用。

再看强度削减

换 8.1 里的 sum 函数,源码是 s += i * 2,看最终汇编:

cd /tmp/gbrt3/ssa
GOTOOLCHAIN=go1.27.0 go build -gcflags='-S' -o /dev/null . 2>&1 | sed -n '/main.sum STEXT/,/main.pick STEXT/p'
main.sum STEXT size=48 align=0x0 args=0x8 locals=0x0 funcid=0x0 leaf
	0x0000 00000 (/tmp/gbrt3/ssa/main.go:7)	TEXT	main.sum(SB), LEAF|NOFRAME|ABIInternal, $0-8
	0x0000 00000 (/tmp/gbrt3/ssa/main.go:9)	MOVD	ZR, R1
	0x0004 00004 (/tmp/gbrt3/ssa/main.go:9)	MOVD	ZR, R2
	0x0008 00008 (/tmp/gbrt3/ssa/main.go:9)	JMP	20
	0x000c 00012 (/tmp/gbrt3/ssa/main.go:10)	ADD	R1<<1, R2, R2
	0x0010 00016 (/tmp/gbrt3/ssa/main.go:9)	ADD	$1, R1, R1
	0x0014 00020 (/tmp/gbrt3/ssa/main.go:9)	CMP	R1, R0
	0x0018 00024 (/tmp/gbrt3/ssa/main.go:9)	BGT	12
	0x001c 00028 (/tmp/gbrt3/ssa/main.go:12)	MOVD	R2, R0
	0x0020 00032 (/tmp/gbrt3/ssa/main.go:12)	RET	(R30)

i * 2 没有变成 MUL,而是被折叠进 ADD 的移位操作数:ADD R1<<1, R2, R2(R1=i,R2=s)。这条指令同时完成「左移一位」和「累加」两件事,正是 opt 与 lower 里强度削减加寻址模式合并的结果。

对照 -N:哪些 pass 是必需的

用 -gcflags='-N' 关掉优化再 dump 一次同一个 sum,pass 数量立刻缩水:

cd /tmp/gbrt3/ssa
GOSSAFUNC=sum GOTOOLCHAIN=go1.27.0 go build -gcflags='-N' -o /dev/null .
python3 - <<'PY'
import re
h = open('ssa.html', encoding='utf-8').read()
names = [re.sub(r'<[^>]+>', '', m.group(1)).split('[')[0].strip()
         for m in re.finditer(r'<h2[^>]*>(.*?)</h2>', h)]
print('phases:', len(names))
print(names)
PY
phases: 31
['sources', 'AST', 'before insert phis', 'start', 'number lines', 'decompose user', 'opt', 'zero arg cse', 'opt deadcode', 'gcse deadcode', 'divisible', 'divmod', 'middle opt', 'expand calls', 'decompose builtin', 'softfloat', 'late opt', 'generic deadcode', 'writebarrier', 'lower', 'late lower', 'tighten tuple selectors', 'lowered deadcode', 'checkLower', 'tighten', 'critical', 'layout', 'schedule', 'flagalloc', 'regalloc', 'genssa']

默认构建有 58 列(start 到 genssa),-N 只剩 28 列(输出里的 phases: 31 含 sources/AST/before insert phis 三个元列,减去后为 28)——差值就是那些 required: false 的优化 pass(early deadcode、prove、nilcheckelim、cse、dse、memcombine、loop invariant 等)。留下来的 opt、divisible、divmod、expand calls、lower、writebarrier、regalloc 等都是 required: true,它们不是「优化」而是「正确性所需」的降级步骤。

这也解释了一个常见困惑:-N 构建的程序为什么还是比 -O0 的 C 快——它并没有跳过所有 pass,只是关掉了「可以不做」的那部分。

8.2.2 源码:prove、checkbce 与 opt

三个 pass 都注册在同一张表里(src/cmd/compile/internal/ssa/compile.go:passes),它们的顺序不是随意的:prove 在 opt 之后、lower 之前,check bce 更靠后,专门用来在降级前做一次回归检查。

// src/cmd/compile/internal/ssa/compile.go:passes(节选,约 457 行)
{name: "opt", fn: opt, required: true},
...
{name: "prove", fn: prove},
...
{name: "check bce", fn: checkbce},
...
{name: "lower", fn: lower, required: true},

注意 prove 与 check bce 都没有 required: true:前者是纯优化,后者是调试用途,都可以被 -N 跳过。

prove 的入口在 src/cmd/compile/internal/ssa/prove.go:

// src/cmd/compile/internal/ssa/prove.go:prove(节选,约 1573 行)
func prove(f *Func) {
	// Unlock (and discard) the facts tables of all blocks in f,
	// so that we can safely use them during the proof.
	...
}

它维护一张 factsTable(值之间的序关系),并在遇到 OpIsInBounds / OpIsSliceInBounds 时更新这张表:

// src/cmd/compile/internal/ssa/prove.go(节选,约 808 行)
case OpIsInBounds, OpIsSliceInBounds:
	// 0 <= a0 < a1 (or 0 <= a0 <= a1)
	r := lt
	if v.Op == OpIsSliceInBounds {
		r |= eq
	}
	if isTrue {
		// On the positive branch, we learn:
		//   signed: 0 <= a0 < a1 (or 0 <= a0 <= a1)
		//   unsigned:    a0 < a1 (or a0 <= a1)
		ft.setNonNegative(v.Args[0])
		ft.update(v.Block, v.Args[0], v.Args[1], signed, r)
		ft.update(v.Block, v.Args[0], v.Args[1], unsigned, r)
	}

guarded 里那句 if uint(i) >= uint(len(a)) 走了 else(负分支),prove 从 uint(i) >= 8 学到 i 的范围信息,于是在后续的 a[i] 处判定检查冗余。

要确认「还有哪些检查没被消除」,用 checkbce pass,它的实现只有几十行:

// src/cmd/compile/internal/ssa/checkbce.go:checkbce(节选,约 13 行)
func checkbce(f *Func) {
	if f.pass.debug <= 0 && !logopt.Enabled() {
		return
	}
	for _, b := range f.Blocks {
		for _, v := range b.Values {
			if v.Op == OpIsInBounds || v.Op == OpIsSliceInBounds {
				if f.pass.debug > 0 {
					f.Warnl(v.Pos, "Found %v", v.Op)
				}
				...
			}
		}
	}
}

f.Warnl(v.Pos, "Found %v", v.Op) 就是命令行里那句 Found IsInBounds 的来源。它默认关闭,只有加 -d=ssa/check_bce/debug=1 才打印。

opt 是执行重写规则的 pass,入口极短,因为规则由代码生成器展开:

// src/cmd/compile/internal/ssa/opt.go:opt(节选,约 8 行)
func opt(f *Func) {
	applyRewrite(f, rewriteBlockgeneric, rewriteValuegeneric, removeDeadValues)
}

rewriteValuegeneric 是一张巨型重写表,i*2 变成移位就是它里面的某条规则;具体规则可在 src/cmd/compile/internal/ssa/rewritegeneric.go 里按 OpMul64 等关键字搜到。

8.2.3 决策:pass 知识怎么用

把「观察到某个优化」的路径固化成清单:

想确认的事命令看什么
边界检查是否消除-gcflags='-d=ssa/check_bce/debug=1'有 Found IsInBounds 就说明没消除
哪个 pass 消除了它GOSSAFUNC=<fn> go build + 统计节点节点在哪个 pass 之后归零
乘法是否被削减-gcflags='-S'汇编里是 MUL 还是 << 移位
死代码是否清除GOSSAFUNC=<fn> go build对比 deadcode pass 前后的块数
优化是否被 -N 关掉默认 vs -gcflags='-N' 的 ssa.html列数差异即被跳过的 pass

三条纪律:

  • 优化是「尽力而为」:prove 能消除 guarded 的检查,是因为守卫写法(uint(i) >= uint(len(a)))给了它可推导的区间;换成 i > 100 这种与长度无关的守卫,消除就不会发生。写代码时要顺着编译器能证明的形状写。
  • 不要为优化而写怪代码:i*2 的移位折叠是编译器自动做的,手写 i<<1 只会降低可读性,收益为零。
  • ssa.html 用完即删:它是构建产物,不该进版本库;本节的所有 dump 都只存在于 /tmp/gbrt3/ 下。

阅读导航:上一节:8.1 从源码到 SSA:编译阶段与 dump · 下一节:8.3 内联、边界检查消除与 -gcflags 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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