Go语言精进之路:从新手到高手的编程思想、方法和技巧(2)
上QQ阅读APP看书,第一时间看更新

46.4 排除额外干扰,让基准测试更精确

从前面对顺序执行和并行执行的性能基准测试原理的介绍可知,每个基准测试都可能会运行多轮,每个BenchmarkXxx函数都可能会被执行多次。有些复杂的基准测试在真正执行For循环之前或者在每个循环中,除了执行真正的被测代码之外,可能还需要做一些测试准备工作,比如建立基准测试所需的测试上下文等。如果不做特殊处理,这些测试准备工作所消耗的时间也会被算入最终结果中,这就会导致最终基准测试的数据受到干扰而不够精确。为此,testing.B中提供了多种灵活操控基准测试计时器的方法,通过这些方法可以排除掉额外干扰,让基准测试结果更能反映被测代码的真实性能。来看一个例子:

// chapter8/sources/benchmark_with_expensive_context_setup_test.go

var sl = []string{
    "Rob Pike ",
    "Robert Griesemer ",
    "Ken Thompson ",
}

func concatStringByJoin(sl []string) string {
    return strings.Join(sl, "")
}

func expensiveTestContextSetup() {
    time.Sleep(200 * time.Millisecond)
}

func BenchmarkStrcatWithTestContextSetup(b *testing.B) {
    expensiveTestContextSetup()
    for n := 0; n < b.N; n++ {
        concatStringByJoin(sl)
    }
}

func BenchmarkStrcatWithTestContextSetupAndResetTimer(b *testing.B) {
    expensiveTestContextSetup()
    b.ResetTimer()
    for n := 0; n < b.N; n++ {
        concatStringByJoin(sl)
    }
}

func BenchmarkStrcatWithTestContextSetupAndRestartTimer(b *testing.B) {
    b.StopTimer()
    expensiveTestContextSetup()
    b.StartTimer()
    for n := 0; n < b.N; n++ {
        concatStringByJoin(sl)
    }
}

func BenchmarkStrcat(b *testing.B) {
    for n := 0; n < b.N; n++ {
        concatStringByJoin(sl)
    }
}

在这个例子中,我们来对比一下不建立测试上下文、建立测试上下文以及在对计时器控制下建立测试上下文等情况下的基准测试数据:

$go test -bench . benchmark_with_expensive_context_setup_test.go
goos: darwin
goarch: amd64
BenchmarkStrcatWithTestContextSetup-8                 16943037     65.9 ns/op
BenchmarkStrcatWithTestContextSetupAndResetTimer-8    21700249     52.7 ns/op
BenchmarkStrcatWithTestContextSetupAndRestartTimer-8  21628669     50.5 ns/op
BenchmarkStrcat-8                                     22915291     50.7 ns/op
PASS
ok       command-line-arguments 9.838s

我们看到,如果不通过testing.B提供的计数器控制接口对测试上下文带来的消耗进行隔离,最终基准测试得到的数据(BenchmarkStrcatWithTestContextSetup)将偏离准确数据(BenchmarkStrcat)很远。而通过testing.B提供的计数器控制接口对测试上下文带来的消耗进行隔离后,得到的基准测试数据(BenchmarkStrcatWithTestContextSetupAndResetTimer和Bench-markStrcatWithTestContextSetupAndRestartTimer)则非常接近真实数据。

虽然在上面的例子中,ResetTimer和StopTimer/StartTimer组合都能实现对测试上下文带来的消耗进行隔离的目的,但二者是有差别的:ResetTimer并不停掉计时器(无论计时器是否在工作),而是将已消耗的时间、内存分配计数器等全部清零,这样即便计数器依然在工作,它仍然需要从零开始重新记;而StopTimer只是停掉一次基准测试运行的计时器,在调用StartTimer后,计时器即恢复正常工作。

但这样一来,将ResetTimer或StopTimer用在每个基准测试的For循环中是有副作用的。在默认情况下,每个性能基准测试函数的执行时间为1秒。如果执行一轮所消耗的时间不足1秒,那么会修改b.N值并启动新的一轮执行。这样一旦在For循环中使用StopTimer,那么想要真正运行1秒就要等待很长时间;而如果在For循环中使用了ResetTimer,由于其每次执行都会将计数器数据清零,因此这轮基准测试将一直执行下去,无法退出。综上,尽量不要在基准测试的For循环中使用ResetTimer!但可以在限定条件下在For循环中使用StopTimer/StartTimer,就像下面的Go标准库中这样:

// $GOROOT/src/runtime/map_test.go
func benchmarkMapDeleteInt32(b *testing.B, n int) {
    a := make(map[int32]int, n)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        if len(a) == 0 {
            b.StopTimer()
            for j := i; j < i+n; j++ {
                a[int32(j)] = j
            }
            b.StartTimer()
        }
        delete(a, int32(i))
    }
}

上面的测试代码虽然在基准测试的For循环中使用了StopTimer,但其是在if len(a) == 0这个限定条件下使用的,StopTimer方法并不会在每次循环中都被调用。

小结

无论你是否认为性能很重要,都请你为被测代码(尤其是位于系统关键业务路径上的代码)建立性能基准。如果你编写的是供其他人使用的软件包,则更应如此。只有这样,我们才能至少保证后续对代码的修改不会带来性能回退。已经建立的性能基准可以为后续是否进一步优化的决策提供数据支撑,而不是靠程序员的直觉。

本条要点:

  • 性能基准测试在Go语言中是“一等公民”,在Go中我们可以很容易为被测代码建立性能基准;
  • 了解Go的两种性能基准测试的执行原理;
  • 使用性能比较工具协助解读测试结果数据,优先使用benchstat工具;
  • 使用testing.B提供的定时器操作方法排除额外干扰,让基准测试更精确,但不要在Run-Parallel中使用ResetTimer、StartTimer和StopTimer,因为它们具有全局副作用。