★
目前看过除了《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)。我们将把基准测试的结果保存在这个变量中。然后我们定义两个基准函数 BenchmarkConcatenateBuffer
和 BenchmarkConcatenateJoin
。请注意,它们的结构非常相似。连接结果存储到变量 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
在这里,我们正在运行当前软件包的所有基准测试以及内存统计信息。基准测试结果包含以下统计数据:
-
在基准测试结果中,首先打印的是两个 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 行文本,由堆栈跟踪组成。如何利用它们?如果我们参考文档,每个程序的内存分配都会生成一个堆栈跟踪。我们可以手动搜索该文件以查看分配附加的位置。但我们可以使用 cat
和 grep
这两个命令:
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.log
和 grep -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 文件以生成表格并绘制图表:
对数刻度
图 2 显示了对数线性图上的数据。对数线性图是纵轴为对数、横轴为对数刻度的图表。您可能不熟悉该方法(如果您已经知道,可以跳过本节)。当数据范围较大时,使用对数刻度。在统计学中,极差是最大值与最小值之差。对于数据集:一个轴可以具有对数刻度,另一轴可以具有线性刻度。这种类型的图表称为“对数线性图”。如果两个轴都有对数刻度,则称为双对数图。比较图4和图2,哪个图更好?
解析基准测试结果
不幸的是,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 中,您可以看到突出显示的捕获组:正则表达式捕获组突出显示[图: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