原文连接: https://github.com/sxs2473/go...
本文使用 Creative Commons Attribution-ShareAlike 4.0 International 协议进行受权许可。
本节重点讨论如何使用 Go 测试框架构建一个有效的基准测试,并提供一些实用的技巧来避免性能缺陷。git
在进行基准测试以前,咱们必需要有一个稳定的环境来得到可重现的结果。github
若是你负担得起,最好购买专用的性能测试硬件。并禁用全部电源管理和热缩放,保持机器上的软件版本不变。golang
对于其余人,请使用先后样本并屡次运行它们以得到一致的结果。正则表达式
testing
包已经内置了支持基准测试的能力. 好比你有一个简单的函数:算法
// 此函数计算斐波那契数列中第 N 个数字 func Fib(n int) int { switch n { case 0: return 0 case 1: return 1 default: return Fib(n-1) + Fib(n-2) } }
咱们可使用 testing
包以以下形式为此函数写一个基准测试。基准测试函数也写在以 _test.go
结尾的文件里,它和test
函数共存.服务器
func BenchmarkFib20(b *testing.B) { for n := 0; n < b.N; n++ { Fib(20) // 运行 Fib 函数 N 次 } }
基准测试和普通单元测试相似。 惟一的区别是基准测试接收的参数是*testing.B
而不是 *testing.T
。 这两种类型都实现了 testing.TB
接口,这个接口提供了一些比较经常使用的方法 Errorf()
, Fatalf()
, and FailNow()
。架构
由于基准测试使用testing
包,它们一样经过 go test 命令执行。可是,默认状况下,当你调用go test
时,基准测试是不执行的。框架
要显式地执行基准测试请使用 -bench
标识。 -bench
接收一个与待运行的基准测试名称相匹配的正则表达式,所以,若是要运行包中全部的基准测试,最多见的方法是这样写 -bench=.
。例如:函数
% go test -bench=. ./examples/fib/ goos: darwin goarch: amd64 BenchmarkFib20-8 30000 44514 ns/op PASS ok _/Users/dfc/devel/gophercon2018-performance-tuning-workshop/2-benchmarking/examples/fib 1.795s
注意 : go test
会在运行基准测试以前以前执行包里全部的单元测试,全部若是你的包里有不少单元测试,或者它们会运行很长时间,你也能够经过 go test
的-run
标识排除这些单元测试,不让它们执行; 好比: go test -run=^$
。工具
基准测试函数会被一直调用直到b.N
无效,它是基准测试循环的次数
b.N
从 1 开始,若是基准测试函数在1秒内就完成 (默认值),则 b.N
增长,并再次运行基准测试函数。
b.N
在近似这样的序列中不断增长;1, 2, 3, 5, 10, 20, 30, 50, 100 等等。 基准框架试图变得聪明,若是它看到当b.N
较小并且测试很快就完成的时候,它将让序列增长地更快。
看上面的例子, BenchmarkFib20-8
发现约 30000 次迭代只须要1秒钟。 From there the benchmark framework computed that
注意 : The -8
后缀和用于运行次测试的 GOMAXPROCS
值有关。 与GOMAXPROCS
同样,此数字默认为启动时Go进程可见的CPU数。 你可使用-cpu
标识更改此值,能够传入多个值以列表形式来运行基准测试。
% go test -bench=. -cpu=1,2,4 ./examples/fib/ goos: darwin goarch: amd64 BenchmarkFib20 30000 44644 ns/op BenchmarkFib20-2 30000 44504 ns/op BenchmarkFib20-4 30000 44848 ns/op PASS
fib
函数是一个模拟的例子 — 除非你编写 TechPower 服务器基准测试来验证,不然你的业务不太多是你计算斐波那契数列中第20个数字的速度。 可是,基准确实展示了我认为有效的基准。
具体来讲,当你的基准测试运行几千次迭代的时候,咱们能够认为得到了一个每次运行的平均值,而若是基准测试只运行几十次,那么这个平均值极可能不稳定,也就不能说明问题。
要增长迭代次数,可使用-benchtime
标识增长运行时间,例如
% go test -bench=. -benchtime=10s ./examples/fib/ goos: darwin goarch: amd64 BenchmarkFib20-8 300000 44616 ns/op
运行一个相同的基准测试,直到它到达b.N
的值,运行时间超过10秒。当咱们运行时间是10倍的时候,迭代次数也会增长到10倍。然而每一次执行的结果却没有什么变化,这正是咱们所预期的。
若是你有一个基准测试,它运行数百万次或数十亿次迭代,每次操做的时间都在微秒或纳秒级,那么你可能会发现基准测试结果不稳定,由于热缩放、内存局部性、后台处理、gc活动等等。
对于每次操做是以10或个位数纳秒为单位计算的函数来讲,指令从新排序和代码对齐的相对效应都将对结果产生影响。
可使用-count
标识屡次运行基准测试来解决这个问题:
% go test -bench=Fib1 -count=10 ./examples/fib/ goos: darwin goarch: amd64 BenchmarkFib1-8 2000000000 1.99 ns/op BenchmarkFib1-8 1000000000 1.95 ns/op BenchmarkFib1-8 2000000000 1.99 ns/op BenchmarkFib1-8 2000000000 1.97 ns/op BenchmarkFib1-8 2000000000 1.99 ns/op BenchmarkFib1-8 2000000000 1.96 ns/op BenchmarkFib1-8 2000000000 1.99 ns/op BenchmarkFib1-8 2000000000 2.01 ns/op BenchmarkFib1-8 2000000000 1.99 ns/op BenchmarkFib1-8 1000000000 2.00 ns/op
得出Fib(1)
的基准测试在2纳秒左右,方差为正负2%.
提示 : 若是你发现须要针对特定的包调整不一样的默认值,我建议使用Makefile
中完成这些设定,这样每一个想要运行基准测试的人均可以使用相同的配置进行编码。
在上一节中,我建议屡次运行基准测试以得到更多的平均数据。对于任何基准测试来讲,这都是一个很好的建议,由于测试过程会受到电源管理、后台进程和热管理的影响,这个问题我在本章的开头已经提到过。
下面我将介绍一个由 Russ Cox 编写的测试工具 benchstat
% go get golang.org/x/perf/cmd/benchstat
Benchstat 能够获取一组基准测试数据,并告诉你它的稳定性如何。如下是使用电池时的数据:
% go test -bench=Fib20 -count=10 ./examples/fib/ | tee old.txt goos: darwin goarch: amd64 BenchmarkFib20-8 30000 46295 ns/op BenchmarkFib20-8 30000 41589 ns/op BenchmarkFib20-8 30000 42204 ns/op BenchmarkFib20-8 30000 43923 ns/op BenchmarkFib20-8 30000 44339 ns/op BenchmarkFib20-8 30000 45340 ns/op BenchmarkFib20-8 30000 45754 ns/op BenchmarkFib20-8 30000 45373 ns/op BenchmarkFib20-8 30000 44283 ns/op BenchmarkFib20-8 30000 43812 ns/op PASS ok _/Users/dfc/devel/gophercon2018-performance-tuning-workshop/2-benchmarking/examples/fib 17.865s % benchstat old.txt name time/op Fib20-8 44.3µs ± 6%
benchstat
告诉咱们,平均值为44.3微秒,样本间的波动区间为正负 6%。 这对电池电量来讲在乎料之中。
肯定两组基准测试结果之间的差别多是单调乏味且容易出错的。 Benchstat 能够帮助咱们解决这个问题。
提示 : 保存基准运行的输出颇有用,但你也能够保存生成它的二进制文件。 为此,请使用-c
标志来保存测试二进制文件;我常常将这个二进制文件从.test
重命名为.golden
。
% go test -c % mv fib.test fib.golden
Fib
性能先前的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) } }
为了比较咱们的新版本,咱们编译了一个新的测试二进制文件并对它们都进行了基准测试,并使用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 44.3µs ± 6% 25.6µs ± 2% -42.31% (p=0.000 n=10+10)
比较基准测试时须要检查三件事
-count=10
。拒绝率小于10%通常是没问题的,而高于10%可能代表你的设置是不稳定的,也多是比较的样本太少了。有时候每次基准测试运行前都有一些初始化操做。 b.ResetTimer()
将让你跳过这些运行时间。
func BenchmarkExpensive(b *testing.B) { boringAndExpensiveSetup() b.ResetTimer() // HL for n := 0; n < b.N; n++ { // 被测试的功能 } }
若是每次循环迭代内部都有一些高成本的其余逻辑,请使用b.StopTimer()
和b.StartTimer()
来暂停基准计时器。
func BenchmarkComplicated(b *testing.B) { for n := 0; n < b.N; n++ { b.StopTimer() // HL complicatedSetup() b.StartTimer() // HL // 被测试的功能 } }
分配计数和大小与基准测试的执行时间密切相关。 你能够告诉测试框架记录被测代码所作的分配数量。
func BenchmarkRead(b *testing.B) { b.ReportAllocs() for n := 0; n < b.N; n++ { // 被测试的功能 } }
如下是使用bufio软件包基准测试的示例:
% go test -run=^$ -bench=. bufio goos: darwin goarch: amd64 pkg: bufio BenchmarkReaderCopyOptimal-8 20000000 103 ns/op BenchmarkReaderCopyUnoptimal-8 10000000 159 ns/op BenchmarkReaderCopyNoWriteTo-8 500000 3644 ns/op BenchmarkReaderWriteToOptimal-8 5000000 344 ns/op BenchmarkWriterCopyOptimal-8 20000000 98.6 ns/op BenchmarkWriterCopyUnoptimal-8 10000000 131 ns/op BenchmarkWriterCopyNoReadFrom-8 300000 3955 ns/op BenchmarkReaderEmpty-8 2000000 789 ns/op 4224 B/op 3 allocs/op BenchmarkWriterEmpty-8 2000000 683 ns/op 4096 B/op 1 allocs/op BenchmarkWriterFlush-8 100000000 17.0 ns/op 0 B/op 0 allocs/op
注意 : 想对全部基准测试都生效,你也可使用go test -benchmem
标识。
% go test -run=^$ -bench=. -benchmem bufio goos: darwin goarch: amd64 pkg: bufio BenchmarkReaderCopyOptimal-8 20000000 93.5 ns/op 16 B/op 1 allocs/op BenchmarkReaderCopyUnoptimal-8 10000000 155 ns/op 32 B/op 2 allocs/op BenchmarkReaderCopyNoWriteTo-8 500000 3238 ns/op 32800 B/op 3 allocs/op BenchmarkReaderWriteToOptimal-8 5000000 335 ns/op 16 B/op 1 allocs/op BenchmarkWriterCopyOptimal-8 20000000 96.7 ns/op 16 B/op 1 allocs/op BenchmarkWriterCopyUnoptimal-8 10000000 124 ns/op 32 B/op 2 allocs/op BenchmarkWriterCopyNoReadFrom-8 500000 3219 ns/op 32800 B/op 3 allocs/op BenchmarkReaderEmpty-8 2000000 748 ns/op 4224 B/op 3 allocs/op BenchmarkWriterEmpty-8 2000000 662 ns/op 4096 B/op 1 allocs/op BenchmarkWriterFlush-8 100000000 16.9 ns/op 0 B/op 0 allocs/op PASS ok bufio 20.366s
这个例子来自 issue 14813。
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 } func BenchmarkPopcnt(b *testing.B) { for i := 0; i < b.N; i++ { popcnt(uint64(i)) } }
你以为这个基准测试会有多快?让咱们来看看。
% go test -bench=. ./examples/popcnt/ goos: darwin goarch: amd64 BenchmarkPopcnt-8 2000000000 0.30 ns/op PASS
0.3 纳秒,这基本上是一个时钟周期。即便假设CPU每一个时钟周期内会执行多条指令,这个数字彷佛也不合理地低。 发生了什么?
要了解发生了什么,咱们必须看看benchmark下的函数popcnt。 popcnt是一个叶子函数 - 它不调用任何其余函数 - 所以编译器能够内联它。
由于函数是内联的,因此编译器如今能够看到它没有反作用。 popcnt不会影响任何全局变量的状态。 这样,调用就被消除了。 这是编译器看到的:
func BenchmarkPopcnt(b *testing.B) { for i := 0; i < b.N; i++ { // 优化了 } }
在全部版本的Go编译器上,仍然会生成循环。 可是英特尔CPU很是擅长优化循环,尤为是空循环。
须要去掉的是,经过删除没必要要的计算使真正的代码快速运行的优化,与删除没有明显反作用的基准测试的优化是相同的。
随着Go编译器的改进,这只会变得更加广泛。
要修复此基准测试,咱们必须确保编译器没法检验BenchmarkPopcnt
的主体不会致使全局状态发生变化。
var Result uint64 func BenchmarkPopcnt(b *testing.B) { var r uint64 for i := 0; i < b.N; i++ { r = popcnt(uint64(i)) } Result = r }
这是确保编译器没法优化循环体的推荐方法。
首先,咱们经过将调用popcnt
的结果存储在r
中。 而后,当测试基准结束时,r
在BenchmarkPopcnt
的范围内被声明,r
的结果对于程序的另外一部分是不可见的,因此最终,咱们将r
值赋给包级别的公共变量Result
。
由于Result
是公共的,因此编译器没法证实导入此类的另外一个包将没法看到Result
随时间变化的值,所以它没法优化致使其赋值的任何操做。
for
循环对基准测试的执行很是重要
下面是两个错误的的基准测试例子:
func BenchmarkFibWrong(b *testing.B) { Fib(b.N) }
func BenchmarkFibWrong2(b *testing.B) { for n := 0; n < b.N; n++ { Fib(n) } }
结果是,它们会一直执行下去
testing
包内置了支持生成CPU,内存和块的profile文件。
-cpuprofile=$FILE
将 CPU 分析结果写入 $FILE
.-memprofile=$FILE
将内存分析结果写入 $FILE
, -memprofilerate=N
调整记录速率为 1/N
.-blockprofile=$FILE
, 将块分析结果写入 $FILE
.使用这些标识中的任何一个同时都会保留二进制文件。
% go test -run=XXX -bench=. -cpuprofile=c.p bytes % go tool pprof c.p