Go 博客
使用 testing.B.Loop 进行更可预测的基准测试
使用 testing
包编写过基准测试的 Go 开发者可能遇到过它的一些陷阱。Go 1.24 引入了一种新的基准测试编写方式,它同样易于使用,同时更加健壮:testing.B.Loop
。
传统上,Go 基准测试是使用从 0 到 b.N
的循环编写的
func Benchmark(b *testing.B) {
for range b.N {
... code to measure ...
}
}
改用 b.Loop
是一个微不足道的改动
func Benchmark(b *testing.B) {
for b.Loop() {
... code to measure ...
}
}
testing.B.Loop
具有许多优点
- 它可以防止基准测试循环中出现不必要的编译器优化。
- 它会自动将设置和清理代码排除在基准测试计时之外。
- 代码不会意外依赖于总迭代次数或当前迭代。
这些都是使用 b.N
风格的基准测试容易犯的错误,它们会悄悄地导致虚假的基准测试结果。此外,使用 b.Loop
风格的基准测试甚至完成得更快!
让我们探讨 testing.B.Loop
的优势以及如何有效利用它。
旧的基准测试循环问题
在 Go 1.24 之前,虽然基准测试的基本结构很简单,但更复杂的基准测试需要更仔细的处理
func Benchmark(b *testing.B) {
... setup ...
b.ResetTimer() // if setup may be expensive
for range b.N {
... code to measure ...
... use sinks or accumulation to prevent dead-code elimination ...
}
b.StopTimer() // if cleanup or reporting may be expensive
... cleanup ...
... report ...
}
如果设置或清理不是微不足道的,开发者需要用 ResetTimer
和/或 StopTimer
调用来包围基准测试循环。这些很容易忘记,即使开发者记得它们可能是必要的,也很难判断设置或清理是否“足够昂贵”而需要它们。
如果没有这些,testing
包只能对整个基准测试函数计时。如果一个基准测试函数省略了它们,设置和清理代码将包含在总体时间测量中,悄悄地扭曲最终的基准测试结果。
还有一个更微妙的陷阱需要更深入的理解:(示例来源)
func isCond(b byte) bool {
if b%3 == 1 && b%7 == 2 && b%17 == 11 && b%31 == 9 {
return true
}
return false
}
func BenchmarkIsCondWrong(b *testing.B) {
for range b.N {
isCond(201)
}
}
在此示例中,用户可能会观察到 isCond
在亚纳秒时间内执行。CPU 很快,但没那么快!这个看似异常的结果源于 isCond
被内联,并且由于其结果从未被使用,编译器将其作为死代码消除。因此,这个基准测试根本没有测量 isCond
;它测量的是什么都不做所需的时间。在这种情况下,亚纳秒的结果是一个明确的危险信号,但在更复杂的基准测试中,部分死代码消除可能导致结果看起来合理,但仍然没有测量预期目标。
testing.B.Loop
如何提供帮助
与 b.N
风格的基准测试不同,testing.B.Loop
能够跟踪它在基准测试中何时首次被调用以及最终迭代何时结束。循环开始时的 b.ResetTimer
和结束时的 b.StopTimer
已集成到 testing.B.Loop
中,消除了手动管理设置和清理代码的基准测试计时器的需要。
此外,Go 编译器现在会检测条件仅为调用 testing.B.Loop
的循环,并防止循环内的死代码消除。在 Go 1.24 中,这是通过禁止内联到此类循环的主体中实现的,但我们计划在未来改进这一点。
testing.B.Loop
的另一个优点是其一次性预热(ramp-up)方法。使用 b.N
风格的基准测试时,testing
包必须使用不同的 b.N
值多次调用基准测试函数,逐渐增加直到测量时间达到阈值。相比之下,b.Loop
可以简单地运行基准测试循环直到达到时间阈值,并且只需调用基准测试函数一次。在内部,b.Loop
仍然使用一个预热过程来分摊测量开销,但这对于调用者是隐藏的,并且可能更有效率。
b.N
风格循环的某些限制仍然适用于 b.Loop
风格的循环。在必要时,用户仍然有责任在基准测试循环内管理计时器:(示例来源)
func BenchmarkSortInts(b *testing.B) {
ints := make([]int, N)
for b.Loop() {
b.StopTimer()
fillRandomInts(ints)
b.StartTimer()
slices.Sort(ints)
}
}
在此示例中,为了对 slices.Sort
的原地排序性能进行基准测试,每次迭代都需要一个随机初始化的数组。在这种情况下,用户仍必须手动管理计时器。
此外,基准测试函数主体中仍然必须只有一个这样的循环(b.N
风格的循环不能与 b.Loop
风格的循环共存),并且循环的每次迭代都应该做同样的事情。
何时使用
testing.B.Loop
方法现在是编写基准测试的首选方式
func Benchmark(b *testing.B) {
... setup ...
for b.Loop() {
// optional timer control for in-loop setup/cleanup
... code to measure ...
}
... cleanup ...
}
testing.B.Loop
提供了更快、更准确、更直观的基准测试。
致谢
非常感谢社区中所有对提案问题提供反馈以及在新功能发布时报告 bug 的人!我也感谢 Eli Bendersky 提供的有益的博客摘要。最后,特别感谢 Austin Clements、Cherry Mui 和 Michael Pratt 的审阅、对设计方案的深思熟虑以及文档改进。感谢大家的贡献!
上一篇文章:告别核心类型 - 迎接我们熟悉和喜爱的 Go!
博客索引