bigywater.tech
Go · 46

内联:什么函数会被内联,-gcflags=-m 怎么读

内联是编译器最重要的优化之一,但它由一个“代价预算”决定,不是由函数行数决定。这篇用编译器自己的诊断看清:哪些写法能内联、代价怎么算、预算的边界在哪里、内联真正改变了什么——以及它没有改变什么。

预算 80
caller 代价 96 被拒绝,k = 26 条 x += i 代价 80 刚好通过,k = 27 代价 83 被拒绝
不能内联
defer、recover、go 语句(unhandled op DEFER / GO、call to recover)
0 次 vs 1 次
返回指针的小函数:内联后分配 0 次,关闭内联后分配 1 次

怎么读诊断

实测 · 本机执行输出
  add              可以内联,代价 4
  abs              可以内联,代价 9
  sum              可以内联,代价 16
  forWithBreak     可以内联,代价 19
  withClosure      可以内联,代价 17
  withDefer        不能内联:unhandled op DEFER
  withRecover      不能内联:call to recover
  withGo           不能内联:unhandled op GO
  withSelect       可以内联,代价 14
  withSwitch       可以内联,代价 16
  callsMethod      可以内联,代价 6
  callsInterface   可以内联,代价 61
  callsFuncValue   可以内联,代价 21
  recursive        可以内联,代价 71
  panics           可以内联,代价 9
  usesStd          可以内联,代价 29
  big              可以内联,代价 62
  caller           不能内联:function too complex: cost 96 exceeds budget 80
  main             不能内联:function too complex: cost 310 exceeds budget 80
  (方法 (*T).M:can inline 3)

can inline f with cost N 是说 f 能被内联,N 是它的代价;cannot inline f: function too complex: cost 96 exceeds budget 80 是代价超出;unhandled op DEFER 这类是遇到了编译器目前不支持内联的语句。-m=2 比 -m 多打印原因与代价。

代价是怎么累加的

实测 · 本机执行输出
  caller 函数体(add、abs、callsMethod、sum、panics、withSwitch 六个调用)里被内联的调用:add, abs, callsMethod, sum, panics, withSwitch, (*T).M
  main.go:84:6: cannot inline caller: function too complex: cost 96 exceeds budget 80
  main.go:89:6: cannot inline main: function too complex: cost 310 exceeds budget 80

每个节点(运算、语句、调用)按固定权重累加;内联进来的函数的代价也要计入调用者。所以 add、abs 这样的小函数一个个都能内联,但把它们放在同一个 caller 里之后,caller 自己就超出预算,不再能被别人内联。实验里连续的 x += i 每条约 3 的代价,最多 26 条。

哪些写法被拒绝

实测 · 本机执行输出
  最大的 k = 26:(True, 80);k = 27:(False, 83)
  k = 1:(True, 5)
  k = 10:(True, 32)
  k = 20:(True, 62)

这个版本里 for(含 break)、闭包、select、switch、方法、函数值调用、自递归都能内联;defer、recover、go 语句不行。别把“有循环就不能内联”之类旧结论当成现状,以你所用版本的诊断为准。

内联到底改变了什么

实测 · 本机执行输出
  内联的 addInl(i, 1)                     中位 2.33 ns/op(最小 2.32 / 最大 2.35)
  //go:noinline 的 addNoInl(i, 1)       中位 1.99 ns/op(最小 1.98 / 最大 2.22)
  内联的 newPInl(i, 1).x(指针不逃逸)           中位 2.07 ns/op(最小 2.06 / 最大 2.37)
  //go:noinline 的 newPNoInl(i, 1).x(指针逃逸到堆) 中位 6.34 ns/op(最小 6.31 / 最大 6.69)
  AllocsPerRun:newPInl 0 次;newPNoInl 1 次
  同一份代码加 -gcflags=-l(关闭内联):
  内联的 addInl(i, 1)                     中位 1.98 ns/op(最小 1.97 / 最大 2.04)
  //go:noinline 的 addNoInl(i, 1)       中位 1.99 ns/op(最小 1.97 / 最大 2.00)
  内联的 newPInl(i, 1).x(指针不逃逸)           中位 6.52 ns/op(最小 6.28 / 最大 6.84)
  //go:noinline 的 newPNoInl(i, 1).x(指针逃逸到堆) 中位 6.57 ns/op(最小 6.39 / 最大 6.78)
  AllocsPerRun:newPInl 1 次;newPNoInl 1 次

最直观的收益是去掉调用开销,但在这个紧循环微基准里没有看到(内联的 addInl 反而略慢,差异我没有深究)。更实在的收益是内联之后逃逸分析能看到调用者的全貌:newPInl 返回的指针没有逃出调用者,于是 &P{} 留在栈上,0 次分配;关闭内联后同一段代码就多一次分配、慢 3 倍(见上一篇逃逸分析)。

跨包内联

实测 · 本机执行输出
  inlining call to strings.Index
  inlining call to strings.HasPrefix
  inlining call to bytes.Equal
  inlining call to utf8.RuneLen
  inlining call to stringslite.HasPrefix

标准库里的小函数(strings.HasPrefix、bytes.Equal、utf8.RuneLen 等)也会被内联进你的代码,因为编译好的包里带着可内联函数的函数体。math.Abs 没有出现在诊断里,我没有查它是不是被当成内建指令处理了。

分步演示

内联
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。诊断来自 go build -gcflags=-m=2 的原样输出(函数代价在源码里按名字对应);预算边界用二分法反复编译;基准测试每个变体 7 轮取中位数,每个函数 //go:noinline 或不加,-l 对照另跑一遍
    • 没有验证:PGO 提高内联预算的效果(见 PGO 一篇)、编译器源码里每种节点的具体权重、-gcflags=-l=4 等历史选项、泛型函数与接口方法的去虚拟化内联、math.Abs 等内建函数的处理、amd64 上的诊断是否相同
    京ICP备2026067502号-1京公网安备11010502063487号