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 的另一个优点是其一次性启动方法。对于 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 提供更快、更准确、更直观的基准测试。

致谢

非常感谢社区中所有为提案问题提供反馈并在该功能发布时报告错误的人!我也感谢 Eli Bendersky 提供的有益博客摘要。最后,非常感谢 Austin Clements、Cherry Mui 和 Michael Pratt 对设计选项和文档改进的审查和深思熟虑的工作。感谢大家的所有贡献!

下一篇文章:Go 加密安全审计
上一篇文章:再见核心类型 - 你好我们熟悉和喜爱的 Go!
博客索引