Go语言中常见100问题-#89 Writing inaccurate benchmarks

2023-08-17 08:35:04 浏览数 (1)

性能测试benchmark编写不正确

我们不要猜测程序性能,在对代码进行优化的时候,可能会有很多因素发挥作用,所以需要综合考虑,进行测试验证准没错。然而,编写benchmark并不是一件简单的事情,很容易因编写错误的benchmark导致做出不正确优化。本章节将列举一系列非正确编写benchmark问题点。

在讨论问题点之前,简单回顾下Go语言中benchmark测试方法,整个骨架代码如下所示。性能测试函数以Benchmark开头,被测试函数foo放在循环中,一共循环b.N次,在运行时,b.N的值根据性能测试时间来设定,默认情况下,benchmark执行时间为1秒,不过可以通过参数 -benchtime重新设置。b.N值从1开始,如果循环逻辑在1秒能够完成,b.N 的值会按照序列 1,2,5,10,20,50,... 增加,同时再次运行基准测试函数。

代码语言:javascript复制
func BenchmarkFoo(b *testing.B) {
 for i := 0; i < b.N; i   {
  foo()
 }
}

执行性能测试函数命令如下,在1秒内执行73次foo调用,foo平均执行时间为16511228纳秒。

代码语言:javascript复制
$ go test -bench=.
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkFoo-4                73          16511228 ns/op

现在可以通过设置 -benchtime为2秒,调整性能测试运行时间。foo被执行150次,近乎是前面的2倍。

代码语言:javascript复制
$ go test -bench=. -benchtime=2s
BenchmarkFoo-4               150          15832169 ns/op
没有重置或暂停timer

有时候,我们需要在benchmark循环开始之前做一些操作,这些操作可能需要花费一定时间(例如,创建一个大的切片数据)并且会影响性能测试结果。

代码语言:javascript复制
func BenchmarkFoo(b *testing.B) {
 expensiveSetup()
 for i := 0; i < b.N; i   {
  functionUnderTest()
 }
}

在这种情况下,我们需要在开始循环之前调用 ResetTimer 方法. 调用该方法将已流逝的benchmark时间和内存分配计数器归零,这样可以消除 expensiveSetup对测试结果影响。

代码语言:javascript复制
func BenchmarkFoo(b *testing.B) {
 expensiveSetup()
 b.ResetTimer()
 for i := 0; i < b.N; i   {
  functionUnderTest()
 }
}

如果要执行的其它操作需要在循环内部进行呢?这种情况如何处理?这时就不能再调用ResetTimer,每次循环将benchmark时间和内存分配计数器归零。

代码语言:javascript复制
func BenchmarkFoo(b *testing.B) {
 for i := 0; i < b.N; i   {
  expensiveSetup()
  functionUnderTest()
 }
}

我们可以在 expensiveSetup执行前停止Timer,在该函数执行完后恢复Time. 代码框架如下:

代码语言:javascript复制
func BenchmarkFoo(b *testing.B) {
 for i := 0; i < b.N; i   {
  b.StopTimer()
  expensiveSetup()
  b.StartTimer()
  functionUnderTest()
 }
}

「NOTE注意一点:如果待测的性能函数相比辅助函数运行更快,则可能会导致整个benchmark时间偏长。因为完成整个测试辅助函数也占用了不少时间,所以运行时间要大于1秒。benchmark时间统计的只是funtionUnderTest执行所用时间,如果在循环中辅助函数运行要花费不少时间,完成整个benchmark测试需要更长时间,这时想要性能测试在较短时间完成,一种可行的方法是将benchtime设置为较小值。」

对小规模基准测试做出错误假设

小规模基准测试测量的是一个较小的执行单元,很容易对其做出错误的假设。下面通过一个例子说明,现在给变量原子赋值,不知道选用 atomic.StoreInt32atomic.StoreInt64哪种方法好(保存的是一个4字节数据),分别对其进行性能测试。

代码语言:javascript复制
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)
 }
}

上述benchmark运行结果如下:

代码语言:javascript复制
go test -bench=.                                                                                   
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt32-4     233152768                5.266 ns/op
BenchmarkAtomicStoreInt64-4     228359295                5.145 ns/op

测试结果表明StoreInt64比Store32要快,为了公平起见,交互下两个benchmark位置,即先测试BenchmarkAtomicStoreInt64,然后再测试BenchmarkAtomicStoreInt32。

代码语言:javascript复制
go test -bench=.                                                                                       
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt64-4     223825905                5.043 ns/op
BenchmarkAtomicStoreInt32-4     237407841                5.037 ns/op

奇怪了?现在运行的结果是StoreInt32比StoreInt64快。为啥这样呢?在小规模基准测试中,影响结果因素有很多,像在运行基准测试时、电源管理、热缩放时机器活动等。

「NOTE 我们应该确保执行基准测试的机器处于空闲状态,但是有其他进程可能在后台运行,这会影响基准测试结果。可以采用 perflock 工具限制基准测试消耗多少CPU. 例如,可以运行一个基准测试使用总可用CPU的70%,将其他的30%分配给操作系统和其他进程,通过这种方式减少其他因素对性能测试结果影响。」

