基准测试得到的数字有时好得不真实:0.3 ns 一次的“函数调用”比一个 CPU 周期还短。这篇复现几个最常见的陷阱——被编译器优化掉、常量折叠、被测数据被上一次迭代改变——并用汇编证明循环里真的什么都没做,再看 Go 1.24 的 b.Loop 怎么堵这个洞。
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。看到小于一个时钟周期的数字就该去看汇编。
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 本身的每次迭代开销,我没有把两部分拆开量,所以比较极小的函数时要意识到这个底噪。