Go 高性能系列教程之一:基准测试

2023-01-31 15:16:36 浏览数 (1)

要想改进程序的性能,首先要知道程序的当前性能。 本节主要关注使用 Go testing 包如何构建有用的基准测试,并且给出一些最佳实践以及常见的陷阱。

benchmark 是 go 语言中用于测试基准性能的工具。该工具用于测试被测试函数的平均运行耗时、内存分配次数。主要适用于在已知性能瓶颈在哪里时的场景。通过对相同功能函数的不同实现的性能指标(平均运行耗时、平均内存分配次数)进行比较,以判断性能的优劣。

下面,我们来详细介绍一下使用benchmark如何构建有用的基准测试。

01 基准测试基本原则

为了保证基准测试结果的相对稳定性,需要保持硬件环境的稳定。即:

  • 机器处于空闲状态
  • 机器关闭了节能模式
  • 避免使用虚拟机和云主机

02 使用 testing 包构建基准测试

Go的tesing包中内置了基准测试功能。在编写基准测试时基本和编写单元测试的原则相似:

  • 文件名必须以 _test.go 为后缀
  • 函数名必须以 BenchmarkXxxx开头
  • 基准测试函数的参数类型是 *Testing.B,而非 *Testing.T
代码语言:javascript复制
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

0 人点赞