常州模板网站建设企业,网页设计与制作需要学什么软件,个人网站 后台管理,成都一网吃尽小程序一般来说#xff0c;我们不应该对性能进行猜测。在编写优化时#xff0c;会有许多因素可能起作用#xff0c;即使我们对结果有很强的看法#xff0c;测试它们很少是一个坏主意。然而#xff0c;编写基准测试并不简单。很容易编写不准确的基准测试#xff0c;并且基于这些…
一般来说我们不应该对性能进行猜测。在编写优化时会有许多因素可能起作用即使我们对结果有很强的看法测试它们很少是一个坏主意。然而编写基准测试并不简单。很容易编写不准确的基准测试并且基于这些测试得出错误的假设。这篇文章的目标是探讨导致不准确的四个常见和具体陷阱
不重置或暂停计时器对微基准测试做出错误假设不注意编译器优化被观察效应所误导
通用概念
在讨论这些陷阱之前让我们简要回顾一下 Go 语言中基准测试的工作原理。基准测试的框架大致如下
func BenchmarkFoo(b *testing.B) {for i : 0; i b.N; i {foo()}
}函数名以Benchmark前缀开头。被测试的函数foo在for循环内被调用。b.N代表着可变数量的迭代次数。在运行基准测试时Go 会尝试使其匹配所请求的基准测试时间。基准测试时间默认设置为1秒可以使用-benchtime标志进行更改。b.N从1开始如果基准测试在1秒内完成b.N会增加然后再次运行基准测试直到b.N大致匹配benchtime为止。
$ go test -bench.
cpu: Intel(R) Core(TM) i5-7360U CPU 2.30GHz
BenchmarkFoo-4 73 16511228 ns/op在这里基准测试大约花费了1秒钟foo被执行了73次平均执行时间为16,511,228纳秒。我们可以使用-benchtime来更改基准测试时间
$ go test -bench. -benchtime2s
BenchmarkFoo-4 150 15832169 ns/opfoo 的执行次数大约是上一个基准测试的两倍。
接下来让我们看一些常见的陷阱。
不重置或暂停计时器
在某些情况下我们需要在基准测试循环之前执行一些操作。这些操作可能需要相当长的时间例如生成一个大型数据切片可能会对基准测试结果产生显著影响
func BenchmarkFoo(b *testing.B) {expensiveSetup()for i : 0; i b.N; i {functionUnderTest()}
}在这种情况下我们可以在进入循环之前使用ResetTimer方法
func BenchmarkFoo(b *testing.B) {expensiveSetup()b.ResetTimer() // Reset the benchmark timerfor i : 0; i b.N; i {functionUnderTest()}
}调用ResetTimer会将自测试开始以来的经过时间和内存分配计数器归零。这样一个昂贵的设置步骤可以从测试结果中排除。
如果我们不仅需要在测试中执行一次昂贵的设置步骤而是需要在每个循环迭代中都执行呢
func BenchmarkFoo(b *testing.B) {for i : 0; i b.N; i {expensiveSetup()functionUnderTest()}
}我们不能重置计时器因为那样会在每次循环迭代期间执行。但是我们可以停止和恢复基准测试计时器将调用expensiveSetup包裹起来
func BenchmarkFoo(b *testing.B) {for i : 0; i b.N; i {b.StopTimer() // Pause the benchmark timerexpensiveSetup()b.StartTimer() // Resume the benchmark timerfunctionUnderTest()}
}在这里我们暂停基准测试计时器来执行昂贵的设置步骤然后恢复计时器。
注意 关于这种方法有一个需要记住的地方如果被测试的函数执行速度远远比设置函数要快那么基准测试可能会花费太长时间才能完成。原因是它需要比 benchtime 更长的时间才能完成。基准测试时间的计算完全基于 functionUnderTest 的执行时间。所以如果在每个循环迭代中等待了相当长的时间基准测试就会比1秒要慢得多。如果我们想保持基准测试一种可能的缓解方法是减少 benchtime 。
我们必须确保使用计时器方法来保持基准测试的准确性。
对微基准测试做出错误假设
微基准测试测量的是微小的计算单元很容易对它做出错误的假设。比如说我们不确定是应该使用atomic.StoreInt32还是atomic.StoreInt64假设处理的值始终适合32位。我们想编写一个基准测试来比较这两个函数
func BenchmarkAtomicStoreInt32(b *testing.B) {var v int32for i : 0; i b.N; i {atomic.StoreInt32(v, 1)}
}func BenchmarkAtomicStoreInt64(b *testing.B) {var v int64for i : 0; i b.N; i {atomic.StoreInt64(v, 1)}
}如果我们运行这个基准测试以下是一些示例输出
cpu: Intel(R) Core(TM) i5-7360U CPU 2.30GHz
BenchmarkAtomicStoreInt32
BenchmarkAtomicStoreInt32-4 197107742 5.682 ns/op
BenchmarkAtomicStoreInt64
BenchmarkAtomicStoreInt64-4 213917528 5.134 ns/op我们很容易认为这个基准测试并且决定使用atomic.StoreInt64因为它看起来更快。现在为了进行一次公平的基准测试我们改变顺序先测试atomic.StoreInt64然后测试atomic.StoreInt32。以下是一些示例输出
BenchmarkAtomicStoreInt64
BenchmarkAtomicStoreInt64-4 224900722 5.434 ns/op
BenchmarkAtomicStoreInt32
BenchmarkAtomicStoreInt32-4 230253900 5.159 ns/op这次atomic.StoreInt32有更好的结果。发生了什么事
在微基准测试中许多因素都会影响结果比如在运行基准测试时的机器活动、电源管理、热管理和一系列指令的更好的缓存对齐。我们必须记住许多因素甚至超出我们 Go 项目范围的因素都可能会影响结果。
注意 我们应该确保运行基准测试的机器处于空闲状态。但是外部进程可能在后台运行这可能会影响基准测试结果。因此像 perflock 这样的工具可以限制基准测试可以消耗的CPU。例如我们可以使用总可用CPU的70%来运行基准测试将30%分配给操作系统和其他进程减少机器活动因素对结果的影响。
一个选择是使用-benchtime选项增加基准测试时间。类似于概率论中的大数定律如果我们运行大量次数的基准测试它应该趋向于接近其期望值假设我们忽略了指令缓存等机制的好处。
另一个选择是在经典基准测试工具之上使用外部工具。例如benchstat工具是golang.org/x代码库的一部分它允许我们计算和比较有关基准测试执行的统计数据。
让我们使用-count选项运行基准测试10次并将输出重定向到一个特定的文件
$ go test -bench. -count10 | tee stats.txt
cpu: Intel(R) Core(TM) i5-7360U CPU 2.30GHz
BenchmarkAtomicStoreInt32-4 234935682 5.124 ns/op
BenchmarkAtomicStoreInt32-4 235307204 5.112 ns/op
// ...
BenchmarkAtomicStoreInt64-4 235548591 5.107 ns/op
BenchmarkAtomicStoreInt64-4 235210292 5.090 ns/op
// ...然后我们可以在这个文件上运行benchstat
$ benchstat stats.txt
name time/op
AtomicStoreInt32-4 5.10ns ± 1%
AtomicStoreInt64-4 5.10ns ± 1%结果是一样的两个函数的平均完成时间都是5.10纳秒。我们还可以看到给定基准测试执行间的百分比变化±1%。这个指标告诉我们两个基准测试都是稳定的这让我们对计算出的平均结果更有信心。因此我们不能得出atomic.StoreInt32比atomic.StoreInt64快或慢的结论而是可以得出对于我们测试的用途在特定的Go版本和特定的机器上它们的执行时间是相似的。
总的来说我们在进行微基准测试时应该保持谨慎。许多因素都可以对结果产生重大影响并潜在地导致错误的假设。增加基准测试时间或重复基准测试的执行并使用诸如benchstat之类的工具计算统计数据可以是限制外部因素并获得更准确结果的有效方法从而得出更好的结论。
我们还应该注意如果另一个系统最终运行该应用程序要小心使用在特定机器上执行的微基准测试的结果。生产系统的行为可能与我们运行微基准测试的系统大不相同。
不注意编译器优化
与编写基准测试相关的另一个常见错误是被编译器优化所欺骗这也可能导致错误的基准测试假设。在这一节中我们将看看Go语言的14813号问题https://github.com/golang/go/issues/14813也被Go项目成员Dave Cheney讨论过涉及到一个人口统计函数一个计算设置为1的位数的函数
const m1 0x5555555555555555
const m2 0x3333333333333333
const m4 0x0f0f0f0f0f0f0f0f
const h01 0x0101010101010101func popcnt(x uint64) uint64 {x - (x 1) m1x (x m2) ((x 2) m2)x (x (x 4)) m4return (x * h01) 56
}这个函数接受一个uint64并返回一个uint64。为了对这个函数进行基准测试我们可以编写以下内容
func BenchmarkPopcnt1(b *testing.B) {for i : 0; i b.N; i {popcnt(uint64(i))}
}然而如果我们执行这个基准测试结果会非常低得令人惊讶
cpu: Intel(R) Core(TM) i5-7360U CPU 2.30GHz
BenchmarkPopcnt1-4 1000000000 0.2858 ns/op一个持续时间为0.28纳秒大约是一个时钟周期所以这个数字是非常不合理地低。问题在于开发者对编译器优化不够谨慎。在这种情况下被测试的函数非常简单可以成为内联的候选对象一种优化用被调用函数的主体替换函数调用让我们避免函数调用这具有很小的开销。一旦函数被内联编译器注意到调用没有副作用并用以下基准测试替换了它
func BenchmarkPopcnt1(b *testing.B) {for i : 0; i b.N; i {// Empty}
}现在基准测试是空的这就是我们得到接近一个时钟周期结果的原因。为了防止这种情况发生最佳实践是遵循以下模式
在每个循环迭代中将结果赋值给一个局部变量在基准测试函数的上下文中为局部变量。将最新的结果赋值给一个全局变量。
在我们的情况下我们编写以下基准测试
var global uint64 // Define a global variablefunc BenchmarkPopcnt2(b *testing.B) {var v uint64 // Define a local variablefor i : 0; i b.N; i {v popcnt(uint64(i)) // Assign the result to the local variable}global v // Assign the result to the global variable
}global 是一个全局变量而 v 是一个局部变量其作用域限定在基准测试函数内部。在每个循环迭代中我们将 popcnt 的结果赋值给局部变量 v。然后将最新的结果赋值给全局变量 global。
如果我们运行这两个基准测试现在结果之间会有显著的差异
cpu: Intel(R) Core(TM) i5-7360U CPU 2.30GHz
BenchmarkPopcnt1-4 1000000000 0.2858 ns/op
BenchmarkPopcnt2-4 606402058 1.993 ns/opBenchmarkPopcnt2 是基准测试的准确版本。它确保我们避免了内联优化这种优化可能会人为降低执行时间甚至去除对被测试函数的调用。依赖BenchmarkPopcnt1的结果可能会导致错误的假设。
让我们记住避免编译器优化误导基准测试结果的模式将被测试函数的结果赋值给一个局部变量然后将最新的结果赋值给一个全局变量。这种最佳实践还可以防止我们做出不正确的假设。
被观察效应的欺骗
在物理学中观察者效应是通过观察行为干扰观察系统的现象。这种效应也可以在基准测试中看到并可能导致对结果做出错误的假设。让我们看一个具体的例子然后尝试减轻这种影响。
我们想要实现一个接收int64元素矩阵的函数。这个矩阵有固定的512列我们想要计算前八列的总和如图11.2所示。 计算前八列的总和
为了优化的目的我们还想确定变化列数是否会产生影响所以我们也实现了一个有513列的第二个函数。实现如下
func calculateSum512(s [][512]int64) int64 {var sum int64for i : 0; i len(s); i { // Iterate over each rowfor j : 0; j 8; j { // Iterate over the first eight columnssum s[i][j] // Increment sum}}return sum
}func calculateSum513(s [][513]int64) int64 {// Same implementation as calculateSum512
}我们遍历每一行然后遍历前八列并增加一个我们要返回的总和变量。在calculateSum513中的实现保持不变。
我们想对这些函数进行基准测试以决定在固定行数的情况下哪一个性能最好
const rows 1000var res int64func BenchmarkCalculateSum512(b *testing.B) {var sum int64s : createMatrix512(rows) // Create a matrix of 512 columnsb.ResetTimer()for i : 0; i b.N; i {sum calculateSum512(s) // Create a matrix of 512 columns}res sum
}func BenchmarkCalculateSum513(b *testing.B) {var sum int64s : createMatrix513(rows) // Create a matrix of 513 columnsb.ResetTimer()for i : 0; i b.N; i {sum calculateSum513(s) // Calculate the sum}res sum
}我们希望只创建一次矩阵以限制对结果的影响。因此我们在循环外调用createMatrix512和createMatrix513。我们可能期望结果会类似因为我们只想遍历前八列但事实并非如此在我的机器上
cpu: Intel(R) Core(TM) i5-7360U CPU 2.30GHz
BenchmarkCalculateSum512-4 81854 15073 ns/op
BenchmarkCalculateSum513-4 161479 7358 ns/op第二个拥有513列的基准测试要快大约50%。再次强调因为我们只遍历了前八列这个结果相当令人惊讶。
要理解这种差异我们需要了解CPU缓存的基础知识。简而言之CPU由不同的缓存组成通常是L1、L2和L3。这些缓存降低了从主存储器访问数据的平均成本。在某些条件下CPU可以从主存储器获取数据并将其复制到L1。在这种情况下CPU试图将calculateSum感兴趣的矩阵子集每行的前八列放入L1。然而一个情况下矩阵可以完全放入内存513列而另一个情况下不能512列。
回到基准测试主要问题在于我们在两种情况下都重复使用相同的矩阵。因为该函数重复执行数千次我们并未测量接收纯新矩阵的函数执行情况。相反我们测量的是一个已经包含在缓存中的矩阵的子集的函数。因此由于calculateSum513导致的缓存未命中更少它具有更好的执行时间。
这是观察效应的一个例子。因为我们一直在观察重复调用的CPU密集型函数CPU缓存可能会起作用并且会对结果产生显著影响。在这个例子中为了防止这种效应我们应该在每次测试中创建一个新的矩阵而不是重复使用一个
func BenchmarkCalculateSum512(b *testing.B) {var sum int64for i : 0; i b.N; i {b.StopTimer()s : createMatrix512(rows) // Create a new matrix during each loop iterationb.StartTimer()sum calculateSum512(s)}res sum
}现在在每次循环迭代中都创建了一个新的矩阵。如果我们再次运行基准测试并调整benchtime——否则执行时间太长结果会更接近
cpu: Intel(R) Core(TM) i5-7360U CPU 2.30GHz
BenchmarkCalculateSum512-4 1116 33547 ns/op
BenchmarkCalculateSum513-4 998 35507 ns/op不是做出calculateSum513更快的错误假设我们看到当接收新矩阵时两个基准测试的结果更接近。
正如我们在本文中看到的那样因为我们重复使用了同一个矩阵CPU缓存对结果产生了显著影响。为了防止这种情况发生我们必须在每个循环迭代期间创建一个新的矩阵。总的来说我们应该记住在观察一个被测试函数的情况下可能会在结果中产生显著差异特别是在涉及低级优化的CPU密集型函数的微基准测试中。强制基准测试在每次迭代期间重新创建数据可能是防止这种影响的一种好方法。
结论
使用时间方法来保持基准测试的准确性。当处理微基准测试时增加benchtime或使用benchstat等工具可能会有所帮助。注意微基准测试的结果如果最终运行应用程序的系统与运行微基准测试的系统不同。确保被测试函数产生了副作用以防止编译器优化让您对基准测试结果产生误解。为了防止观察者效应强制基准测试重新创建CPU密集型函数使用的数据。