本文偏手册性质,需要写 benchmark 时,希望能通过本文快速上手基准测试。
Benchmark是Go中一个特殊的函数,和单元测试类似,主要目的就是测试代码的性能,经常使用的主要有2种:
b.N
测试某个函数的耗时和内存分配情况。b.RunParallel()
利用多核CPU测试某个函数的并发情况。我们通过2个例子,来分别介绍一下这2种主流用法。
以 leetcode 509 斐波那契数 为例,我们实现了一种最简单的递归版本的解法,现在假设我们要编写 Benchmark 来测试这种解法的性能,让我们对算法的好坏有一个直观的了解。
1.创建一个go mod项目
$ mkdir example && cd example
$ go mod init example
2.新建一个 fib.go 文件,实现递归解法:
$ vim fib.go
package main
func Fib(n int) int {
if n == 0 || n == 1 {
return n
}
return Fib(n-2) + Fib(n-1)
}
3.然后和单元测试一样,需要新建一个同名以test结尾的文件 fib_test.go
:
$ vim fib_test.go
4.写下如下基准测试代码:
package main
import "testing"
func BenchmarkFib(b *testing.B) {
for n := 0; n < b.N; n++ {
Fib(30) // 重复运行Fib(30)函数b.N次
}
}
Benchmark
开头,参数是 b *testing.B
。和普通的单元测试用例很像,单元测试函数名以 Test 开头,参数是 t *testing.T。b.N
:循环次数,如果函数运行足够快,下一次 go test
调用BenchmarkFib时,b.N的值最多以100倍增长(1、100、10000次……),具体参见go源码中(testing/benchmark.go:lanuch)函数中的算法。Fib(30)
:计算第30个斐波那契数列通过如下命令运行基准测试:
$ go test -bench="."
此时,输出如下结果:
goos: darwin
goarch: arm64
pkg: goexample/13_benchmark
BenchmarkFib-10 316 3636114 ns/op
PASS
ok goexample/13_benchmark 1.969s
BenchmarkFib-10
:“-10” 表明启动了10个cpu执行测试,但是因为 BenchmarkFib
函数是单协程运行,故和一个cpu执行的效果一样。316
:代表BenchmarkFib
在1秒内执行了316次,通过 -benchtime
可以改变测试时长。3636114 ns/op
:函数执行的平均耗时,纳秒单位,除以1000*1000后约3.63 ms,因为有一些启动初始化等工作,所以:耗时 * 次数 > 1秒 是正常现象。benchmark用例的参数 b *testing.B
中,有一个属性 b.N
,代表用例中测试代码循环的次数。
运行测试时,b.N
会先从1开始,如果该用例能在1s内完成,说明函数足够快,则 go test 会根据一定的规则增加 b.N并再次运行该用例,最多以100倍的速度增加,最多以100倍的速度增加(go1.19)。
我们加个log试验下:
func BenchmarkFib(b *testing.B) {
b.Log("BenchmarkFib, b.N=", b.N)
for n := 0; n < b.N; n++ {
Fib(30) // 重复运行Fib(30)函数b.N次
}
}
输出:
$ go test -run=none -bench="BenchmarkFib$"
goos: darwin
goarch: arm64
pkg: goexample/13_benchmark
BenchmarkFib-10 321 3687196 ns/op
--- BENCH: BenchmarkFib-10
fib_test.go:9: BenchmarkFib, b.N= 1
fib_test.go:9: BenchmarkFib, b.N= 100
fib_test.go:9: BenchmarkFib, b.N= 321
PASS
ok goexample/13_benchmark 1.958s
说明用例 BenchmarkFib
1秒内被调用了3次,第一次b.N=1,第二次b.N=100,第三次b.N=321,此时时间耗尽,整个测试执行完成。
通过上面的例子,我们发现 Fib(30)
函数运行一次需要 3.6ms
,感觉有点慢。此时,我们可以继续增加位数,比如计算第 40 位的数列,来观察函数的时间复杂度:
func benchFib(b *testing.B, num int) {
for n := 0; n < b.N; n++ {
Fib(num)
}
}
func BenchmarkFib_10(b *testing.B) {
benchFib(b, 10)
}
func BenchmarkFib_30(b *testing.B) {
benchFib(b, 30)
}
func BenchmarkFib_40(b *testing.B) {
benchFib(b, 40)
}
运行后输出:
$ go test -run=none -bench="BenchmarkFib_"
goos: darwin
goarch: arm64
pkg: goexample/13_benchmark
BenchmarkFib_10-10 4688688 244.3 ns/op
BenchmarkFib_30-10 321 3730468 ns/op
BenchmarkFib_40-10 3 455273528 ns/op
发现了吗?计算 Fib(40)
居然需要 455ms
,如果计算第 60 位数字,不知道要多久,运行出来的同学评论区留言一下。
也就是说,递归解法的性能下降趋势超越了指数级(4688688 -> 321 -> 3)。时间复杂度网上的一种说法是O(2^n),另一种说法我打不出来,感兴趣的可以查一下。
我们换另一个迭代版本 O(n)
复杂度的解法试试:
// FibIterator 迭代版本解法,O(n)时间复杂度,算法性能大幅度提升
func FibIterator(n int) int {
if n <= 1 {
return n
}
// 空间换时间,把前一个结果缓存起来,避免重复计算
var n2, n1 = 0, 1
for i := 2; i < n; i++ {
n2, n1 = n1, n1+n2
}
return n2 + n1
}
再次运行:
BenchmarkFib_10-10 303285657 3.821 ns/op
BenchmarkFib_30-10 100000000 10.22 ns/op
BenchmarkFib_40-10 89248639 13.44 ns/op
我们看到,随着计算数字的增大,耗时线性增长,且第40位数字计算只花费了 13.44 ns
,性能提升了:455273528 / 13.44,大约 3300万倍
!
go test
命令用来运行某个 package 内除 Benchmark 测试代码之外的 所有测试用例
:
$ go test <module name>/<package name> # module name: go mod项目名,可以省略
$ go test . # 运行当前 package 内的所有用例,可以省略 "."
所以,这也是上文中为什么需要我们额外指定 -bench
命令的原因(不然不会运行基准测试):
$ go test -bench="."
等价于下面的命令:
$ go test -run="." -bench="."
-run regexp
: 运行所有正则匹配的 tests, examples, fuzz tests 等类型的测试(函数名)。在正则中 “.” 表示所有字符串,故会运行所有单元测试,来源:官网-bench regexp
:除运行-run匹配的测试之外,额外根据正则匹配结果,运行所有匹配的 benchmarks 测试,同样这里也是匹配所有 benchmark 函数名,即运行所有基准测试。更多正则语法,请参考:w3cschool比如,我们可以使用 “Fib$” 只运行以Fib关键字结尾的 Benchmark(注意单测也会运行,如果有的话):
$ go test -bench="Fib$"
输出:
goos: darwin
goarch: arm64
pkg: goexample/13_benchmark
BenchmarkFib-10 315 3669052 ns/op
PASS
ok goexample/13_benchmark 1.968s
需要注意的是,如果当前package下还有其他 tests 单元测试,会一并运行,可以通过 -v
命令查看是否执行以及执行了那些单测。
$ go test -run="none" -bench="BenchmarkFib"
我们只需要给 -run
中指定一个不匹配任何单测的正则,即可跳过单测,只运行 benchmark测试。
为了方便大家,这里附上几个常用正则的含义:
另外,搭配在线工具能更高效的写出正确的正则( https://tool.lu/regex/ ):
完整命令参加官方文档:Testing Flags
常用的高级命令如下:
-cpu 1,2,4
Specify a list of GOMAXPROCS values for which the tests, benchmarks or
fuzz tests should be executed. The default is the current value
of GOMAXPROCS. -cpu does not apply to fuzz tests matched by -fuzz.
-benchtime t
Run enough iterations of each benchmark to take t, specified
as a time.Duration (for example, -benchtime 1h30s).
The default is 1 second (1s).
The special syntax Nx means to run the benchmark N times
(for example, -benchtime 100x).
-benchmem
Print memory allocation statistics for benchmarks.
-count n
Run each test, benchmark, and fuzz seed n times (default 1).
If -cpu is set, run n times for each GOMAXPROCS value.
Examples are always run once. -count does not apply to
fuzz tests matched by -fuzz.
-cpuprofile cpu.out
Write a CPU profile to the specified file before exiting.
Writes test binary as -c would.
-memprofile mem.out
Write an allocation profile to the file after all tests have passed.
Writes test binary as -c would.
上文中,我们为了测试不同输入下,斐波拉契算法的耗时,写了如下代码:
func benchFib(b *testing.B, num int) {
for n := 0; n < b.N; n++ {
FibIterator(num)
}
}
func BenchmarkFib_10(b *testing.B) {
benchFib(b, 10)
}
func BenchmarkFib_30(b *testing.B) {
benchFib(b, 30)
}
func BenchmarkFib_40(b *testing.B) {
benchFib(b, 40)
}
实际上,我们可以利用 b.Run 运行子测试,把代码合并成:
func BenchmarkFib_Table(b *testing.B) {
var table = []int{10, 30, 40}
for i := 0; i < len(table); i++ {
num := table[i]
name := fmt.Sprintf("%s_%d", "BenchmarkFib", num)
b.Run(name, func(b *testing.B) {
benchFib(b, num)
})
}
}
运行后输出:
$ go test -run=none -bench="BenchmarkFib_Table$"
goos: darwin
goarch: arm64
pkg: goexample/13_benchmark
BenchmarkFib_Table/BenchmarkFib_10-10 301852904 3.781 ns/op
BenchmarkFib_Table/BenchmarkFib_30-10 100000000 10.12 ns/op
BenchmarkFib_Table/BenchmarkFib_40-10 89885114 13.29 ns/op
PASS
ok goexample/13_benchmark 4.271s
达到了同样的效果,且代码更简洁!类似单测中的表格驱动测试法。
通常情况下,b.N
用来测试函数的执行耗时,而 b.RunParallel
看名字就知道是为了测试不同 CPU 情况下,函数的并发次数。
以mysql场景举例,假设我要测试创建群组函数的QPS:
func (p pushGroup) Create(ctx context.Context, group *model.BizPushGroup) error {
val, err := p.client.BizPushGroup.Query().Where(bizpushgroup.BizUUID(group.BizUUID)).Only(ctx)
if ent.IsNotFound(err) {
val, err = p.client.BizPushGroup.Create().SetBizUUID(group.BizUUID).Save(ctx)
if err != nil {
return err
}
} else if val != nil && val.Status { // 已删除,恢复
err = p.client.BizPushGroup.Update().SetStatus(false).Where(bizpushgroup.ID(val.ID)).Exec(ctx)
if err != nil {
return err
}
}
group.ID = val.ID
return nil
}
我们的 benchmark 代码如下:
func BenchmarkPushGroup_Create(b *testing.B) {
entClient := newEntClient(b)
redisCli := unittest.NewRedis(b)
group := NewPushGroupDao(entClient, redisCli, NewPushGroupMemberDao(entClient, redisCli))
// 忽略连接mysql等初始化耗时
b.ResetTimer()
rand.Seed(time.Now().Unix())
key := fmt.Sprintf("benchmark-group-%d", rand.Int())
num := atomic.Int32{}
// 启动 -cpu 1,2,4 指令中指定个数的routine,并且同时执行
b.RunParallel(func(pb *testing.PB) {
// 注意,这里不再是判断 b.N,而是通过 pb.Next() 确定是否需要继续运行测试
for pb.Next() {
num.Inc()
group.Create(context.Background(), &model.BizPushGroup{
BizUUID: fmt.Sprintf("%s-%d", key, num.Load()),
})
}
})
}
b.RunParallel()
中会在一个go routine中执行,直到 pb.Next() 变成false为止,这个由go tool工具控制。b.ResetTimer()
显示忽略初始化的耗时,除此之外,还可以使用 StopTimer 和 StartTimer 精确忽略某一段代码的耗时。运行时,我们可以通过 -cpu 指令控制cpu个数(不指定则默认机器的cpu个数):
$ go test -run=none -bench="BenchmarkPushGroup_Create" -cpu 1,2,4
goos: darwin
goarch: arm64
pkg: git.shuodev.com/server/msg-dispatcher/internal/dao
BenchmarkPushGroup_Create 272 6760698 ns/op
BenchmarkPushGroup_Create-2 370 4379713 ns/op
BenchmarkPushGroup_Create-4 367 2874840 ns/op
PASS
ok git.shuodev.com/server/msg-dispatcher/internal/dao 8.253s
我们发现,增加cpu超过2个时,并发能力并没有上去,可以得出一个初步结论:Create
函数依赖mysql,它的qps能力在300-400左右。
本文介绍了2种主要的benchmark测试:
b.N
测试函数耗时。并且以斐波拉契算法为例,演示了使用 b.Run
运行多个子测试了以验证不同输入情况下函数的耗时对比,从而把算法复杂度从 O(2^n)
降低到了 O(n)
。并且给出了第40个数字对比,2种算法耗时相差 3000万倍
。b.RunParallel
测试服务QPS,以向mysql插入群组为例,演示了单机场景下的一种压测方式。同时,罗列了以下常用的命令以做备忘(官网完整命令):
-run regex
:运行单测匹配正则的所有单测-bench regex
:运行匹配正则的所有benchmark测试-benchtime
: 测试执行的时长,默认1s-count
:执行轮次-cpu 1,2,4
:指定运行测试的cpu,以逗号隔开时,会执行多次。-benchmem
: 输出内存分配情况-cpuprofile
:输出pprof文件,可以使用go tool pprof 打开分析详细cpu耗时。-memprofile
:输出pprof文件,可以使用go tool pprof 打开分析详细内存分配情况。作者简介:一线Gopher,公众号《Go和分布式IM》运营者,开源项目: CoffeeChat 、interview-golang 发起人 & 核心开发者,终身程序员。
参考