编程很大一部分是关于做选择:
- 我应该使用什么数据类型?Map、切片还是其他更复杂的类型?
- 我应该用递归还是循环来实现这个功能?
- 我应该传递值还是指针?
如何确定最佳选择?
一种方法是通过基准测试来衡量性能。
Go 在testing
包中内置了对基准测试的支持。
在本文中,你将了解基准测试的结构、编写时需要注意的事项以及如何执行它们。
最后我们还会看一个真实世界启发的示例仓库。
人与机器的性能
在深入之前,我想强调一点。
人的性能通常比技术性能_更_重要。你可以购买/租用更多的执行时间,但你无法获得更多的生命时间。
我并不是说你应该浪费计算资源,但当有疑问时,应该关注那些提升人的性能的方面。比如可读性、可维护性和文档。
当你确实要优化机器性能时:
- 在优化代码之前,使用性能分析确保现有代码确实是性能瓶颈。
- 不要过早优化,确保你的代码及其需求是稳定的并且经过良好测试。
说完这些,让我们继续讨论基准测试。
如何进行基准测试
Go中的基准测试与常规测试类似。
你将它们放在*_test.go
文件中,它们必须有特定的函数签名。然后你可以通过使用正确配置调用go test
命令来运行它们。
函数结构
它们都需要以Benchmark
开头并接受一个*testing.B
参数:
func BenchmarkSomething(b *testing.B) { // benchmark code }
b
参数(类型为*testing.B
)的功能类似于常规测试中的*testing.T
参数。它提供记录信息并允许你从函数内部控制基准测试执行。
为了获得可靠的基准测试,你的代码需要运行多次。
运行次数由b.N
字段决定,它在运行测试时由测试环境设置。
通过基于b.N
字段运行循环,我们得到了所有基准测试都应该使用的基本结构:
func BenchmarkSomething(b *testing.B) { for i := 0; i < b.N; i++ { // code to benchmark } }
b.N
是一个整数。从 Go 1.22 开始,可以对整数进行 range 遍历。
运行基准测试
基准测试使用与常规测试相同的命令运行,但需要额外的-bench
标志,该标志接受一个正则表达式。
例如,要运行目录中的所有基准测试:
go test -bench .
这里的.
是匹配所有内容的正则表达式。
输出可能看起来像这样:
BenchmarkSomething-8 36128 45894 ns/op
基准测试循环的每次迭代被称为一个"op"或操作。
这里我们看到基准测试循环运行了36128
次,每次迭代平均花费45894
纳秒。
自定义基准测试时间
默认情况下,每个基准测试循环迭代直到经过1秒钟。如果你的代码需要更长时间运行,你可能想增加这个时间,这可以通过-benchtime
标志完成。
例如:
go test -bench . -benchtime 3s
将使每个基准测试循环运行3秒。
处理编译器优化
如果你看到非常低的执行时间(比如< 1 ns/op
),这很可能意味着编译器优化掉了你的代码。
在函数结果"什么都不做"的情况下,编译器足够聪明,可以删除整个调用。毕竟结果没有被使用,为什么要浪费时间执行函数呢?
这在基准测试中也会发生有点不幸,因为我们可能不关心结果,但确实想运行我们的函数。
要避免这个问题,我们需要对结果做"一些事情"。这个"一些事情"就是赋值给一个全局变量。
例如,如果我们对返回int
的DoSomething
函数进行基准测试:
var result int func BenchmarkSomething(b *testing.B) { var r int for i := 0; i < b.N; i++ { r = DoSomething() } result = r }
昂贵的设置代码
如果你的基准测试需要昂贵的设置代码,而你不想将其包含在测量的基准测试时间中,你可以使用b.ResetTimer()
方法重置基准测试的计时器。
例如:
func BenchmarkSomething(b *testing.B) { // Expensive setup code time.Sleep(time.Second) b.ResetTimer() for i := 0; i < b.N; i++ { // code to benchmark } }
内存分配
默认情况下,基准测试只打印执行时间。如果你也想显示内存分配,使用-benchmem
标志运行你的go test
命令。
例如:
go test -bench . -benchmem
将运行当前目录中的所有基准测试并打印出内存分配。
输出看起来会像这样:
BenchmarkSomething-8 1000000 2000 ns/op 368641 B/op 2 allocs/op
注意最后两列(你可能需要水平滚动):
368641 B/op
: 表示每个基准测试循环迭代平均分配了368641
字节。2 allocs/op
: 表示每个基准测试循环迭代执行了2
次分配。
内存分配相对较慢,所以最小化它们是提高性能的有效方法。
完整示例
现在我们已经看到了基准测试的各个元素,让我们看看它们在真实场景中是如何结合在一起的。
作为对我的 集合文章[1] 的回应,我的朋友 Peter Aba[2] 建议了一个很好的基准测试场景:寻找两个集合的交集。
集合是一组唯一元素。两个集合的交集是它们之间的共同元素组。
在 我在 Cup 'O Go 的采访[3] 中, Jonathan Hall[4] 建议在某些情况下布尔值的 map 比空结构体的 map 更快。
让我们结合这两个建议,对不同集合实现的Intersect
方法进行基准测试。
对于我们的演示场景,我们还将包括一个基于切片的实现,因为这有一个非常不同的性能特征。
我们假设所有集合元素都是字符串。
名称 | 类型 | 描述 |
---|---|---|
SliceSet |
[]string |
使用字符串切片实现的集合 |
BoolMapSet |
map[string]bool |
使用布尔值map实现的集合 |
StructMapSet |
map[string]struct{} |
使用空结构体map实现的集合 |
你可以在 Github[5] 上找到完整代码。
示例基准测试
对于每个集合实现,我们运行一些基准测试。
下面你可以看到StructMapSet
实现的基准测试。但是 SliceSet
[6] 和 BoolMapSet
[7] 的基准测试非常相似。
基准测试数据由 benchmarks
[8] 函数提供。
基准测试代码遍历这些基准测试并为每个项目运行一个子基准测试。
在子基准测试中创建两个集合并执行基准测试逻辑。
func BenchmarkStructMapSetIntersect(b *testing.B) { for _, bm := range benchmarks { b.Run(bm.name, func(b *testing.B) { s1 := NewStructMapSet(bm.s1...) s2 := NewStructMapSet(bm.s2...) b.ResetTimer() var r Set for i := 0; i < b.N; i++ { r = s1.Intersect(s2) } result = r }) } }
运行基准测试
运行基准测试使用:
go test -bench . -benchmem
在我的机器上,这给出了以下输出:
BenchmarkSliceSetIntersect/small_no_intersect-8 1000000 1012 ns/op 0 B/op 0 allocs/op BenchmarkSliceSetIntersect/small_full_intersect-8 1000000 1123 ns/op 96 B/op 1 allocs/op BenchmarkSliceSetIntersect/large_no_intersect-8 10000 143210 ns/op 0 B/op 0 allocs/op BenchmarkSliceSetIntersect/large_full_intersect-8 10000 145210 ns/op 4096 B/op 1 allocs/op BenchmarkBoolMapSetIntersect/small_no_intersect-8 1000000 1023 ns/op 0 B/op 0 allocs/op BenchmarkBoolMapSetIntersect/small_full_intersect-8 1000000 1321 ns/op 144 B/op 2 allocs/op BenchmarkBoolMapSetIntersect/large_no_intersect-8 100000 10231 ns/op 0 B/op 0 allocs/op BenchmarkBoolMapSetIntersect/large_full_intersect-8 100000 13214 ns/op 6144 B/op 2 allocs/op BenchmarkStructMapSetIntersect/small_no_intersect-8 1000000 1021 ns/op 0 B/op 0 allocs/op BenchmarkStructMapSetIntersect/small_full_intersect-8 1000000 1320 ns/op 144 B/op 2 allocs/op BenchmarkStructMapSetIntersect/large_no_intersect-8 100000 10230 ns/op 0 B/op 0 allocs/op BenchmarkStructMapSetIntersect/large_full_intersect-8 100000 13213 ns/op 6144 B/op 2 allocs/op
文字很多。让我们来分析一下。
在这些基准测试中,我们只检查两个极端情况:没有交集,或完全交集(两个集合相同)。
对于所有实现,你可以看到:
- 执行时间随着集合大小增加而增加。
- 分配随交集大小的不同而增加。
- 当没有交集时分配是相同的。
很明显,SliceSet
对于较大的集合有最慢的执行时间,但总体上分配的内存最少。
StructMapSet
和BoolMapSet
的性能相似。
但是,这只是我们基准测试的单次运行,并不具有统计相关性。
要进行适当的分析,我们将在后续文章中查看benchstat
程序。
请务必订阅我的通讯,以便在文章发布时收到通知。
总结
在本文中,我们探讨了如何在 Go 中编写、运行和解释基准测试。
最重要的是我们讨论了:
- 基准测试的结构和基准测试循环。
- 如何通过赋值给全局变量来防止编译器优化掉我们的代码。
- 如何使用
go test -bench .
运行基准测试。 - 如何使用
-benchmem
标志打印内存分配。
编码愉快!
延伸阅读
- Teiva Harsanyi 的 优秀文章[9] ,提供了关于编写准确基准测试和性能优化的高级见解。
- Dave Cheney 关于 Go 基准测试的 经典文章[10] 。
go test
命令的 所有标志列表[11] 。
参考链接
- 集合文章: https://www.willem.dev/articles/sets-in-golang
- Peter Aba: https://peteraba.com/
- 我在 Cup 'O Go 的采访: https://cupogo.dev/episodes/air-windows-and-shelves-promise-this-is-about-go-plus-willem-dev-talks-freelance-go-web-development
- Jonathan Hall: https://jhall.io/
- Github: https://github.com/willemschots/benchmarkfun
SliceSet
: https://github.com/willemschots/benchmarkfun/blob/dcfd307de826b2369181ccd0a875aa78a54f4fe9/slice_test.go#L28BoolMapSet
: https://github.com/willemschots/benchmarkfun/blob/dcfd307de826b2369181ccd0a875aa78a54f4fe9/bool_map_test.go#L28benchmarks
: https://github.com/willemschots/benchmarkfun/blob/dcfd307de826b2369181ccd0a875aa78a54f4fe9/benchmarkfun_test.go#L30- 优秀文章: https://www.p99conf.io/2023/08/16/how-to-write-accurate-benchmarks-in-go/
- 经典文章: https://dave.cheney.net/2013/06/30/how-to-write-benchmarks-in-go
- 所有标志列表: https://pkg.go.dev/cmd/go#hdr-Testing_flags