【Practical-Go-Lessons】基准测试

目前看过除了《go语言程序设计》以外最好的教程:https://www.practical-go-lessons.com/

在本章学到什么?

  • 什么是基准测试?
  • 如何编写基准测试?
  • 如何阅读基准测试的结果?

涵盖的技术概念

  • 基准
  • Solver
  • 内存分配(动态和静态)

介绍

一个问题能有多种不同解。举个例子:你丢失了钥匙,并且你现在想要打开家里的大门。解法有以下几种:

  • 给有备用钥匙的人打电话;(前提你孤寡青年 (* ̄︶ ̄))
  • 网上叫一个开锁师傅(小区最好问物业,以免踩坑);
  • 到处再找找你的钥匙,如果找了很久没找到,用上面两个办法;
  • 定点爆破,给你的门来上一脚;

这些解决方案将得到相同的结果;你的门将会打开。但如果您理智的话,您可以根据成本或时间对这些解决方案进行排名。解决方案 2 和 4 将花费您金钱。解决方案 3(查找密钥)可能会花费您更多时间。但是,如果您忘记把钥匙放在停在 5 分钟外的车里怎么办?显然,在这种情况下,解决方案三的成本将低于预期。通过检查所有不同的可能解决方案并在您的想象中测试它们,当前这就是正在制定基准

什么是基准

基准测试是一种比较系统和组件的工具。设计和运行基准测试的目的是找到最佳求解策略(称为Solver求解器)。 求解器通常是一种方法。 要选择最佳求解器,必须定义规则。在基准测试期间,会收集执行统计信息(计算时间、影响次数、函数调用次数……)。借助这些统计数据,我们可以选择决策规则。不存在一般规则(没有银弹)。规则可能会根据您的需求而有所不同;例如,

  • 如果您想选择 CPU 使用率较低的程序,则只需关注这些统计信息。
  • 如果您设计的程序在可用内存非常小的设备上运行,您可能会关注内存使用统计信息以选择最佳求解器。

怎么写基准测试

举个例子:golang中不同的字符串连接测试。第一步是创建两个函数来实现这两个解决方案:

package basic

import (
    "bytes"
    "strings"
)

func ConcatenateBuffer(first string, second string) string {
    var buffer bytes.Buffer
    buffer.WriteString(first)
    buffer.WriteString(second)
    return buffer.String()
}

func ConcatenateJoin(first string, second string) string {
    return strings.Join([]string{first, second}, "")
}

这两个函数都连接两个字符串。他们使用两种不同的方法。第一个函数 ConcatenateBuffer 将使用缓冲区(来自 buffer 包)。第二个函数是字符串包中 Join 函数的包装器。我们想知道哪种方法是最好的。 基准测试与单元测试相邻。基准测试是位于测试文件中的函数。它的名称必须以 Benchmark 开头。基准函数具有以下签名:

func BenchmarkXXX(b *testing.B) {
}

该函数将指向类型 struct testing.B 的指针作为参数。该类型结构体仅导出一个属性:N。它表示要运行的迭代次数。基准测试会运行多次该函数,以收集有关基准测试函数执行情况的可靠数据。这就是为什么基准函数总是封装这种 for 循环:

for i := 0; i < b.N; i++ {
    // execute the function here
}

您可以看到循环从 0 开始,并在达到 b.N 时停止。不要用值代替 b.N。基准测试包将运行基准测试一次,然后决定是否应该继续运行它。调整 N 的值以达到理想的可靠性水平(我们将在本章后面深入讨论)。让我们看看我们的两个基准:

var result string
func BenchmarkConcatenateBuffer(b *testing.B) {
    var s string
    for i := 0; i < b.N; i++ {
        s = ConcatenateBuffer("test2","test3")
    }
    result = s
}

func BenchmarkConcatenateJoin(b *testing.B) {
    var s string
    for i := 0; i < b.N; i++ {
        s = ConcatenateJoin("test2","test3")
    }
    result = s
}

