Go-Benchmark入门-基础篇(上)

引言

本文偏手册性质,需要写 benchmark 时,希望能通过本文快速上手基准测试。

Benchmark是Go中一个特殊的函数,和单元测试类似,主要目的就是测试代码的性能,经常使用的主要有2种:

  • 使用 b.N 测试某个函数的耗时和内存分配情况。
  • 使用 b.RunParallel() 利用多核CPU测试某个函数的并发情况。

我们通过2个例子,来分别介绍一下这2种主流用法。

入门例子

简介

以 leetcode 509 斐波那契数 为例,我们实现了一种最简单的递归版本的解法,现在假设我们要编写 Benchmark 来测试这种解法的性能,让我们对算法的好坏有一个直观的了解。

1.创建一个go mod项目

$ mkdir example && cd example
$ go mod init example

2.新建一个 fib.go 文件,实现递归解法:

$ vim fib.go

package main

func Fib(n int) int {
   if n == 0 || n == 1 {
      return n
   }
   return Fib(n-2) + Fib(n-1)
}

3.然后和单元测试一样,需要新建一个同名以test结尾的文件 fib_test.go:

$ vim fib_test.go

4.写下如下基准测试代码:

package main

import "testing"

func BenchmarkFib(b *testing.B) {
   for n := 0; n < b.N; n++ {
      Fib(30) // 重复运行Fib(30)函数b.N次
   }
}
  • 注意函数名以 Benchmark 开头,参数是 b *testing.B。和普通的单元测试用例很像,单元测试函数名以 Test 开头,参数是 t *testing.T。
  • b.N:循环次数,如果函数运行足够快,下一次 go test调用BenchmarkFib时,b.N的值最多以100倍增长(1、100、10000次……),具体参见go源码中(testing/benchmark.go:lanuch)函数中的算法。
  • Fib(30):计算第30个斐波那契数列

运行用例

通过如下命令运行基准测试:

$ go test -bench="."

此时,输出如下结果:

goos: darwin
goarch: arm64
pkg: goexample/13_benchmark
BenchmarkFib-10              316           3636114 ns/op
PASS
ok      goexample/13_benchmark  1.969s
  • BenchmarkFib-10:“-10” 表明启动了10个cpu执行测试,但是因为 BenchmarkFib 函数是单协程运行,故和一个cpu执行的效果一样。
  • 316:代表BenchmarkFib 在1秒内执行了316次,通过 -benchtime 可以改变测试时长。
  • 3636114 ns/op:函数执行的平均耗时,纳秒单位,除以1000*1000后约3.63 ms,因为有一些启动初始化等工作,所以:耗时 * 次数 > 1秒 是正常现象。

benchmark 是如何工作的

benchmark用例的参数 b *testing.B 中,有一个属性 b.N ,代表用例中测试代码循环的次数。

运行测试时,b.N会先从1开始,如果该用例能在1s内完成,说明函数足够快,则 go test 会根据一定的规则增加 b.N并再次运行该用例,最多以100倍的速度增加,最多以100倍的速度增加(go1.19)。

我们加个log试验下:

func BenchmarkFib(b *testing.B) {
   b.Log("BenchmarkFib, b.N=", b.N)
   for n := 0; n < b.N; n++ {
      Fib(30) // 重复运行Fib(30)函数b.N次
   }
}

输出:

$ go test -run=none -bench="BenchmarkFib$"
goos: darwin
goarch: arm64
pkg: goexample/13_benchmark
BenchmarkFib-10              321           3687196 ns/op
--- BENCH: BenchmarkFib-10
    fib_test.go:9: BenchmarkFib, b.N= 1
    fib_test.go:9: BenchmarkFib, b.N= 100
    fib_test.go:9: BenchmarkFib, b.N= 321
PASS
ok      goexample/13_benchmark  1.958s

