Golang中的基准测试。提高函数性能

535 阅读6分钟

基准是一种函数,它多次执行一个代码段,并将每个输出与一个标准进行比较,评估代码的整体性能水平。Golang在testing 包和go 工具中包含了写基准的内置工具,所以你可以在不安装任何依赖的情况下写出有用的基准。

在本教程中,我们将介绍一些在Go中运行一致和准确的基准的最佳做法,包括编写基准函数和解释结果的基本原理。

要跟上本教程,你需要具备Go语法的基本知识,并在你的电脑上安装Go。让我们开始吧!

为基准测试设置正确的条件

为了使基准测试发挥作用,每次执行的结果必须是一致和相似的,否则就很难衡量被测试代码的真实性能。

基准测试的结果可能会受到运行基准的机器状态的极大影响。电源管理、后台进程和热管理的影响会影响测试结果,使其不准确和不稳定。

因此,我们需要尽可能地减少对环境的影响。在可能的情况下,你应该使用一台物理机器或一台没有其他东西在运行的远程服务器来执行你的基准测试。

但是,如果你不能使用预留的机器,你应该在运行基准测试之前尽可能多地关闭程序,尽量减少其他进程对基准测试结果的影响。

此外,为了确保更稳定的结果,你应该在记录测量结果之前多次运行基准,确保系统得到充分的预热。

最后,将被测试的代码与程序的其他部分隔离是至关重要的,例如,通过模拟网络请求。

在Golang中写一个基准测试

让我们通过编写一个简单的基准测试来展示Go中基准测试的基本原理。我们将确定以下函数的性能,该函数计算1和一个整数之间的所有质数。

// main.go
func primeNumbers(max int) []int {
    var primes []int

    for i := 2; i < max; i++ {
        isPrime := true

        for j := 2; j <= int(math.Sqrt(float64(i))); j++ {
            if i%j == 0 {
                isPrime = false
                break
            }
        }

        if isPrime {
            primes = append(primes, i)
        }
    }

    return primes
}

上面的函数通过检查一个数字是否能被2和它的平方根之间的数字整除来确定它是否是一个素数。让我们继续在main_test.go ,为这个函数写一个基准测试。

package main

import (
    "testing"
)

var num = 1000

func BenchmarkPrimeNumbers(b *testing.B) {
    for i := 0; i < b.N; i++ {
        primeNumbers(num)
    }
}

Go中的单元测试一样,基准函数被放置在_test.go 文件中,每个基准函数都应该有func BenchmarkXxx(*testing.B) 作为签名,testing.B 类型管理基准的时间。

b.N 指定了迭代次数;该值不是固定的,而是动态分配的,确保基准函数默认运行至少一秒。

在上面的BenchmarkPrimeNumbers() 函数中,primeNumbers() 函数将被执行b.N 次,直到开发者对基准的稳定性感到满意。

在Go中运行一个基准测试

要在Go中运行一个基准,我们将把-bench 标志附加到go test 命令中。-bench 的参数是一个正则表达式,指定应该运行哪些基准,当你想运行基准函数的一个子集时,这很有帮助。

要运行所有的基准,使用-bench=. ,如下所示。

$ go test -bench=.
goos: linux
goarch: amd64
pkg: github.com/ayoisaiah/random
cpu: Intel(R) Core(TM) i7-7560U CPU @ 2.40GHz
BenchmarkPrimeNumbers-4            14588             82798 ns/op
PASS
ok      github.com/ayoisaiah/random     2.091s

goos,goarch,pkg,cpu 分别描述了操作系统、体系结构、软件包和CPU的规格。BenchmarkPrimeNumbers-4 表示运行的基准函数的名称。-4 后缀表示用于运行基准的CPU数量,由GOMAXPROCS 指定。

在函数名称的右侧,你有两个值,1458882798 ns/op 。前者表示循环被执行的总次数,而后者是每次迭代完成的平均时间,以纳秒为单位表示。

在我的笔记本电脑上,primeNumbers(1000) 函数运行了14,588次,每次调用平均需要82,798纳秒来完成。为了验证该基准产生的结果是否一致,你可以通过向-count 标志传递一个数字来多次运行它。

$ go test -bench=. -count 5
goos: linux
goarch: amd64
pkg: github.com/ayoisaiah/random
cpu: Intel(R) Core(TM) i7-7560U CPU @ 2.40GHz
BenchmarkPrimeNumbers-4            14485             82484 ns/op
BenchmarkPrimeNumbers-4            14557             82456 ns/op
BenchmarkPrimeNumbers-4            14520             82702 ns/op
BenchmarkPrimeNumbers-4            14407             87850 ns/op
BenchmarkPrimeNumbers-4            14446             82525 ns/op
PASS
ok      github.com/ayoisaiah/random     10.259s

