1. Go 性能调优之 —— 基准测试

 阅读约 24 分钟
原文链接: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中。 然后,当测试基准结束时,rBenchmarkPopcnt的范围内被声明,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
阅读 3.4k更新于 2018-09-12

推荐阅读
Golang 攻略
用户专栏

针对 Golang:介绍正确用法;剖析核心设计;总结最佳实践。

188 人关注
17 篇文章
专栏主页
目录