基准测试分享 - golang

背景

中介绍如何进行单元测试,可以帮助我们看写的对不对,如果对性能有要求,还要看单元好不好,这时候就需要基准测试了。

通过基准测试我们得到:

  • 函数的执行耗时是否符合预期

  • 内存占用、内存分配次数是否符合预期

  • cpu 消耗是否否和预期

  • ...

基本用法

基本格式

  • benchmark 和普通的单元测试用例一样,都位于 _test.go 文件中。

  • 函数名以 Benchmark 开头,参数是 b *testing.B。和普通的单元测试用例很像,单元测试函数名以 Test 开头,参数是 t *testing.T

  • go test 命令默认不运行 benchmark 用例的,如果我们想运行 benchmark 用例,则需要加上 -bench 参数

命令行参数

先看一个复杂一些的命令,再逐步讲解每个参数的含义

Go 复制代码
go test -bench=BenchmarkIsInListReflect -benchmem -count=2 -cpu=2,8,12,20 -benchtime=5s -cpuprofile=cpu.out -memprofile=cpu.out
参数 含义
-bench regexp 性能测试,支持表达式对测试函数进行筛选。可以用 . 表示所有的函数
-benchmem 性能测试的时候显示测试函数的内存分配的统计信息
-count n 运行测试和性能多少次,默认一次
-cpu 执行测试的cpu 核数,默认为 GOMAXPROCS,支持传入一个列表作为参数,如:-cpu=2,4
-benchtime benchmark 的默认时间是 1s,可以通 -benchtime 来改变测试时间。
-cpuprofile 输出cpu性能文件
-memprofile 输出mem内存性能文件
-run regexp 只运行特定的测试函数, 比如-run ABC只测试函数名中包含ABC的测试函数
-v 显示测试的详细信息,也会把Log、Logf方法的日志显示出来

testing.B 方法&属性

先看一个简单的基准测试的写法

Go 复制代码
func IsInList(value int, list []int) bool {
   for i := 0; i < len(list); i++ {
      if list[i] == value {
         return true
      }
   }
   return false
}

func IsInListReflect(elem interface{}, list interface{}) bool {

   v := reflect.ValueOf(list)
   for i := 0; i < v.Len(); i++ {
      if v.Index(i).Interface() == elem {
         return true
      }
   }
   return false
}
// ------------------------------------------------------
func BenchmarkIsInListNormal(b *testing.B) {
   n := b.N
   fmt.Printf("BenchmarkIsInListNormal: ========== %d\n", n)
   for i := 0; i < n; i++ {
      IsInList(list_value, list)
   }
}

func BenchmarkIsInListReflect(b *testing.B) {
   n := b.N
   fmt.Printf("BenchmarkIsInListReflect: ========== %d\n", n)
   for i := 0; i < b.N; i++ {
      IsInListReflect(list_value, list)
   }
}
testing.B 的方法或者属性 含义
b.N b.N 表示这个用例需要运行的次数。b.N 对于每个用例都是不一样的。b.N 从 1 开始,如果该用例能够在 1s 内完成,b.N 的值便会增加,再次执行。
ResetTimer() 如果在 benchmark 开始前,需要一些准备工作,如果准备工作比较耗时,则需要将这部分代码的耗时忽略掉。在耗时的步骤结束后调用此方法。
StopTimer() 分别为停止计时和开始计时,如果在一个基准测试中有些步骤的顺序要除外,如数据准备等,可以通过这两个函数配合完成。
StartTimer() 分别为停止计时和开始计时,如果在一个基准测试中有些步骤的顺序要除外,如数据准备等,可以通过这两个函数配合完成。
ReportAllocs() 它相当于设置-benchmem,但它只影响调用ReportAllocs的基准函数。
RunParallel(body func(*PB)) RunParallel 并行运行基准测试。它创建多个 goroutine 并在它们之间分配 b.N 次迭代。Goroutine 的数量默认为 GOMAXPROCS。要增加非 CPU 限制基准测试的并行性,请在 RunParallel 之前调用 SetParallelism。RunParallel 通常与 go test -cpu 标志一起使用。主体函数将在每个 goroutine 中运行。它应该设置任何 goroutine-local 状态,然后迭代直到 pb.Next 返回 false。它不应该使用 StartTimer、StopTimer 或 ResetTimer 函数,因为它们具有全局作用。它也不应该调用 Run。

结果输出

运行下上面的测试看看结果输出

Go 复制代码
go test -bench=BenchmarkIsInListReflect -benchmem

对上面的结果逐行分析

testing.B 的方法或者属性 含义
BenchmarkIsInListReflect: ========== 1(100、10000、39852) 基准的函数的日志,此处打印了 b.N ,可以清楚看到 b.N 的递增过程
BenchmarkIsInListReflect-12 BenchmarkIsInListReflect 是测试的函数名 -12 表示GOMAXPROCS(线程数)的值为12
39852 表示一共执行了39852次,即b.N的值
29603 ns/op 表示平均每次操作花费了29603 纳秒
8024 B/op 表示每次申请了 8024 Byte 内存
1001 allocs/op 表示每次操作申请了 1001 次内存

原理

以单个Benchmark举例串起流程分析下原理

如上图,浅蓝色部分就是开发者自行编写的benchmark方法,调用逻辑按箭头方向依次递进。

B.run1()的作用是先尝试跑一次,在这次尝试中要做 竟态检查当前benchmark是否被skip了。目的检查当前benchmark是否有必要继续执行。

