Go 部落格

使用 testing.B.Loop 進行更可預測的基準測試

邵俊陽 (Junyang Shao)
2025 年 4 月 2 日

使用 testing 包編寫基準測試的 Go 開發者可能遇到過它的各種陷阱。Go 1.24 引入了一種新的編寫基準測試的方法,它同樣易於使用,但同時更健壯:testing.B.Loop

傳統上,Go 基準測試是使用從 0 到 b.N 的迴圈編寫的

func Benchmark(b *testing.B) {
  for range b.N {
    ... code to measure ...
  }
}

改為使用 b.Loop 是一個微不足道的改變

func Benchmark(b *testing.B) {
  for b.Loop() {
    ... code to measure ...
  }
}

testing.B.Loop 有許多優點

  • 它防止了基準測試迴圈中不必要的編譯器最佳化。
  • 它自動將設定和清理程式碼排除在基準測試計時之外。
  • 程式碼不會意外地依賴於總迭代次數或當前迭代。

這些都是使用 b.N 風格基準測試時容易犯的錯誤,它們會悄無聲息地導致虛假的基準測試結果。此外,b.Loop 風格的基準測試甚至完成得更快!

讓我們探討一下 testing.B.Loop 的優勢以及如何有效地利用它。

舊基準測試迴圈的問題

在 Go 1.24 之前,雖然基準測試的基本結構很簡單,但更復雜的基準測試需要更多的注意

func Benchmark(b *testing.B) {
  ... setup ...
  b.ResetTimer() // if setup may be expensive
  for range b.N {
    ... code to measure ...
    ... use sinks or accumulation to prevent dead-code elimination ...
  }
  b.StopTimer() // if cleanup or reporting may be expensive
  ... cleanup ...
  ... report ...
}

如果設定或清理並非微不足道,開發者需要用 ResetTimer 和/或 StopTimer 呼叫來包圍基準測試迴圈。這些很容易忘記,即使開發者記得它們可能是必要的,也很難判斷設定或清理是否“足夠昂貴”以至於需要它們。

沒有這些,testing 包只能計時整個基準測試函式。如果基準測試函式省略了它們,設定和清理程式碼將包含在總時間測量中,悄無聲息地扭曲最終的基準測試結果。

還有另一個更微妙的陷阱,需要更深入的理解:(示例來源

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 range b.N {
    isCond(201)
  }
}

在此示例中,使用者可能會觀察到 isCond 在亞納秒時間內執行。CPU 很快,但沒那麼快!這個看似異常的結果源於 isCond 被內聯,並且由於其結果從未使用,編譯器將其作為死程式碼消除。因此,此基準測試根本沒有測量 isCond;它測量的是什麼都不做所需的時間。在這種情況下,亞納秒的結果是一個明顯的危險訊號,但在更復雜的基準測試中,部分死程式碼消除可能導致看起來合理但仍然沒有測量預期結果的情況。

testing.B.Loop 如何提供幫助

b.N 風格的基準測試不同,testing.B.Loop 能夠跟蹤它在基準測試中首次呼叫以及最終迭代結束的時間。迴圈開始時的 b.ResetTimer 和結束時的 b.StopTimer 已整合到 testing.B.Loop 中,無需手動管理設定和清理程式碼的基準測試計時器。

此外,Go 編譯器現在檢測條件僅為呼叫 testing.B.Loop 的迴圈,並防止迴圈內的死程式碼消除。在 Go 1.24 中,這是透過禁止內聯到此類迴圈的主體中實現的,但我們計劃在將來改進這一點。

testing.B.Loop 的另一個優點是其一次性啟動方法。對於 b.N 風格的基準測試,testing 包必須多次呼叫基準測試函式,並使用不同的 b.N 值,直到測得的時間達到閾值。相比之下,b.Loop 可以簡單地執行基準測試迴圈,直到達到時間閾值,並且只需呼叫基準測試函式一次。在內部,b.Loop 仍然使用啟動過程來分攤測量開銷,但這對於呼叫者是隱藏的,並且效率更高。

b.N 風格迴圈的某些限制仍然適用於 b.Loop 風格迴圈。如有必要,使用者仍然有責任在基準測試迴圈中管理計時器:(示例來源

func BenchmarkSortInts(b *testing.B) {
  ints := make([]int, N)
  for b.Loop() {
    b.StopTimer()
    fillRandomInts(ints)
    b.StartTimer()
    slices.Sort(ints)
  }
}

在此示例中,為了對 slices.Sort 的原地排序效能進行基準測試,每次迭代都需要一個隨機初始化的陣列。在這種情況下,使用者仍然必須手動管理計時器。

此外,基準測試函式體中仍然只能有一個這樣的迴圈(b.N 風格的迴圈不能與 b.Loop 風格的迴圈共存),並且迴圈的每次迭代都應該做同樣的事情。

何時使用

testing.B.Loop 方法現在是編寫基準測試的首選方式

func Benchmark(b *testing.B) {
  ... setup ...
  for b.Loop() {
    // optional timer control for in-loop setup/cleanup
    ... code to measure ...
  }
  ... cleanup ...
}

testing.B.Loop 提供更快、更準確、更直觀的基準測試。

致謝

非常感謝社群中所有為提案問題提供反饋並在該功能釋出時報告錯誤的人!我也感謝 Eli Bendersky 提供的有益部落格摘要。最後,非常感謝 Austin Clements、Cherry Mui 和 Michael Pratt 對設計選項和文件改進的審查和深思熟慮的工作。感謝大家的所有貢獻!

下一篇文章:Go 加密安全審計
上一篇文章:再見核心型別 - 你好我們熟悉和喜愛的 Go!
部落格索引