一种处理方法是增加性能测试时间,通过 -benchtime 选项设置。类似于概率论中大数定律,对性能做很多次测试,测试结果将趋于真实值(假设忽略掉指令缓存以及类似机制影响)。

另一种处理方法是在经典的基准测试工具基础上使用一些其他工具。例如,benchstat工具,它是Golang官方推荐的一款命令行工具,可以针对一组或多组样本进行分析,其源码在 golang.org/x里面。

通过设置 -count 参数 为10, 运行10次性能测试,并将测试结果通过管道输出到文件中。操作命令如下:

代码语言:javascript复制
go test -bench=. -count=10 | tee stats.txt   

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAtomicStoreInt64-4     236803782                5.048 ns/op
BenchmarkAtomicStoreInt64-4     237760161                5.061 ns/op
BenchmarkAtomicStoreInt64-4     237598474                5.070 ns/op
BenchmarkAtomicStoreInt64-4     237964878                5.053 ns/op
BenchmarkAtomicStoreInt64-4     237597190                5.068 ns/op
BenchmarkAtomicStoreInt64-4     238228754                5.043 ns/op
BenchmarkAtomicStoreInt64-4     236419087                5.046 ns/op
BenchmarkAtomicStoreInt64-4     237955666                5.064 ns/op
BenchmarkAtomicStoreInt64-4     237543985                5.044 ns/op
BenchmarkAtomicStoreInt64-4     236941857                5.066 ns/op
BenchmarkAtomicStoreInt32-4     237639082                5.060 ns/op
BenchmarkAtomicStoreInt32-4     237918097                5.057 ns/op
BenchmarkAtomicStoreInt32-4     237698049                5.052 ns/op
BenchmarkAtomicStoreInt32-4     236977020                5.043 ns/op
BenchmarkAtomicStoreInt32-4     237817485                5.061 ns/op
BenchmarkAtomicStoreInt32-4     236342242                5.042 ns/op
BenchmarkAtomicStoreInt32-4     236469364                5.049 ns/op
BenchmarkAtomicStoreInt32-4     237664869                5.039 ns/op
BenchmarkAtomicStoreInt32-4     238076143                5.050 ns/op
BenchmarkAtomicStoreInt32-4     237817887                5.042 ns/op
PASS

通过 benchstat工具对输出文件进行性能分析, 如果没有安装 benchstat 通过命令 go install golang.org/x/perf/cmd/benchstat 安装。

代码语言:javascript复制
benchstat stats.txt                                                                                   
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
                   │  stats.txt  │
                   │   sec/op    │
AtomicStoreInt64-4   5.057n ± 0%
AtomicStoreInt32-4   5.050n ± 0%

上述结果表明两者的性能基本是一致的,没有大的差别,Store操作花费的平均时间为5纳秒, 上下浮动在0%,说明结果是挺稳定的。这推翻了前面 atomic.StoreInt32更快或更慢的结论,通过多次测试求平均值,得到真实情况。

通常来说,对小规模基准测试应保持谨慎,在测试时有很多因素会影响结果并误导我们做出错误判断。增加基准测试时间和次数,借助 benchstat 工具有助于得到准确结果。此外,还要注意一点,如果生产环境上的机器与实验测试的机器不一致(CPU类型、位数),线上运行的效果可能与我们预期的不一致。

注意编译器优化

进行基准测试时,要留意编译器优化导致我们做出错误判断。结合golang代码仓库中的14813号问题说明(https://github.com/golang/go/issues/14813), Go语言核心成员 Dave Cheney 也参考该问题讨论。该问题讨论的是一个计数函数,计算一个uint64数二进制中bit为1的数量,实现代码如下。

代码语言:javascript复制
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
}

编写上面函数的性能测试代码如下:

代码语言:javascript复制
func BenchmarkPopcnt1(b *testing.B) {
 for i := 0; i < b.N; i   {
  popcnt(uint64(i))
 }
}

进行性能测试得到如下数据:

代码语言:javascript复制
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4              1000000000               0.3070 ns/op

平均每次运行时间为0.3070纳秒,太不可思议了,尽然这么短. 0.3070几乎是一个CPU时钟周期,原因在哪里呢?是编译器做了优化处理,上面的被测函数非常简单,被内联处理。内联处理:就是用函数体内容替换函数调用. 一旦函数内联以后,编译器发现处理逻辑对基准测没有任何副作用,直接将其替换为下面的代码。

代码语言:javascript复制
func BenchmarkPopcnt1(b *testing.B) {
    for i := 0; i < b.N; i   {
        // Empty
    }
}

现在benchmark测试代码是空的,也进一步说明了前面性能测试时间几乎是一个CPU时钟周期原因。为了防止编译器进行优化,最佳处理方法如下:

  • 在每次循环中,将运行的结果赋值到一个本地变量中(benchmark函数作用域内)
  • 再将本地变量的值赋值给全局变量

重新编写的性能测试代码如下:

代码语言:javascript复制
var global uint64
 
