• 简介
  • 数据结构
  • 关键函数
    • 启动计时:B.StartTimer()
    • 停止计时:B.StopTimer()
    • 重置计时:B.ResetTimer()
    • 设置处理字节数:B.SetBytes(n int64)
    • 报告内存信息:
  • 性能测试是如何启动的
  • B.N是如何调整的?
  • 内存是如何统计的?

    简介

    跟据前面章节,我们可以快速的写出一个性能测试并执行,最令我感到神奇的是b.N的值,虽然官方资料中说b.N会自动调整以保证可靠的计时,可还是想了解具体的实现机制。

    本节,我们先分析testing.B数据结构,再看几个典型的成员函数,以期从源码中寻找以下问题的答案:

    • b.N是如何自动调整的?
    • 内存统计是如何实现的?
    • SetBytes()其使用场景是什么?

    数据结构

    源码包src/testing/benchmark.go:B定义了性能测试的数据结构,我们提取其比较重要的一些成员进行分析:

    1. type B struct {
    2. common // 与testing.T共享的testing.common,负责记录日志、状态等
    3. importPath string // import path of the package containing the benchmark
    4. context *benchContext
    5. N int // 目标代码执行次数,不需要用户了解具体值,会自动调整
    6. previousN int // number of iterations in the previous run
    7. previousDuration time.Duration // total duration of the previous run
    8. benchFunc func(b *B) // 性能测试函数
    9. benchTime time.Duration // 性能测试函数最少执行的时间,默认为1s,可以通过参数'-benchtime 10s'指定
    10. bytes int64 // 每次迭代处理的字节数
    11. missingBytes bool // one of the subbenchmarks does not have bytes set.
    12. timerOn bool // 是否已开始计时
    13. showAllocResult bool
    14. result BenchmarkResult // 测试结果
    15. parallelism int // RunParallel creates parallelism*GOMAXPROCS goroutines
    16. // The initial states of memStats.Mallocs and memStats.TotalAlloc.
    17. startAllocs uint64 // 计时开始时堆中分配的对象总数
    18. startBytes uint64 // 计时开始时时堆中分配的字节总数
    19. // The net total of this test after being run.
    20. netAllocs uint64 // 计时结束时,堆中增加的对象总数
    21. netBytes uint64 // 计时结束时,堆中增加的字节总数
    22. }

    其主要成员如下:

    • common: 与testing.T共享的testing.common,管理着日志、状态等;
    • N:每个测试中用户代码执行次数
    • benchFunc:测试函数
    • benchTime:性能测试最少执行时间,默认为1s,可以通过能数-benchtime 2s指定
    • bytes:每次迭代处理的字节数
    • timerOn:计时启动标志,默认为false,启动计时为true
    • startAllocs:测试启动时记录堆中分配的对象数
    • startBytes:测试启动时记录堆中分配的字节数
    • netAllocs:测试结束后记录堆中新增加的对象数,公式:结束时堆中分配的对象数-
    • netBytes:测试对事后记录堆中新增加的字节数

    关键函数

    启动计时:B.StartTimer()

    StartTimer()负责启动计时并初始化内存相关计数,测试执行时会自动调用,一般不需要用户启动。

    1. func (b *B) StartTimer() {
    2. if !b.timerOn {
    3. runtime.ReadMemStats(&memStats) // 读取当前堆内存分配信息
    4. b.startAllocs = memStats.Mallocs // 记录当前堆内存分配的对象数
    5. b.startBytes = memStats.TotalAlloc // 记录当前堆内存分配的字节数
    6. b.start = time.Now() // 记录测试启动时间
    7. b.timerOn = true // 标记计时标志
    8. }
    9. }

    StartTimer()负责启动计时,并记录当前内存分配情况,不管是否有“-benchmem”参数,内存都会被统计,参数只决定是否要在结果中输出。

    停止计时:B.StopTimer()

    StopTimer()负责停止计时,并累加相应的统计值。

    1. func (b *B) StopTimer() {
    2. if b.timerOn {
    3. b.duration += time.Since(b.start) // 累加测试耗时
    4. runtime.ReadMemStats(&memStats) // 读取当前堆内存分配信息
    5. b.netAllocs += memStats.Mallocs - b.startAllocs // 累加堆内存分配的对象数
    6. b.netBytes += memStats.TotalAlloc - b.startBytes // 累加堆内存分配的字节数
    7. b.timerOn = false // 标记计时标志
    8. }
    9. }

    需要注意的是,StopTimer()并不一定是测试结束,一个测试中有可能有多个统计阶段,所以其统计值是累加的。

    重置计时:B.ResetTimer()

    ResetTimer()用于重置计时器,相应的也会把其他统计值也重置。

    1. func (b *B) ResetTimer() {
    2. if b.timerOn {
    3. runtime.ReadMemStats(&memStats) // 读取当前堆内存分配信息
    4. b.startAllocs = memStats.Mallocs // 记录当前堆内存分配的对象数
    5. b.startBytes = memStats.TotalAlloc // 记录当前堆内存分配的字节数
    6. b.start = time.Now() // 记录测试启动时间
    7. }
    8. b.duration = 0 // 清空耗时
    9. b.netAllocs = 0 // 清空内存分配对象数
    10. b.netBytes = 0 // 清空内存分配字节数
    11. }

    ResetTimer()比较常用,典型使用场景是一个测试中,初始化部分耗时较长,初始化后再开始计时。

    设置处理字节数:B.SetBytes(n int64)

    1. // SetBytes records the number of bytes processed in a single operation.
    2. // If this is called, the benchmark will report ns/op and MB/s.
    3. func (b *B) SetBytes(n int64) {
    4. b.bytes = n
    5. }

    这是一个比较含糊的函数,通过其函数说明很难明白其作用。

    其实它是用来设置单次迭代处理的字节数,一旦设置了这个字节数,那么输出报告中将会呈现“xxx MB/s”的信息,用来表示待测函数处理字节的性能。待测函数每次处理多少字节数只有用户清楚,所以需要用户设置。

    举个例子,待测函数每次执行处理1M数据,如果我们想看待测函数处理数据的性能,那么我们在测试中设置SetByte(1024 *1024),假如待测函数需要执行1s的话,那么结果中将会出现 “1 MB/s”(约等于)的信息。示例代码如下所示:

    1. func BenchmarkSetBytes(b *testing.B) {
    2. b.SetBytes(1024 * 1024)
    3. for i := 0; i < b.N; i++ {
    4. time.Sleep(1 * time.Second) // 模拟待测函数
    5. }
    6. }

    打印结果:

    1. E:\OpenSource\GitHub\RainbowMango\GoExpertProgrammingSourceCode\GoExpert\src\gotest>go test -bench SetBytes benchmark_test.go
    2. BenchmarkSetBytes-4 1 1010392800 ns/op 1.04 MB/s
    3. PASS
    4. ok command-line-arguments 1.412s

    可以看到测试执行了一次,花费时间约1S,数据处理能力约为1MB/s。

    报告内存信息:

    1. func (b *B) ReportAllocs() {
    2. b.showAllocResult = true
    3. }

    ReportAllocs() 用于设置是否打印内存统计信息,与命令行参数“-benchmem”一致,但本方法只作用于单个测试函数。

    性能测试是如何启动的

    性能测试要经过多次迭代,每次迭代可能会有不同的b.N值,每次迭代执行测试函数一次,跟据此次迭代的测试结果来分析要不要继续下一次迭代。

    我们先看一下每次迭代时所用到的方法,runN():

    1. func (b *B) runN(n int) {
    2. b.N = n // 指定B.N
    3. b.ResetTimer() // 清空统计数据
    4. b.StartTimer() // 开始计时
    5. b.benchFunc(b) // 执行测试
    6. b.StopTimer() // 停止计时
    7. }

    该方法指定b.N的值,执行一次测试函数。

    与T.Run()类似,B.Run()也用于启动一个子测试,实际上用户编写的任何一个测试都是使用Run()方法启动的,我们看下B.Run()的伪代码:

    1. func (b *B) Run(name string, f func(b *B)) bool {
    2. sub := &B{ // 新建子测试数据结构
    3. common: common{
    4. signal: make(chan bool),
    5. name: name,
    6. parent: &b.common,
    7. },
    8. benchFunc: f,
    9. }
    10. if sub.run1() { // 先执行一次子测试,如果子测试不出错且子测试没有子测试的话继续执行sub.run()
    11. sub.run() // run()里决定要执行多少次runN()
    12. }
    13. b.add(sub.result) // 累加统计结果到父测试中
    14. return !sub.failed
    15. }

    所有的测试都是先使用run1()方法执行一次测试,run1()方法中实际上调用了runN(1),执行一次后再决定要不要继续迭代。

    测试结果实际上以最后一次迭代的数据为准,当然,最后一次迭代往往意味着b.N更大,测试准确性相对更高。

    B.N是如何调整的?

    B.launch()方法里最终决定B.N的值。我们看下伪代码:

    1. func (b *B) launch() { // 此方法自动测算执行次数,但调用前必须调用run1以便自动计算次数
    2. d := b.benchTime
    3. for n := 1; !b.failed && b.duration < d && n < 1e9; { // 最少执行b.benchTime(默认为1s)时间,最多执行1e9次
    4. last := n
    5. n = int(d.Nanoseconds()) // 预测接下来要执行多少次,b.benchTime/每个操作耗时
    6. if nsop := b.nsPerOp(); nsop != 0 {
    7. n /= int(nsop)
    8. }
    9. n = max(min(n+n/5, 100*last), last+1) // 避免增长较快,先增长20%,至少增长1次
    10. n = roundUp(n) // 下次迭代次数向上取整到10的指数,方便阅读
    11. b.runN(n)
    12. }
    13. }

    不考虑程序出错,而且用户没有主动停止测试的场景下,每个性能测试至少要执行b.benchTime长的秒数,默认为1s。先执行一遍的意义在于看用户代码执行一次要花费多长时间,如果时间较短,那么b.N值要足够大才可以测得更精确,如果时间较长,b.N值相应的会减少,否则会影响测试效率。

    最终的b.N会被定格在某个10的指数级,是为了方便阅读测试报告。

    内存是如何统计的?

    我们知道在测试开始时,会把当前内存值记入到b.startAllocs和b.startBytes中,测试结束时,会用最终内存值与开始时的内存值相减,得到净增加的内存值,并记入到b.netAllocs和b.netBytes中。

    每个测试结束,会把结果保存到BenchmarkResult对象里,该对象里保存了输出报告所必需的统计信息:

    1. type BenchmarkResult struct {
    2. N int // 用户代码执行的次数
    3. T time.Duration // 测试耗时
    4. Bytes int64 // 用户代码每次处理的字节数,SetBytes()设置的值
    5. MemAllocs uint64 // 内存对象净增加值
    6. MemBytes uint64 // 内存字节净增加值
    7. }

    其中MemAllocs和MemBytes分别对应b.netAllocs和b.netBytes。

    那么最终统计时只需要把净增加值除以b.N即可得到每次新增多少内存了。

    每个操作内存对象新增值:

    1. func (r BenchmarkResult) AllocsPerOp() int64 {
    2. return int64(r.MemAllocs) / int64(r.N)
    3. }

    每个操作内存字节数新增值:

    1. func (r BenchmarkResult) AllocedBytesPerOp() int64 {
    2. if r.N <= 0 {
    3. return 0
    4. }
    5. return int64(r.MemBytes) / int64(r.N)
    6. }