我们首先创建一个结果变量。这个变量在这里只是为了避免编译器优化(Dave Cheney 在博客文章中给出的提示:https://dave.cheney.net/2013/06/30/how-to-write-benchmarks-in-go)。我们将把基准测试的结果保存在这个变量中。然后我们定义两个基准函数 BenchmarkConcatenateBufferBenchmarkConcatenateJoin。请注意,它们的结构非常相似。连接结果存储到变量 s 中。然后我们定义一个 for 循环,在其中执行我们想要测试的函数。 循环的参数都是固定的,我们在相同条件下测试该功能。

执行基准测试

go test -bench=.

goos: darwin
goarch: amd64
pkg: go_book/benchmark
BenchmarkConcatenateBuffer-8    20000000                98.9 ns/op
BenchmarkConcatenateJoin-8      30000000                56.1 ns/op
PASS
ok      go_book/benchmark       3.833s

在后文,将解释该测试结果。

仅运行一项基准测试

如果只想运行ConcatenateBuffer:

go test -bench ConcatenateBuffer

完整命令:(与上面命令相等)

go test -test.bench ConcatenateBuffer

在代码里使用

测试包公开了运行基准测试的公共方法:

// benchmark/without-cli/main.go
package main

import (
    "bytes"
    "fmt"
    "testing"
)

func main() {
    res := testing.Benchmark(BenchmarkConcatenateBuffer)
    fmt.Printf("Memory allocations : %d n", res.MemAllocs)
    fmt.Printf("Number of bytes allocated: %d n", res.Bytes)
    fmt.Printf("Number of run: %d n", res.N)
    fmt.Printf("Time taken: %s n", res.T)
}

// ..
func BenchmarkConcatenateBuffer(b *testing.B) {
    //..
}

testing.Benchmark将等待有效的基准函数,即:func(b *testing.B) 类型的变量。请记住,在 Go 中,函数是一等公民,可以传递给其他函数。 Benchmark 函数返回 BenchmarkResult 类型的变量:

// 标准库
// src/testing/benchmark.go (v1.11.4)
type BenchmarkResult struct {
    N         int           // 迭代次数
    T         time.Duration // 总运行时长
    Bytes     int64         // 一次迭代中处理的字节数。
    MemAllocs uint64        // 内存分配总数
    MemBytes  uint64        // 内存中字节分配总数
}

Benchmark flags

-cpu默认情况下,基准测试使用 GOMAXPROCS 处理器执行。为了有一个可靠的基准,我建议你控制这个值;它应该等于目标机器的处理器数量。 您必须将正则表达式传递给该标志。它将启动名称与正则表达式匹配的基准函数。例如,命令:(这里没有传CPU,我猜作者应该想表示这里使用了默认值)

 go test -bench .

完整执行:

go test -bench Join

将启动包含字符串“Join”的所有基准测试函数。在示例中,BenchmarkConcatenateJoin 将启动,但不会启动 BenchmarkConcatenateBuffer-benchtime该标志允许您控制基准测试的执行时间。您必须传递持续时间字符串(例如:3s)。系统将解析持续时间并在指定的时间内执行基准测试。这意味着您可以增加/减少基准测试所需的时间。 示例:让我们运行名为 BenchmarkConcatenateJoin 的基准测试 5 秒:

go test -bench BenchmarkConcatenateJoin -benchtime 5s
goos: darwin
goarch: amd64
pkg: go_book/benchmark
BenchmarkConcatenateJoin-8      100000000               56.9 ns/op
PASS
ok      go_book/benchmark       5.760s

-benchmem将在结果中显示内存分配统计信息。该标志是布尔值;默认设置为 false。只需将其添加到命令行即可激活此功能。 示例:我们可以使用以下命令运行内存统计数据基准测试:

go test -bench . -benchmem
goos: darwin
goarch: amd64
pkg: go_book/benchmark
BenchmarkConcatenateBuffer-8    20000000               105 ns/op      128 B/op          2 allocs/op
BenchmarkConcatenateJoin-8      30000000                60.2 ns/op      16 B/op          1 allocs/op
PASS
ok      go_book/benchmark       4.093s

请注意,基准测试结果中打印了另外两列。在下一节中,我们将了解如何读取这些统计数据。

解析基准测试结果

我发现基准测试结果很难阅读。我们将逐一进行统计。对于每一项统计数据,我们将尽力提供可行的建议……

 go test -bench . -benchmem

【Practical-Go-Lessons】基准测试在这里,我们正在运行当前软件包的所有基准测试以及内存统计信息。基准测试结果包含以下统计数据:

  • 在基准测试结果中,首先打印的是两个 Go 环境变量 GOOS (操作系统)和 GOARCH(系统架构。你已经了解了它们,但它们对于比较基准测试结果非常有用。
  • 持续时间:这是执行基准测试所需的总时间。
  • 核心数量附加到基准测试函数名称后面的数字):基准测试结果与运行它的系统相关。这就是为什么了解运行它所使用的核心数量很重要。在我们的情况下,基准测试是在八个核心上运行的。你可以通过使用 -cpu 标志来调整用于运行基准测试的核心数量。默认情况下,它使用可用的最大核心数。
  • 迭代次数(第二列):请记住,在每个基准测试函数内部,都包含一个 for 循环。这个数字表示 for 循环运行的次数,以获取统计数据。你可以通过使用 -benchtime 标志来增加基准测试的持续时间,从而增加迭代次数。这并不是基准测试执行的总迭代次数。
  • 每个操作的纳秒数(第三列):它给出了solver平均运行速度的一个概念。在我们的示例中,ConcatenateBuffer 函数平均运行时间为 55.97 纳秒/次,而 ConcatenateJoin 函数平均运行时间为 33.63 纳秒/次。在我们的基准测试环境中,ConcatenateJoin 函数是最快的。
  • 每个操作分配的字节数(第四列):只有在添加 -benchmem 标志时才会显示这一列。这将帮助你了解你的解算器的内存消耗情况。如果你关注内存使用情况的改进,那么你应该关注这个统计数据。
  • 每个操作的分配次数(第五列):这个统计数据的名称已经说明了其含义。这是每次运行的平均内存分配次数。在 [sec:Detect-memory-allocations] 部分,我们将看到如何检测内存分配以改进你的代码。

检测内存分配

Go 有一个调试模式,允许您打印有关程序性能的大量且非常有价值的信息。内存分配是了解程序执行情况的重要变量。它们大致是两种类型的内存分配:

  • Static:程序启动时分配内存。在 C 中,当您创建全局变量或静态变量时,就会发生这种情况。当程序停止时,该内存被释放。只分配一次。
  • **Dynamic: **在程序中,当程序被编译或启动时,一切都是未知的。例如,程序的行为可以根据用户输入的功能而变化。想象一个计算高度复杂数学运算的程序,该程序所需的内存将取决于输入,并且可能会有很大变化(进行加法不需要大量内存,而获得 !10000 的结果需要更多空间)。这就是程序运行时需要动态分配内存的原因。

我们将重点关注动态内存分配。我们将使用变量 **GODEBUG **来输出两个函数完成的内存分配。 首先要做的是创建一个示例应用程序,它将调用我们的两个函数:

package main

// imports

func main() {
    basic.ConcatenateBuffer("first","second")
    basic.ConcatenateJoin("first","second")
}

该应用程序将调用我们的两个函数(它们是 basic 包的一部分,导入路径为 go_book/benchmark/basic)。然后我们编译我们的程序:

go build -o allocDetect main.go

请注意,-o 标志用于为我们的二进制文件指定特定名称。这里我们选择命名为 allocDetect 当然,你也可以将其命名为其他名称。然后我们可以使用 GODEBUG 变量集运行我们的二进制文件:

GODEBUG=allocfreetrace=1 ./allocDetect &>> trace.log

GODEBUG 是一个接受键值对列表的环境变量。在这里,我们告诉 go 运行时为每次分配和释放生成堆栈跟踪。然后我们添加“&>>trace.log”以将标准输出和标准错误重定向到文件trace.log。如果该文件不存在,它将创建该文件;如果存在,日志将附加到该文件。在我们的trace.log 中,我有 1034 行文本,由堆栈跟踪组成。如何利用它们?如果我们参考文档,每个程序的内存分配都会生成一个堆栈跟踪。我们可以手动搜索该文件以查看分配附加的位置。但我们可以使用 catgrep 这两个命令:

cat trace.log | grep -n /path/to/the/package/basic/bench.go

在这里,我们首先使用“cat trace.log”打印trace.log文件的内容,然后要求grep在该文件中搜索字符串“/path/to/the/package/basic/bench.go”(字符串“/path/to/the/package/basic/bench.go”需要更改为要分析的包文件的路径)cat trace.loggrep -n /path/to/the/package/basic/bench.go 两个命令之间有一个管道 (|)。管道用于链接命令。第一个命令的输出是第二个命令的输入,整个命令形成一个管道。 这是输出:

988:    /path/to/the/package/basic/bench.go:9 +0x31 fp=0xc000044758 sp=0xc000044710 pc=0x1055c81
1005:   /path/to/the/package/basic/bench.go:12 +0xca fp=0xc000044758 sp=0xc000044710 pc=0x1055d1a
1028:   /path/to/the/package/basic/bench.go:16 +0x7e fp=0xc00008af58 sp=0xc00008aef0 pc=0x1055dde

该路径已在trace.log 的第988、1005 和1028 行中找到了3 次(行号由grep 返回,因为我们添加了标志-n)。在路径字符串旁边,有导致 /path/to/the/package/basic/bench.go 中分配的行号。下一组是分析您的代码,看看内存分配发生在哪里以及如何避免它。在 ConcatenateBuffer 函数中,第二行导致了内存分配。缓冲区的创建:

var buffer bytes.Buffer

以及对 String 方法的调用:

buffer.String()

调试选项的完整列表可在此处找到:https://golang.org/pkg/runtime/#hdr-Environment_Variables

具有可变输入的基准

在前面的部分中,我们编写了输入保持稳定的基准。这种方法足以满足大多数用例。但您可能需要了解函数的参数更改时的行为方式。 我们将使用测试包中定义的方法Run。该方法的接收者是一个指向testing.B变量的指针。 如果我们想更深入地分析,我们可以使用可变长度字符串来测试我们的两个函数。我们将使用 2 的幂的长度:

  • 2
  • 16
  • 128
  • 1024
  • 8192
  • 65536
  • 524288
  • 4194304
  • 16777216
  • 134217728

第一步是将这些整数放入名为 lengths 的切片中。

lengths := []int{2,16,128,1024,8192,65536,524288,4194304,16777216,134217728}

通过 for range 循环,我们迭代这些数字。在每次迭代中,我们创建两个随机字符串。

for _, l := range lengths {
    first := generateRandomString(l)
    second := generateRandomString(l)
}

创建这两个字符串后,我们可以将它们用作两个基准函数的输入。我们将创建两个子基准。子基准是在 Run 方法的帮助下定义的。它们必须被定义为经典的基准函数。我们将这个包装函数命名为“BenchmarkConcatenation”:


func BenchmarkConcatenation(b *testing.B){
    var s string
    lengths := []int{2,16,128,1024,8192,65536,524288,4194304,16777216,134217728}
    for _, l := range lengths {
        first := generateRandomString(l)
        second := generateRandomString(l)

    }
}

在 for 循环内,我们将调用 b.Run 方法两次(b.Run 将创建一个子基准)。首先,我们对 ConcatenateJoin 函数进行基准测试:

b.Run(fmt.Sprintf("ConcatenateJoin-%d",l), func(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s = ConcatenateJoin(first, second)
    }
    result = s
})