跳过单元测试

如果在测试文件中存在任何单元测试函数,当你运行基准时,这些函数也会被执行,导致整个过程耗时更长或基准失败。

为了避免执行测试文件中的任何测试函数,可以向-run 标志传递一个正则表达式。

$ go test -bench=. -count 5 -run=^#

-run 标志是用来指定哪些单元测试应该被执行。通过使用^# 作为-run 的参数,我们有效地过滤掉所有的单元测试函数。

用各种输入进行基准测试

当对你的代码进行基准测试时,测试一个函数在面对各种输入时的表现是至关重要的。我们将利用通常用于编写Go单元测试的表驱动测试模式来指定各种输入。接下来,我们将使用 b.Run() method来为每个输入创建一个子基准。

var table = []struct {
    input int
}{
    {input: 100},
    {input: 1000},
    {input: 74382},
    {input: 382399},
}

func BenchmarkPrimeNumbers(b *testing.B) {
    for _, v := range table {
        b.Run(fmt.Sprintf("input_size_%d", v.input), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                primeNumbers(v.input)
            }
        })
    }
}

当你运行基准时,结果将以下面的格式呈现。请注意每个子基准的名称是如何附加到主基准函数名称上的;给每个子基准起一个反映被测试输入的独特名称被认为是最佳做法

$ go test -bench=.
BenchmarkPrimeNumbers/input_size_100-4            288234              4071 ns/op
BenchmarkPrimeNumbers/input_size_1000-4            14337             82603 ns/op
BenchmarkPrimeNumbers/input_size_74382-4              43          27331405 ns/op
BenchmarkPrimeNumbers/input_size_382399-4              5         242932020 ns/op

对于较大的输入值,该函数需要更多的时间来计算结果,它完成的迭代次数也较少。

调整最小时间

前面的基准只运行了五次,这个样本量太小,不值得信任。为了得到更准确的结果,我们可以使用-benchtime 标志增加基准运行的最小时间。

$ go test -bench=. -benchtime=10s
BenchmarkPrimeNumbers/input_size_100-4           3010218              4073 ns/op
BenchmarkPrimeNumbers/input_size_1000-4           143540             86319 ns/op
BenchmarkPrimeNumbers/input_size_74382-4             451          26289573 ns/op
BenchmarkPrimeNumbers/input_size_382399-4             43         240926221 ns/op
PASS
ok      github.com/ayoisaiah/random     54.723s

-benchtime 的参数设置了基准函数运行的最小时间。在这种情况下,我们把它设置为10秒。

另一种控制基准运行时间的方法是为每个基准指定所需的迭代次数。要做到这一点,我们将传递一个输入,其形式为:Nx-benchtime ,其中N 为期望的数字。

$ go test -bench=. -benchtime=100x
BenchmarkPrimeNumbers/input_size_100-4               100              4905 ns/op
BenchmarkPrimeNumbers/input_size_1000-4              100             87004 ns/op
BenchmarkPrimeNumbers/input_size_74382-4             100          24832746 ns/op
BenchmarkPrimeNumbers/input_size_382399-4            100         241834688 ns/op
PASS
ok      github.com/ayoisaiah/random     26.953s

显示内存分配统计

Go运行时也会跟踪被测试代码所做的内存分配,帮助你确定你的一部分代码是否可以更有效地使用内存。

要在基准输出中包括内存分配的统计数据,在运行基准时添加-benchmem 标志。

$ go test -bench=. -benchtime=10s -benchmem
BenchmarkPrimeNumbers/input_size_100-4           3034203              4170 ns/op             504 B/op          6 allocs/op
BenchmarkPrimeNumbers/input_size_1000-4           138378             83258 ns/op            4088 B/op          9 allocs/op
BenchmarkPrimeNumbers/input_size_74382-4             422          26562731 ns/op          287992 B/op         19 allocs/op
BenchmarkPrimeNumbers/input_size_382399-4             46         255095050 ns/op         1418496 B/op         25 allocs/op
PASS
ok      github.com/ayoisaiah/random     55.121s

在上面的输出中,第四列和第五列分别表示每个操作分配的平均字节数和每个操作分配的数量。

让你的代码更快

如果你已经确定你正在进行基准测试的函数没有达到可接受的性能阈值,那么下一步就是找到一种方法来使操作更快。

根据有关的操作,有几种不同的方法可以做到这一点。其一,你可以尝试使用一个更有效的算法来达到预期的结果。另外,你可以并发地执行计算的不同部分。

