原文链接:
https://github.com/sxs2473/go…本文使用
Creative Commons Attribution-ShareAlike 4.0 International 协议进行授权许可。
基准测试
本节重点讨论如何使用 Go 测试框架构建一个有效的基准测试,并提供一些实用的技巧来避免性能缺陷。
基准测试的基本规则
在进行基准测试之前,我们必须要有一个稳定的环境来获得可重现的结果。
- 机器必须是空闲的——不要运行在共享硬件上,在长时间运行基准测试时不要进行其他操作
- 注意节电和热缩放(主要指 CPU 受温度影响导致频率不稳定)
- 避免虚拟机和共享云托管; 它们太乱,无法进行一致的测量。
如果你负担得起,最好购买专用的性能测试硬件。并禁用所有电源管理和热缩放,保持机器上的软件版本不变。
对于其他人,请使用前后样本并多次运行它们以获得一致的结果。
使用测试包进行基准测试
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
中完成这些设定,这样每个想要运行基准测试的人都可以使用相同的配置进行编码。
Benchstat
在上一节中,我建议多次运行基准测试以获得更多的平均数据。对于任何基准测试来说,这都是一个很好的建议,因为测试过程会受到电源管理、后台进程和热管理的影响,这个问题我在本章的开头已经提到过。
下面我将介绍一个由 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%。 这对电池电量来说在意料之中。
- 第一次运行是最慢的,因为操作系统的 CPU 时钟频率已经降低以节省功耗。
- 接下来的两次运行是最快的,因为操作系统识别到有一个较大的工作负载加入,就会提高 CPU 时钟速度,以尽快通过工作。
- 剩下的是当 CPU 高速运转发热,因为功耗导致又被限制,所以又慢了下来。
对比标准 benchmarks 和 benchstat
确定两组基准测试结果之间的差异可能是单调乏味且容易出错的。 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)
比较基准测试时需要检查三件事
- 新老两次的方差。1-2% 是不错的, 3-5% 也还行,但是大于5%的话,可能不太可靠。 在比较一方具有高差异的基准时要小心,您可能看不到改进。
- p值。p值低于0.05是比较好的情况,大于0.05则意味着基准测试结果可能没有统计学意义。
- 样本不足。benchstat将报告它认为有效的新旧样本的数量,有时你可能只发现9个报告,即使你设置了
-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