第二次使用 Concatenate Buffer

b.Run(fmt.Sprintf("ConcatenateBuffer-%d",l), func(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s = ConcatenateBuffer(first, second)
    }
    result = s
})

请注意,run 函数有两个参数:

  • 名称:将显示在基准测试结果中;
  • 代表子基准的函数:它必须将指向testing.B 变量的指针作为参数。

我们自定义基准的名称。我们在名称末尾附加 l 值(表示两个连接字符串的字符数)。这种定制对于提高结果的可读性是必要的。第二个参数是一个非常经典的基准函数:一个将从 1 迭代到 b.N 的 for 循环。在这个 for 循环中,您最终找到了对基准测试函数的调用。我们保存函数的结果以避免编译器优化。 要运行此基准测试,您可以使用与之前相同的命令:

go test -bench BenchmarkConcatenation -benchmem
goos: darwin
goarch: amd64
pkg: go_book/benchmark/variableInput
BenchmarkConcatenation/ConcatenateJoin-2-8              30000000         51.2 ns/op             4 B/op          1 allocs/op
BenchmarkConcatenation/ConcatenateBuffer-2-8            20000000         93.0 ns/op           116 B/op          2 allocs/op
BenchmarkConcatenation/ConcatenateJoin-16-8             20000000         62.5 ns/op            32 B/op          1 allocs/op
BenchmarkConcatenation/ConcatenateBuffer-16-8           20000000        103 ns/op             144 B/op          2 allocs/op
//...
ok      go_book/benchmark/variableInput 33.975s

