目录
一、基本含义
二、基准测试和功能测试
三、Benchmark过程
四、benchmark的工具及关键所在
五、benchmark的整个流程
六、以下是一个网上的一个benchmark的一个简单例子
运行用例
benchmark 工作过程
提升准确度
内存分配情况
测试不同的输入
七、结语
benchmark大致是“基准”的意思,在计算机领域,我们称其为“基准测试”。
“基准测试”通常是技术人员通过一些工具,手法等等来衡量、评估和比较系统、产品或服务性能的一个过程。更多的时候,“基准测试”往往就是系统的性能测试。
一般来说,基准测试都是在功能测试之后进行的。
功能测试在保证了软件系统设计出了各种实现方式,修复了各种bug等过程后,使其最终可以达到按照预先的规格说明和设计正常运行,就交由基准测试的工作人员在正常功能实现的基础上对系统的性能、稳定性和可拓展性进行测试,目的就是确保软件系统在不同的负载条件下依然可以正常运行,尤其是负载条件比较大的时候。
为什么基准测试都要在功能测试之后?
因为如果产品的功能测试并没有通过,产品就可能存在一些实现的缺陷,一些bug,那么这些缺陷和bug会直接影响到基准测试时系统的表现,比如大大降低系统的性能表现。
很多互联网公司,往往会做很多次系统上线的功能测试,然后再进行一次的性能测试。
所以产品的功能测试通过,说明产品的系统没有问题的前提下,基准测试才会开始。
一般来说,benchmark测试过程都是集中关注不同负载下系统的某些性能指标来去判断系统的情况的。
很多时候,benchmark会先重点关注几个十分重要的指标,可以称为核心指标,其他的很多指标都是让测试人员来确认他的判断的。
常见核心指标包括响应时间,吞吐量,并发用户数,CPU利用率,内存利用率等等。
所以,“基准测试”之所以被称为“基准”,是因为它提供了一个参考的标准,或者说基准,以这个基准来评价系统的性能。而这个基准往往指的就是我们的指标,尤其是核心指标。
举个简单的例子,比如现在技术部门要规划出双十二所需的服务器资源是多少,根据往期的数据推算,此接口线上高峰期会达到2W/s。测试人员可以通过测试工具来模拟海量的客户端对http接口发起请求,再来观察服务器CPU,内存,网卡,吞吐量,响应时间等等来确定最后的结果。
当然,这只是一个简单的例子来帮助我们理解,实际的测试过程远比其复杂得多。
市面上的benchmark工具有很多,比如SPEC、Geekbench、3DMark、Apache JMeter等等,这里举例的只是一小部分常用的工具,实际上还有很多其他的工具和框架供测试人员来使用,具体选择什么工具要看进行测试的系统、硬件、软件的特点以及测试的需求。
其实,在benchmark中,工具的选择往往不是最重要的。进行benchmark的关键是测试人员对系统性能的一个分析能力。一个合格的测试人员需要对要进行测试的系统有一个自己的判断,制定出合理的测试计划,并且可以通过指标来确认自己的判断,最后给出一个建议。这才是benchmark的关键所在。
如果要进行benchmark,测试人员首先要去了解业务需求,也就是测试要达到的目标是什么。比如响应时间要达到150ms以内,系统压力大约在2W次每秒,还有我们需要的服务器数量是多少等等。
其次,测试人员需要运用自己的抽象思维能力来思考如何计划这个测试。比如,确定指标,找出性能测试通过的标准线,对代码、配置、服务器硬件一定的情况下对系统进行测试,以及后续对每台服务器的吞吐量以及其他重要指标的计算。
然后,测试人员再选择一个合适的测试工具来实现他的测试过程。
接着,测试人员就要根据自己的测试计划和工具来配置测试环境,执行测试,同时在测试过程中进行监测和收集测试数据。
测试结束后对测试的数据进行计算、分析、绘图等等的后续的数据处理工作。
然后根据测试结果,给出合理的建议。
最后还需要重复测试的过程,来保证测试结论的正确全面。
Go 语言标准库内置了支持 benchmark 的 testing
库,使用 go mod init example
初始化一个模块,新增 fib.go
文件,实现函数 fib
,用于计算第 N 个菲波那切数。
1 2 3 4 5 6 7 8 9 |
// fib.go package main func fib(n int) int { if n == 0 || n == 1 { return n } return fib(n-2) + fib(n-1) } |
接下来,我们在 fib_test.go
中实现一个 benchmark 用例:
1 2 3 4 5 6 7 8 9 10 |
// fib_test.go package main import "testing" func BenchmarkFib(b *testing.B) { for n := 0; n < b.N; n++ { fib(30) // run fib(30) b.N times } } |
_test.go
文件中。Benchmark
开头,参数是 b *testing.B
。和普通的单元测试用例很像,单元测试函数名以 Test
开头,参数是 t *testing.T
。go test
用来运行某个 package 内的所有测试用例。
go test example
或 go test .
go test example/
或 go test ./
go test ./...
或 go test example/...
。go test
命令默认不运行 benchmark 用例的,如果我们想运行 benchmark 用例,则需要加上 -bench
参数。例如:
1 2 3 4 5 6 7 |
$ go test -bench . goos: darwin goarch: amd64 pkg: example BenchmarkFib-8 200 5865240 ns/op PASS ok example 1.782s |
-bench
参数支持传入一个正则表达式,匹配到的用例才会得到执行,例如,只运行以 Fib
结尾的 benchmark 用例:
1 2 3 4 5 6 7 |
$ go test -bench='Fib$' . goos: darwin goarch: amd64 pkg: example BenchmarkFib-8 202 5980669 ns/op PASS ok example 1.813s |
benchmark 用例的参数 b *testing.B
,有个属性 b.N
表示这个用例需要运行的次数。b.N
对于每个用例都是不一样的。
那这个值是如何决定的呢?b.N
从 1 开始,如果该用例能够在 1s 内完成,b.N
的值便会增加,再次执行。b.N
的值大概以 1, 2, 3, 5, 10, 20, 30, 50, 100 这样的序列递增,越到后面,增加得越快。我们仔细观察上述例子的输出:
1 |
BenchmarkFib-8 202 5980669 ns/op |
BenchmarkFib-8 中的 -8
即 GOMAXPROCS
,默认等于 CPU 核数。可以通过 -cpu
参数改变 GOMAXPROCS
,-cpu
支持传入一个列表作为参数,例如:
1 2 3 4 5 6 7 8 |
$ go test -bench='Fib$' -cpu=2,4 . goos: darwin goarch: amd64 pkg: example BenchmarkFib-2 206 5774888 ns/op BenchmarkFib-4 205 5799426 ns/op PASS ok example 3.563s |
在这个例子中,改变 CPU 的核数对结果几乎没有影响,因为这个 Fib 的调用是串行的。
202
和 5980669 ns/op
表示用例执行了 202 次,每次花费约 0.006s。总耗时比 1s 略多。
对于性能测试来说,提升测试准确度的一个重要手段就是增加测试的次数。我们可以使用 -benchtime
和 -count
两个参数达到这个目的。
benchmark 的默认时间是 1s,那么我们可以使用 -benchtime
指定为 5s。例如:
1 2 3 4 5 6 7 |
$ go test -bench='Fib$' -benchtime=5s . goos: darwin goarch: amd64 pkg: example BenchmarkFib-8 1033 5769818 ns/op PASS ok example 6.554s |
实际执行的时间是 6.5s,比 benchtime 的 5s 要长,测试用例编译、执行、销毁等是需要时间的。
将 -benchtime
设置为 5s,用例执行次数也变成了原来的 5倍,每次函数调用时间仍为 0.6s,几乎没有变化。
-benchtime
的值除了是时间外,还可以是具体的次数。例如,执行 30 次可以用 -benchtime=30x
:
1 2 3 4 5 6 7 |
$ go test -bench='Fib$' -benchtime=50x . goos: darwin goarch: amd64 pkg: example BenchmarkFib-8 50 6121066 ns/op PASS ok example 0.319s |
调用 50 次 fib(30)
,仅花费了 0.319s。
-count
参数可以用来设置 benchmark 的轮数。例如,进行 3 轮 benchmark。
1 2 3 4 5 6 7 8 9 |
$ go test -bench='Fib$' -benchtime=5s -count=3 . goos: darwin goarch: amd64 pkg: example BenchmarkFib-8 975 5946624 ns/op BenchmarkFib-8 1023 5820582 ns/op BenchmarkFib-8 961 6096816 ns/op PASS ok example 19.463s |
-benchmem
参数可以度量内存分配的次数。内存分配次数也性能也是息息相关的,例如不合理的切片容量,将导致内存重新分配,带来不必要的开销。
在下面的例子中,generateWithCap
和 generate
的作用是一致的,生成一组长度为 n 的随机序列。唯一的不同在于,generateWithCap
创建切片时,将切片的容量(capacity)设置为 n,这样切片就会一次性申请 n 个整数所需的内存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
// generate_test.go package main import ( "math/rand" "testing" "time" ) 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 generate(n int) []int { rand.Seed(time.Now().UnixNano()) nums := make([]int, 0) for i := 0; i < n; i++ { nums = append(nums, rand.Int()) } return nums } func BenchmarkGenerateWithCap(b *testing.B) { for n := 0; n < b.N; n++ { generateWithCap(1000000) } } func BenchmarkGenerate(b *testing.B) { for n := 0; n < b.N; n++ { generate(1000000) } } |
运行该用例的结果是:
1 2 3 4 5 6 7 8 |
go test -bench='Generate' . goos: darwin goarch: amd64 pkg: example BenchmarkGenerateWithCap-8 44 24294582 ns/op BenchmarkGenerate-8 34 30342763 ns/op PASS ok example 2.171s |
可以看到生成 100w 个数字的随机序列,GenerateWithCap
的耗时比 Generate
少 20%。
我们可以使用 -benchmem
参数看到内存分配的情况:
1 2 3 4 5 6 7 |
goos: darwin goarch: amd64 pkg: example BenchmarkGenerateWithCap-8 43 24335658 ns/op 8003641 B/op 1 allocs/op BenchmarkGenerate-8 33 30403687 ns/op 45188395 B/op 40 allocs/op PASS ok example 2.121s |
Generate
分配的内存是 GenerateWithCap
的 6 倍,设置了切片容量,内存只分配一次,而不设置切片容量,内存分配了 40 次。
不同的函数复杂度不同,O(1),O(n),O(n^2) 等,利用 benchmark 验证复杂度一个简单的方式,是构造不同的输入。对刚才的 benchmark 稍作改造,便能够达到目的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// generate_test.go package main import ( "math/rand" "testing" "time" ) func generate(n int) []int { rand.Seed(time.Now().UnixNano()) nums := make([]int, 0) for i := 0; i < n; i++ { nums = append(nums, rand.Int()) } return nums } func benchmarkGenerate(i int, b *testing.B) { for n := 0; n < b.N; n++ { generate(i) } } func BenchmarkGenerate1000(b *testing.B) { benchmarkGenerate(1000, b) } func BenchmarkGenerate10000(b *testing.B) { benchmarkGenerate(10000, b) } func BenchmarkGenerate100000(b *testing.B) { benchmarkGenerate(100000, b) } func BenchmarkGenerate1000000(b *testing.B) { benchmarkGenerate(1000000, b) } |
这里,我们实现一个辅助函数 benchmarkGenerate
允许传入参数 i,并构造了 4 个不同输入的 benchmark 用例。运行结果如下:
1 2 3 4 5 6 7 8 9 10 |
$ go test -bench . goos: darwin goarch: amd64 pkg: example BenchmarkGenerate1000-8 34048 34643 ns/op BenchmarkGenerate10000-8 4070 295642 ns/op BenchmarkGenerate100000-8 403 3230415 ns/op BenchmarkGenerate1000000-8 39 32083701 ns/op PASS ok example 6.597s |
通过测试结果可以发现,输入变为原来的 10 倍,函数每次调用的时长也差不多是原来的 10 倍,这说明复杂度是线性的。
这个例子也许和我们现实实际应用有点距离,但也揭示了benchmark的一些基本过程和思想。从中也可以看到,在实际操作中,benchmark的进行并不是那么简单轻松的,不仅需要深入设计测试过程,编写测试代码,还需要对各种指标数据进行对比分析,不断测试,需要我们有深厚的技术实力和分析能力。
总的来说,benchmark是分析系统性能的一个重要过程,它让我们更好地了解系统的性能状况,识别潜在的瓶颈,并采取相应的优化措施。无论是在硬件选择、软件优化、系统调优还是云服务选择等方面,都是必不可少的一个过程。
当然,我也希望自己在基准测试方面的知识和技能能不断提升,未来在benchmark领域有一定成果。