技巧:如何在 Go 中編寫準確的基準測試?

語言: CN / TW / HK

爭做團隊核心程序員,關注「 幽鬼

大家好,我是程序員幽鬼。

今天給大家帶來一篇關於基準測試的文章。

一般來説,我們永遠不應該猜測性能。在編寫優化時,可能會有很多因素髮揮作用,即使我們對結果有強烈的看法,測試它們也不是一個壞主意。然而,編寫基準測試並不簡單。編寫不準確的基準並基於它們做出錯誤的假設非常簡單。這篇文章的目的是檢查導致不準確的四個常見和具體的陷阱:

  • 不重置或暫停計時器

  • 對微基準做出錯誤假設

  • 不留意編譯器優化

  • 被觀察者效應愚弄

概念

在討論這些陷阱之前,讓我們簡要回顧一下基準測試在 Go 中是如何工作的。基準的骨架如下:

func BenchmarkFoo(b *testing.B) {
 for i := 0; i < b.N; i++ {
  foo()
 }
}

函數名以 Benchmark 前綴開頭。在循環 for 中調用被測函數 (foo) 。 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=. -benchtime=2s
BenchmarkFoo-4               150          15832169 ns/op

foo 執行的次數大約是上一次基準測試期間的兩倍。

接下來,我們來看看一些常見的陷阱。

不重置或暫停計時器

在某些情況下,我們需要在基準循環之前執行操作。這些操作可能需要相當長的時間(例如,生成大量數據)並且可能會顯着影響基準測試結果:

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 timer
 for 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 timer
  expensiveSetup()
  b.StartTimer() // Resume the benchmark timer
  functionUnderTest()
 }
}

在這裏,我們暫停基準計時器以執行耗時的設置,然後恢復計時器。

注意:關於這種方法有一個需要記住的問題:如果被測函數與設置函數相比執行速度太快,則基準測試可能需要很長時間才能完成。原因是它需要超過 1 秒才能到達 * benchtime *。計算基準時間僅基於 * functionUnderTest *。因此,如果我們在每次循環迭代中等待相當長的時間,基準測試將比 1 秒慢得多。如果我們想保持基準,一種可能的緩解措施是降低 * benchtime *。

我們必須確保使用計時器方法來保持基準的準確性。

對微觀基準做出錯誤假設

微基準測量一個微小的計算單元,很容易對它做出錯誤的假設。例如,假設我們不確定是否使用 atomic.StoreInt32atomic.StoreInt64 (假設我們處理的值總是適合 32 位)。我們想編寫一個基準來比較這兩個函數:

func BenchmarkAtomicStoreInt32(b *testing.B) {
 var v int32
 for i := 0; i < b.N; i++ {
  atomic.StoreInt32(&v, 1)
 }
}

func BenchmarkAtomicStoreInt64(b *testing.B) {
 var v int64
 for 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=. -count=10 | 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.StoreInt64 用法相似(在特定機器上的特定 Go 版本中),而不是得出 atomic.StoreInt32 更快或更慢這樣的結論。

一般來説,我們應該對微觀基準保持謹慎。許多因素會顯着影響結果並可能導致錯誤的假設。增加基準測試時間或使用工具重複執行基準測試和計算統計數據比如 benchstat 可以是限制外部因素並獲得更準確結果的有效方法,從而得出更好的結論。

讓我們還強調一下,如果另一個系統最終運行應用程序,我們應該小心使用在給定機器上執行的微基準測試的結果。生產系統的行為可能與我們運行微基準測試的系統完全不同。

不關心編譯器優化

另一個與編寫基準相關的常見錯誤是被編譯器優化所愚弄,這也可能導致錯誤的基準假設。在本節中,我們使用人口計數函數(計算位數的 函數 [1] 設置為 1):

const m1 = 0x5555555555555555
const m2 = 0x3333333333333333
const m4 = 0x0f0f0f0f0f0f0f0f
const h01 = 0x0101010101010101

func popcnt(x uint64) uint64 {
 x -= (x >> 1) & m1
 x = (x & m2) + ((x >> 2) & m2)
 x = (x + (x >> 4)) & m4
 return (x * h01) >> 56
}

這個函數接受並返回一個 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
 }
}

基準現在是空的——這就是我們得到接近一個時鐘週期的結果的原因。為防止這種情況發生,最佳做法是遵循以下模式:

  1. 在每次循環迭代期間,將結果分配給局部變量(在基準函數的上下文中為局部變量)。

  2. 將最新結果分配給全局變量。

在我們的例子中,我們編寫了以下基準:

var global uint64 // Define a global variable

