背景
中介绍如何进行单元测试,可以帮助我们看写的对不对,如果对性能有要求,还要看单元好不好,这时候就需要基准测试了。
通过基准测试我们得到:
-
函数的执行耗时是否符合预期
-
内存占用、内存分配次数是否符合预期
-
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)
}
}
- 可以看到结果明显变了,这种写法可以有效排除干扰因素,把关注点放在真正关心的地方。
思考
-
写代码时要有基准测试的思想,在实现某个功能时,可以多思考是否有更优的写法,当然,不能一味追求性能,同时需要平衡其他因素。
-
推荐
-
不能只是完成了,也要看完成的好不好。