(go)(google官网)

(go)(google官网)

【编者按】本文主要介绍在 Go 语言中进行基准测试的一些常见陷阱和误区。

原文链接:https://eli.thegreenplace.net/2023/common-pitfalls-in-go-benchmarking/

未经允许,禁止转载!


作者 | Eli Bendersky 译者 | 明明如月
责编 | 夏萌
出品 | CSDN(ID:CSDNnews)

虽然 Go 语言标准库的 test包提供了出色的测试和基准测试工具,但执行基准测试并不是一件简单的事情。这个道理并非只针对 Go 语言,其他语言也同样适用。

这篇文章就列举了 Go 程序员在进行基准测试时常遇到的一些问题。我们假设你对编写 Go 基准测试有基本的了解;如果需要,你可以查看 测试包的文档。这些问题不仅存在于 Go 语言中,它们在其他编程语言或环境中都同样存在,因此获得的经验教训具有普遍适用性。

(go)(google官网)

测试了非目标内容

假设我们想要对 Go 1.21 版本开始在 slices 包中新增的排序功能进行基准测试。

下面是一个基准测试示例:

const N = 100_000
func BenchmarkSortIntsWrong(b *testing.B) { ints := makeRandomInts(N) b.ResetTimer()
for i := 0; i < b.N; i++ { slices.Sort(ints) }}
func makeRandomInts(n int) []int { ints := make([]int, n) for i := 0; i < n; i++ { ints[i] = rand.Intn(n) } return ints}

为什么这个基准测试错误呢?因为它并未实际测试到我们想要测试的功能。slices.Sort 是在原地对一个 slice 进行排序。一旦第一次迭代完成,ints slice 就已经排序完毕,因此,所有后续的迭代实际上都在对已排序的 slice 进行“排序”。这并不是我们想要测试的功能。正确的测试方式应该是在每次迭代时都创建一个新的随机 slice:

func BenchmarkSortInts(b *testing.B) { for i := 0; i < b.N; i++ { b.StopTimer() ints := makeRandomInts(N) b.StartTimer() slices.Sort(ints) }}

在我的机器上,第二个基准测试的执行速度几乎比第一个测试慢了100倍。这是合理的,因为它在每次迭代中真正做了排序操作。

(go)(google官网)

未重置基准测试计时器问题

值得注意的是,正如上文所述,这些基准测试都非常谨慎地在某些操作前后重置或暂停/启动基准测试计时器。通常基准测试框架会多次执行整个 Benchmark* 函数,然后测量其总执行时间,并除以b.N 。这个过程我们将在后续的文章中进行更深入的讨论。

在 BenchmarkSortInts 的基准测试循环中删除 b.StopTimer() 和 b.StartTimer() 的调用,然后对比结果。你会发现由于生成随机整数切片的操作也被重复执行了 b.N 次,现在基准测试报告中每次操作的执行时间明显变慢了。

在某些情况下,这可能会大幅度偏离实际结果。即便我们使用相同的技术来进行两种方法的基准测试,错误也不一定会被抵消。根据 Amdahl's law (其基本思想是,如果一个任务的一部分能够并行执行,另一部分只能串行执行。那么当我们提高系统资源性能时,只有并行部分能够受益,串行部分的执行时间则不会有所改变。因此,整个任务的执行时间取决于这两部分的比例和速度)。如果我们在进行基准测试时没有准确地对我们关注的计算部分进行测试,那么可能会导致基准测试的结果出现偏差。

(go)(google官网)

被编译器误导

当我们遇到困难的基准测试时,我们需要解决由于编译器优化产生的问题,因为这可能会影响测试结果。Go 编译器并未对 Benchmark* 函数进行特殊处理,它优化这类函数及其内容,就像优化其他任何 Go 代码一样。这可能导致出现错误的结果,并且我们可能无法轻易地发现这些错误。

以下是一种基准测试的情况:

func isCond(b byte) bool { if b%3 == 1 && b%7 == 2 && b%17 == 11 && b%31 == 9 { return true } return false}
func BenchmarkIsCondWrong(b *testing.B) { for i := 0; i < b.N; i++ { isCond(201) }}

我们试图对 isCond 函数进行基准测试,但在最新版的 Go 中,编译器可能会对我们隐藏真实的结果:

BenchmarkIsCondWrong-8 1000000000 0.2401 ns/op