func BenchmarkPopcnt2(b *testing.B) {
 var v uint64 // Define a local variable
 for 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 局部變量。然後我們將最新的結果分配給全局變量。

注意:為什麼不將* *popcnt* *調用結果直接分配給 global 以簡化測試?寫入全局變量比寫入局部變量慢(這些概念在 100 個 Go 錯誤 [2] 中討論,錯誤 #95:“ 不理解堆棧與堆 [3] ”)。因此,我們應該將每個結果寫入一個局部變量,以限制每次循環迭代期間的足跡。

如果我們運行這兩個基準測試,我們現在得到的結果有明顯的差異:

cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkPopcnt1-4      1000000000               0.2858 ns/op
BenchmarkPopcnt2-4      606402058                1.993 ns/op

BenchmarkPopcnt2 是基準的準確版本。它保證我們避免內聯優化,這可以人為地降低執行時間,甚至刪除對被測函數的調用。依賴結果 BenchmarkPopcnt1 可能會導致錯誤的假設。

讓我們記住避免編譯器優化欺騙基準測試結果的模式:將被測函數的結果分配給局部變量,然後將最新結果分配給全局變量。這種最佳實踐還可以防止我們做出錯誤的假設。

被觀察者效應愚弄

在物理學中,觀察者效應是觀察行為對被觀察系統的擾動。這種影響也可以在基準測試中看到,並可能導致對結果的錯誤假設。讓我們看一個具體的例子,然後嘗試減輕它。

我們想要實現一個接收 int64 元素矩陣的函數。這個矩陣有固定的 512 列,我們要計算前 8 列的總和,如圖 11.2 所示。

計算前八列的總和

為了優化,我們還想確定改變列數是否有影響,所以我們還實現了第二個函數,有 513 列。實現如下:

func calculateSum512(s [][512]int64) int64 {
 var sum int64
 for i := 0; i < len(s); i++ { // Iterate over each row
  for j := 0; j < 8; j++ { // Iterate over the first eight columns
   sum += s[i][j] // Increment sum
  }
 }
 return sum
}

func calculateSum513(s [][513]int64) int64 {
 // Same implementation as calculateSum512
}

我們遍歷每一行,然後遍歷前八列,並增加一個返回的 sum 變量。 calculateSum513 中的實現保持不變。

我們想要對這些函數進行基準測試,以確定在給定固定行數的情況下哪個函數的性能最高:

const rows = 1000

var res int64

func BenchmarkCalculateSum512(b *testing.B) {
 var sum int64
 s := createMatrix512(rows) // Create a matrix of 512 columns
 b.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 int64
 s := createMatrix513(rows) // Create a matrix of 513 columns
 b.ResetTimer()
 for i := 0; i < b.N; i++ {
  sum = calculateSum513(s) // Calculate the sum
 }
 res = sum
}

我們只想創建一次矩陣,以限制結果的足跡。因此,我們在循環之外調用 createMatrix512 and 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 列)則不適合。

注意:這不在本文的範圍內解釋原因,但我們會在* *100 Go Mistakes* [4] 中查看這個問題,錯誤 #91:“ *不瞭解 CPU 緩存* [5] *”。

回到基準測試,主要問題是我們在兩種情況下都重複使用相同的矩陣。因為函數重複了數千次,所以當函數接收到一個普通的新矩陣時,我們不會測量函數的執行。相反,我們測量一個函數,該函數獲取一個矩陣,該矩陣已經在緩存中存在單元的子集。因此,由於 calculateSum513 導致更少的緩存未命中,它具有更好的執行時間。

這是觀察者效應的一個例子。因為我們一直在觀察一個重複調用的 CPU-bound 函數,CPU 緩存可能會發揮作用並顯着影響結果。在這個例子中,為了防止這種影響,我們應該在每次測試期間創建一個矩陣,而不是重用一個:

func BenchmarkCalculateSum512(b *testing.B) {
 var sum int64
 for i := 0; i < b.N; i++ {
  b.StopTimer()
  s := createMatrix512(rows) // Create a new matrix during each loop iteration
  b.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 綁定函數使用的數據。

這篇文章摘自作者圖書 *100 Go Mistakes and How to Avoid Them* [6] ,該書於 2022 年 8 月發佈。

原文鏈接:https://teivah.medium.com/how-to-write-accurate-benchmarks-in-go-4266d7dd1a95

參考資料

[1]

函數: https://github.com/golang/go/issues/14813

[2]

100 個 Go 錯誤: https://www.manning.com/books/100-go-mistakes-and-how-to-avoid-them

[3]

不理解堆棧與堆: https://livebook.manning.com/book/100-go-mistakes-and-how-to-avoid-them/chapter-12/section-12-5

[4]

100 Go Mistakes : https://www.manning.com/books/100-go-mistakes-and-how-to-avoid-them

[5]

不瞭解 CPU 緩存 : https://livebook.manning.com/book/100-go-mistakes-and-how-to-avoid-them/chapter-12/section-12-1

[6]

100 Go Mistakes and How to Avoid Them : https://www.manning.com/books/100-go-mistakes-and-how-to-avoid-them

往期推薦

歡迎關注「 幽鬼 」,像她一樣做團隊的核心。