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 的另一個優點是其一次性預熱(ramp-up)方法。使用 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 提供了更快、更準確、更直觀的基準測試。

致謝

非常感謝社群中所有對提案問題提供反饋以及在新功能釋出時報告 bug 的人!我也感謝 Eli Bendersky 提供的有益的部落格摘要。最後,特別感謝 Austin Clements、Cherry Mui 和 Michael Pratt 的審閱、對設計方案的深思熟慮以及文件改進。感謝大家的貢獻!

上一篇文章:告別核心型別 - 迎接我們熟悉和喜愛的 Go!
部落格索引