Go 部落格
使用 testing.B.Loop 進行更可預測的基準測試
使用 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!
部落格索引