func BenchmarkPopcnt2(b *testing.B) {
    var v uint64
    for i := 0; i < b.N; i   {
        v = popcnt(uint64(i))
    }
    global = v
}

「NOTE 为啥不将 popcnt 运行的结果直接赋值给全局变量呢?还要赋值两次,搞这么麻烦!原因是赋值给全局变量的操作比本地变量要慢,在循环内只是赋值给本地变量,在循环外只赋值一次给全局变量减少对性能测试影响。」

运行新版性能测试代码,得到运行结果如下, 可以看到BenchmarkPopcnt2与1有显著不同,它避免了内联优化,版本2是准确的测试结果。

代码语言:javascript复制
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4              1000000000               0.2815 ns/op
BenchmarkPopcnt2-4              599907250                1.971 ns/op
注意观测者效应

在物理学中,有一个观测者效应,说的是观察行为对被观察系统的干扰影响。对应到本文的性能测试,这种效应也存在,并会导致我们做出错误判断。下面来看一个具体的例子。

需要实现一个函数,该函数入参是一个矩阵,里面的元素是int64类型,矩阵有512列,对矩阵的前8列元素进行求和。

为了优化,我们想知道改变矩阵的列数对结果是否有影响,所以再实现第二版本,接收矩阵有513列。两个版本的函数代码如下。

代码语言:javascript复制
func calculateSum512(s [][512]int64) int64 {
 var sum int64
 for i := 0; i < len(s); i   {
  for j := 0; j < 8; j   {
   sum  = s[i][j]
  }
 }
 return sum
}

func calculateSum513(s [][513]int64) int64 {
 var sum int64
 for i := 0; i < len(s); i   {
  for j := 0; j < 8; j   {
   sum  = s[i][j]
  }
 }
 return sum
}

对上述函数编写性能测试代码,验证上述哪个版本性能更好。

代码语言:javascript复制
const rows = 1000

var res int64

func createMatrix512(r int) [][512]int64 {
 return make([][512]int64, r)
}

func createMatrix513(r int) [][513]int64 {
 return make([][513]int64, r)
}

func BenchmarkCalculateSum512(b *testing.B) {
 var sum int64
 s := createMatrix512(rows)
 b.ResetTimer()
 for i := 0; i < b.N; i   {
  sum = calculateSum512(s)
 }
 res = sum
}

func BenchmarkCalculateSum513(b *testing.B) {
 var sum int64
 s := createMatrix513(rows)
 b.ResetTimer()
 for i := 0; i < b.N; i   {
  sum = calculateSum513(s)
 }
 res = sum
}

嗯,输入的矩阵都是差不多的,一个只是比另一个多1列,但计算的都是前8列,并且两个矩阵的行数都是1000,猜测测试结果是差不多的。实际测试结果是这样的吗?

代码语言:javascript复制
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkCalculateSum512-4         97682             12406 ns/op
BenchmarkCalculateSum513-4        151142              7394 ns/op

运行结果与我们想的完全不一致,BenchmarkCalculateSum513 竟然比 BenchmarkCalculateSum512 快50%,为啥是这样的呢?

要搞清楚原因需要知道CPU缓存知识。CPU由不同的缓存组成(L1、L2和L3)。这些高速缓存降低了从主内存访问数据的平均时间成本,在某些情况下,CPU 可以从主存中取出数据并将其复制到 L1, 在这种情况下,CPU 尝试将calculateSum感兴趣的矩阵子集(每行的前八列)存储到 L1 中。但是,矩阵在一种情况下(513 列)适合内存,但在另一种情况下(512 列)不适合内存。

「NOTE 为啥512列的矩阵不适合内存,本文不做分析,将在 #91不了解CPU缓存 中讲解。」

回到本文基准测试,主要问题是在两种情况下都重复使用相同的矩阵。因为函数重复了数千次,所以当函数接收到一个普通的新矩阵时,我们不会测量函数的执行(即将矩阵的创建操作剔除,放到b.ResetTimer前面)。相反,我们测量一个函数,该函数获取一个矩阵,该矩阵已经在缓存中存在单元的子集。因此,由于calculateSum513有更好的缓存命中,它具有更好的执行时间。

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

代码语言:javascript复制
func BenchmarkCalculateSum512_2(b *testing.B) {
 var sum int64
 for i := 0; i < b.N; i   {
  b.StopTimer()
  s := createMatrix512(rows)
  b.StartTimer()
  sum = calculateSum512(s)
 }
 res = sum
}

func BenchmarkCalculateSum513_2(b *testing.B) {
 var sum int64
 for i := 0; i < b.N; i   {
  b.StopTimer()
  s := createMatrix512(rows)
  b.StartTimer()
  sum = calculateSum512(s)
 }
 res = sum
}

现在在每次循环迭代期间创建一个新矩阵。如果我们再次运行基准测试(并进行调整benchtime,否则执行时间太长),可以看到两者的运行结果是非常接近的。

代码语言:javascript复制
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkCalculateSum512_2-4       41904             31377 ns/op
BenchmarkCalculateSum513_2-4       42580             32396 ns/op

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

0 人点赞