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

思考

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

  • 推荐

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

参考

相关推荐
lekami_兰4 小时前
MySQL 长事务:藏在业务里的性能 “隐形杀手”
数据库·mysql·go·长事务
却尘7 小时前
一篇小白也能看懂的 Go 字符串拼接 & Builder & cap 全家桶
后端·go
ん贤8 小时前
一次批量删除引发的死锁,最终我选择不加锁
数据库·安全·go·死锁
mtngt1121 小时前
AI DDD重构实践
go
Grassto2 天前
12 go.sum 是如何保证依赖安全的?校验机制源码解析
安全·golang·go·哈希算法·go module
Grassto4 天前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
程序设计实验室5 天前
2025年的最后一天,分享我使用go语言开发的电子书转换工具网站
go
我的golang之路果然有问题5 天前
使用 Hugo + GitHub Pages + PaperMod 主题 + Obsidian 搭建开发博客
golang·go·github·博客·个人开发·个人博客·hugo
啊汉7 天前
古文观芷App搜索方案深度解析:打造极致性能的古文搜索引擎
go·软件随想
asaotomo7 天前
一款 AI 驱动的新一代安全运维代理 —— DeepSentry(深哨)
运维·人工智能·安全·ai·go