Go语言基准测试

2023-12-10 ⏳3.2分钟(1.3千字)

基准测试英文关键词 benchmark,主要用于测试 CPU 和内存使用效率。配合一定的工具,还能直观地展示出代码优化的效果。因为跟性能相关,有时候也叫性能测试。Go语言内置基准测试工具包,使用非常方便。建议所有Go开发者都能熟练使用。

我们以素数查找问题为例演示基准测试的用法。给定数字NN,找出小于NN的所有素数。判断是否为素数的算法也很直观,数字ii22开始,到N\sqrt{N} 为止,如果NN能被 ii整除,那就不是素数。所以第一版代码如下:

func primeNumbers(max int) []int {
  var primes []int

  for i := 2; i < max; i++ {
    isPrime := true

    for j := 2; j <= int(math.Sqrt(float64(i))); j++ {
      if i%j == 0 {
        isPrime = false
        break
      }
    }

    if isPrime {
      primes = append(primes, i)
    }
  }

  return primes
}

这就是我们的测试对象。现在开始写 benchmark 代码。

func BenchmarkPrimeNumbers(b *testing.B) {
  for i := 0; i < b.N; i++ {
    primeNumbers(1000)
  }
}

普通测试用例以 Test 开头,基准测试代码都以 Benchmark 开头。普通测试用例的入参是 *testing.T类型,基准测试为testing.B类型。大家注意区分。

基准测试需要在循环里调用被测试函数,一共调用b.N次。循环次数由 benchmark 框架决定。这应该是基准测试跟普通测试最不同的地方了。

然后执行基准测试:

go test -run '^$' -bench=.
goos: darwin
goarch: amd64
pkg: ben
cpu: Intel(R) Core(TM) i7-8557U CPU @ 1.70GHz
BenchmarkPrimeNumbers-8           421335              2384 ns/op
PASS
ok      ben     1.042s

这里的-bench=.表示执行当前所有基准测试函数。-run '^$' 参数有点迷。你有时候会见到别人会写成 -run '^#'。该参数说白了就是过滤要执行哪些普通测试。go test 会同时执行普通测试和基准测试。如果只想做基准测试,那就想办法把普通测试过滤到。所有普通测试都以Test开头,都不满足^$^#这两个正则表达式,所以都不会运行。

默认输出结果有三列,分别是测试名字,执行次数,以及单个操作需要的时间。如果我们想测试内存效率,则需要开启-benchmem参数。

go test -run '^$' -bench=. -benchmem
goos: darwin
goarch: amd64
pkg: ben
cpu: Intel(R) Core(TM) i7-8557U CPU @ 1.70GHz
BenchmarkPrimeNumbers-8           501062              2471 ns/op            5136 B/op         11 allocs/op
PASS
ok      ben     2.274s

会额外输出单次操作分配的内存数量和分配次数。

有了基准测试,我们就可以验证优化效果了。前面的素数判定算法用到了开平方,效率很低。我们可以用乘法运算替换开方运算。另外,我们还可以把平方结果保存下来,进一步减少重复运算。完整代码如下:

func primeNumbers(max int) []int {
  b := make([]bool, max)

  var primes []int

  for i := 2; i < max; i++ {
    if b[i] {
      continue
    }

    primes = append(primes, i)

    for k := i * i; k < max; k += i {
      b[k] = true
    }
  }

  return primes
}

理论上后一个实现效率更高。但空口无屏,拿数据说话。

我们先保存第一版的基准测试结果:

go test -run '^$' -bench=. -benchmem > /tmp/old.txt

然后修改为新的代码,再次执行基准测试,并保存结果:

go test -run '^$' -bench=. -benchmem > /tmp/new.txt

最后比较性能差异。这时候需要用 benchstate 工具:

go install golang.org/x/perf/cmd/benchstat@latest

安装后比较性能差异:

benchstat /tmp/old.txt /tmp/new.txt
goos: darwin
goarch: amd64
pkg: ben
cpu: Intel(R) Core(TM) i7-8557U CPU @ 1.70GHz
               │ /tmp/old.txt  │          /tmp/new.txt           │
               │    sec/op     │    sec/op     vs base           │