说明用例 BenchmarkFib 1秒内被调用了3次,第一次b.N=1,第二次b.N=100,第三次b.N=321,此时时间耗尽,整个测试执行完成。

测试时间复杂度

通过上面的例子,我们发现 Fib(30) 函数运行一次需要 3.6ms,感觉有点慢。此时,我们可以继续增加位数,比如计算第 40 位的数列,来观察函数的时间复杂度:

func benchFib(b *testing.B, num int) {
   for n := 0; n < b.N; n++ {
      Fib(num)
   }
}
func BenchmarkFib_10(b *testing.B) {
   benchFib(b, 10)
}
func BenchmarkFib_30(b *testing.B) {
   benchFib(b, 30)
}
func BenchmarkFib_40(b *testing.B) {
   benchFib(b, 40)
}

运行后输出:

$ go test -run=none -bench="BenchmarkFib_"
goos: darwin
goarch: arm64
pkg: goexample/13_benchmark
BenchmarkFib_10-10                       4688688               244.3 ns/op
BenchmarkFib_30-10                           321           3730468 ns/op
BenchmarkFib_40-10                             3         455273528 ns/op

发现了吗?计算 Fib(40) 居然需要 455ms,如果计算第 60 位数字,不知道要多久,运行出来的同学评论区留言一下。

也就是说,递归解法的性能下降趋势超越了指数级(4688688 -> 321 -> 3)。时间复杂度网上的一种说法是O(2^n),另一种说法我打不出来,感兴趣的可以查一下。

我们换另一个迭代版本 O(n) 复杂度的解法试试:

// FibIterator 迭代版本解法,O(n)时间复杂度,算法性能大幅度提升
func FibIterator(n int) int {
   if n <= 1 {
      return n
   }

   // 空间换时间,把前一个结果缓存起来,避免重复计算
   var n2, n1 = 0, 1
   for i := 2; i < n; i++ {
      n2, n1 = n1, n1+n2
   }

   return n2 + n1
}

再次运行:

BenchmarkFib_10-10    303285657     3.821 ns/op
BenchmarkFib_30-10    100000000     10.22 ns/op
BenchmarkFib_40-10    89248639      13.44 ns/op

我们看到,随着计算数字的增大,耗时线性增长,且第40位数字计算只花费了 13.44 ns,性能提升了:455273528 / 13.44,大约 3300万倍

Benchmark命令简介

语法

go test命令用来运行某个 package 内除 Benchmark 测试代码之外的 所有测试用例

$ go test <module name>/<package name>   # module name: go mod项目名,可以省略
$ go test .                              # 运行当前 package 内的所有用例,可以省略 "."

所以,这也是上文中为什么需要我们额外指定 -bench命令的原因(不然不会运行基准测试):

$ go test -bench="."

等价于下面的命令:

$ go test -run="." -bench="."
  • -run regexp: 运行所有正则匹配的 tests, examples, fuzz tests 等类型的测试(函数名)。在正则中 “.” 表示所有字符串,故会运行所有单元测试,来源:官网
  • -bench regexp:除运行-run匹配的测试之外,额外根据正则匹配结果,运行所有匹配的 benchmarks 测试,同样这里也是匹配所有 benchmark 函数名,即运行所有基准测试。更多正则语法,请参考:w3cschool

比如,我们可以使用 “Fib$” 只运行以Fib关键字结尾的 Benchmark(注意单测也会运行,如果有的话):

$ go test -bench="Fib$"

输出:

goos: darwin
goarch: arm64
pkg: goexample/13_benchmark
BenchmarkFib-10              315           3669052 ns/op
PASS
ok      goexample/13_benchmark  1.968s

需要注意的是,如果当前package下还有其他 tests 单元测试,会一并运行,可以通过 -v 命令查看是否执行以及执行了那些单测。

跳过unit test

$ go test -run="none" -bench="BenchmarkFib"

我们只需要给 -run 中指定一个不匹配任何单测的正则,即可跳过单测,只运行 benchmark测试。