这是部分输出。我没有复制所有标准输出。我们可以根据这些数据生成图表,以更好地理解结果。我们将输出重定向到一个文件以进行进一步处理:

go test -bench BenchmarkConcatenation -benchmem &>> benchmarkConcatenation.log

然后我们可以解析 benchmarkConcatenation.log 文件以生成表格并绘制图表:【Practical-Go-Lessons】基准测试

对数刻度

图 2 显示了对数线性图上的数据。对数线性图是纵轴为对数、横轴为对数刻度的图表。您可能不熟悉该方法(如果您已经知道,可以跳过本节)。当数据范围较大时,使用对数刻度。在统计学中,极差是最大值与最小值之差。对于数据集:【Practical-Go-Lessons】基准测试一个轴可以具有对数刻度,另一轴可以具有线性刻度。这种类型的图表称为“对数线性图”。如果两个轴都有对数刻度,则称为双对数图。比较图4和图2,哪个图更好?【Practical-Go-Lessons】基准测试

解析基准测试结果

不幸的是,Go 没有内部工具来生成这种图。我必须手动解析标准基准输出才能获取数据。这是我使用的脚本:

package main

import (
    "fmt"
    "io/ioutil"
    "regexp"
)

func main() {
    b, err := ioutil.ReadFile("/path/to/benchmarkConcatenation.log")
    if err!= nil {
        panic(err)
    }
    benchmarkResult := string(b)
    regexBench := regexp.MustCompile(`([a-zA-Z]*)-(d+)-.* (d+.?d+?)[t]ns.*[t](d+)[t]B.* (d+) allocs`)
    matches := regexBench.FindAllStringSubmatch(benchmarkResult,-1)
    fmt.Println("benchmarkedFunction,stringLen,nsPerOp,bytesPerOp,mallocs")
    for _, m := range matches {
        fmt.Printf("%s,%s,%s,%s,%sn",m[1],m[2],m[3],m[4],m[5])
    }
}