尽管 isCond 函数的简单性让其具有纳秒级的操作时间,但这仍然让人怀疑。然而,在这里我们犯了两个重大的错误:

  1. 我们为 isCond 提供了一个常数输入;由于 isCond 是一个可能被内联到基准测试函数中的简单函数,理论上编译器可以对其输入进行常量传播,将函数内容替换为在编译时计算出的结果。

  2. 即使我们对输入使用了非常量,isCond 的结果也未被使用,所以编译器可能会对其进行优化。

实际上,在这个例子中,编译器完全优化掉了基准测试循环的内容。

看看反汇编,我们会发现:

JMP BenchmarkIsCondWrong_pc7BenchmarkIsCondWrong_pc4: INCQ CXBenchmarkIsCondWrong_pc7: CMPQ 416(AX), CX JGT BenchmarkIsCondWrong_pc4 RET

CX 中保存了 i,416(AX) 是对 b.N 的访问。这只是一个空循环!现在对每次迭代 0.24 纳秒的时间就能理解了。

下面是这个问题更为复杂的展现形式:

func countCond(b []byte) int { result := 0 for i := 0; i < len(b); i++ { if isCond(b[i]) { result++ } } return result}
func BenchmarkCountWrong(b *testing.B) { inp := getInputContents() b.ResetTimer() for i := 0; i < b.N; i++ { countCond(inp) }}
func getInputContents() []byte { n := 400000 buf := make([]byte, n) for i := 0; i < n; i++ { buf[i] = byte(n % 32) } return buf}

现在我们确定,由于 countCond 内部显然使用了 isCond 的结果,常量传播不会成为问题。但是我们又犯下了同样的错误!虽然使用了 isCond 的结果,但未使用 countCond 的结果,因此编译器会执行以下操作:

  • 将 isCond 内联到 countCond 中

  • 将 countCond 内联到基准测试函数中

  • 注意到 countCond 的循环体既没有产生副作用,也没有任何结果在其外部使用

  • 将 countCond 中的循环体挖空,只留下一个空的循环

这个过程让基准测试结果变得令人困惑,因为即使循环仍然存在,增加输入也会增加基准测试的执行时间。 基准测试中一项常用的技巧就是改变输入的大小,并观察执行时间如何变化。如果执行时间没有变化,那就可能存在问题。如果执行时间随着输入的增大而增大,与被测试代码的复杂度相符,那么这个测试至少通过了初级的检验。然而,在这个例子中,由于执行了特定的优化,这个经验法则并没有起作用。

(go)(google官网)

如何控制基准测试中的编译器优化

在 Go 语言中,我们主要使用两种技术来抵消那些我们不希望出现的编译器优化。

第一种方法是使用 runtime.KeepAlive 函数。它最初是为了对 finalizers 进行精细控制而引入的,但同时它也可以用于在代码中明确标识:“即使编译器可能认为我不需要这个值,我仍然真的需要它。” 下面我们用它修复我们的 countCond 基准测试的示例:

func BenchmarkCountKeepAlive(b *testing.B) { inp := getInputContents() b.ResetTimer() result := 0 for i := 0; i < b.N; i++ { result += countCond(inp) } runtime.KeepAlive(result)}

运行基准测试,我们可以明显看到每次迭代需要更多的时间:

BenchmarkCountWrong-8 12481 95911 ns/opBenchmarkCountKeepAlive-8 4143 285527 ns/op

另一种方法并不依赖于特定的函数,但具有一定的风险;我们可以使用一个全局导出的值来收集结果:

var Sink int
func BenchmarkCountSink(b *testing.B) { inp := getInputContents() b.ResetTimer() for i := 0; i < b.N; i++ { Sink += countCond(inp) }}

管我们的基准测试并未使用 Sink,但由于它是一个包级别的导出值,可在程序几乎任何地方使用,因此,编译器想要消除它是非常困难的。我说它稍微危险一些,是因为理论上,编译器可以使用更复杂的跨包分析来证明它是多余的。

这让我们得出最后的观点:当有疑惑的时候,我们始终应该查看基准测试函数生成的汇编代码,并确保编译器没有过度优化。

(go)(google官网)

Go 标准库的更新进展

