性能测试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.StoreInt32
和 atomic.StoreInt64
哪种方法好(保存的是一个4字节数据),分别对其进行性能测试。
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
安装。
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密集型函数的微基准测试环境中。在每次迭代期间重新创建数据可能是防止这种影响的好方法。