过早优化是万恶之源(premature optimization is the root of all evil)

过早的引用“过早优化是万恶之源”是一切龟速软件之源(Premature quoting of “premature optimization is the root of all evil” is the root of all slow software)

1. 性能基准测试在 Go 语言中是“一等公民”

在前面的章节中,我们已经接触过许多的性能基准测试(benchmark test)。和上一节所讲的模糊测试的境遇不同,性能基准测试在 Go 语言中是和普通的单元测试一样被原生支持的,得到的是 “一等公民” 的待遇。我们可以像普通单元测试那样在*_test.go文件中创建被测对象的性能基准测试,每个以Benchmark前缀开头的函数都会被当作一个独立的性能基准测试:

  1. func BenchmarkXxx(b *testing.B) {
  2. //... ...
  3. }

下面是一个对多种字符串连接方法的性能基准测试(改编自前面“了解 string 实现原理和高效使用”一节):

  1. // benchmark_intro_test.go
  2. package main
  3. import (
  4. "fmt"
  5. "strings"
  6. "testing"
  7. )
  8. var sl = []string{
  9. "Rob Pike ",
  10. "Robert Griesemer ",
  11. "Ken Thompson ",
  12. }
  13. func concatStringByOperator(sl []string) string {
  14. var s string
  15. for _, v := range sl {
  16. s += v
  17. }
  18. return s
  19. }
  20. func concatStringBySprintf(sl []string) string {
  21. var s string
  22. for _, v := range sl {
  23. s = fmt.Sprintf("%s%s", s, v)
  24. }
  25. return s
  26. }
  27. func concatStringByJoin(sl []string) string {
  28. return strings.Join(sl, "")
  29. }
  30. func BenchmarkConcatStringByOperator(b *testing.B) {
  31. for n := 0; n < b.N; n++ {
  32. concatStringByOperator(sl)
  33. }
  34. }
  35. func BenchmarkConcatStringBySprintf(b *testing.B) {
  36. for n := 0; n < b.N; n++ {
  37. concatStringBySprintf(sl)
  38. }
  39. }
  40. func BenchmarkConcatStringByJoin(b *testing.B) {
  41. for n := 0; n < b.N; n++ {
  42. concatStringByJoin(sl)
  43. }
  44. }

上面的源文件中定义了三个性能基准测试:BenchmarkConcatStringByOperator、BenchmarkConcatStringBySprintf和BenchmarkConcatStringByJoin,我们可以一起运行这三个基准测试:

  1. $go test -bench . benchmark_intro_test.go
  2. goos: darwin
  3. goarch: amd64
  4. BenchmarkConcatStringByOperator-8 12810092 88.5 ns/op
  5. BenchmarkConcatStringBySprintf-8 2777902 432 ns/op
  6. BenchmarkConcatStringByJoin-8 23994218 49.7 ns/op
  7. PASS
  8. ok command-line-arguments 4.117s

也可以通过正则匹配选择其中一个或几个运行:

  1. $go test -bench=ByJoin ./benchmark_intro_test.go
  2. goos: darwin
  3. goarch: amd64
  4. BenchmarkConcatStringByJoin-8 23429586 49.1 ns/op
  5. PASS
  6. ok command-line-arguments 1.209s

我们关注的是 go test 输出结果中的第三列的那个值。

以BenchmarkConcatStringByJoin为例,其第三列的值为49.1 ns/op,该值表示BenchmarkConcatStringByJoin这个基准测试中 for 循环的每次循环平均执行时间为49.1 ns,即op就代表每次循环操作。这里 for 循环调用的是concatStringByJoin,即执行一次concatStringByJoin的平均时长为49.1 ns。

性能基准测试还可以通过传入-benchmem命令行参数输出内存分配信息(与基准测试代码中显式调用b.ReportAllocs的效果是等价的):

  1. $go test -bench=Join ./benchmark_intro_test.go -benchmem
  2. goos: darwin
  3. goarch: amd64 这列 这列
  4. BenchmarkConcatStringByJoin-8 23004709 48.8 ns/op 48 B/op 1 allocs/op
  5. PASS
  6. ok command-line-arguments 1.183s

