Go 开发者在使用 testing
包编写基准测试用例时,如果不注意,可能会遇到各种陷阱。这些陷阱,导致基准测试结果不准确。Go1.24 版本引入了一种新的基准测试编写方式,它同样易用,并且可以帮助规避编写基准测试时的一些坑。
Go 1.24 版本推荐使用 testing.B.Loop
代替 testing.B.N
来编写基准测试用例。
Go1.24 版本前,我们使用 b.N
来编写基准测试用例,例如:
go
func Benchmark(b *testing.B) {
for range b.N {
... 要测量的代码 ...
}
}
改用b.Loop
仅需要微不足道的改动:
go
func Benchmark(b *testing.B) {
for b.Loop() {
... 要测量的代码 ...
}
}
testing.B.Loop
有很多优点:
- 可以防止基准测试循环内的不当编译优化;
- 可以自动将设置和清理部分代码耗时从基准测试时间统计中剔除;
- 代码不应意外地依赖于总迭代次数或当前迭代。
上述这些优点都是在使用 b.N
编写基准测试代码时易犯的错误,这些错误会导致基准测试不准确。除了上述优点之外,b.Loop
风格的基准测试,还能再更短的时间执行完。
接下来,我们来看下testing.B.Loop
的优势以及如何有效地使用它。
旧基准测试循环问题
在 Go 1.24 之前,大部分的基准测试用例结构简单。但是复杂的测试用例,却需要很小心的编写:
go
func Benchmark(b *testing.B) {
... setup ...
b.ResetTimer() // 如果设置可能很昂贵
for range b.N {
... 代码测量 ...
... 使用汇点或累积防止未用代码消除 ...
}
b.StopTimer() // 如果清理或报告可能很昂贵
... 清理 ...
... 报告 ...
}
如果设置 (setup)或清理 (cleanup)逻辑复杂,耗时较久,为了避免这些准备性的逻辑参与到核心代码的耗时统计中,需要使用ResetTimer
和 StopTimer
方法,将这些时间剔除掉。但是真正开发的过程中, 可能有一些开发者会遗漏这些逻辑。即使开发者没有遗漏,也很难判断设置或清理过程是否"足够耗时"到需要使用它们。
还有一个更微妙的陷阱,需要更深入的理解(示例源代码):
go
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
时的循环,并阻止 testing.B.Loop
内的代码死循环。
testing.B.Loop
的另一个优点是其一次性快速提升的方法。对于 b.N
风格的基准测试,测试包必须多次调用基准测试函数,并使用不同的 b.N
值逐步增加,直到测量时间达到一个阈值。相比之下,b.Loop
可以简单地运行基准测试循环,直到达到时间阈值,只需调用基准测试函数一次。
b.N
风格循环的某些限制仍适用于 b.Loop
风格的循环。用户仍需在必要时负责在基准测试循环中管理计时器。(示例源)
go
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
方法,是从 Go 1.24 版本起,编写基准测试的首选方式。使用示例如下:
go
func Benchmark(b *testing.B) {
... 设置 ...
for b.Loop() {
// 可选的循环内部设置/清理计时器控制
... 要测量的代码 ...
}
... 清理 ...
}