bigywater.tech
Go · 105

基准测试的陷阱:编译器优化掉了被测代码

基准测试得到的数字有时好得不真实:0.3 ns 一次的“函数调用”比一个 CPU 周期还短。这篇复现几个最常见的陷阱——被编译器优化掉、常量折叠、被测数据被上一次迭代改变——并用汇编证明循环里真的什么都没做,再看 Go 1.24 的 b.Loop 怎么堵这个洞。

0.297 ns
不使用结果时,一个能内联的函数的基准耗时:比一个时钟周期还短,说明调用已被消除
7635 vs 462796 ns
反复对同一个切片排序与每次用新副本排序
1.20
同一个基准重复 10 次,最大耗时与最小耗时之比

调用被整个删掉

实测 · 本机执行输出
  BenchmarkPop_NoSink          0.297 ns/op  0 B/op  0 allocs/op(循环了 780407283 次)
  BenchmarkPop_Sink            0.340 ns/op  0 B/op  0 allocs/op(循环了 719676152 次)
  BenchmarkPop_Loop            2.350 ns/op  0 B/op  0 allocs/op(循环了 100000000 次)
  BenchmarkSlow_NoSink        25.850 ns/op  0 B/op  0 allocs/op(循环了 9107298 次)

结果没被使用、函数又能内联,编译器就把调用删掉了:这个基准只有 0.297 ns/op,比一个时钟周期还短。把结果赋给全局变量是 0.340 ns(popcount 本身只要一条指令,所以差别很小);标了 //go:noinline 的同类函数 25.850 ns 才是真实的计算成本。用 for b.Loop() 写同样不使用结果的基准是 2.350 ns,调用没有被删(原因见最后一节)。

常量参数

实测 · 本机执行输出
  BenchmarkMul_ConstArgs       0.309 ns/op
  BenchmarkMul_VarArgs         0.447 ns/op
  BenchmarkMul_ConstArgsLoop     2.128 ns/op

Mul(123, 456) 这种常量参数的调用在编译期被折叠成常量,0.309 ns;换成含循环变量的参数是 0.447 ns,依然很小,因为乘法本身就一条指令。要测的函数必须用运行时才知道的输入。

输入被改变

实测 · 本机执行输出
  BenchmarkSort_SameSlice              7635 ns/op
  BenchmarkSort_FreshCopy            462796 ns/op
  BenchmarkSort_FreshCopyNoStop       421463 ns/op

对同一个切片反复 sort.Ints,第一次之后排的是已经排好的数据:7635 ns,比用新副本的 462796 ns 小了约 60 倍。基准里被测的输入每次迭代都要一样。StopTimer/StartTimer 能把复制排除在计时之外,但它自己有开销,我这里没有量。

噪声

实测 · 本机执行输出
  10 次的 ns/op:368448 365389 369454 368951 366887 365990 360273 362217 375844 431496
  最小 360273 / 中位 367668 / 最大 431496,最大/最小 = 1.20

同一个基准连续 10 次:360273 到 431496 ns,最大/最小 1.20;还和前一节同一个基准的单次结果(421463)差了一截。比较两个版本时,差别小于 20% 的单次结果不能当结论,要多次运行并用统计工具(本文没有用 benchstat)。

汇编证据

实测 · 本机执行输出
  BenchmarkPop_NoSink 的机器指令共 8 条,其中调用 Pop 或 OnesCount64 的 CALL 指令 0 条;含 CNT/popcount 类指令 0 条
  前 12 条:aa1f03e1 MOVD ZR, R1 ; 14000002 JMP 2(PC) ; 91000421 ADD $1, R1, R1 ; f9410802 MOVD 528(R0), R2 ; eb02003f CMP R2, R1 ; 54ffffab BLT -3(PC) ; d65f03c0 RET ; 00000000 ?
  BenchmarkPop_Sink 的机器指令共 12 条;含 VCNT 的 1 条

BenchmarkPop_NoSink 的循环体只有 8 条指令(MOVD、JMP、ADD、CMP、BLT、RET),没有 CALL,也没有 popcount 指令;而把结果赋给全局变量的版本有 12 条指令,含 1 条 VCNT。看到小于一个时钟周期的数字就该去看汇编。

b.Loop

实测 · 本机执行输出
  Within the body of a "for b.Loop() { ... }" loop, arguments to and results from function calls and assigned variables within the loop are kept alive, preventing the compiler from fully optimizing away the loop body. Currently, this is implemented as a compiler transformation that wraps such variables with a runtime.KeepAlive intrinsic call. This applies only to statements

文档说 for b.Loop() 的循环体里,函数调用的参数与结果、被赋值的变量都会被 runtime.KeepAlive 保住,所以不使用结果也不会被删:实验里同样的调用是 2.350 ns(常量参数的 Mul 是 2.128 ns)。这 2 ns 里包含 b.Loop 本身的每次迭代开销,我没有把两部分拆开量,所以比较极小的函数时要意识到这个底噪。

分步演示

基准测试的陷阱
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。基准用 go test -bench -benchtime=200ms -benchmem;汇编用 go test -c 与 go tool objdump;重复运行用 -count=10;读过的文档:go doc testing.B.Loop
    • 没有验证:benchstat 的显著性判断、-cpuprofile 辅助检查基准、更大的函数体里被部分消除的情形、b.Loop 与 for b.N 在更长的被测函数上的差别、Linux 与其它架构上的结果
    京ICP备2026067502号-1京公网安备11010502063487号