这里输出的内存分配信息告诉我们:每执行一次concatStringByJoin平均进行一次内存分配,每次平均分配 48 字节的数据。

2. 顺序执行和并行执行的性能基准测试

根据是否并行执行,Go 的性能基准测试可以分为两类:一类是顺序执行的性能基准测试,其代码写法如下:

  1. func BenchmarkXxx(b *testing.B) {
  2. //... ...
  3. for i := 0; i < b.N; i++ {
  4. //被测对象的执行代码
  5. }
  6. }

前面的对多种字符串连接方法的性能基准测试就归属于这类。关于顺序执行的性能基准测试的执行过程原理,我们可以通过下面例子来说明:

  1. // benchmark-impl/sequential_test.go
  2. package bench
  3. import (
  4. "fmt"
  5. "sync"
  6. "sync/atomic"
  7. "testing"
  8. tls "github.com/huandu/go-tls"
  9. )
  10. var (
  11. m map[int64]struct{} = make(map[int64]struct{}, 10)
  12. mu sync.Mutex
  13. round int64 = 1
  14. )
  15. func BenchmarkSequential(b *testing.B) {
  16. fmt.Printf("\ngoroutine[%d] enter BenchmarkSequential: round[%d], b.N[%d]\n",
  17. tls.ID(), atomic.LoadInt64(&round), b.N)
  18. defer func() {
  19. atomic.AddInt64(&round, 1)
  20. }()
  21. for i := 0; i < b.N; i++ {
  22. mu.Lock()
  23. _, ok := m[round]
  24. if !ok {
  25. m[round] = struct{}{}
  26. fmt.Printf("goroutine[%d] enter loop in BenchmarkSequential: round[%d], b.N[%d]\n",
  27. tls.ID(), atomic.LoadInt64(&round), b.N)
  28. }
  29. mu.Unlock()
  30. }
  31. fmt.Printf("goroutine[%d] exit BenchmarkSequential: round[%d], b.N[%d]\n",
  32. tls.ID(), atomic.LoadInt64(&round), b.N)
  33. }

运行这个例子:

  1. $go test -bench . sequential_test.go
  2. goroutine[1] enter BenchmarkSequential: round[1], b.N[1]
  3. goroutine[1] enter loop in BenchmarkSequential: round[1], b.N[1]
  4. goroutine[1] exit BenchmarkSequential: round[1], b.N[1]
  5. goos: darwin
  6. goarch: amd64
  7. BenchmarkSequential-8
  8. goroutine[2] enter BenchmarkSequential: round[2], b.N[100]
  9. goroutine[2] enter loop in BenchmarkSequential: round[2], b.N[100]
  10. goroutine[2] exit BenchmarkSequential: round[2], b.N[100]
  11. goroutine[2] enter BenchmarkSequential: round[3], b.N[10000]
  12. goroutine[2] enter loop in BenchmarkSequential: round[3], b.N[10000]
  13. goroutine[2] exit BenchmarkSequential: round[3], b.N[10000]
  14. goroutine[2] enter BenchmarkSequential: round[4], b.N[1000000]
  15. goroutine[2] enter loop in BenchmarkSequential: round[4], b.N[1000000]
  16. goroutine[2] exit BenchmarkSequential: round[4], b.N[1000000]
  17. goroutine[2] enter BenchmarkSequential: round[5], b.N[65666582]
  18. goroutine[2] enter loop in BenchmarkSequential: round[5], b.N[65666582]
  19. goroutine[2] exit BenchmarkSequential: round[5], b.N[65666582]
  20. 65666582 20.6 ns/op
  21. PASS
  22. ok command-line-arguments 1.381s

我们看到:

  • BenchmarkSequential 被执行了多轮(见输出结果中的round值);
  • 每一轮执行,for 循环的b.N值均不相同,依次为 1、100、10000、1000000 和 65666582;
  • 除 b.N 为 1 的首轮,其余各轮均在一个 goroutine(goroutine[2])中顺序执行。