我使用以下带有五个捕获组的正则表达式来检索基准数据:

`([a-zA-Z]*)-(d+)-.* (d+.?d+?)[t]ns.*[t](d+)[t]B.* (d+) allocs`

在图 5 中,您可以看到突出显示的捕获组:【Practical-Go-Lessons】基准测试正则表达式捕获组突出显示[图:Regex-capturing-groups]

  • 第一组捕获基准函数的名称(存储在 m[1] 中)
  • 第二组捕获字符串的长度(m[2])
  • 第三组捕获每个操作的纳秒 (m[3])
  • 第四个是每次操作在内存中的字节数(m[4])
  • 最后一组代表分配数量(m[5])。

变量 matches 是字符串的二维切片: [][]string.matches[0] 表示第一个基准测试, matches[0][1] 表示基准测试函数的名称。

一些建议

  • 考虑时间变量 (ns/op) 和内存使用指标。
  • 选择与您的目标一致的变量(或变量组合)。
  • 适当时在图表上使用对数刻度(大范围数据)

常见错误:b.N 作为参数

当 b.N 的值增加时,基准测试函数所花费的时间不应增加。你的函数的输入不应该依赖于 b.N 数字。否则,您的基准测试结果将不显着。 让我们举个例子:

func BenchmarkConcatenateBuffer(b *testing.B) {
    var s string
    for i := 0; i < b.N; i++ {
        s = ConcatenateBuffer(generateRandomString(b.N),generateRandomString(b.N))
    }
    result = s
}

这里我们修改了ConcatenateBuffer的输入。我们使用名为generateRandomString 的随机字符串生成器,而不是两个固定字符串。该函数将在 math/rand 包的帮助下生成一个伪随机字符串。让我们看看基准测试的结果:

BenchmarkConcatenateBuffer-8   30000        138583 ns/op      319600 B/op    8 allocs/op

最终的操作次数只有 30.000 次,每次操作平均需要 138,583 纳秒。这些结果与我们使用两个固定字符串收集的结果非常不同:每次操作 100 纳秒。


原文始发于微信公众号(小唐云原生):【Practical-Go-Lessons】基准测试

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/247576.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!