常用正则

为了方便大家,这里附上几个常用正则的含义:

  • .:匹配所有字符串
  • $:匹配输入字符串的结尾位置
  • *:匹配前面的子表达式零次或多次
  • ^:匹配输入字符串的开始位置

另外,搭配在线工具能更高效的写出正确的正则( https://tool.lu/regex/ ):

高级命令

完整命令参加官方文档:Testing Flags

常用的高级命令如下:

-cpu 1,2,4
    Specify a list of GOMAXPROCS values for which the tests, benchmarks or
    fuzz tests should be executed. The default is the current value
    of GOMAXPROCS. -cpu does not apply to fuzz tests matched by -fuzz.

-benchtime t
    Run enough iterations of each benchmark to take t, specified
    as a time.Duration (for example, -benchtime 1h30s).
    The default is 1 second (1s).
    The special syntax Nx means to run the benchmark N times
    (for example, -benchtime 100x).

-benchmem
    Print memory allocation statistics for benchmarks.

-count n
    Run each test, benchmark, and fuzz seed n times (default 1).
    If -cpu is set, run n times for each GOMAXPROCS value.
    Examples are always run once. -count does not apply to
    fuzz tests matched by -fuzz.

-cpuprofile cpu.out
    Write a CPU profile to the specified file before exiting.
    Writes test binary as -c would.

-memprofile mem.out
    Write an allocation profile to the file after all tests have passed.
    Writes test binary as -c would.

高级例子

b.Run子测试

上文中,我们为了测试不同输入下,斐波拉契算法的耗时,写了如下代码:

func benchFib(b *testing.B, num int) {
   for n := 0; n < b.N; n++ {
      FibIterator(num)
   }
}
func BenchmarkFib_10(b *testing.B) {
   benchFib(b, 10)
}
func BenchmarkFib_30(b *testing.B) {
   benchFib(b, 30)
}
func BenchmarkFib_40(b *testing.B) {
   benchFib(b, 40)
}

实际上,我们可以利用 b.Run 运行子测试,把代码合并成:

func BenchmarkFib_Table(b *testing.B) {
   var table = []int{10, 30, 40}
   for i := 0; i < len(table); i++ {
      num := table[i]
      name := fmt.Sprintf("%s_%d", "BenchmarkFib", num)
      b.Run(name, func(b *testing.B) {
         benchFib(b, num)
      })
   }
}

运行后输出:

$ go test -run=none -bench="BenchmarkFib_Table$"
goos: darwin
goarch: arm64
pkg: goexample/13_benchmark
BenchmarkFib_Table/BenchmarkFib_10-10           301852904                3.781 ns/op
BenchmarkFib_Table/BenchmarkFib_30-10           100000000               10.12 ns/op
BenchmarkFib_Table/BenchmarkFib_40-10           89885114                13.29 ns/op
PASS
ok      goexample/13_benchmark  4.271s

达到了同样的效果,且代码更简洁!类似单测中的表格驱动测试法。

b.RunParallel 并发测试

通常情况下,b.N 用来测试函数的执行耗时,而 b.RunParallel 看名字就知道是为了测试不同 CPU 情况下,函数的并发次数。

以mysql场景举例,假设我要测试创建群组函数的QPS:

func (p pushGroup) Create(ctx context.Context, group *model.BizPushGroup) error {
   val, err := p.client.BizPushGroup.Query().Where(bizpushgroup.BizUUID(group.BizUUID)).Only(ctx)
   if ent.IsNotFound(err) {
      val, err = p.client.BizPushGroup.Create().SetBizUUID(group.BizUUID).Save(ctx)
      if err != nil {
         return err
      }
   } else if val != nil && val.Status { // 已删除,恢复
      err = p.client.BizPushGroup.Update().SetStatus(false).Where(bizpushgroup.ID(val.ID)).Exec(ctx)
      if err != nil {
         return err
      }
   }
   group.ID = val.ID
   return nil
}

我们的 benchmark 代码如下:

func BenchmarkPushGroup_Create(b *testing.B) {
   entClient := newEntClient(b)
   redisCli := unittest.NewRedis(b)
   group := NewPushGroupDao(entClient, redisCli, NewPushGroupMemberDao(entClient, redisCli))

   // 忽略连接mysql等初始化耗时
   b.ResetTimer()

   rand.Seed(time.Now().Unix())
   key := fmt.Sprintf("benchmark-group-%d", rand.Int())
   num := atomic.Int32{}

   // 启动 -cpu 1,2,4 指令中指定个数的routine,并且同时执行
   b.RunParallel(func(pb *testing.PB) {
      // 注意,这里不再是判断 b.N,而是通过 pb.Next() 确定是否需要继续运行测试
      for pb.Next() {
         num.Inc()
         group.Create(context.Background(), &model.BizPushGroup{
            BizUUID: fmt.Sprintf("%s-%d", key, num.Load()),
         })
      }
   })
}
  • b.RunParallel() 中会在一个go routine中执行,直到 pb.Next() 变成false为止,这个由go tool工具控制。
  • b.ResetTimer() 显示忽略初始化的耗时,除此之外,还可以使用 StopTimer 和 StartTimer 精确忽略某一段代码的耗时。

运行时,我们可以通过 -cpu 指令控制cpu个数(不指定则默认机器的cpu个数):

$ go test -run=none -bench="BenchmarkPushGroup_Create" -cpu 1,2,4 
goos: darwin
goarch: arm64
pkg: git.shuodev.com/server/msg-dispatcher/internal/dao
BenchmarkPushGroup_Create            272           6760698 ns/op
BenchmarkPushGroup_Create-2          370           4379713 ns/op
BenchmarkPushGroup_Create-4          367           2874840 ns/op
PASS
ok      git.shuodev.com/server/msg-dispatcher/internal/dao      8.253s

我们发现,增加cpu超过2个时,并发能力并没有上去,可以得出一个初步结论:Create 函数依赖mysql,它的qps能力在300-400左右。

总结

本文介绍了2种主要的benchmark测试:

  • 演示了如何使用 b.N 测试函数耗时。并且以斐波拉契算法为例,演示了使用 b.Run 运行多个子测试了以验证不同输入情况下函数的耗时对比,从而把算法复杂度从 O(2^n) 降低到了 O(n)。并且给出了第40个数字对比,2种算法耗时相差 3000万倍
  • 演示了如何使用 b.RunParallel 测试服务QPS,以向mysql插入群组为例,演示了单机场景下的一种压测方式。

同时,罗列了以下常用的命令以做备忘(官网完整命令):

  • -run regex:运行单测匹配正则的所有单测
  • -bench regex:运行匹配正则的所有benchmark测试
  • -benchtime: 测试执行的时长,默认1s
  • -count:执行轮次
  • -cpu 1,2,4:指定运行测试的cpu,以逗号隔开时,会执行多次。
  • -benchmem: 输出内存分配情况
  • -cpuprofile:输出pprof文件,可以使用go tool pprof 打开分析详细cpu耗时。
  • -memprofile:输出pprof文件,可以使用go tool pprof 打开分析详细内存分配情况。

关于作者

作者简介:一线Gopher,公众号《Go和分布式IM》运营者,开源项目: CoffeeChat 、interview-golang 发起人 & 核心开发者,终身程序员。


参考

  • benchmark 基准测试
  • How to write benchmarks in Go
  • Introduction to benchmarks in Go
  • Testing flags
  • Benchmarking in Golang: Improving function performance
  • 《Go语言标准库》The Golang Standard Library by Example

你可能感兴趣的:(Go最佳实践,Golang学习和进阶,golang,benchmark,性能测试,单元测试,基准测试)