**
默认情况下,每个性能基准测试函数(比如:BenchmarkSequential)的执行时间为 1 秒。

  • 如果执行一轮所消耗的时间不足 1 秒,那么 go test 会按近似顺序增加 b.N 的值:1、2、3、5、10、20、30、50、100 等。
  • 如果当 b.N 较小时,基准测试执行可以很快完成,那么 go test 基准测试框架将跳过中间的一些值,选择较大些的值,比如就像这里b.N从 1 直接跳到 100。
  • 选定新的 b.N 之后,go test 基准测试框架会启动新一轮性能基准测试函数的执行,直到某一轮执行所消耗的时间超出 1 秒。上面例子中最后一轮的b.N值为 65666582,这个值应该是 go test 根据上一轮执行后得到的每次循环平均执行时间计算出来的。
  • go test 发现:如果将上一轮每次循环平均执行时间再扩大 100 倍的 N 值相乘,那下一轮的执行时间会超出 1 秒很多,于是 go test 用 1 秒与上一轮每次循环平均执行时间一起估算了一个循环次数,即上面的65666582。

如果基准测试仅运行 1 秒,并且在这 1 秒内仅运行 10 轮迭代,那么这些基准测试运行所得的平均值可能会有较高的标准偏差。如果基准测试运行了数百万或数十亿次迭代,那么其所得平均值可能更趋于准确。要增加迭代次数,可以使用-benchtime命令行选项来增加基准测试执行的时间。

下面的例子中,我们通过go test的命令行参数-benchtime将 1 秒这个默认性能基准测试函数执行时间改为 2 秒:

  1. $go test -bench . sequential_test.go -benchtime 2s
  2. ... ...
  3. goroutine[2] enter BenchmarkSequential: round[4], b.N[1000000]
  4. goroutine[2] enter loop in BenchmarkSequential: round[4], b.N[1000000]
  5. goroutine[2] exit BenchmarkSequential: round[4], b.N[1000000]
  6. goroutine[2] enter BenchmarkSequential: round[5], b.N[100000000]
  7. goroutine[2] enter loop in BenchmarkSequential: round[5], b.N[100000000]
  8. goroutine[2] exit BenchmarkSequential: round[5], b.N[100000000]
  9. 100000000 20.5 ns/op
  10. PASS
  11. ok command-line-arguments 2.075s

我们也可以通过-benchtime手动指定b.N的值,这样 go test 就会以你指定的 N 值作为最终轮的循环次数:

  1. $go test -v -benchtime 5x -bench . sequential_test.go
  2. goos: darwin
  3. goarch: amd64
  4. BenchmarkSequential
  5. goroutine[1] enter BenchmarkSequential: round[1], b.N[1]
  6. goroutine[1] enter loop in BenchmarkSequential: round[1], b.N[1]
  7. goroutine[1] exit BenchmarkSequential: round[1], b.N[1]
  8. goroutine[2] enter BenchmarkSequential: round[2], b.N[5]
  9. goroutine[2] enter loop in BenchmarkSequential: round[2], b.N[5]
  10. goroutine[2] exit BenchmarkSequential: round[2], b.N[5]
  11. BenchmarkSequential-8 5 5470 ns/op
  12. PASS
  13. ok command-line-arguments 0.006s

上面的每个性能基准测试函数(比如:BenchmarkSequential)虽然实际执行了多轮,但也仅算一次执行。有时候考虑到性能基准测试单次执行的数据不具代表性,我们可能会显式要求 go test 多次执行以收集多次数据,并将这些数据经过统计学方法处理后的结果作为最终结果。通过-count命令行选项可以显式指定每个性能基准测试函数执行次数:

  1. $go test -v -count 2 -bench . benchmark_intro_test.go
  2. goos: darwin
  3. goarch: amd64
  4. BenchmarkConcatStringByOperator
  5. BenchmarkConcatStringByOperator-8 12665250 89.8 ns/op
  6. BenchmarkConcatStringByOperator-8 13099075 89.7 ns/op
  7. BenchmarkConcatStringBySprintf
  8. BenchmarkConcatStringBySprintf-8 2781075 433 ns/op
  9. BenchmarkConcatStringBySprintf-8 2662507 433 ns/op
  10. BenchmarkConcatStringByJoin
  11. BenchmarkConcatStringByJoin-8 23679480 49.1 ns/op
  12. BenchmarkConcatStringByJoin-8 24135014 49.6 ns/op
  13. PASS
  14. ok command-line-arguments 8.225s

