Go高性能系列教程之一:基准测试
1. 基准测试
在我们试图改进程序性能之前,我们首先要知道程序的当前性能。 本节主要关注使用Go testing包如何构建有用的基准测试,并且给出一些最佳实践避免踩坑。
1.1 基准测试基本原则
在进行基准测试之前,你必须要有一个稳定的环境以得到可重复的输出结果。
- 机器必须是空闲状态。
- 机器是否关闭了节能模式。
- 避免使用虚拟机和云主机。一般情况下,为了尽可能地提高资源的利用率,虚拟机和云主机 CPU 和内存一般会超分配,超分机器的性能表现会非常地不稳定。
1.2 使用testing包构建基准测试
testing包中已经内置了基准测试的功能。如果我们有一个如下简单的函数:
func Fib3(n int) int {
switch n {
case 0:
return 0
case 1:
return 1
case 2:
return 1
default:
return Fib(n-1) + Fib(n-2)
}
}
我们可以通过testing包来写基准测试,基准测试的代码如下:
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函数
}
}
注意:基准测试函数应该写在文件名后缀是 _test.go的文件中
基准测试类似单元测试,唯一的不同就是在测试函数中传的参数类型是 *testing.B,而非 *testing.T。这两个类型都实现了 testing.TB接口,该接口提供了常用的Errorf(),Fatalf()和FailNow()常用函数。
1.2.1 执行一个包下的基准测试
因为基准测试使用的是testing包,所以要执行基准测试函数需要使用go test命令。但是,默认情况下,当我们调用go test的时候,基准测试会被排除在外,只执行单元测试。
所以,需要在go test命令中添加 -bench标记,以执行基准测试。-bench标记使用一个正则表达式来匹配要运行的基准测试函数名称。所以,最常用的方式就是通过 -bench=. 标记来执行该包下的所有的基准函数。如下:
% go test -bench=. ./examples/fib/
goos: darwin
goarch: amd64
pkg: high-performance-go-workshop/examples/fib
BenchmarkFib20-8 28947 40617 ns/op
PASS
ok high-performance-go-workshop/examples/fib 1.602s
go test在匹配基准测试之前会执行所有的单元测试,如果你的代码里有很多单元测试,或者单元测试会耗费很长的时间,你可以通过go test的-run参数将单元测试排除掉。例如: golang % go test -run=none
1.2.2 基准测试工作原理
每个基准函数被执行时都有一个不同的b.N值,这个值代表基准函数应该执行的迭代次数。
b.N从1开始,如果基准函数在1秒内就执行完了,那么b.N的值会递增以便基准函数再重新执行(译者注:即基准函数默认要运行1秒,如果该函数的执行时间在1秒内就运行完了,那么就递增b.N的值,重新再执行一次)
b.N按照近似顺序增加,每次迭代大约增长20%。基准框架试图更智能,如果它看到较小的b.N值相对较快的完成了迭代,它将b.N增加的更快。
1.2.3 改进基准测试的准确性
具体来说,你希望你的基准测试可以运行数万次迭代,以便你可以获得一个较为准确的平均耗时。如果你的基准测试只执行了100次或10次迭代,那么最终得出的平均值可能会偏高。如果你的基准测试执行了上百万或十亿次迭代,那么得出的平均耗时将会非常准确,但这受代码布局的限制。
可以使用-benchtime标识增加基准测试执行的时间的方式来增加迭代的次数。例如:
% go test -bench=. -benchtime=10s ./examples/fib/
goos: darwin
goarch: amd64
pkg: high-performance-go-workshop/examples/fib
BenchmarkFib20-8 313048 41673 ns/op
PASS
ok high-performance-go-workshop/examples/fib 13.442s
运行相同的基准测试,直到其达到b.N的值需要花费超过10秒的时间才能返回。由于我们的运行时间增加了10倍,因此迭代的总次数也增加了10倍。结果(每次操作耗时 41673ns/op)没有太大的变化,这就是我们所期望的。
为什么总的耗时是13秒,而不是10秒呢? 如果你又一个基准测试运行了数百万次或数十亿次迭代,导致每次操作的时间都在微秒或纳秒范围内,则你可能会发现基准值不稳定,因为你的机器硬件的散热性能、内存局部性、后台进程、gc等因素。
对于每次操作在10纳秒以下的,指令重新排序,并且代码对齐的相对论效应将影响基准时间。
通过-count标志,可以指定基准测试多跑几次:
% 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
1.3 使用benchstat工具比较基准测试
在上面我建议运行多次基准测试以便获取更多的数据来求平均值。对于任何一个基准测试来说,这是一个非常好的建议,由于基准测试受电源管理、后台进程、散热的影响。
接下来,我将介绍一个由Russ Cox编写的工具:benchstat
% go get golang.org/x/perf/cmd/benchstat
Benchstat可以进行一组基准测试,并告诉你他们的稳定性。这是Fib(20)函数在使用电池的电脑上执行的基准示例:
% 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的平均操作耗时是38.8微妙,并且误差在+/-2%。这是因为在运行基准测试期间,我没动过机器。
1.3.1 改进Fib函数
确定两组基准测试之间的性能差异可能是非常乏味且容易出错的。Benchstat工具可以帮助我们做这个事情。
保存基准测试的输出结果是非常有用的,同时,你也需要保存产生它的二进制文件。这个会让你有机会重新执行之前的基准测试。为了达到这个目标,在执行go test时需要添加 -c标记以保存测试的二进制文件--同时我还经常将生成的二进制文件.text重命名为.golden golang % go test -c mv fib.test fib.golden
先前的Fib函数具有斐波那契数列中第0和第1个数字的硬编码值。在之后使用递归调用了自身。稍后,我们将讨论递归的成本,但目前,我们假设递归是有成本的,尤其是因为我们的算法使用的是指数时间。
对此的简单解决方法是对斐波那契数列中的另一个数字进行硬编码,从而将每个可回溯调用的深度减少一个。
func Fib(n int) int {
switch n {
case 0:
return 0
case 1:
return 1
case 2:
return 1
default:
return Fib(n-1) + Fib(n-2)
}
}
该文件还包含针对Fib的全面测试。 如果没有通过验证当前行为的测试,请勿尝试提高基准。
为了能和我们的新版本进行比较,我们编译一个新的测试的二进制文件并对其进行了基准测试,并使用Benchstat工具比较输出。
% 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)
运行完上面的比较结果后,有2件事情需要确认: + 两次运行基准间上下浮动的值。1-2%是较好的,3-5%还可以,高于5%时就需要考虑你程序的稳定性了。要当心当差异较大时,请不要贸然改进性能。 + 样本缺失。benchstat工具将报告有多少有效的样本数据。有时即使你执行了10次,但也可能只发现了9个样本。10%或更低的拒绝率是可以接受的,高于10%可能表明您的设置不稳定,并且你可能比较的样本太少。
1.3.2 注意p值
低于0.05的p值可能具有统计学意义。 p值大于0.05表示基准可能没有统计意义。
1.4 避免基准测试的启动耗时
有时候你的基准测试每次执行的时候会有一次启动配置耗时。b.ResetTimer()函数可以用于忽略启动的累积耗时。
func BenchmarkExpensive(b *testing.B) {
boringAndExpensiveSetup()
b.ResetTimer()
for n := 0; n < b.N; n++ {
//function under test
}
}
在上例代码中,使用b.ResetTimer()函数重置了基准测试的计时器
如果在每次循环迭代中,你有一些费时的配置逻辑,要使用b.StopTimer()和b.StartTimer()函数来暂定基准测试计时器。
func BenchmarkComplicated(b *testing.B) {
for n := 0; n < b.N;n++ {
b.StopTimer()
complicatedSetup()
b.StartTimer()
//function under test
}
}
上例中,先使用b.StopTimer()暂停计时器
然后执行完复杂的配置逻辑后,再使用b.StartTimer()启动计时器
通过以上两个函数,则可以忽略掉启动配置所耗费的时间。
1.5 基准测试的内存分配
内存分配的次数和分配的大小和基准测试的执行时间强相关。你可以通过在代码中增加b.ReportAllocs()函数来告诉testing框架记录内存分配的数据。
func BenchmarkRead(b *testing.B) {
b.ReportAllocs()
for n := 0; n < b.N; n++ {
//function under test
}
}
下面是使用bufio包中的基准测试的一个示例:
% go test -run=^$ -bench=. bufio
goos: darwin
goarch: amd64
pkg: bufio
BenchmarkReaderCopyOptimal-8 12999212 78.6 ns/op
BenchmarkReaderCopyUnoptimal-8 8495018 133 ns/op
BenchmarkReaderCopyNoWriteTo-8 360471 2805 ns/op
BenchmarkReaderWriteToOptimal-8 3839959 291 ns/op
BenchmarkWriterCopyOptimal-8 13878241 82.7 ns/op
BenchmarkWriterCopyUnoptimal-8 9932562 117 ns/op
BenchmarkWriterCopyNoReadFrom-8 385789 2681 ns/op
BenchmarkReaderEmpty-8 1863018 640 ns/op 4224 B/op 3 allocs/op
BenchmarkWriterEmpty-8 2040326 579 ns/op 4096 B/op 1 allocs/op
BenchmarkWriterFlush-8 88363759 12.7 ns/op 0 B/op 0 allocs/op
PASS
ok bufio 13.249s
你也可以使用 go test -benchmem
标识来强制 testing
框架打印出所有基准测试的内存分配次数
golang % 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
1.6 收集基准测试数据
该testing包内置了对生成CPU,内存和模块配置文件的支持。 + -cpuprofile=$FILE 收集CPU性能分析到$FILE文件 + -memprofile=$FILE,将内存性能分析写入到$FILE文件,-memprofilerate=N 调节采样频率为1/N + -blockprofile=$FILE,输出内部goroutine阻塞的性能分析文件数据到$FILE
这些标识也同样可以用于二进制文件
% go test -run=XXX -bench=. -cpuprofile=c.p bytes
% go tool pprof c.p