Go 博客

使用 testing.B.Loop 进行更可预测的基准测试

Junyang Shao
2025 年 4 月 2 日

使用 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!
博客索引