如何在Go中写出准确的基准#golang

89 阅读13分钟

一般来说,我们不应该猜测性能。在编写优化时,有很多因素可能会起作用,即使我们对结果有很强的意见,也很少会有坏主意去测试它们。然而,编写基准并不简单。写出不准确的基准并在此基础上做出错误的假设可能相当简单。这篇文章的目的是研究导致不准确的四个常见和具体的陷阱:

  • 没有重设或暂停定时器
  • 对微观测评做出错误的假设
  • 对编译器的优化不谨慎
  • 被观察者效应所迷惑

一般概念

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

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

$ go test -bench=.cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHzBenchmarkFoo-4                73          16511228 ns/op

在这里,基准测试花了大约1秒,foo 执行了73次,平均执行时间为16,511,228纳秒。我们可以用-benchtime 来改变基准时间。

$ go test -bench=. -benchtime=2sBenchmarkFoo-4               150          15832169 ns/op

foo 被执行的时间大约是之前基准测试的两倍。

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

没有重设或暂停定时器

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

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

调用ResetTimer ,将测试开始后的基准时间和内存分配计数器清零。这样一来,昂贵的设置就可以从测试结果中舍弃了。

如果我们不仅要进行一次昂贵的设置,而且要在每个循环迭代中进行,怎么办?

我们不能重置定时器,因为这将在每个循环迭代中执行。但是我们可以停止和恢复基准计时器,围绕着对expensiveSetup 的调用。

在这里,我们暂停了基准定时器以执行昂贵的设置,然后恢复定时器。

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

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

对微型基准做出错误的假设

微型基准测量的是一个微小的计算单元,对它做错误的假设是非常容易的。比方说,我们不确定是使用atomic.StoreInt32 还是atomic.StoreInt64 (假设我们处理的值总是适合32位)。我们想写一个基准来比较这两个函数。

如果我们运行这个基准,这里有一些输出的例子:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHzBenchmarkAtomicStoreInt32BenchmarkAtomicStoreInt32-4    197107742           5.682 ns/opBenchmarkAtomicStoreInt64BenchmarkAtomicStoreInt64-4    213917528           5.134 ns/op

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

BenchmarkAtomicStoreInt64BenchmarkAtomicStoreInt64-4    224900722           5.434 ns/opBenchmarkAtomicStoreInt32BenchmarkAtomicStoreInt32-4    230253900           5.159 ns/op

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

在微型基准测试的情况下,很多因素都会影响结果,比如在运行基准时的机器活动、电源管理、热扩展、以及更好的指令序列的缓存对齐。我们必须记住,许多因素,甚至是我们围棋项目范围之外的因素,都会影响结果。

注意我们应该确保执行基准测试的机器是空闲的。然而,外部进程可能在后台运行,这可能会影响基准测试结果。出于这个原因, perflock 等工具 可以限制一个基准的CPU占用量。例如,我们可以用总的可用CPU的70%来运行一个基准,把30%给操作系统和其他进程,减少机器活动因素对结果的影响。

一种选择是使用-benchtime 选项来增加基准时间。类似于概率论中的大数法则,如果我们大量运行一个基准,它应该倾向于接近其预期值(假设我们省略指令缓存和类似机制的好处)。

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

让我们使用-count 选项来运行10次基准,并将输出管到一个特定的文件:

$ go test -bench=. -count=10 | tee stats.txtcpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHzBenchmarkAtomicStoreInt32-4     234935682                5.124 ns/opBenchmarkAtomicStoreInt32-4     235307204                5.112 ns/op// ...BenchmarkAtomicStoreInt64-4     235548591                5.107 ns/opBenchmarkAtomicStoreInt64-4     235210292                5.090 ns/op// ...

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

$ benchstat stats.txtname                time/opAtomicStoreInt32-4  5.10ns ± 1%AtomicStoreInt64-4  5.10ns ± 1%

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

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

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

对编译器的优化不慎重

