要想改进程序的性能,首先要知道程序的当前性能。 本节主要关注使用 Go testing 包如何构建有用的基准测试,并且给出一些最佳实践以及常见的陷阱。
benchmark 是 go 语言中用于测试基准性能的工具。该工具用于测试被测试函数的平均运行耗时、内存分配次数。主要适用于在已知性能瓶颈在哪里时的场景。通过对相同功能函数的不同实现的性能指标(平均运行耗时、平均内存分配次数)进行比较,以判断性能的优劣。
下面,我们来详细介绍一下使用benchmark如何构建有用的基准测试。
01 基准测试基本原则
为了保证基准测试结果的相对稳定性,需要保持硬件环境的稳定。即:
- 机器处于空闲状态
- 机器关闭了节能模式
- 避免使用虚拟机和云主机
02 使用 testing 包构建基准测试
Go的tesing包中内置了基准测试功能。在编写基准测试时基本和编写单元测试的原则相似:
- 文件名必须以 _test.go 为后缀
- 函数名必须以 BenchmarkXxxx开头
- 基准测试函数的参数类型是 *Testing.B,而非 *Testing.T
func Fib3(n int) int {
switch n {
case 0:
return 0
case 1:
return 1
default:
return Fib(n-1) Fib(n-2)
}
}
基准测试代码:
代码语言:javascript复制func BenchmarkFib20(b *testing.B) {
for n := 0; n < b.N; n {
Fib(20) //执行b.N次Fib函数
}
}
func BenchmarkFib28(b *testing.B) {
for n := 0; n < b.N; n {
Fib(28) //执行b.N次Fib函数
}
}
1.2.1 执行基准测试
使用 go test -bench=. ./examples/fib/ 命令工具执行基准测试
默认情况下,执行go test命令时 只会执行单元测试,而基准测试会被排除在外。所以,需要在 go test 命令中添加 -bench 标记,以执行基准测试。
-bench 标记使用正则表达式来匹配要运行的基准测试函数名称。所以,最常用的方式是通过 -bench=. 标记来执行该包下的所有的基准函数。如下:
代码语言:javascript复制% go test -bench=. ./examples/fib/
1. goos: darwin
2. goarch: amd64
3. pkg: high-performance-go-workshop/examples/fib
4. BenchmarkFib20-8 28947 40617 ns/op
5. PASS
6. ok high-performance-go-workshop/examples/fib 1.602s
指标关注:
- 第4行:BenchmarkFib20-8函数中共循环迭代了28947次。平均每次40617纳秒(该数据即为函数的运行时间指标)。
- 第6行:BenchmarkFib20-8函数共运行1.602秒
1.2.2 基准测试工作原理
每个基准函数被执行时都有一个 b.N 值,该值是由go运行时自动生成的,代表基准函数应该执行的次数。
b.N 从 1 开始,基准函数默认要运行 1 秒,如果该函数的执行时间在 1 秒内就运行完了,那么就递增 b.N 的值,再重新再执行一次。
在上面的 BenchmarkFi20-8 的例子中,我们发现迭代大约 29000 次耗时超过了 1 秒。依据此数据,基准框架计算得知,平均每次运行耗时 40617 纳秒。
BenchmarkFi20-8 中第8行的 -8 后缀是和运行该测试用例时的GOMAXPROCS值有关系,默认为启动时 Go 进程可见的 CPU 数。
代码语言:javascript复制% go test -bench=. -cpu=1,2,4 ./examples/fib/
goos: darwin
goarch: amd64
pkg:high-performance-go-workshop/examples/fib
BenchmarkFib20 31479 37987 ns/op
BenchmarkFib20-2 31846 37859 ns/op
BenchmarkFib20-4 31716 39255 ns/op
PASS
ok high-performance-go-workshop/examples/fib 4.805s
该示例展示了分别用 CPU 为 1 核、2 核、4 核时运行基准测试的结果。在该案例中,该参数对结果几乎没有影响,因为该基准测试的代码是完全顺序执行的。
1.2.3 改进基准测试的准确性
基准测试运行的时间越长,迭代次数越多,最终的平均值结果越准确。
如果你的基准测试只执行了 100 次或 10 次迭代,那么最终得出的平均值可能会偏高。如果你的基准测试执行了上百万或十亿次迭代,那么得出的平均耗时将会非常准确。
可以使用 -benchtime 标识指定基准测试执行的时间以调整迭代次数(即b.N的值),以便得到更准确的结果。例如:
代码语言:javascript复制1. % go test -bench=. -benchtime=10s ./examples/fib/
2. goos: darwin
3. goarch: amd64
4. pkg: high-performance-go-workshop/examples/fib
5. BenchmarkFib20-8 313048 41673 ns/op
6. PASS
7. ok high-performance-go-workshop/examples/fib 13.442s
执行以上命令,直到其达到 b.N 的值需要花费超过 10 秒的时间才能返回。由于我们的运行时间增加了 10 倍,因此迭代的总次数也增加了 10 倍。结果(每次操作耗时 41673ns/op) 没有太大的变化,说明我们的数据相对比较稳定,是我们所期望的。
如果你有一个基准测试运行了数百万次或数十亿次迭代,你可能会发现基准值不稳定,因为你的机器硬件的散热性能、内存局部性、后台进程、gc 等因素都会影响函数执行的时间。
通过 -count 标志,可以指定基准测试跑多次,以消除上述的不稳定因素:
代码语言:javascript复制% go test -bench=Fib20 -count=10 ./examples/fib/ | tee old.txt
goos: darwin
goarch: amd64
pkg: high-performance-go-workshop/examples/fib
BenchmarkFib20-8 30099 38117 ns/op
BenchmarkFib20-8 31806 40433 ns/op
BenchmarkFib20-8 30052 43412 ns/op
BenchmarkFib20-8 28392 39225 ns/op
BenchmarkFib20-8 28270 42956 ns/op
BenchmarkFib20-8 28276 49493 ns/op
BenchmarkFib20-8 26047 45571 ns/op
BenchmarkFib20-8 27392 43803 ns/op
BenchmarkFib20-8 27507 44896 ns/op
BenchmarkFib20-8 25647 43579 ns/op
PASS
ok high-performance-go-workshop/examples/fib 16.516s
03 使用 benchstat 工具比较基准测试
由于基准测试受电源管理、后台进程、散热的影响,所以对于任何一个基准测试来说,运行多次来求平均值是一个非常好的建议。
下面介绍一个由 Russ Cox 编写的工具:benchstat
代码语言:javascript复制% go get golang.org/x/perf/cmd/benchstat
benchstat 可以对一组基准测试的结果求平均值,并显示出对应的稳定性。这是 Fib(20) 函数在使用电池的电脑上执行的基准示例:
代码语言:javascript复制% go test -bench=Fib20 -count=10 ./examples/fib/ | tee old.txt
goos: darwin
goarch: amd64
pkg: high-performance-go-workshop/examples/fib
BenchmarkFib20-8 30721 37893 ns/op
BenchmarkFib20-8 31468 38695 ns/op
BenchmarkFib20-8 31726 37521 ns/op
BenchmarkFib20-8 31686 37583 ns/op
BenchmarkFib20-8 31719 38087 ns/op
BenchmarkFib20-8 31802 37703 ns/op
BenchmarkFib20-8 31754 37471 ns/op
BenchmarkFib20-8 31800 37570 ns/op
BenchmarkFib20-8 31824 37644 ns/op
BenchmarkFib20-8 31165 38354 ns/op
PASS
ok high-performance-go-workshop/examples/fib 15.808s
% benchstat old.txt
name time/op
Fib20-8 37.9µs ± 2%
benchstat 告诉我们,Fib20-8 的平均操作耗时是 37.9 微妙,并且误差在 /-2%。
比较两组基准测试的差异
benchstat还可以比较两组基准测试之间的性能差异。
在执行 go test 时需要添加 -c 标记以保存测试的二进制文件,以便可以在程序改进后和以前的基准测试进行比较。
代码语言:javascript复制% go test -c
mv fib.test fib.golden
当我们对Fib函数进行改进后,为了能和我们的旧版本进行比较,我们编译一个新的测试二进制文件fib.test,并对其进行了基准测试,然后使用 Benchstat 工具比较输出。
代码语言:javascript复制% go test -c
% ./fib.golden -test.bench=. -test.count=10 > old.txt
% ./fib.test -test.bench=. -test.count=10 > new.txt
% benchstat old.txt new.txt
name old time/op new time/op delta
Fib20-8 37.9µs ± 2% 24.1µs ± 3% -36.26% (p=0.000 n=10 10)
运行完上面的比较结果后,有 3 件事情需要确认:
- 新老基准值都有上下浮动。1-2% 是较好的,3-5% 还可以,高于 5% 时就需要考虑你程序的稳定性了。要当心当差异较大时,请不要贸然改进性能。
- 样本数量(结果中的n=10 10代表新老测试的样本采样都是10)。benchstat 工具将报告有多少有效的样本数据。有时即使你执行了 10 次,但也可能只发现了 9 个样本。10% 或更低的拒绝率是可以接受的,高于 10% 可能表明您的设置不稳定,并且你可能比较的样本太少。
- p值。低于 0.05 的 p 值可能具有统计学意义。p 值大于 0.05 表示基准可能没有统计意义
04 避免基准测试的启动耗时
有时候基准测试每次执行的时候会有一次启动配置耗时。b.ResetTimer() 函数可以用于忽略启动的累积耗时。如下:
代码语言:javascript复制func BenchmarkExpensive(b *testing.B) {
boringAndExpensiveSetup() //启动配置。默认这里的执行时间是被计算在内的
b.ResetTimer()
for n := 0; n < b.N; n {
//function under test
}
}
在上例代码中,使用 b.ResetTimer() 函数重置了基准测试的计时器
如果在每次循环迭代中,你有一些费时的配置逻辑,要使用 b.StopTimer() 和 b.StartTimer() 函数来暂定基准测试计时器。
代码语言:javascript复制func BenchmarkComplicated(b *testing.B) {
for n := 0; n < b.N;n {
b.StopTimer()
complicatedSetup()
b.StartTimer()
//function under test
}
}
- 上例中,先使用 b.StopTimer() 暂停计时器
- 然后执行完复杂的配置逻辑后,再使用 b.StartTimer() 启动计时器
通过以上三个函数,则可以忽略掉启动配置所耗费的时间。
05 基准测试的内存分配
内存分配的次数和分配的大小跟基准测试的执行时间相关。在基准测试中有两种方式可以记录并输出内存分配:
- 在代码中增加 b.ReportAllocs() 函数来告诉 testing 框架记录内存分配的数据。
- 在go test命令中添加 ** -benchmem** 标识来强制 testing 框架打印出所有基准测试的内存分配次数
方式一:代码中添加 b.ReportAllocs()
代码语言:javascript复制func BenchmarkRead(b *testing.B) {
b.ReportAllocs()
for n := 0; n < b.N; n {
//function under test
}
}
方式二:go test命令中添加 -benchmem标识
代码语言:javascript复制% go test -run=^$ -bench=. -benchmem bufio
goos: darwin
goarch: amd64
pkg: bufio
BenchmarkReaderCopyOptimal-8 13860543 82.8 ns/op 16 B/op 1 allocs/op
BenchmarkReaderCopyUnoptimal-8 8511162 137 ns/op 32 B/op 2 allocs/op
BenchmarkReaderCopyNoWriteTo-8 379041 2850 ns/op 32800 B/op 3 allocs/op
BenchmarkReaderWriteToOptimal-8 4013404 280 ns/op 16 B/op 1 allocs/op
BenchmarkWriterCopyOptimal-8 14132904 82.7 ns/op 16 B/op 1 allocs/op
BenchmarkWriterCopyUnoptimal-8 10487898 113 ns/op 32 B/op 2 allocs/op
BenchmarkWriterCopyNoReadFrom-8 362676 2816 ns/op 32800 B/op 3 allocs/op
BenchmarkReaderEmpty-8 1857391 639 ns/op 4224 B/op 3 allocs/op
BenchmarkWriterEmpty-8 2041264 577 ns/op 4096 B/op 1 allocs/op
BenchmarkWriterFlush-8 87643513 12.5 ns/op 0 B/op 0 allocs/op
PASS
ok bufio 13.430s
06 基准测试中常见的错误
在基准测试中,for 循环是至关重要的。
以下是两个错误的基准测试:
代码语言:javascript复制func BenchmarkFibWrong(b *testing.B) {
Fib(b.N)
}
func BenchmarkFibWrong2(b *testing.B) {
for n := 0; n < b.N; n {
Fib(n)
}
}
首先,第一个基准测试中,直接将b.N用作了Fib的参数,没有迭代,只运行一次,达不到求平均值的效果。因为b.N是用来控制迭代次数以求的被测试函数的平均执行时间的。
其次,在执行中会根据实际的case执行时间是否是稳定的,会一直增加b.N的次数以达到执行时间是一种稳定的状态。第二个函数中每次执行Fib函数时执行的时间都有一定的变化,永远也不会达到相对稳定的状态,因此,基准测试也不会停止。
07 将基准测试数据输出到文件
Go的 testing 包内置了对生成 CPU,内存和模块配置文件的支持。
- -cpuprofile=FILE: 收集 CPU 性能分析到 FILE 文件
- -memprofile=FILE:将内存性能分析写入到 FILE 文件,
- ** -memprofilerate=N**: 调节采样频率为 1/N
- -blockprofile=FILE :输出内部 goroutine 阻塞的性能分析文件数据到 FILE
这些标识也同样可以用于二进制文件
代码语言:javascript复制% go test -run=XXX -bench=. -cpuprofile=c.p bytes
% go tool pprof c.p
参考文章:https://dave.cheney.net/high-performance-go-workshop/gophercon-2019.html#benchmarking