PrimeNumbers-8   44.244µ ± ∞ ¹   2.275µ ± ∞ ¹  ~ (p=1.000 n=1) ²
¹ need >= 6 samples for confidence interval at level 0.95
² need >= 4 samples to detect a difference at alpha level 0.05

               │ /tmp/old.txt  │           /tmp/new.txt           │
               │     B/op      │     B/op       vs base           │
PrimeNumbers-8   3.992Ki ± ∞ ¹   4.992Ki ± ∞ ¹  ~ (p=1.000 n=1) ²
¹ need >= 6 samples for confidence interval at level 0.95
² need >= 4 samples to detect a difference at alpha level 0.05

               │ /tmp/old.txt │          /tmp/new.txt           │
               │  allocs/op   │  allocs/op    vs base           │
PrimeNumbers-8    9.000 ± ∞ ¹   10.000 ± ∞ ¹  ~ (p=1.000 n=1) ²
¹ need >= 6 samples for confidence interval at level 0.95
² need >= 4 samples to detect a difference at alpha level 0.05

可以看到 benchstat 提示测试标本太少。这里的 confidence interval level 应该是数理统计中的置信概率,alpha level 是显著水平。具体的含义和用法我已经还给大学老师了。在这里大家只需要明确,benchstat 会根据测试样本统计两次基准数据的差异。如果样本太少,统计结果不可信。上面的提示说致少应该有 6 组样本,建议测试 10 组以上。

我们可以使用-count 10来指定基准测试次轮数:

go test -run '^$' -bench=. -benchmem -count 10 > /tmp/new.txt

新的测试结果如下:

benchstat /tmp/old.txt /tmp/new.txt
goos: darwin
goarch: amd64
pkg: ben
cpu: Intel(R) Core(TM) i7-8557U CPU @ 1.70GHz
               │ /tmp/old.txt │            /tmp/new.txt             │
               │    sec/op    │   sec/op     vs base                │
PrimeNumbers-8   42.869µ ± 2%   2.272µ ± 1%  -94.70% (p=0.000 n=10)

               │ /tmp/old.txt │             /tmp/new.txt             │
               │     B/op     │     B/op      vs base                │
PrimeNumbers-8   3.992Ki ± 0%   4.992Ki ± 0%  +25.05% (p=0.000 n=10)

               │ /tmp/old.txt │            /tmp/new.txt             │
               │  allocs/op   │  allocs/op   vs base                │
PrimeNumbers-8     9.000 ± 0%   10.000 ± 0%  +11.11% (p=0.000 n=10)

我们看第三列,CPU 耗时减少 94.7%,但内存消耗增加 25.05%,内存分配也增加 11.11%。这符合咱们空间换时间的策略,优化有效。

以上展示了求 1000 以内素数这一种场景。但在实际开发过程中,我们需要同时测试多种场景,这时就得用到b.Run()函数。

func BenchmarkPrimeNumbers(b *testing.B) {
    table := []int{100, 1000, 3000}
    b.ResetTimer()
    for _, v := range table {
        b.Run(fmt.Sprintf("input_size_%d", v), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                primeNumbers(v)
            }
        })
    }
}

这里用于了b.ResetTimer(),是让测试框架重置时间计数器,避免前面的操作影响到测试结果。当然了,table赋值也不会有实质影响,这里只是跟大家展示用法。如果准备操作非常耗时,那就有必要重置计时状态。

测试框架会依次输出三组结果:

go test -run '^$' -bench=.
goos: darwin
goarch: amd64
pkg: ben
cpu: Intel(R) Core(TM) i7-8557U CPU @ 1.70GHz
BenchmarkPrimeNumbers/input_size_100-8    3407444   349.0 ns/op
BenchmarkPrimeNumbers/input_size_1000-8    483118  2124 ns/op
BenchmarkPrimeNumbers/input_size_3000-8    173293  6402 ns/op
PASS
ok      ben     4.834s

另外,Dave Cheney 提到1需要注意编译器自动优化,测试函数可能直接被编译器跳过,所以拿不到直正的结果。我这边没有复现,可能是新版本优化过,大家自己注意吧。

以上就是本文的全部内容了。希望大家能多用基准测试,用数据量化自己的代码性能和优化效果。

参考文档:


  1. https://dave.cheney.net/2013/06/30/how-to-write-benchmarks-in-go↩︎