我们看到上面例子每个性能基准测试函数都被执行了两次(当然每次执行实质上都会运行多轮(b.N 不同)),输出了两个结果。

另外一类性能基准测试则是并行执行的,其代码写法如下:

  1. func BenchmarkXxx(b *testing.B) {
  2. //... ...
  3. b.RunParallel(func(pb *testing.PB) {
  4. for pb.Next() {
  5. // 被测对象的执行代码
  6. }
  7. }
  8. }

并行执行的基准测试主要用于为包含多 goroutine 同步设施(比如:互斥锁(mutex)、读写锁(rwlock)、原子操作等)的被测代码建立性能基准。相比于顺序执行的基准测试,并行执行的基准测试更能真实反映出多 goroutine 情况下,被测代码在 goroutine 同步上的真实消耗。比如下面这个例子:

  1. //benchmark_paralell_demo_test.go
  2. package paralelldemo
  3. import (
  4. "sync"
  5. "sync/atomic"
  6. "testing"
  7. )
  8. var n1 int64
  9. func addSyncByAtomic(delta int64) int64 {
  10. return atomic.AddInt64(&n1, delta)
  11. }
  12. func readSyncByAtomic() int64 {
  13. return atomic.LoadInt64(&n1)
  14. }
  15. var n2 int64
  16. var rwmu sync.RWMutex
  17. func addSyncByMutex(delta int64) {
  18. rwmu.Lock()
  19. n2 += delta
  20. rwmu.Unlock()
  21. }
  22. func readSyncByMutex() int64 {
  23. var n int64
  24. rwmu.RLock()
  25. n = n2
  26. rwmu.RUnlock()
  27. return n
  28. }
  29. func BenchmarkAddSyncByAtomic(b *testing.B) {
  30. b.RunParallel(func(pb *testing.PB) {
  31. for pb.Next() {
  32. addSyncByAtomic(1)
  33. }
  34. })
  35. }
  36. func BenchmarkReadSyncByAtomic(b *testing.B) {
  37. b.RunParallel(func(pb *testing.PB) {
  38. for pb.Next() {
  39. readSyncByAtomic()
  40. }
  41. })
  42. }
  43. func BenchmarkAddSyncByMutex(b *testing.B) {
  44. b.RunParallel(func(pb *testing.PB) {
  45. for pb.Next() {
  46. addSyncByMutex(1)
  47. }
  48. })
  49. }
  50. func BenchmarkReadSyncByMutex(b *testing.B) {
  51. b.RunParallel(func(pb *testing.PB) {
  52. for pb.Next() {
  53. readSyncByMutex()
  54. }
  55. })
  56. }

运行该性能基准测试:

  1. $go test -v -bench . benchmark_paralell_demo_test.go -cpu 2,4,8
  2. goos: darwin
  3. goarch: amd64
  4. BenchmarkAddSyncByAtomic
  5. BenchmarkAddSyncByAtomic-2 75208119 15.3 ns/op
  6. BenchmarkAddSyncByAtomic-4 70117809 17.0 ns/op
  7. BenchmarkAddSyncByAtomic-8 68664270 15.9 ns/op
  8. BenchmarkReadSyncByAtomic
  9. BenchmarkReadSyncByAtomic-2 1000000000 0.744 ns/op
  10. BenchmarkReadSyncByAtomic-4 1000000000 0.384 ns/op
  11. BenchmarkReadSyncByAtomic-8 1000000000 0.240 ns/op
  12. BenchmarkAddSyncByMutex
  13. BenchmarkAddSyncByMutex-2 37533390 31.4 ns/op
  14. BenchmarkAddSyncByMutex-4 21660948 57.5 ns/op
  15. BenchmarkAddSyncByMutex-8 16808721 72.6 ns/op
  16. BenchmarkReadSyncByMutex
  17. BenchmarkReadSyncByMutex-2 35535615 32.3 ns/op
  18. BenchmarkReadSyncByMutex-4 29839219 39.6 ns/op
  19. BenchmarkReadSyncByMutex-8 29936805 39.8 ns/op
  20. PASS
  21. ok command-line-arguments 12.454s

和顺序执行的基准测试不同,并行执行的基准测试会启动多个 goroutine 并行执行基准测试函数中的循环,我们也用一个例子来说明一下其执行流程:

  1. //benchmark-impl/paralell_test.go
  2. package bench
  3. import (
  4. "fmt"
  5. "sync"
  6. "sync/atomic"
  7. "testing"
  8. tls "github.com/huandu/go-tls"
  9. )
  10. var (
  11. m map[int64]int = make(map[int64]int, 20)
  12. mu sync.Mutex
  13. round int64 = 1
  14. )
  15. func BenchmarkParalell(b *testing.B) {
  16. fmt.Printf("\ngoroutine[%d] enter BenchmarkParalell: round[%d], b.N[%d]\n",
  17. tls.ID(), atomic.LoadInt64(&round), b.N)
  18. defer func() {
  19. atomic.AddInt64(&round, 1)
  20. }()
  21. b.RunParallel(func(pb *testing.PB) {
  22. id := tls.ID()
  23. fmt.Printf("goroutine[%d] enter loop func in BenchmarkParalell: round[%d], b.N[%d]\n", tls.ID(), atomic.LoadInt64(&round), b.N)
  24. for pb.Next() {
  25. mu.Lock()
  26. _, ok := m[id]
  27. if !ok {
  28. m[id] = 1
  29. } else {
  30. m[id] = m[id] + 1
  31. }
  32. mu.Unlock()
  33. }
  34. mu.Lock()
  35. count := m[id]
  36. mu.Unlock()
  37. fmt.Printf("goroutine[%d] exit loop func in BenchmarkParalell: round[%d], loop[%d]\n", tls.ID(), atomic.LoadInt64(&round), count)
  38. })
  39. fmt.Printf("goroutine[%d] exit BenchmarkParalell: round[%d], b.N[%d]\n",
  40. tls.ID(), atomic.LoadInt64(&round), b.N)
  41. }

我们以-cpu=2运行该例子:

  1. $go test -v -bench . paralell_test.go -cpu=2
  2. goos: darwin
  3. goarch: amd64
  4. BenchmarkParalell
  5. goroutine[1] enter BenchmarkParalell: round[1], b.N[1]
  6. goroutine[2] enter loop func in BenchmarkParalell: round[1], b.N[1]
  7. goroutine[2] exit loop func in BenchmarkParalell: round[1], loop[1]
  8. goroutine[3] enter loop func in BenchmarkParalell: round[1], b.N[1]
  9. goroutine[3] exit loop func in BenchmarkParalell: round[1], loop[0]
  10. goroutine[1] exit BenchmarkParalell: round[1], b.N[1]
  11. goroutine[4] enter BenchmarkParalell: round[2], b.N[100]
  12. goroutine[5] enter loop func in BenchmarkParalell: round[2], b.N[100]
  13. goroutine[5] exit loop func in BenchmarkParalell: round[2], loop[100]
  14. goroutine[6] enter loop func in BenchmarkParalell: round[2], b.N[100]
  15. goroutine[6] exit loop func in BenchmarkParalell: round[2], loop[0]
  16. goroutine[4] exit BenchmarkParalell: round[2], b.N[100]
  17. goroutine[4] enter BenchmarkParalell: round[3], b.N[10000]
  18. goroutine[7] enter loop func in BenchmarkParalell: round[3], b.N[10000]
  19. goroutine[8] enter loop func in BenchmarkParalell: round[3], b.N[10000]
  20. goroutine[8] exit loop func in BenchmarkParalell: round[3], loop[4576]
  21. goroutine[7] exit loop func in BenchmarkParalell: round[3], loop[5424]
  22. goroutine[4] exit BenchmarkParalell: round[3], b.N[10000]
  23. goroutine[4] enter BenchmarkParalell: round[4], b.N[1000000]
  24. goroutine[9] enter loop func in BenchmarkParalell: round[4], b.N[1000000]
  25. goroutine[10] enter loop func in BenchmarkParalell: round[4], b.N[1000000]
  26. goroutine[9] exit loop func in BenchmarkParalell: round[4], loop[478750]
  27. goroutine[10] exit loop func in BenchmarkParalell: round[4], loop[521250]
  28. goroutine[4] exit BenchmarkParalell: round[4], b.N[1000000]
  29. goroutine[4] enter BenchmarkParalell: round[5], b.N[25717561]
  30. goroutine[11] enter loop func in BenchmarkParalell: round[5], b.N[25717561]
  31. goroutine[12] enter loop func in BenchmarkParalell: round[5], b.N[25717561]
  32. goroutine[12] exit loop func in BenchmarkParalell: round[5], loop[11651491]
  33. goroutine[11] exit loop func in BenchmarkParalell: round[5], loop[14066070]
  34. goroutine[4] exit BenchmarkParalell: round[5], b.N[25717561]
  35. BenchmarkParalell-2 25717561 43.6 ns/op
  36. PASS
  37. ok command-line-arguments 1.176s

3. 使用性能基准比较工具

现在我们已经可以通过 go 原生提供的性能基准测试为被测对象建立性能基准了。但被测代码更新前后的性能基准比较依然要靠人工计算和肉眼比对,十分不方便。为此,Go 核心团队先后开发了两款性能基准比较工具:benchcmpbenchstat

benchcmp 上手快,简单易用,输出的比较结果无需参考文档帮助即可自行解读。下面我们看一个使用 benchcmp 进行性能基准比较的例子。

  1. // benchmark-compare/strcat_test.go
  2. package main
  3. import (
  4. "strings"
  5. "testing"
  6. )
  7. var sl = []string{
  8. "Rob Pike ",
  9. "Robert Griesemer ",
  10. "Ken Thompson ",
  11. }
  12. func Strcat(sl []string) string {
  13. return concatStringByOperator(sl)
  14. }
  15. func concatStringByOperator(sl []string) string {
  16. var s string
  17. for _, v := range sl {
  18. s += v
  19. }
  20. return s
  21. }
  22. func concatStringByJoin(sl []string) string {
  23. return strings.Join(sl, "")
  24. }
  25. func BenchmarkStrcat(b *testing.B) {
  26. for n := 0; n < b.N; n++ {
  27. Strcat(sl)
  28. }
  29. }

上面例子中的被测目标为Strcat,最初Strcat使用通过 Go 原生的操作符(“+”)连接的方式实现了字符串的连接,我们采集一下它的性能基准数据:

  1. $go test -run=NONE -bench . strcat_test.go > old.txt

然后,我们升级Strcat的实现,采用strings.Join函数来实现多个字符串的连接:

  1. func Strcat(sl []string) string {
  2. return concatStringByJoin(sl)
  3. }

我们再采集优化后的性能基准数据:

  1. $go test -run=NONE -bench . strcat_test.go > new.txt

接下来就轮到benchcmp登场了:

  1. $benchcmp old.txt new.txt
  2. benchmark old ns/op new ns/op delta
  3. BenchmarkStrcat-8 92.4 49.6 -46.32%

如果我们使用-count对BenchmarkStrcat执行多次,那么benchcmp给出的结果如下:

  1. $go test -run=NONE -count 5 -bench . strcat_test.go > old.txt
  2. $go test -run=NONE -count 5 -bench . strcat_test.go > new.txt
  3. $benchcmp old.txt new.txt
  4. benchmark old ns/op new ns/op delta
  5. BenchmarkStrcat-8 92.8 51.4 -44.61%
  6. BenchmarkStrcat-8 91.9 55.3 -39.83%
  7. BenchmarkStrcat-8 96.1 52.6 -45.27%
  8. BenchmarkStrcat-8 89.4 50.2 -43.85%
  9. BenchmarkStrcat-8 91.2 51.5 -43.53%

如果我们给benchcmp传入-best命令行选项,benchcmp将分别从 old.txt 和 new.txt 中挑选性能最好的一条数据,然后进行比对:

  1. $benchcmp -best old.txt new.txt
  2. benchmark old ns/op new ns/op delta
  3. BenchmarkStrcat-8 89.4 50.2 -43.85%

benchcmp还可以按性能基准数据前后变化的大小对输出结果进行排序(通过-mag命令行选项):

  1. $benchcmp -mag old.txt new.txt
  2. benchmark old ns/op new ns/op delta
  3. BenchmarkStrcat-8 96.1 52.6 -45.27%
  4. BenchmarkStrcat-8 92.8 51.4 -44.61%
  5. BenchmarkStrcat-8 89.4 50.2 -43.85%
  6. BenchmarkStrcat-8 91.2 51.5 -43.53%
  7. BenchmarkStrcat-8 91.9 55.3 -39.83%

为了提高对性能基准数据比对的科学性,Go 核心团队又开发了benchstat这款工具以替代benchcmp。下面我们用benchstat比较一下上面例子中的性能基准数据:

  1. $benchstat old.txt new.txt
  2. name old time/op new time/op delta
  3. Strcat-8 92.3ns ± 4% 52.2ns ± 6% -43.43% (p=0.008 n=5+5)

我们看到即便我们的 old.txt 和 new.txt 中各自有 5 次运行的数据,但benchstat不会像benchcmp那样输出 5 行比较结果,而是输出一行经过统计学方法处理后的比较结果。以第二列数据92.3ns ± 4%为例,这是benchcmp对old.txt中的数据进行处理后的结果:± 4%是样本数据中最大值和最小值距样本平均值的最大偏差百分比。如果这个偏差百分比数值大于 5%,则说明样本数据质量不佳,有些样本数据是不可信的,由此可以看出我们这里 new.txt 中的样本数据就是质量不佳的。

benchstat 输出结果的最后一列(delta)为两次基准测试对比的变化量,我们看到采用strings.Join方法连接字符串的平均耗时要比采用原生操作符连接字符串的性能减少 43%,这个指标后面括号中的p=0.008是一个用于检验两个样本集合的均值是否有显著差异的指标。benchstat 支持两种检验算法,一种是 UTest(Mann Whitney UTest,曼-惠特尼 U 检验),UTest 也是默认检验算法;另外一种是 Welch T 检验(TTest)。一般 p 值小于 0.05 的结果是可接受的。

上述两款工具也都支持对内存分配数据情况的前后比较,这里以 benchstat 为例:

  1. $go test -run=NONE -count 5 -bench . strcat_test.go -benchmem > old_with_mem.txt
  2. $go test -run=NONE -count 5 -bench . strcat_test.go -benchmem > new_with_mem.txt
  3. $benchstat old_with_mem.txt new_with_mem.txt
  4. name old time/op new time/op delta
  5. Strcat-8 90.5ns ± 1% 50.6ns ± 2% -44.14% (p=0.008 n=5+5)
  6. name old alloc/op new alloc/op delta
  7. Strcat-8 80.0B ± 0% 48.0B ± 0% -40.00% (p=0.008 n=5+5)
  8. name old allocs/op new allocs/op delta
  9. Strcat-8 2.00 ± 0% 1.00 ± 0% -50.00% (p=0.008 n=5+5)

Go 核心团队已经将benchcmp工具打上了“不建议使用(deprecation)”的标签,因此这里也建议大家以后使用benchstat来进行性能基准数据的比较。

4. 排除额外干扰,让基准测试更精确

从前面对顺序执行和并行执行的基准测试原理的介绍,我们知道每个基准测试都可能会运行多轮,每个BenchmarkXxx函数可能都会被重入执行多次。有些复杂的基准测试,在真正执行For循环之前或者在每个循环中除了执行真正的被测代码之外,可能还需要做一些测试准备工作,比如建立基准测试所需的测试上下文环境等。如果不做特殊处理,这些测试准备工作所消耗的时间也会被算入最终结果中,这就会导致最终基准测试的数据受到干扰而不足够精确。为此,testing.B中提供了多种灵活操控基准测试计时器的方法,通过这些方法可以排除掉额外干扰,让基准测试结果更能反映被测代码的真实性能情况。我们来看一个例子:

  1. // benchmark_with_expensive_context_setup_test.go
  2. package benchmark
  3. import (
  4. "strings"
  5. "testing"
  6. "time"
  7. )
  8. var sl = []string{
  9. "Rob Pike ",
  10. "Robert Griesemer ",
  11. "Ken Thompson ",
  12. }
  13. func concatStringByJoin(sl []string) string {
  14. return strings.Join(sl, "")
  15. }
  16. func expensiveTestContextSetup() {
  17. time.Sleep(200 * time.Millisecond)
  18. }
  19. func BenchmarkStrcatWithTestContextSetup(b *testing.B) {
  20. expensiveTestContextSetup()
  21. for n := 0; n < b.N; n++ {
  22. concatStringByJoin(sl)
  23. }
  24. }
  25. func BenchmarkStrcatWithTestContextSetupAndResetTimer(b *testing.B) {
  26. expensiveTestContextSetup()
  27. b.ResetTimer()
  28. for n := 0; n < b.N; n++ {
  29. concatStringByJoin(sl)
  30. }
  31. }
  32. func BenchmarkStrcatWithTestContextSetupAndRestartTimer(b *testing.B) {
  33. b.StopTimer()
  34. expensiveTestContextSetup()
  35. b.StartTimer()
  36. for n := 0; n < b.N; n++ {
  37. concatStringByJoin(sl)
  38. }
  39. }
  40. func BenchmarkStrcat(b *testing.B) {
  41. for n := 0; n < b.N; n++ {
  42. concatStringByJoin(sl)
  43. }
  44. }

在这个例子中,我们来对比一下无需建立测试上下文、建立测试上下文以及在对计时器控制下建立测试上下文等几种情况的基准测试数据:

  1. $go test -bench . benchmark_with_expensive_context_setup_test.go
  2. goos: darwin
  3. goarch: amd64
  4. BenchmarkStrcatWithTestContextSetup-8 16943037 65.9 ns/op
  5. BenchmarkStrcatWithTestContextSetupAndResetTimer-8 21700249 52.7 ns/op
  6. BenchmarkStrcatWithTestContextSetupAndRestartTimer-8 21628669 50.5 ns/op
  7. BenchmarkStrcat-8 22915291 50.7 ns/op
  8. PASS
  9. ok command-line-arguments 9.838s

虽然上面例子中,ResetTimer和StopTimer/StartTimer组合都能实现相同的对测试上下文带来的消耗进行隔离的目的,但二者还是有差别的。ResetTimer并不停掉计时器(无论计时器是否在工作),而是将已消耗的时间、内存分配计数器等全部清零,这样即便计数器依然在工作,它仍然需要从 0 开始重新记;而StopTimer只是简单的停掉一次基准测试运行的计时器,当调用StartTimer后,计时器恢复正常工作。

但这样一来,将ResetTimer或StopTimer用在每个基准测试的 For 循环中都是有副作用的。前面提到默认情况下,每个性能基准测试函数的执行时间为 1 秒。如果执行一轮所消耗的时间不足 1 秒,那么会修改b.N值并启动新的一轮执行。这样一旦在 For 循环中使用StopTimer,那么想要真正运行 1 秒就要等待很长时间;而如果在 For 循环中使用了ResetTimer,由于其每次执行都会将计数器数据清零,因此这轮基准测试将一直执行下去,无法退出。综上,尽量不要在基准测试的 For 循环中使用ResetTimer!但可以在限定条件的前提下在 For 循环中使用StopTimer/StartTimer,就像下面 Go 标准库中这样:

  1. // $GOROOT/src/runtime/map_test.go
  2. func benchmarkMapDeleteInt32(b *testing.B, n int) {
  3. a := make(map[int32]int, n)
  4. b.ResetTimer()
  5. for i := 0; i < b.N; i++ {
  6. if len(a) == 0 {
  7. b.StopTimer()
  8. for j := i; j < i+n; j++ {
  9. a[int32(j)] = j
  10. }
  11. b.StartTimer()
  12. }
  13. delete(a, int32(i))
  14. }
  15. }