基准测试:Go语言中的性能测试

文摘   2024-10-31 09:58   上海  

编程很大一部分是关于做选择:

  • 我应该使用什么数据类型?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),这很可能意味着编译器优化掉了你的代码。

在函数结果"什么都不做"的情况下,编译器足够聪明,可以删除整个调用。毕竟结果没有被使用,为什么要浪费时间执行函数呢?

这在基准测试中也会发生有点不幸,因为我们可能不关心结果,但确实想运行我们的函数。

要避免这个问题,我们需要对结果做"一些事情"。这个"一些事情"就是赋值给一个全局变量。

例如,如果我们对返回intDoSomething函数进行基准测试:

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对于较大的集合有最慢的执行时间,但总体上分配的内存最少。

StructMapSetBoolMapSet的性能相似。

但是,这只是我们基准测试的单次运行,并不具有统计相关性。

要进行适当的分析,我们将在后续文章中查看benchstat程序。

请务必订阅我的通讯,以便在文章发布时收到通知。

总结

在本文中,我们探讨了如何在 Go 中编写、运行和解释基准测试。

最重要的是我们讨论了:

  • 基准测试的结构和基准测试循环。
  • 如何通过赋值给全局变量来防止编译器优化掉我们的代码。
  • 如何使用go test -bench .运行基准测试。
  • 如何使用-benchmem标志打印内存分配。

编码愉快!

延伸阅读

  • Teiva Harsanyi 的 优秀文章[9] ,提供了关于编写准确基准测试和性能优化的高级见解。
  • Dave Cheney 关于 Go 基准测试的 经典文章[10]
  • go test命令的 所有标志列表[11]

参考链接

  1. 集合文章: https://www.willem.dev/articles/sets-in-golang
  2. Peter Aba: https://peteraba.com/
  3. 我在 Cup 'O Go 的采访: https://cupogo.dev/episodes/air-windows-and-shelves-promise-this-is-about-go-plus-willem-dev-talks-freelance-go-web-development
  4. Jonathan Hall: https://jhall.io/
  5. Github: https://github.com/willemschots/benchmarkfun
  6. SliceSet: https://github.com/willemschots/benchmarkfun/blob/dcfd307de826b2369181ccd0a875aa78a54f4fe9/slice_test.go#L28
  7. BoolMapSet: https://github.com/willemschots/benchmarkfun/blob/dcfd307de826b2369181ccd0a875aa78a54f4fe9/bool_map_test.go#L28
  8. benchmarks: https://github.com/willemschots/benchmarkfun/blob/dcfd307de826b2369181ccd0a875aa78a54f4fe9/benchmarkfun_test.go#L30
  9. 优秀文章: https://www.p99conf.io/2023/08/16/how-to-write-accurate-benchmarks-in-go/
  10. 经典文章: https://dave.cheney.net/2013/06/30/how-to-write-benchmarks-in-go
  11. 所有标志列表: https://pkg.go.dev/cmd/go#hdr-Testing_flags

幻想发生器
图解技术本质
 最新文章