另一个与编写基准有关的常见错误是被编译器的优化所迷惑,这也会导致错误的基准假设。在这一节中,我们看看Go问题14813(https://github.com/golang/go/issues/14813,Go项目成员Dave Cheney也讨论过)的人口计数函数(一个计算设置为1的比特数的函数)。

这个函数接收并返回一个uint64 。为了对这个函数进行基准测试,我们可以写出以下内容。

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

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHzBenchmarkPopcnt1-4      1000000000               0.2858 ns/op

0.28纳秒的持续时间大约是一个时钟周期,所以这个数字是不合理的低。问题是,开发人员对编译器的优化不够仔细。在这种情况下,被测试的函数简单到足以成为内联的候选者:这种优化用被调用函数的主体代替了函数调用,让我们避免了函数调用,其占用的空间很小。一旦该函数被内联,编译器就会注意到该调用没有副作用,并将其替换为以下基准。

基准现在是空的--这就是为什么我们得的结果接近于一个时钟周期。为了防止这种情况发生,最好的做法是遵循这种模式。

  1. 在每个循环迭代期间,将结果分配给一个局部变量(在基准函数的上下文中为局部)。
  2. 将最新的结果分配给一个全局变量。

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

global 是一个全局变量,而v是一个局部变量,其范围是基准函数。在每个循环迭代过程中,我们把 的结果分配给本地变量。然后我们把最新的结果分配给全局变量。popcnt

注意为什么不把调用的结果直接分配 给全局呢? *popcnt* 调用的结果直接分配给全局以简化测试?写到全局变量比写到局部变量要慢(这些概念在 100个围棋错误,错误#95:"不了解堆栈与堆").因此,我们应该把每个结果写到一个局部变量,以限制每个循环迭代过程中的足迹。

如果我们运行这两个基准,我们现在得到的结果有很大差别:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHzBenchmarkPopcnt1-4      1000000000               0.2858 ns/opBenchmarkPopcnt2-4      606402058                1.993 ns/op

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

让我们记住避免编译器优化愚弄基准结果的模式:将被测函数的结果分配给一个局部变量,然后将最新的结果分配给一个全局变量。这种最佳做法还可以防止我们做出不正确的假设。

被观察者效应愚弄

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

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

计算前八列的总和

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

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

我们想对这些函数进行基准测试,以决定在固定的行数下哪一个是最有性能的。

我们希望只创建一次矩阵,以限制对结果的影响。因此,我们在循环外调用createMatrix512createMatrix513 。我们可能期望结果类似,因为我们同样只想在前八列上进行迭代,但情况并非如此(在我的机器上)。

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHzBenchmarkCalculateSum512-4        81854             15073 ns/opBenchmarkCalculateSum513-4       161479              7358 ns/op

第二个有513列的基准测试快了大约50%。同样,由于我们只对前八列进行迭代,这个结果相当令人惊讶。

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

注意这不是本篇文章要解释的范围,但我们在《,错误#91: " 》中探讨了这个问题100个围棋错误,错误#91:"不了解CPU缓存."

回到基准上,主要的问题是我们在两种情况下都不断重复使用同一个矩阵。因为这个函数被重复了几千次,所以我们并不测量函数收到一个普通的新矩阵时的执行情况。相反,我们测量的是一个获得矩阵的函数,该矩阵已经有一个子集存在于缓存中。因此,由于calculateSum513 导致更少的缓存缺失,它的执行时间更好。

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

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

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHzBenchmarkCalculateSum512-4         1116             33547 ns/opBenchmarkCalculateSum513-4          998             35507 ns/op

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

正如我们在这篇文章中所看到的,由于我们重复使用同一个矩阵,CPU缓存对结果产生了很大影响。为了防止这种情况,我们不得不在每次循环迭代时创建一个新的矩阵。一般来说,我们应该记住,观察被测试的函数可能会导致结果的显著差异,特别是在CPU绑定的函数的微观基准测试中,低级别的优化很重要。强制基准在每次迭代过程中重新创建数据可能是防止这种影响的一个好方法。

About the author

twitter.com/teivah

结论

  • 使用时间方法来保持基准的准确性。
  • 在处理微观基准时,增加benchtime ,或使用诸如benchstat 等工具会有帮助。
  • 如果最终运行应用程序的系统与运行微观基准的系统不同,那么对微观基准的结果要小心。
  • 确保被测试的函数会导致副作用,以防止编译器的优化在基准测试结果上欺骗你。
  • 为了防止观察者效应,强迫基准测试重新创建由CPU绑定的函数使用的数据。