在我们的例子中,primeNumbers() 函数的性能对于小数字来说是可以接受的,然而,随着输入的增长,它表现出指数行为。为了提高它的性能,我们可以将其实现方式改为更快的算法,如埃拉托塞尼斯的筛子

// main.go
func sieveOfEratosthenes(max int) []int {
    b := make([]bool, max)

    var primes []int

    for i := 2; i < max; i++ {
        if b[i] {
            continue
        }

        primes = append(primes, i)

        for k := i * i; k < max; k += i {
            b[k] = true
        }
    }

    return primes
}

新函数的基准与BenchmarkPrimeNumbers 函数相同,但是,sieveOfEratosthenes() 函数被调用。

// main_test.go
func BenchmarkSieveOfErastosthenes(b *testing.B) {
    for _, v := range table {
        b.Run(fmt.Sprintf("input_size_%d", v.input), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                sieveOfEratosthenes(v.input)
            }
        })
    }
}

运行该基准后,我们得到以下结果。

$ go test -bench=Sieve
BenchmarkSieveOfErastosthenes/input_size_100-4           1538118               764.0 ns/op
BenchmarkSieveOfErastosthenes/input_size_1000-4           204426              5378 ns/op
BenchmarkSieveOfErastosthenes/input_size_74382-4            2492            421640 ns/op
BenchmarkSieveOfErastosthenes/input_size_382399-4            506           2305954 ns/op
PASS
ok      github.com/ayoisaiah/random     5.646s

乍一看,我们可以看到Eratosthenes的筛子算法比之前的算法性能要好得多。然而,我们可以使用一个工具,比如说,我们可以用眼看结果来比较不同运行的性能,而不是用手去看。 [benchstat](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat),它可以帮助我们计算和比较基准统计数据。

对比基准结果

为了比较我们的基准与benchstat 的两种实现的输出,让我们先把每一种存储在一个文件中。首先,运行旧的primeNumbers() 函数实现的基准,并将其输出保存到一个名为old.txt 的文件中。

$ go test -bench=Prime -count 5 | tee old.txt

tee 命令将命令的输出发送到指定的文件中,并将其打印到标准输出。现在,我们可以用benchstat 来查看基准测试的结果。首先,让我们确保它已经安装。

$ go install golang.org/x/perf/cmd/benchstat@latest

然后,运行下面的命令。

$ benchstat old.txt
name                              time/op
PrimeNumbers/input_size_100-4     3.87µs ± 1%
PrimeNumbers/input_size_1000-4    79.1µs ± 1%
PrimeNumbers/input_size_74382-4   24.6ms ± 1%
PrimeNumbers/input_size_382399-4   233ms ± 2%

benchstat 显示各样本的平均时间差以及变化百分比。在我的例子中,± 的变化在1%到2%之间,这是最理想的。

任何大于百分之五的数据都表明有些样本不可靠。在这种情况下,你应该重新运行基准,尽可能保持你的环境稳定以提高可靠性。

接下来,将BenchmarkPrimeNumbers() 中对primeNumbers() 的调用改为sieveOfEratosthenes() ,并再次运行基准测试命令,这一次将输出转入一个new.txt 文件。

$ go test -bench=Prime -count 5 | tee new.txt

基准运行结束后,使用benchstat 来比较结果。

$ benchstat new.txt old.txt
name                              old time/op  new time/op    delta
PrimeNumbers/input_size_100-4      751ns ± 2%    3866ns ± 1%    +414.86%  (p=0.008 n=5+5)
PrimeNumbers/input_size_1000-4    5.42µs ± 1%   79.10µs ± 1%   +1358.73%  (p=0.008 n=5+5)
PrimeNumbers/input_size_74382-4    378µs ± 1%   24580µs ± 1%   +6408.21%  (p=0.008 n=5+5)
PrimeNumbers/input_size_382399-4  2.04ms ± 1%  232.91ms ± 2%  +11321.31%  (p=0.008 n=5+5)

delta 列报告了性能变化的百分比、P值以及被认为是有效的样本数,n 。如果你看到n 的值低于所采集的样本数,这可能意味着在采集样本时你的环境不够稳定。请参阅benchstat文档,了解其他可用的选项。

总结

基准测试是衡量代码不同部分性能的一个有用工具。它可以让我们在对系统进行修改后发现潜在的优化、性能改进或回归的机会。

Go提供的用于基准测试的工具很容易使用,而且很可靠。在这篇文章中,我们只是触及了这些软件包的可能性的表面。谢谢你的阅读,并祝你编码愉快

The postBenchmarking in Golang:提高函数性能》首次出现在LogRocket博客上。