深圳幻海软件技术有限公司 欢迎您!

技巧:如何在 Go 中编写准确的基准测试?

2023-02-28

大家好,我是程序员幽鬼。今天给大家带来一篇关于基准测试的文章。一般来说,我们永远不应该猜测性能。在编写优化时,可能会有很多因素发挥作用,即使我们对结果有强烈的看法,测试它们也不是一个坏主意。然而,编写基准测试并不简单。编写不准确的基准并基于它们做出错误的假设非常简单。这篇文章的目的是检查导致不准确的

大家好,我是程序员幽鬼。

今天给大家带来一篇关于基准测试的文章。

一般来说,我们永远不应该猜测性能。在编写优化时,可能会有很多因素发挥作用,即使我们对结果有强烈的看法,测试它们也不是一个坏主意。然而,编写基准测试并不简单。编写不准确的基准并基于它们做出错误的假设非常简单。这篇文章的目的是检查导致不准确的四个常见和具体的陷阱:

  • 不重置或暂停计时器
  • 对微基准做出错误假设
  • 不留意编译器优化
  • 被观察者效应愚弄

概念

在讨论这些陷阱之前,让我们简要回顾一下基准测试在 Go 中是如何工作的。基准的骨架如下:

func BenchmarkFoo(b *testing.B) {
 for i := 0; i < b.N; i++ {
  foo()
 }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

函数名以 Benchmark 前缀开头。在循环 for 中调用被测函数 (foo) 。b.N 表示可变的迭代次数。运行基准测试时,Go 会尝试使其与请求的基准测试时间相匹配。基准时间默认设置为 1 秒,可以通过 -benchtime 标志更改。b.N 从 1 开始;如果基准测试在 1 秒内完成,则 b.N 会增加,并且基准测试会再次运行,直到 b.N 大致匹配 benchtime:

$ go test -bench=.
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkFoo-4                73          16511228 ns/op
  • 1.
  • 2.
  • 3.

在这里,基准测试耗时约 1 秒,foo 执行了 73 次,平均执行时间为 16,511,228 纳秒。我们可以使用以下方法更改基准时间-benchtime:

$ go test -bench=. -benchtime=2s
BenchmarkFoo-4               150          15832169 ns/op
  • 1.
  • 2.

foo 执行的次数大约是上一次基准测试期间的两倍。

接下来,我们来看看一些常见的陷阱。

不重置或暂停计时器

在某些情况下,我们需要在基准循环之前执行操作。这些操作可能需要相当长的时间(例如,生成大量数据)并且可能会显着影响基准测试结果:

func BenchmarkFoo(b *testing.B) {
 expensiveSetup()
 for i := 0; i < b.N; i++ {
  functionUnderTest()
 }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

在这种情况下,我们可以在进入循环之前使用 ResetTimer 方法:

func BenchmarkFoo(b *testing.B) {
 expensiveSetup()
 b.ResetTimer() // Reset the benchmark timer
 for i := 0; i < b.N; i++ {
  functionUnderTest()
 }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

调用ResetTimer将自测试开始以来经过的基准时间和内存分配计数器归零。这样,可以从测试结果中丢弃耗时的设置。

如果我们不仅要执行一次耗时的设置,还要在每次循环迭代中执行一次,该怎么办?

func BenchmarkFoo(b *testing.B) {
 for i := 0; i < b.N; i++ {
  expensiveSetup()
  functionUnderTest()
 }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

我们无法重置计时器,因为这将在每次循环迭代期间执行。但是我们可以停止和恢复基准计时器,在调用 expensiveSetup 前后:

func BenchmarkFoo(b *testing.B) {
 for i := 0; i < b.N; i++ {
  b.StopTimer() // Pause the benchmark timer
  expensiveSetup()
  b.StartTimer() // Resume the benchmark timer
  functionUnderTest()
 }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

在这里,我们暂停基准计时器以执行耗时的设置,然后恢复计时器。

注意:关于这种方法有一个需要记住的问题:如果被测函数与设置函数相比执行速度太快,则基准测试可能需要很长时间才能完成。原因是它需要超过 1 秒才能到达 *benchtime*。计算基准时间仅基于 *functionUnderTest*。因此,如果我们在每次循环迭代中等待相当长的时间,基准测试将比 1 秒慢得多。如果我们想保持基准,一种可能的缓解措施是降低 *benchtime*。

我们必须确保使用计时器方法来保持基准的准确性。

对微观基准做出错误假设。

微基准测量一个微小的计算单元,很容易对它做出错误的假设。例如,假设我们不确定是否使用atomic.StoreInt32 或 atomic.StoreInt64(假设我们处理的值总是适合 32 位)。我们想编写一个基准来比较这两个函数:

func BenchmarkAtomicStoreInt32(b *testing.B) {
 var v int32
 for i := 0; i < b.N; i++ {
  atomic.StoreInt32(&v, 1)
 }
}

func BenchmarkAtomicStoreInt64(b *testing.B) {
 var v int64
 for i := 0; i < b.N; i++ {
  atomic.StoreInt64(&v, 1)
 }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

如果我们运行这个基准测试,这里有一些示例输出:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt32
BenchmarkAtomicStoreInt32-4    197107742           5.682 ns/op
BenchmarkAtomicStoreInt64
BenchmarkAtomicStoreInt64-4    213917528           5.134 ns/op
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

我们可以很容易地认为这个基准是理所当然的并决定使用 atomic.StoreInt64它,因为它似乎更快。现在,为了做一个 公平 的基准测试,我们把顺序颠倒过来,atomic.StoreInt64 先测试,然后再测试 atomic.StoreInt32。这是一些示例输出:

BenchmarkAtomicStoreInt64
BenchmarkAtomicStoreInt64-4    224900722           5.434 ns/op
BenchmarkAtomicStoreInt32
BenchmarkAtomicStoreInt32-4    230253900           5.159 ns/op
  • 1.
  • 2.
  • 3.
  • 4.

这一次,atomic.StoreInt32 有更好的结果。发生了什么?

在微基准测试的情况下,许多因素都会影响结果,例如运行基准测试时的机器活动、电源管理、热缩放以及指令序列的更好缓存对齐。我们必须记住,许多因素,即使在我们的 Go 项目范围之外,也会影响结果。

注意:我们应该确保执行基准测试的机器是空闲的。但是,外部进程可能会在后台运行,这可能会影响基准测试结果。因此,诸如此类的工具 perflock 可以限制基准测试可以消耗多少 CPU。例如,我们可以使用总可用 CPU 的 70% 运行基准测试,将 30% 分配给操作系统和其他进程,并减少机器活动因素对结果的影响。

一种选择是使用 -benchtime 选项增加基准测试时间。类似于概率论中的大数定律,如果我们多次运行基准测试,它应该趋向于接近其预期值(假设我们忽略了指令缓存和类似机制的好处)。

另一种选择是在经典基准测试工具之上使用外部工具。例如,benchstat工具是golang.org/x 仓库的一部分,它允许我们计算和比较有关基准执行的统计数据。

让我们使用-count该选项运行基准测试 10 次并将输出通过管道传输到特定文件:

$ go test -bench=. -count=10 | tee stats.txt
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt32-4     234935682                5.124 ns/op
BenchmarkAtomicStoreInt32-4     235307204                5.112 ns/op
// ...
BenchmarkAtomicStoreInt64-4     235548591                5.107 ns/op
BenchmarkAtomicStoreInt64-4     235210292                5.090 ns/op
// ...
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

然后我们可以在这个文件上运行 benchstat:

$ benchstat stats.txt
name                time/op
AtomicStoreInt32-4  5.10ns ± 1%
AtomicStoreInt64-4  5.10ns ± 1%
  • 1.
  • 2.
  • 3.
  • 4.

结果是相同的:这两个函数平均需要 5.10 纳秒才能完成。我们还看到给定基准的执行之间的百分比变化:± 1%。这个指标告诉我们两个基准都是稳定的,让我们对计算的平均结果更有信心。因此,我们可以得出结论,它的执行时间与我们测试的 atomic.StoreInt64 用法相似(在特定机器上的特定 Go 版本中),而不是得出 atomic.StoreInt32 更快或更慢这样的结论。

一般来说,我们应该对微观基准保持谨慎。许多因素会显着影响结果并可能导致错误的假设。增加基准测试时间或使用工具重复执行基准测试和计算统计数据比如benchstat可以是限制外部因素并获得更准确结果的有效方法,从而得出更好的结论。

让我们还强调一下,如果另一个系统最终运行应用程序,我们应该小心使用在给定机器上执行的微基准测试的结果。生产系统的行为可能与我们运行微基准测试的系统完全不同。

不关心编译器优化

另一个与编写基准相关的常见错误是被编译器优化所愚弄,这也可能导致错误的基准假设。在本节中,我们使用人口计数函数(计算位数的函数[1]设置为 1):

const m1 = 0x5555555555555555
const m2 = 0x3333333333333333
const m4 = 0x0f0f0f0f0f0f0f0f
const h01 = 0x0101010101010101

func popcnt(x uint64) uint64 {
 x -= (x >> 1) & m1
 x = (x & m2) + ((x >> 2) & m2)
 x = (x + (x >> 4)) & m4
 return (x * h01) >> 56
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

这个函数接受并返回一个 uint64。为了对该函数进行基准测试,我们可以编写以下代码:

func BenchmarkPopcnt1(b *testing.B) {
 for i := 0; i < b.N; i++ {
  popcnt(uint64(i))
 }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

但是,如果我们执行这个基准测试,会得到一个令人惊讶的低结果:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4      1000000000               0.2858 ns/op
  • 1.
  • 2.

0.28 纳秒的持续时间大约是一个时钟周期,因此这个数字低得不合理。问题是开发人员对编译器优化不够谨慎。在这种情况下,被测函数非常简单,可以作为内联的候选者:一种用被调用函数的主体替换函数调用并让我们防止占用空间小的函数调用的优化。内联函数后,编译器会注意到该调用没有副作用,并将其替换为以下基准:

func BenchmarkPopcnt1(b *testing.B) {
 for i := 0; i < b.N; i++ {
  // Empty
 }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

基准现在是空的——这就是我们得到接近一个时钟周期的结果的原因。为防止这种情况发生,最佳做法是遵循以下模式:

在每次循环迭代期间,将结果分配给局部变量(在基准函数的上下文中为局部变量)。

将最新结果分配给全局变量。

在我们的例子中,我们编写了以下基准:

var global uint64 // Define a global variable

func BenchmarkPopcnt2(b *testing.B) {
 var v uint64 // Define a local variable
 for i := 0; i < b.N; i++ {
  v = popcnt(uint64(i)) // Assign the result to the local variable
 }
 global = v // Assign the result to the global variable
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

global 是一个全局变量,而 v 是一个局部变量,其范围是基准函数。在每次循环迭代期间,我们将结果分配给popcnt局部变量。然后我们将最新的结果分配给全局变量。

注意:为什么不将**popcnt**调用结果直接分配给 global 以简化测试?写入全局变量比写入局部变量慢(这些概念在 100 个 Go 错误[2] 中讨论,错误 #95:“不理解堆栈与堆[3]”)。因此,我们应该将每个结果写入一个局部变量,以限制每次循环迭代期间的足迹。

如果我们运行这两个基准测试,我们现在得到的结果有明显的差异:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4      1000000000               0.2858 ns/op
BenchmarkPopcnt2-4      606402058                1.993 ns/op
  • 1.
  • 2.
  • 3.

BenchmarkPopcnt2 是基准的准确版本。它保证我们避免内联优化,这可以人为地降低执行时间,甚至删除对被测函数的调用。依赖结果BenchmarkPopcnt1可能会导致错误的假设。

让我们记住避免编译器优化欺骗基准测试结果的模式:将被测函数的结果分配给局部变量,然后将最新结果分配给全局变量。这种最佳实践还可以防止我们做出错误的假设。

被观察者效应愚弄

在物理学中,观察者效应是观察行为对被观察系统的扰动。这种影响也可以在基准测试中看到,并可能导致对结果的错误假设。让我们看一个具体的例子,然后尝试减轻它。

我们想要实现一个接收int64元素矩阵的函数。这个矩阵有固定的 512 列,我们要计算前 8 列的总和,如图 11.2 所示。

计算前八列的总和

为了优化,我们还想确定改变列数是否有影响,所以我们还实现了第二个函数,有 513 列。实现如下:

func calculateSum512(s [][512]int64) int64 {
 var sum int64
 for i := 0; i < len(s); i++ { // Iterate over each row
  for j := 0; j < 8; j++ { // Iterate over the first eight columns
   sum += s[i][j] // Increment sum
  }
 }
 return sum
}

func calculateSum513(s [][513]int64) int64 {
 // Same implementation as calculateSum512
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

我们遍历每一行,然后遍历前八列,并增加一个返回的 sum 变量。calculateSum513中的实现保持不变。

我们想要对这些函数进行基准测试,以确定在给定固定行数的情况下哪个函数的性能最高:

const rows = 1000

var res int64

func BenchmarkCalculateSum512(b *testing.B) {
 var sum int64
 s := createMatrix512(rows) // Create a matrix of 512 columns
 b.ResetTimer()
 for i := 0; i < b.N; i++ {
  sum = calculateSum512(s) // Create a matrix of 512 columns
 }
 res = sum
}

func BenchmarkCalculateSum513(b *testing.B) {
 var sum int64
 s := createMatrix513(rows) // Create a matrix of 513 columns
 b.ResetTimer()
 for i := 0; i < b.N; i++ {
  sum = calculateSum513(s) // Calculate the sum
 }
 res = sum
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.

我们只想创建一次矩阵,以限制结果的足迹。因此,我们在循环之外调用createMatrix512andcreateMatrix513 。我们可能希望结果与我们只想迭代前八列的结果相似,但事实并非如此(在我的机器上):

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkCalculateSum512-4        81854             15073 ns/op
BenchmarkCalculateSum513-4       161479              7358 ns/op
  • 1.
  • 2.
  • 3.

具有 513 列的第二个基准测试大约快 50%。同样,因为我们只迭代前八列,所以这个结果非常令人惊讶。

要了解这种差异,我们需要了解 CPU 缓存的基础知识。简而言之,一个 CPU 由不同的缓存(通常是 L1、L2 和 L3)组成。这些高速缓存降低了从主存储器访问数据的平均成本。在某些情况下,CPU 可以从主存中取出数据并将其复制到 L1。在这种情况下,CPU 尝试将calculateSum感兴趣的矩阵子集(每行的前八列)提取到 L1 中。但是,矩阵在一种情况下(513 列)适合内存,但在另一种情况下(512 列)则不适合。

注意:这不在本文的范围内解释原因,但我们会在**100 Go Mistakes*[4]中查看这个问题,错误 #91:“*不了解 CPU 缓存*[5]*”。

回到基准测试,主要问题是我们在两种情况下都重复使用相同的矩阵。因为函数重复了数千次,所以当函数接收到一个普通的新矩阵时,我们不会测量函数的执行。相反,我们测量一个函数,该函数获取一个矩阵,该矩阵已经在缓存中存在单元的子集。因此,由于calculateSum513导致更少的缓存未命中,它具有更好的执行时间。

这是观察者效应的一个例子。因为我们一直在观察一个重复调用的 CPU-bound 函数,CPU 缓存可能会发挥作用并显着影响结果。在这个例子中,为了防止这种影响,我们应该在每次测试期间创建一个矩阵,而不是重用一个:

func BenchmarkCalculateSum512(b *testing.B) {
 var sum int64
 for i := 0; i < b.N; i++ {
  b.StopTimer()
  s := createMatrix512(rows) // Create a new matrix during each loop iteration
  b.StartTimer()
  sum = calculateSum512(s)
 }
 res = sum
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

现在在每次循环迭代期间创建一个新矩阵。如果我们再次运行基准测试(并进行调整benchtime——否则执行时间太长),结果会更接近:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkCalculateSum512-4         1116             33547 ns/op
BenchmarkCalculateSum513-4          998             35507 ns/op
  • 1.
  • 2.
  • 3.

我们没有做出calculateSum513更快的错误假设,而是看到两个基准在接收新矩阵时会导致相似的结果。

正如我们在这篇文章中看到的,因为我们重用了相同的矩阵,CPU 缓存对结果有很大影响。为了防止这种情况,我们必须在每次循环迭代期间创建一个新矩阵。一般来说,我们应该记住,观察一个被测函数可能会导致结果的显着差异,尤其是在低级优化很重要的 CPU 绑定函数的微基准测试环境中。在每次迭代期间强制基准重新创建数据可能是防止这种影响的好方法。

结论

  • 使用时间方法来保持基准的准确性。
  • 在处理微基准测试时,增加benchtime或使用诸如benchstat此类的工具会很有帮助。
  • 如果最终运行应用程序的系统与运行微基准测试的系统不同,请注意微基准测试的结果。
  • 确保被测函数会产生副作用,以防止编译器优化在基准测试结果上欺骗你。
  • 为防止观察者效应,请强制基准测试重新创建 CPU 绑定函数使用的数据。

这篇文章摘自作者图书*100 Go Mistakes and How to Avoid Them*[6],该书于 2022 年 8 月发布。

原文链接:https://teivah.medium.com/how-to-write-accurate-benchmarks-in-go-4266d7dd1a95

参考资料

[1]函数: https://github.com/golang/go/issues/14813

[2]100 个 Go 错误: https://www.manning.com/books/100-go-mistakes-and-how-to-avoid-them

[3]不理解堆栈与堆: https://livebook.manning.com/book/100-go-mistakes-and-how-to-avoid-them/chapter-12/section-12-5

[4]100 Go Mistakes: https://www.manning.com/books/100-go-mistakes-and-how-to-avoid-them

[5]不了解 CPU 缓存: https://livebook.manning.com/book/100-go-mistakes-and-how-to-avoid-them/chapter-12/section-12-1

[6]100 Go Mistakes and How to Avoid Them: https://www.manning.com/books/100-go-mistakes-and-how-to-avoid-them