15.3 优化器
15.2 把前端的语法树降到了 SSA 这层中间表示,并说明了 SSA 的「每个变量只赋值一次」 为何让优化遍写起来又准又快。这一节接着往下走:在这层表示上,编译器到底跑了哪些优化,又为什么 偏偏是这几样。
读懂 Go 优化器,先要读懂它的取向。同样是把高级语言变成机器码,GCC 与 LLVM 愿意花上几秒甚至几十秒, 把一个函数反复揉搓,换取最后那几个百分点的运行性能。Go 走的是另一条路:它只做性价比最高的那一批 优化,把省下来的时间还给编译速度(1.1)。这不是能力 不足,而是一道清醒的价值排序,本节最后会回到这条红线。我们先看 Go 愿意做的优化,再看 Go 1.21 引入的、把优化从「静态猜测」推向「数据驱动」的性能制导优化(PGO)。
15.3.1 内联:一切优化的入口
在 SSA 上做的诸多优化里,内联(inlining)地位特殊。它本身只做一件朴素的事:把一个小函数的 函数体,直接展开到调用它的地方,省去一次函数调用的开销(建栈帧、传参、跳转、返回)。但它真正的 价值不在省这点开销,而在于它为其他优化创造了条件。一次跨函数的调用,对优化器来说是一堵墙: 墙那边的代码长什么样、参数是不是常量、返回值会不会被用到,它一概不知。内联把这堵墙拆掉,调用点 两侧的代码合到一起,常量就能传播过去,分支就能被判定,死代码就能被消除。
举一个最常见的例子。sync.Once.Do 的快路径只是一次原子读,标准库把它写成一个独立的小方法:
| |
如果不内联,每次 once.Do(f) 都要付一次调用开销,仅仅为了读一个字段。内联之后,atomic.LoadUint32
这一层包装也一并展开,整个快路径塌缩成几条指令,与手写一个内联的原子读没有区别。Go 标准库里大量
「一层薄包装」的设计,正是建立在「编译器会把它内联掉」这个假设之上。想知道某个调用到底有没有被
内联、为什么没有,可以用 go build -gcflags=-m,编译器会逐条打印它的内联决策(can inline、
inlining call to,或是 function too complex 这类拒绝理由)。
内联不能无节制。把每个调用都展开,会让代码体积爆炸(同一个被调函数在上千个调用点各复制一份), 编译时间也随之拉长,指令缓存的命中率反而下降。Go 用一个内联预算(budget)来划界:编译器给 每个候选函数估一个「成本」,大致正比于它的 AST 节点数,成本超过预算就不内联。go1.26 中这个预算是 一个写死的常量:
| |
预算 80 这个数字本身不重要,重要的是它背后的取舍:预算越大,内联越激进,代码越膨胀、编译越慢;
预算越小,越保守,但放过的优化机会越多。Go 把它定在一个偏保守的位置,与「编译要快」的总目标
一致。函数里含有 defer、闭包、recover 等结构会额外加成本甚至直接禁止内联,因为展开它们要么
得不偿失,要么语义上做不到。
这条预算线之所以如此要紧,与内联能力的一次扩张有关。早期的 Go 编译器只内联叶子函数,即自身 不再调用别人的函数。这个限制让内联的威力大打折扣:一个小函数只要里面有一次调用,整条链就断了。 后来编译器支持了跨栈内联(mid-stack inlining),允许把「自身仍含调用」的函数也展开进去,内联 才真正能沿着调用链层层深入。但放开这道闸门的同时,代码膨胀和编译变慢的风险也随之放大,预算与成本 模型的精细调校,正是从那时起成为内联实现的核心。把内联放在第一节,是因为后面几样优化的效力,很大 程度上取决于内联是否先把跨函数的墙拆开。
15.3.2 边界检查消除、常量折叠与公共子表达式
内联之后,SSA 上一批经典的标量优化就能充分发挥。它们各自朴素,合起来却是 Go 生成代码质量的主干。
边界检查消除(Bounds-Check Elimination, BCE)是其中与 Go 安全模型关系最深的一项。Go 保证每次
切片或数组下标访问都不越界,代价是编译器在每次 a[i] 前插入一段检查:若 i 越界就 panic。这层
检查是安全的地基,但在很多情况下它能被证明永远不会触发,于是可以安全删去。最典型的是这样的循环:
| |
循环条件 i < len(a) 已经蕴含了 a[i] 合法,编译器据此把这次访问的边界检查整个去掉。这背后是
SSA 流水线里一个叫 prove 的优化遍:它沿着控制流收集每个值的取值范围与不等式约束(如「进入循环体
时 i < len(a) 成立」),再用这些约束去判定某次访问是否一定合法。BCE 的强弱直接影响数值密集代码
的性能,因此社区里有不少「如何写循环让编译器更容易做 BCE」的经验,例如先写一句 _ = a[len(a)-1]
把上界提前告诉编译器。可以用 go build -gcflags=-d=ssa/check_bce/debug=1 打印出哪些边界检查没能
被消除,作为优化热点代码时的向导。
另外两样是教科书级别的标准优化,在 SSA 上做起来格外直接:
- 常量折叠(constant folding):编译期就能算出结果的表达式,直接换成结果。
1<<20不会留到运行时。 内联常常把更多东西变成常量,于是又触发新一轮折叠,这正是 15.3.1 强调的「内联为后续优化创造条件」。 - 公共子表达式消除(Common Subexpression Elimination, CSE):同一个值被算了两次,第二次复用第一次的 结果。SSA 的「每个值只定义一次」让「两个表达式是否等价」变成简单的值编号比较,CSE 因此几乎是 顺带就做了。
- 死代码消除(dead-code elimination):经过常量折叠后被判定为不可达的分支、没有人使用的计算结果, 统统删去。
这几样优化彼此咬合:内联喂给常量折叠,折叠喂给死代码消除,删掉死代码又让 CSE 看得更清楚。SSA 流水线把它们排成多遍、反复迭代,直到不再有新的化简,这就是 15.2 所说「在 SSA 上做优化」 的具体含义。
15.3.3 去虚化:把接口调用变回直接调用
接口(4.2)的调用是一次间接调用:运行时要从接口值的 类型信息里取出方法地址,再跳过去。这层间接既有运行时开销,更要命的是它对优化器同样是一堵墙,间接 调用的目标在编译期未知,无法内联,常量也传不进去。
去虚化(devirtualization)就是把这堵墙在能拆的时候拆掉。如果编译器能在编译期确定某个接口变量 背后的具体类型,它就把间接调用改写成对那个具体方法的直接调用,去虚化之后这个调用又重新成为 内联的候选,于是「去虚化 + 内联」常常连环发生,把一次接口分发塌缩成几条内联指令:
| |
这里 w 的静态类型是 io.Writer,w.Write 本是一次接口分发;但它在函数内只被赋成过
*bytes.Buffer 一种具体类型,编译器通过流分析认出它的真身,把间接调用改写成直接调用,进而内联。
这是 Go 编译器近年持续投入的方向,因为接口在 Go 代码里无处不在,每拆掉一处接口墙,内联和常量
传播就能多走一步。但静态分析能看穿的情形终究有限,很多接口的具体类型只有在运行时、在某条特定的
负载下才稳定。要突破这个上限,就要请出下一节的 PGO。
15.3.4 PGO:用运行画像指导优化
到这里,前面几样优化有一个共同的软肋:它们都靠静态地猜。预算该给谁、哪个调用值得激进内联、 哪个接口的具体类型稳定,编译器只能从源码结构去推断,而源码结构并不告诉它「这个程序实际怎么跑、 热点在哪里」。Go 1.21 正式引入的性能制导优化(Profile-Guided Optimization, PGO)就是来补上 这块信息的。
思路朴素而有力:先在真实负载下,用 pprof(16.5)采集一份 CPU
画像(profile),它记录了程序运行时各个函数、各个调用点占用了多少 CPU;把这份画像在编译时
喂给编译器,编译器就拿到了一张「哪里是热点」的地图,从而对热点路径做出比静态猜测更激进的决策。
落到具体动作,主要是两处:
更激进地内联热点调用。普通函数的内联预算是 80(15.3.1),而被画像标为热点的调用点,预算被 抬到
inlineHotMaxBudget = 2000,足足放大二十多倍。换言之,平时因为太大而被拒之门外的函数, 只要它在热路径上,就值得展开。判定「热」用的是累积分布:把所有调用点按权重排序,取累积占到总 权重前 99%(inlineCDFHotCallSiteThresholdPercent = 99)的那些算作热点,剩下长尾不予理会。1 2 3 4 5// cmd/compile/internal/inline/inl.go(裁剪) var ( inlineHotMaxBudget int32 = 2000 // 热点调用点的内联预算 inlineCDFHotCallSiteThresholdPercent = float64(99) )去虚化热点接口调用。对一个间接调用,画像若显示它在运行时绝大多数时候都落到同一个具体类型上, 编译器就做有条件去虚化:插入一个类型判断,命中常见类型时走去虚化后的直接调用(并可内联), 其余情形回退到原来的接口分发。这是一次有把握的赌注,赌输了也只是退回原路,赌赢了热路径就被拉直。
PGO 的收益通常是个位数百分比,听起来不多,但它几乎零成本:把采集到的画像命名为 default.pgo 放进
主包目录,go build 就会自动启用,无需任何额外标志。对一个长期运行、负载稳定的服务,几个百分点
意味着实打实省下的机器。
把 Go 的 PGO 放到业界的坐标里看,它在实现路线上有一个值得说明的选择。做画像制导优化有两条路:一条是插桩式 (instrumentation),先编译一个插了计数器的特殊二进制去跑,换取精确的执行次数,代价是要维护两套 构建、且插桩本身拖慢运行;LLVM 传统的 PGO 走的就是这条路。另一条是采样式(sampling),直接 对生产环境正常运行的二进制采 CPU 画像,没有额外的插桩构建。Go 选了后者,官方文档明确说它面向 「AutoFDO」式的工作流,这正是 Google 内部用采样画像优化海量服务的那套思路。采样画像精度不如插桩, 但它能取自真实生产负载、且采集几乎不打扰线上服务,对 Go 面向的服务端场景,这个取舍是划算的。
值得停下来体会的是 PGO 背后的转变:静态地猜「什么重要」是有上限的,用真实运行数据来指导优化, 能突破这个上限。编译器不再只盯着源码结构,而是拿着「这个程序实际怎么跑」的证据去分配它有限的 优化预算。这与 9 调度器、 13 垃圾回收 里反复出现的思路一脉相承:让系统根据运行时反馈 自适应,而不是把一切都钉死在编译期。区别只在反馈作用的时机,调度器与 GC 在运行中实时调节,PGO 则把 上一轮运行的反馈喂回下一次编译。
PGO 目前主要作用在内联与去虚化两处,但官方文档说得很清楚:它是一块刚铺好的地基,后续版本会有更多 优化学会利用这份画像。基本块排布、寄存器分配、栈帧布局等,原则上都能从「哪条路径更热」的信息里获益。 换句话说,今天 PGO 的个位数收益是它能给的下限,而非上限,随着更多优化遍接入画像,这扇门后面还有 不小的空间。
15.3.5 编译速度这条红线
逃逸分析(15.5)决定变量分配在栈还是堆,是 Go 特有、与 GC 紧密相关的另一项关键优化, 它自成一节,这里不展开。把它和前面几样放在一起看,会发现 Go 优化器的轮廓已经清晰:内联、BCE、 常量折叠、CSE、死代码、去虚化、逃逸分析,再加上 PGO 这把数据驱动的放大器。这份清单覆盖了性价比 最高的优化,却也到此为止。
Go 优化器最该记住的,其实是它不做的事。GCC、LLVM 能做的某些激进优化,例如大范围的循环变换、 高耗时的过程间分析、繁复的向量化,Go 有意不追。原因还是那条贯穿全书的红线:编译速度 (1.1)。Go 诞生于 Google 那种动辄上亿行、编译要以小时计 的代码库,「快速编译、快速迭代」是它从第一天起就排在前面的目标,优先级高过「榨干最后几个百分点的 运行性能」。
这是一个需要被理解、而非被原谅的取舍。对绝大多数服务端程序,编译快带来的开发效率,比那几个百分点 更有价值;而真正需要极致性能的热点,Go 给了两条精准的出路:用 PGO 让编译器把火力集中到数据指出的 热路径上,或者由程序员手工优化那一小段关键代码。性能的提升从不白来,它总伴着别处的代价。Go 的选择 是把代价放在「不追求极致优化」上,把红利留给「极快的编译」,理解了这条红线,也就理解了 Go 编译器 为何优化得如此克制。
延伸阅读的文献
- The Go Authors. Profile-Guided Optimization. https://go.dev/doc/pgo
- Michael Pratt. Profile-guided optimization in Go 1.21. The Go Blog, 2023. https://go.dev/blog/pgo
- The Go Authors. cmd/compile/internal/inline(内联预算与 PGO 热点内联). https://github.com/golang/go/tree/master/src/cmd/compile/internal/inline
- David Chase. Mid-stack inlining in the Go compiler. Design document, 2017. https://golang.org/s/go19inliningtext
- The Go Authors. cmd/compile/internal/devirtualize(静态与 PGO 去虚化). https://github.com/golang/go/tree/master/src/cmd/compile/internal/devirtualize
- The Go Authors. Go 1.21 Release Notes(PGO 转正、
default.pgo自动启用). https://go.dev/doc/go1.21 - 本书 15.2 中间表示、15.5 逃逸分析、 16.5 基准测试与性能画像、 4.2 接口.
许可
© 2018-2026 The golang.design Initiative Authors. Licensed under CC-BY-NC-ND 4.0.