8.3 内联、边界检查消除与 -gcflags
8.2 讲了怎么在 SSA 层观察单个 pass,这一节回到最常用的三个 -gcflags:它们不需要打开 ssa.html,一条命令就能回答「有没有内联」「边界检查消了没」。
本节要回答的问题是:内联和边界检查消除各自什么时候发生,怎么用
-gcflags一次问清? 结论是:内联由「代价 ≤ 预算」决定,默认预算inlineMaxBudget = 80,main的 cost 137 因此被拒;边界检查消除靠prove,写对守卫就能从 96 字节的汇编降到 32 字节。本节的增量是内联预算的源码口径与 BCE 的汇编对照;「怎么手写汇编」属于卷三《Go 语言高级编程》8.1/8.2,本节不涉及。
8.3.1 实验:三条命令看清编译器决策
复现基线
- Go 版本:
go version go1.27.0 darwin/arm64(GOTOOLCHAIN=go1.27.0) - 机器:Apple M1 Pro,10 核,32 GiB 内存
- 被测程序:四个小函数——
guarded(显式守卫)、unguarded(无守卫)、sumRange(range 遍历)、pick(返回较大者) - 工具:
-gcflags='-m=2'、-gcflags='-d=ssa/check_bce/debug=1'、-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]
}
// sumRange 用 range 遍历,下标天然在界内。
func sumRange(s []int) int {
t := 0
for i := range s {
t += s[i]
}
return t
}
// pick 是简单分支,可被内联。
func pick(x, y int) int {
if x > y {
return x
}
return y
}
func main() {
fmt.Println(guarded(3), unguarded(3), sumRange([]int{1, 2, 3}), pick(4, 9))
}
命令一:内联决策
cd /tmp/gbrt3/bce
GOTOOLCHAIN=go1.27.0 go build -gcflags='-m=2' -o /dev/null . 2>&1 | grep -E "can inline|cannot inline|inlining call"
./main.go:8:6: can inline guarded with cost 11 as: func(int) int { if uint(i) >= uint(8) { return -1 }; return a[i] }
./main.go:16:6: can inline unguarded with cost 4 as: func(int) int { return a[i] }
./main.go:21:6: can inline sumRange with cost 17 as: func([]int) int { t := 0; for loop; return t }
./main.go:30:6: can inline pick with cost 8 as: func(int, int) int { if x > y { return x }; return y }
./main.go:37:6: cannot inline main: function too complex: cost 137 exceeds budget 80
./main.go:38:21: inlining call to guarded
./main.go:38:35: inlining call to unguarded
./main.go:38:48: inlining call to sumRange
./main.go:38:70: inlining call to pick
每一行都值得读:
can inline X with cost N:函数X的可内联代价是N。四个小函数的 cost 分别是 11、4、17、8,都远低于预算。cannot inline main: function too complex: cost 137 exceeds budget 80:main的 cost 137 超过预算 80,被拒绝内联。这条是理解内联预算最直接的证据。inlining call to X:调用点实际发生了内联。四个函数在main的第 38 行都被内联了。
命令二:边界检查消除
GOTOOLCHAIN=go1.27.0 go build -gcflags='-d=ssa/check_bce/debug=1' -o /dev/null .
# bcetest
./main.go:17:10: Found IsInBounds
只有一处 Found IsInBounds,行号 17:10 指向 unguarded 里的 return a[i]。guarded 里的 a[i](第 13 行)没有出现在输出里——它的检查被 prove 消除了。sumRange 的 s[i] 也没有出现,因为 range 已经保证下标在界内。
命令三:汇编对照
GOTOOLCHAIN=go1.27.0 go build -gcflags='-S' -o /dev/null . 2>&1 | grep "STEXT size"
main.init STEXT size=16 align=0x0 args=0x0 locals=0x0 funcid=0x0 leaf
main.guarded STEXT size=32 align=0x0 args=0x8 locals=0x0 funcid=0x0 leaf
main.unguarded STEXT size=96 align=0x0 args=0x8 locals=0x8 funcid=0x0
main.sumRange STEXT size=48 align=0x0 args=0x18 locals=0x0 funcid=0x0 leaf
main.pick STEXT size=32 align=0x0 args=0x10 locals=0x0 funcid=0x0 leaf
main.main STEXT size=256 align=0x0 args=0x0 locals=0x98 funcid=0x0
guarded 32 字节、无栈帧(LEAF|NOFRAME);unguarded 96 字节、有栈帧(locals=0x8),因为它要调用 runtime.panicBounds:
0x0000 00000 (/tmp/gbrt3/bce/main.go:9) CMP $8, R0
0x0004 00004 (/tmp/gbrt3/bce/main.go:9) BLO 16
0x0008 00008 (/tmp/gbrt3/bce/main.go:10) MOVD $-1, R0
0x000c 00012 (/tmp/gbrt3/bce/main.go:10) RET (R30)
0x0010 00016 (/tmp/gbrt3/bce/main.go:12) MOVD $main.a(SB), R1
0x0018 00024 (/tmp/gbrt3/bce/main.go:12) MOVD (R1)(R0<<3), R0
0x001c 00028 (/tmp/gbrt3/bce/main.go:12) RET (R30)
0x0018 00024 (/tmp/gbrt3/bce/main.go:17) CMP $8, R0
0x001c 00028 (/tmp/gbrt3/bce/main.go:17) BHS 56
0x0020 00032 (/tmp/gbrt3/bce/main.go:17) MOVD $main.a(SB), R1
0x0028 00040 (/tmp/gbrt3/bce/main.go:17) MOVD (R1)(R0<<3), R0
0x002c 00044 (/tmp/gbrt3/bce/main.go:17) MOVD -8(RSP), R29
0x0030 00048 (/tmp/gbrt3/bce/main.go:17) MOVD.P 16(RSP), R30
0x0034 00052 (/tmp/gbrt3/bce/main.go:17) RET (R30)
0x0038 00056 (/tmp/gbrt3/bce/main.go:17) PCDATA $1, $0
0x0038 00056 (/tmp/gbrt3/bce/main.go:17) PCDATA $4, $9261
0x0038 00056 (/tmp/gbrt3/bce/main.go:17) CALL runtime.panicBounds(SB)
0x003c 00060 (/tmp/gbrt3/bce/main.go:17) HINT $0
guarded 里那条 CMP $8, R0; BLO 16 是用户自己写的守卫(uint(i) >= 8 的取反),不是编译器插入的检查——BLO(无符号小于)跳转到返回 -1 的分支,成功路径上直接 MOVD (R1)(R0<<3), R0 取值,没有任何越界检查。unguarded 的 BHS 56 则跳到 CALL runtime.panicBounds。
命令四:内联开关的汇编对照
-m=2 说「发生了内联」,但最终裁判仍是汇编。数一数 main.main 里对被内联函数的 CALL 指令:
# 默认(允许内联)
GOTOOLCHAIN=go1.27.0 go build -gcflags='-S' -o /dev/null . 2>&1 | grep -cE 'CALL\s+main\.'
# 关闭内联
GOTOOLCHAIN=go1.27.0 go build -gcflags='-S -l' -o /dev/null . 2>&1 | grep -cE 'CALL\s+main\.'
0
4
默认构建里,main.main 对被内联的四个函数没有任何 CALL(0 条);加上 -l 关闭内联后,四条调用全部以真实指令形式出现(4 条,分别是 CALL main.guarded、main.unguarded、main.sumRange、main.pick)。这条对照把「内联是否生效」从日志断言变成了可数的机器指令。
8.3.2 源码:内联预算与 prove
内联预算的常量就在 src/cmd/compile/internal/inline/inl.go 开头:
// src/cmd/compile/internal/inline/inl.go(节选,约 50 行)
inlineMaxBudget = 80
inlineExtraAppendCost = 0
...
inlineExtraCallCost = 57 // 57 was benchmarked to provided most benefit with no bad surprises
inlineExtraThrowCost = inlineMaxBudget
...
inlineClosureCalledOnceCost = 10 * inlineMaxBudget // if a closure is just called once, inline it.
inlineMaxBudget = 80 就是命令输出里那句 budget 80 的来源。inlineExtraCallCost = 57 意味着函数体里每多一次调用就吃掉 57 的预算——一个函数只要体内有一次普通调用,几乎就用光了全部预算,这正是「小函数才被内联」的量化解释。
预算的计算在 inlineBudget:
// src/cmd/compile/internal/inline/inl.go:inlineBudget(节选,约 210 行)
func inlineBudget(fn *ir.Func, profile *pgoir.Profile, relaxed bool, verbose bool) int32 {
// Update the budget for profile-guided inlining.
budget := int32(inlineMaxBudget)
budget *= simdCreditMultiplier(fn)
if IsPgoHotFunc(fn, profile) {
budget = inlineHotMaxBudget
...
}
if relaxed {
budget += inlheur.BudgetExpansion(inlineMaxBudget)
}
if fn.ClosureParent != nil {
budget = max(budget, inlineClosureCalledOnceCost)
}
return budget
}
三个会放宽预算的因素:PGO 热点函数(IsPgoHotFunc)、放宽模式(relaxed,用于调用点可能下调得分的情况)、只调用一次的闭包(inlineClosureCalledOnceCost = 800)。这也解释了为什么加了 PGO 之后内联会更激进。
代价的累加规则藏在 CanInline 里:函数体的每个语句、每个操作数都计分,遇到调用再加 inlineExtraCallCost。实测四个函数的 cost 分别是 unguarded 4、pick 8、guarded 11、sumRange 17——都是「几条语句 + 一次取值/比较」的形状。反过来说,只要函数体里塞进一次 fmt.Sprintf 之类的调用(+57),再叠加几条语句,就会轻松越过 80 的预算。这就是「小函数才内联」背后的量化规则。
边界检查消除的实现仍在 prove(见 8.2 的源码片段):它从 if uint(i) >= uint(len(a)) 这条分支学到 i 的区间,进而判定 a[i] 的 OpIsInBounds 冗余。所谓「写对守卫」,本质就是写出 prove 能推导的形状——用无符号比较 uint(i) >= uint(len(a)) 一次性覆盖 i < 0 与 i >= len(a) 两种情况,比 if i < 0 || i >= len(a) 更利于推导。
8.3.3 决策:-gcflags 速查表
把本节用到的标志固化成一张表,日常排查直接照用:
| 目的 | 标志 | 输出关键行 |
|---|---|---|
| 看内联决策 | -gcflags='-m' | can inline / cannot inline |
| 看内联代价与预算 | -gcflags='-m=2' | cost N exceeds budget 80 |
| 看逃逸分析 | -gcflags='-m'(含 escapes to heap) | moved to heap / does not escape |
| 找未消除的边界检查 | -gcflags='-d=ssa/check_bce/debug=1' | Found IsInBounds |
| 看最终汇编 | -gcflags='-S' | TEXT / CALL runtime.panicBounds |
| 关闭所有优化对照 | -gcflags='-N -l' | 内联被禁、pass 数减少 |
| 关内联但保留其它优化 | -gcflags='-l' | 用于定位「是不是内联带来的问题」 |
| 放宽内联预算(调试用) | -gcflags='-d=inlbudgetslack=<n>' | 观察预算放宽后的内联面 |
| 看 PGO 如何影响内联 | go build -pgo=auto -gcflags='-m=2' | hot-node enabled increased budget |
| 查看内联后的 IR | GOSSAFUNC=<fn> go build | 调用者的 SSA 里出现被内联函数的节点 |
读 -m 输出时还有一个易踩的坑:-m 会同时打印逃逸分析的结论(escapes to heap / does not escape),与内联结论混在一起。本节的实验用 grep -E "can inline|cannot inline|inlining call" 做了过滤,只保留内联相关行;若要看逃逸,单独用 grep escapes。这也是为什么排查时建议先过滤关键字,再逐条读——一次打印几百行会让真正重要的那几行被淹没。
四条纪律:
- 内联不是越多越好:
-l关掉内联常用来缩小二进制、加快编译;判断该不该内联,要看-m=2的 cost 与函数调用频率,而不是一律追求「全内联」。 - BCE 靠写对守卫,不靠标志:标志只能告诉你检查还在,消除它要靠调整代码形状。
-m输出量大:只过滤inline与escapes关键字,避免刷屏(本节就是这么做的)。-S是最终裁判:SSA 层看到OpIsInBounds消失,不等于汇编里真的省了指令,务必用-S复核。
最后提醒一点:这些标志都是诊断工具,不要写进生产构建。把 -m=2 或 -N 带进正式产物,轻则刷屏、重则显著变慢(-N 关掉优化、-l 关掉内联)。它们的正确用法是「在本地或 CI 的一次性诊断构建里跑,定位完就撤」,生产构建保持默认标志即可。
下一章我们离开编译器的「优化」话题,回到运行时,看类型系统与接口动态派发在汇编层面到底花了多少指令。
阅读导航:
阅读导航:上一节:8.2 SSA pass 与优化实测 · 下一节:9.1 类型系统与接口动态派发(itab/eface) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。