使用 testing.B.Loop 获得更可预测的基准测试

使用 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 的审阅,以及他们在设计方案和文档改进方面的深入工作。感谢大家的贡献!

原文:More predictable benchmarking with testing.B.Loop。Junyang Shao。原文发表于2025年4月2日,讨论Go 1.24;中文翻译与排版改编。原文及示例版权归原权利人;依转载授权保留来源及原有权利声明。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容