go test 命令有-cpu参数,用于控制benchmark分别在不同的P数量下执行。这里就对应上图绿色部分,每次通过runtime.GOMAXPROCS(n)更新P个数,然后调用B.doBench()。

核心方法是红色部分的B.runN(n)。形参n值就是b.N值,由外部传进。n不断被逼近上限,逼近策略不能过快,过快可能引起benchmark执行超时。

橙色部分就是逼近策略。先通过n/=int(nsop)来估算b.N的上限,然后再通过n=max(min(n+n/5, 100*last), last+1)计算最后的b.N。benchmark可能是CPU型或IO型,若直接使用第一次估算的b.N值会过于粗暴,可能使结果不准确,所以需要做进一步的约束来逼近。

场景演示

学习了基准测试基本用法和原理后,肯定已经跃跃欲试了,下面构造一些场景小试牛刀。

下面的代码收录在 code.byted.org/gaoweizong/...

对比同一个功能的不同实现

需求:把 int 转为 string 。

实现:

Go 复制代码
func Int2StringFmt(n int) string {
   return fmt.Sprintf("%d", n)
}

func Int2StringFormat(n int) string {
   return strconv.FormatInt(int64(n), 10)
}

func Int2StringItoa(n int) string {
   return strconv.Itoa(n)
}
// --------------------------------------------

func BenchmarkInt2StringFmt(b *testing.B) {
   num := 73847363
   for i := 0; i < b.N; i++ {
      Int2StringFmt(num)
   }
}

func BenchmarkInt2StringItoa(b *testing.B) {
   num := 73847363
   for i := 0; i < b.N; i++ {
      Int2StringItoa(num)
   }
}

func BenchmarkInt2StringFormat(b *testing.B) {
   num := 73847363
   for i := 0; i < b.N; i++ {
      Int2StringFormat(num)
   }
}

执行一下看看 :go test -bench=BenchmarkInt2String -benchmem -count 5

  • 通过 -count 指定执行 5次,避免单次执行差异导致结果不准确,每个函数的执行结果还是比较稳定

  • 通过 结果可以看出来 BenchmarkInt2StringFmt 比 其他两个慢了 3 倍左右。从内存分配及分配次数可以看出来,BenchmarkInt2StringFmt 是其他的两个倍。

  • BenchmarkInt2StringItoa 和 BenchmarkInt2StringFormat 性能几乎一样。查看源码可以发现,Itoa 的实现就是调用了 FormatInt ,符合预期。

Go 复制代码
func FormatInt(i int64, base int) string {
   if fastSmalls && 0 <= i && i < nSmalls && base == 10 {
      return small(int(i))
   }
   _, s := formatBits(nil, uint64(i), base, i < 0, false)
   return s
}

// Itoa is equivalent to FormatInt(int64(i), 10).
func Itoa(i int) string {
   return FormatInt(int64(i), 10)
}

去除数据准备时间

需求: 写一个冒泡排序,并构造输入数组进行测试

Go 复制代码
func bubbleSort(nums []int) {
   for i := 0; i < len(nums); i++ {
      for j := 1; j < len(nums)-i; j++ {
         if nums[j] < nums[j-1] {
            nums[j], nums[j-1] = nums[j-1], nums[j]
         }
      }
   }
}

func generateWithCap(n int) []int {
   rand.Seed(time.Now().UnixNano())
   nums := make([]int, 0, n)
   for i := 0; i < n; i++ {
      nums = append(nums, rand.Int())
   }
   return nums
}

// -------------------------------------------------
func BenchmarkBubbleSort(b *testing.B) {
   for n := 0; n < b.N; n++ {
      b.StopTimer()
      nums := generateWithCap(10000)
      b.StartTimer()
      bubbleSort(nums)
   }
}
  • 上面的测试去除了 generateWithCap 的影响,我们可以注释 StopTimer & StartTimer 在执行一次看看
Go 复制代码
func BenchmarkBubbleSort(b *testing.B) {
   for n := 0; n < b.N; n++ {
      // b.StopTimer()
      nums := generateWithCap(10000)
      // b.StartTimer()
      bubbleSort(nums)
   }
}
  • 可以看到结果明显变了,这种写法可以有效排除干扰因素,把关注点放在真正关心的地方。

思考

  • 写代码时要有基准测试的思想,在实现某个功能时,可以多思考是否有更优的写法,当然,不能一味追求性能,同时需要平衡其他因素。

  • 推荐

  • 不能只是完成了,也要看完成的好不好。

参考

相关推荐
幼儿园老大*3 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
童先生10 小时前
Go 项目中实现类似 Java Shiro 的权限控制中间件?
开发语言·go
幼儿园老大*11 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
架构师那点事儿16 小时前
golang 用unsafe 无所畏惧,但使用不得到会panic
架构·go·掘金技术征文
于顾而言1 天前
【笔记】Go Coding In Go Way
后端·go
qq_172805591 天前
GIN 反向代理功能
后端·golang·go
follycat2 天前
2024强网杯Proxy
网络·学习·网络安全·go
OT.Ter2 天前
【力扣打卡系列】单调栈
算法·leetcode·职场和发展·go·单调栈
探索云原生2 天前
GPU 环境搭建指南:如何在裸机、Docker、K8s 等环境中使用 GPU
ai·云原生·kubernetes·go·gpu
OT.Ter2 天前
【力扣打卡系列】移动零(双指针)
算法·leetcode·职场和发展·go