Go 团队正在关注如何防止编译器优化影响基准测试的准确性。目前,有两个提议正在被广泛讨论:

  • Issue 61179:这个提议建议在测试包中添加一种低开销的函数,用于“保留此值”。这个函数的功能与 runtime.KeepAlive 类似,但更加专注于基准测试,其名称和功能承诺也更加具有针对性。

  • Issue 61515:一个更大胆的提议正在探讨如何改变测试包的主要基准测试 API,以便能够更安全地进行基准测试。

(go)(google官网)

b.N的错误使用

存在两种常见的误用基准测试的重复指示器 b.N 的方式。以下是其中的第一种,该方式完全忽略了循环:

import ( "crypto/rand" "testing")
func BenchmarkRandPrimeWrongNoLoop(b *testing.B) { rand.Prime(rand.Reader, 200)}

如果按照正确的测试方法,我在本地机器上运行长度为 200 的 crypto/rand.Prime 需要大约 1 毫秒。但这个基准测试报告的时间却显著低于预期,只有纳秒级别。

第二种情况更复杂一些:

func BenchmarkRandPrimeWrongUseI(b *testing.B) { for i := 0; i < b.N; i++ { rand.Prime(rand.Reader, i) }}

注意每次迭代都在使用 i(间接使用了 b.N)。在我的机器上,这个基准测试无法终止。为什么会这样?

我们需要深入了解 Go 的基准测试运行机制,才能理解为什么基准测试无法终止。基准测试工具会多次调用我们的基准测试函数。具体调用多少次?这可以由类似 -benchtime 的标志来决定,但默认情况下是一秒钟。那么基准测试工具如何决定传给基准测试函数的重复次数,以保证测试持续1秒呢?首先,它尝试少量的重复次数,并测量需要多长时间,然后会逐步增加重复次数,直到达到预期的持续时间。

在这个过程中,有一些关键点需要注意:例如,重复尝试次数不会过快增加,在 Go 1.21 中,增长率最多只能达到 100 倍,并且,重复尝试次数的上限为 10 亿次。

在理解了以上信息之后,我们可以详细讨论上文中提到的两种误用场景。

  1. 忽略了对 b.N 的循环操作。在这种情况下,每次基准测试只会执行一次测试操作。不论 b.N 重复测试的次数如何增加,函数的运行时间始终是一毫秒。因此,基准测试工具最终会触达 10 亿次调用的N 限制,并且会使用执行时间(1 毫秒)除以此 N 得出一个无意义的结果。

  2. 将 b.N 的值用于“重复基准测试次数”之外的操作。在处理小输入时,rand.Prime 的运行速度非常快,但是,对于大输入,它的运行速度就会显著下降。基准测试工具初始会运行一次函数来设定基准,然后再运行 100 次。对于大小为 100 的输入,rand.Prime 的运行时间合适,所以基准测试工具可以将 b.N 增加 100 倍。但是,对于更大的输入,rand.Prime 的运行时间也会随之增加。最后,我们得到一个指数级别增长的运行时间!我们的基准测试函数并未真正的卡住 - 它会最终完成,但可能需要耗时好几分钟甚至是几个小时。

Issue 61515 建议提供一个基准测试 API,从而降低这种误用的可能性,同样能解决上述问题。另一个还有一个 增加对 int 范围的支持 的建议,该建议将使我们能够通过使用 for range b.N 精确地迭代 b.N 次,无需显式定义迭代变量,也有助于解决这个问题。

你还知道哪基准测试的陷阱或误区?欢迎在评论区分享交流。

参考链接

  1. 测试包的文档:https://pkg.go.dev/testing

  2. Amdahl's law:https://en.wikipedia.org/wiki/Amdahl's_law

  3. runtime.KeepAlive:https://pkg.go.dev/runtime#KeepAlive

  4. Issue 61179:https://github.com/golang/go/issues/61179

  5. Issue 61515:https://github.com/golang/go/issues/61515

  6. 增加对 int 范围的支持:https://github.com/golang/go/issues/61405

粉丝福利:

声明:我要去上班所有作品(图文、音视频)均由用户自行上传分享,仅供网友学习交流,版权归原作者CSDN所有,原文出处。若您的权利被侵害,请联系删除。

本文标题:(go)(google官网)
本文链接:https://www.51qsb.cn/article/dvjn40.html

(0)
打赏微信扫一扫微信扫一扫QQ扫一扫QQ扫一扫
上一篇2023-08-07
下一篇2023-08-07

你可能还想知道

发表回复

登录后才能评论