11.4. 基準測試

基準測試是測量一個程序在固定工作負載下的性能。在Go語言中,基準測試函數和普通測試函數寫法類似,但是以Benchmark爲前綴名,併且帶有一個*testing.B類型的參數;*testing.B參數除了提供和*testing.T類似的方法,還有額外一些和性能測量相關的方法。它還提供了一個整數N,用於指定操作執行的循環次數。

下面是IsPalindrome函數的基準測試,其中循環將執行N次。

import "testing"

func BenchmarkIsPalindrome(b *testing.B) {
    for i := 0; i < b.N; i++ {
        IsPalindrome("A man, a plan, a canal: Panama")
    }
}

我們用下面的命令運行基準測試。和普通測試不同的是,默認情況下不運行任何基準測試。我們需要通過-bench命令行標誌參數手工指定要運行的基準測試函數。該參數是一個正則表達式,用於匹配要執行的基準測試函數的名字,默認值是空的。其中“.”模式將可以匹配所有基準測試函數,但是這里總共隻有一個基準測試函數,因此和-bench=IsPalindrome參數是等價的效果。

$ cd $GOPATH/src/gopl.io/ch11/word2
$ go test -bench=.
PASS
BenchmarkIsPalindrome-8 1000000                1035 ns/op
ok      gopl.io/ch11/word2      2.179s

結果中基準測試名的數字後綴部分,這里是8,表示運行時對應的GOMAXPROCS的值,這對於一些和併發相關的基準測試是重要的信息。

報告顯示每次調用IsPalindrome函數花費1.035微秒,是執行1,000,000次的平均時間。因爲基準測試驅動器開始時併不知道每個基準測試函數運行所花的時間,它會嚐試在眞正運行基準測試前先嚐試用較小的N運行測試來估算基準測試函數所需要的時間,然後推斷一個較大的時間保證穩定的測量結果。

循環在基準測試函數內實現,而不是放在基準測試框架內實現,這樣可以讓每個基準測試函數有機會在循環啟動前執行初始化代碼,這樣併不會顯著影響每次迭代的平均運行時間。如果還是擔心初始化代碼部分對測量時間帶來榦擾,那麽可以通過testing.B參數提供的方法來臨時關閉或重置計時器,不過這些一般很少會用到。

現在我們有了一個基準測試和普通測試,我們可以很容易測試新的讓程序運行更快的想法。也許最明顯的優化是在IsPalindrome函數中第二個循環的停止檢査,這樣可以避免每個比較都做兩次:

n := len(letters)/2
for i := 0; i < n; i++ {
    if letters[i] != letters[len(letters)-1-i] {
        return false
    }
}
return true

不過很多情況下,一個明顯的優化併不一定就能代碼預期的效果。這個改進在基準測試中隻帶來了4%的性能提陞。

$ go test -bench=.
PASS
BenchmarkIsPalindrome-8 1000000              992 ns/op
ok      gopl.io/ch11/word2      2.093s

另一個改進想法是在開始爲每個字符預先分配一個足夠大的數組,這樣就可以避免在append調用時可能會導致內存的多次重新分配。聲明一個letters數組變量,併指定合適的大小,像下面這樣,

letters := make([]rune, 0, len(s))
for _, r := range s {
    if unicode.IsLetter(r) {
        letters = append(letters, unicode.ToLower(r))
    }
}

這個改進提陞性能約35%,報告結果是基於2,000,000次迭代的平均運行時間統計。

$ go test -bench=.
PASS
BenchmarkIsPalindrome-8 2000000                      697 ns/op
ok      gopl.io/ch11/word2      1.468s

如這個例子所示,快的程序往往是伴隨着較少的內存分配。-benchmem命令行標誌參數將在報告中包含內存的分配數據統計。我們可以比較優化前後內存的分配情況:

$ go test -bench=. -benchmem
PASS
BenchmarkIsPalindrome    1000000   1026 ns/op    304 B/op  4 allocs/op

這是優化之後的結果:

$ go test -bench=. -benchmem
PASS
BenchmarkIsPalindrome    2000000    807 ns/op    128 B/op  1 allocs/op

用一次內存分配代替多次的內存分配節省了75%的分配調用次數和減少近一半的內存需求。

這個基準測試告訴我們所需的絶對時間依賴給定的具體操作,兩個不同的操作所需時間的差異也是和不同環境相關的。例如,如果一個函數需要1ms處理1,000個元素,那麽處理10000或1百萬將需要多少時間呢?這樣的比較揭示了漸近增長函數的運行時間。另一個例子:I/O緩存該設置爲多大呢?基準測試可以幫助我們選擇較小的緩存但能帶來滿意的性能。第三個例子:對於一個確定的工作那種算法更好?基準測試可以評估兩種不同算法對於相同的輸入在不同的場景和負載下的優缺點。

一般比較基準測試都是結構類似的代碼。它們通常是采用一個參數的函數,從幾個標誌的基準測試函數入口調用,就像這樣:

func benchmark(b *testing.B, size int) { /* ... */ }
func Benchmark10(b *testing.B)         { benchmark(b, 10) }
func Benchmark100(b *testing.B)        { benchmark(b, 100) }
func Benchmark1000(b *testing.B)       { benchmark(b, 1000) }

通過函數參數來指定輸入的大小,但是參數變量對於每個具體的基準測試都是固定的。要避免直接脩改b.N來控製輸入的大小。除非你將它作爲一個固定大小的迭代計算輸入,否則基準測試的結果將毫無意義。

基準測試對於編寫代碼是很有幫助的,但是卽使工作完成了也應當保存基準測試代碼。因爲隨着項目的發展,或者是輸入的增加,或者是部署到新的操作繫統或不同的處理器,我們可以再次用基準測試來幫助我們改進設計。

練習 11.6: 爲2.6.2節的練習2.4和練習2.5的PopCount函數編寫基準測試。看看基於表格算法在不同情況下對提陞性能會有多大幫助。

練習 11.7: 爲*IntSet(§6.5)的Add、UnionWith和其他方法編寫基準測試,使用大量隨機輸入。你可以讓這些方法跑多快?選擇字的大小對於性能的影響如何?IntSet和基於內建map的實現相比有多快?