基准测试分享 - 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)
   }
}
  • 可以看到结果明显变了,这种写法可以有效排除干扰因素,把关注点放在真正关心的地方。

思考

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

  • 推荐

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

参考

相关推荐
心月狐的流火号1 小时前
分布式锁技术详解与Go语言实现
分布式·微服务·go
一个热爱生活的普通人3 小时前
使用 Makefile 和 Docker 简化你的 Go 服务部署流程
后端·go
HyggeBest19 小时前
Golang 并发原语 Sync Pool
后端·go
来杯咖啡19 小时前
使用 Go 语言别在反向优化 MD5
后端·go
郭京京1 天前
redis基本操作
redis·go
郭京京1 天前
go操作redis
redis·后端·go
你的人类朋友2 天前
说说你对go的认识
后端·云原生·go
用户580559502102 天前
channel原理解析(流程图+源码解读)
go
HiWorld2 天前
Go源码学习(基于1.24.1)-slice扩容机制-实践才是真理
go
程序员爱钓鱼2 天前
Go语言实战案例-Redis连接与字符串操作
后端·google·go