前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go语言中常见100问题-#89 Writing inaccurate benchmarks

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

作者头像
数据小冰
发布2023-08-17 08:35:04
2270
发布2023-08-17 08:35:04
举报
文章被收录于专栏:数据小冰数据小冰
性能测试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密集型函数的微基准测试环境中。在每次迭代期间重新创建数据可能是防止这种影响的好方法。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2023-08-14,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 数据小冰 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 性能测试benchmark编写不正确
    • 没有重置或暂停timer
      • 对小规模基准测试做出错误假设
        • 注意编译器优化
          • 注意观测者效应
          相关产品与服务
          腾讯云服务器利旧
          云服务器(Cloud Virtual Machine,CVM)提供安全可靠的弹性计算服务。 您可以实时扩展或缩减计算资源,适应变化的业务需求,并只需按实际使用的资源计费。使用 CVM 可以极大降低您的软硬件采购成本,简化 IT 运维工作。
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档