使用 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 包只能对整个基准测试函数计时。如果省略它们,准备和清理代码也会计入总测量时间,悄然使最终结果偏离目标。
还有一个更隐蔽、需要深入理解的陷阱。下例来自原文链接的示例文章: (Example source)
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 中,这通过禁止将函数内联到这类循环体中实现;Go 团队计划在未来改进这一机制,相关议题链接保留如下。 (improve)
testing.B.Loop 的另一个优点是一次调用内完成测量规模提升。使用 b.N 时,testing 包必须以不同的 b.N 多次调用基准测试函数,逐步增加迭代次数,直到测量时长达到阈值。b.Loop 则可以持续运行同一个循环直到达到阈值,只需调用基准测试函数一次。内部仍会逐步调整测量规模,以摊薄测量开销,但这个过程对调用方不可见,也可能更高效。
b.N 循环的某些约束仍然适用于 b.Loop。必要时,开发者仍应管理循环内部的计时器。下例同样来自原文链接的示例文章: (Example source)
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 可以让基准测试更快、更准确,也更直观。
致谢
感谢社区中所有在提案议题中提供反馈、并在该特性发布后报告缺陷的人!也感谢 Eli Bendersky 的博客总结。最后,特别感谢 Austin Clements、Cherry Mui 和 Michael Pratt 的审阅,以及他们在设计方案和文档改进方面的深入工作。感谢大家的贡献!











暂无评论内容