Go 高效能系列教程之一:基準測試

yudotyang發表於2021-05-26

1. 基準測試

三思而後行(Measure twice and cut once)

在我們試圖改程式序效能之前,我們首先要知道程式的當前效能。 本節主要關注使用 Go testing 包如何構建有用的基準測試,並且給出一些最佳實踐避免踩坑。

1.1 基準測試基本原則

在進行基準測試之前,你必須要有一個穩定的環境以得到可重複的輸出結果。

  • 機器必須是空閒狀態 -- 不能在共享的硬體上採集資料,當長時間執行基準測試時不能瀏覽網頁等
  • 機器是否關閉了節能模式。一般膝上型電腦上會預設開啟該模式。
  • 避免使用虛擬機器和雲主機。一般情況下,為了儘可能地提高資源的利用率,虛擬機器和雲主機 CPU 和記憶體一般會超分配,超分機器的效能表現會非常地不穩定。

如果負擔得起,請購買專用的效能測試硬體。 機架安裝,禁用所有電源管理和熱量縮放功能,並且永遠不要在這些計算機上更新軟體。 最後一點是從系統管理的角度來看糟糕的建議,但是如果軟體更新改變了核心或庫的執行方式 - 想想 Spectre 補丁 - 這將使以前的任何基準測試結果無效。

對於其他的原則,請進行前後取樣,然後多次執行以獲取一致的結果。

1.2 使用 testing 包構建基準測試

testing 包中已經內建了基準測試的功能。如果我們有一個如下簡單的函式:

func Fib3(n int) int {
    switch n {
    case 0:
        return 0
    case 1:
        return 1
    case 2:
        return 1
    default:
        return Fib(n-1) + Fib(n-2)
    }
}

我們可以通過 testing 包來寫基準測試,基準測試的程式碼如下:

func BenchmarkFib20(b *testing.B) {
    for n := 0; n < b.N; n++ {
        Fib(20) //執行b.N次Fib函式
    }
}

func BenchmarkFib28(b *testing.B) {
    for n := 0; n < b.N; n++ {
        Fib(28) //執行b.N次Fib函式
    }
}

注意:基準測試函式應該寫在檔名字尾是 _test.go 的檔案中

基準測試類似單元測試,唯一的不同就是在測試函式中傳的引數型別是 *testing.B,而非 *testing.T。這兩個型別都實現了 testing.TB介面,該介面提供了常用的 Errorf(),Fatalf() 和 FailNow() 常用函式。

1.2.1 執行一個包下的基準測試

因為基準測試使用的是 testing 包,所以要執行基準測試函式需要使用 go test 命令。但是,預設情況下,當我們呼叫 go test 的時候,基準測試會被排除在外,只執行單元測試。

所以,需要在 go test 命令中新增 -bench標記,以執行基準測試。-bench 標記使用一個正規表示式來匹配要執行的基準測試函式名稱。所以,最常用的方式就是通過 *** -bench=.*** 標記來執行該包下的所有的基準函式。如下:

% go test -bench=. ./examples/fib/
goos: darwin
goarch: amd64
pkg: high-performance-go-workshop/examples/fib
BenchmarkFib20-8           28947             40617 ns/op
PASS
ok      high-performance-go-workshop/examples/fib       1.602s

go test在匹配基準測試之前會執行所有的單元測試,如果你的程式碼裡有很多單元測試,或者單元測試會耗費很長的時間,你可以通過 go test 的-run 引數將單元測試排除掉。例如:

% go test -run=none

1.2.2 基準測試工作原理

每個基準函式被執行時都有一個不同的 b.N 值,這個值代表基準函式應該執行的迭代次數。

b.N 從 1 開始,如果基準函式在 1 秒內就執行完了,那麼 b.N 的值會遞增以便基準函式再重新執行(譯者注:即基準函式預設要執行 1 秒,如果該函式的執行時間在 1 秒內就執行完了,那麼就遞增 b.N 的值,重新再執行一次

b.N 按照近似順序增加,每次迭代大約增長 20%。基準框架試圖更智慧,如果它看到較小的 b.N 值相對較快的完成了迭代,它將 b.N 增加的更快。

在上面的 BenchmarkFi20-8 的例子中,我們發現迭代大約 29000 次耗時超過了 1 秒。依據此資料,基準框架計算得知,平均每次執行耗時 40617 納秒。

BenchmarkFi20-8 中的-8字尾是和執行該測試用例時的GOMAXPROCS值有關係。和GOMAXPROCS一樣,此數字預設為啟動時 Go 程式可見的 CPU 數

% go test -bench=. -cpu=1,2,4 ./examples/fib/
goos: darwin
goarch: amd64
pkg:high-performance-go-workshop/examples/fib
BenchmarkFib20             31479             37987 ns/op
BenchmarkFib20-2           31846             37859 ns/op
BenchmarkFib20-4           31716             39255 ns/op
PASS
ok      high-performance-go-workshop/examples/fib       4.805s

該示例展示了分別用 CPU 為 1 核、2 核、4 核時執行基準測試的結果。在該案例中,該引數對結果> > 幾乎沒有影響,因為該基準測試的程式碼是完全順序執行的。

1.2.3 Go 1.13 中基準測試框架的一些改變

在 Go1.13 版本之前,基準測試的迭代次數四捨五入為 1、2、3、5 的序列增長,這種四捨五入的初衷是使更便於肉眼閱讀(make it easier to eyeball times)。然而,正確的分析都需要工具才能進行,因此,隨著工具的改進,人們容易理解的數字變得不那麼有價值。

四捨五入可能會隱藏一個數量級的變化。

幸運的是,在 Go 1.13 版本中,四捨五入的方式已經被移除了,這提高了在低的單位操作耗時(ns/op) 的準確性,並隨著基準測試框架更快的達到正確的迭代次數而減少了整體基準測試的執行時間。

1.2.4 改進基準測試的準確性

fib 函式是一個特意設計的例子 -- 除非我們正在為 TechPower web 專案寫基準測試 -- 計算斐波那契數列中第 20 個數字的素材也不太可能會影響您的業務。但是,該示例提供了一個編寫基準測試的很好的示例。

具體來說,你希望你的基準測試可以執行數萬次迭代,以便你可以獲得一個較為準確的平均耗時。如果你的基準測試只執行了 100 次或 10 次迭代,那麼最終得出的平均值可能會偏高。如果你的基準測試執行了上百萬或十億次迭代,那麼得出的平均耗時將會非常準確,但這受程式碼佈局的限制。

可以使用-benchtime標識增加基準測試執行的時間的方式來增加迭代的次數。例如:

% go test -bench=. -benchtime=10s ./examples/fib/
goos: darwin
goarch: amd64
pkg: high-performance-go-workshop/examples/fib
BenchmarkFib20-8          313048             41673 ns/op
PASS
ok      high-performance-go-workshop/examples/fib       13.442s

執行相同的基準測試,直到其達到 b.N 的值需要花費超過 10 秒的時間才能返回。由於我們的執行時間增加了 10 倍,因此迭代的總次數也增加了 10 倍。結果(每次操作耗時 41673ns/op) 沒有太大的變化,這就是我們所期望的。

為什麼總的耗時是 13 秒,而不是 10 秒呢? 如果你又一個基準測試執行了數百萬次或數十億次迭代,導致每次操作的時間都在微秒或納秒範圍內,則你可能會發現基準值不穩定,因為你的機器硬體的散熱效能、記憶體區域性性、後臺程式、gc 等因素。

對於每次操作在 10 納秒以下的,指令重新排序,並且程式碼對齊的相對論效應將影響基準時間。

通過-count 標誌,可以指定基準測試多跑幾次:

% go test -bench=Fib20 -count=10 ./examples/fib/ | tee old.txt
goos: darwin
goarch: amd64
pkg: high-performance-go-workshop/examples/fib
BenchmarkFib20-8           30099             38117 ns/op
BenchmarkFib20-8           31806             40433 ns/op
BenchmarkFib20-8           30052             43412 ns/op
BenchmarkFib20-8           28392             39225 ns/op
BenchmarkFib20-8           28270             42956 ns/op
BenchmarkFib20-8           28276             49493 ns/op
BenchmarkFib20-8           26047             45571 ns/op
BenchmarkFib20-8           27392             43803 ns/op
BenchmarkFib20-8           27507             44896 ns/op
BenchmarkFib20-8           25647             43579 ns/op
PASS
ok      high-performance-go-workshop/examples/fib       16.516s

1.3 使用 benchstat 工具比較基準測試

在上面我建議執行多次基準測試以便獲取更多的資料來求平均值。對於任何一個基準測試來說,這是一個非常好的建議,由於基準測試受電源管理、後臺程式、散熱的影響。

接下來,我將介紹一個由 Russ Cox 編寫的工具:benchstat

% go get golang.org/x/perf/cmd/benchstat

Benchstat 可以進行一組基準測試,並告訴你他們的穩定性。這是 Fib(20) 函式在使用電池的電腦上執行的基準示例:

% go test -bench=Fib20 -count=10 ./examples/fib/ | tee old.txt
goos: darwin
goarch: amd64
pkg: high-performance-go-workshop/examples/fib
BenchmarkFib20-8           30721             37893 ns/op
BenchmarkFib20-8           31468             38695 ns/op
BenchmarkFib20-8           31726             37521 ns/op
BenchmarkFib20-8           31686             37583 ns/op
BenchmarkFib20-8           31719             38087 ns/op
BenchmarkFib20-8           31802             37703 ns/op
BenchmarkFib20-8           31754             37471 ns/op
BenchmarkFib20-8           31800             37570 ns/op
BenchmarkFib20-8           31824             37644 ns/op
BenchmarkFib20-8           31165             38354 ns/op
PASS
ok      high-performance-go-workshop/examples/fib       15.808s

% benchstat old.txt
name     time/op
Fib20-8  37.9µs ± 2%

benchstat 告訴我們,Fib20-8 的平均操作耗時是 38.8 微妙,並且誤差在 +/-2%。這是因為在執行基準測試期間,我沒動過機器。

1.3.1 改進 Fib 函式

確定兩組基準測試之間的效能差異可能是非常乏味且容易出錯的。Benchstat 工具可以幫助我們做這個事情。

儲存基準測試的輸出結果是非常有用的,同時,你也需要儲存產生它的二進位制檔案。這個會讓你有機會重新執行之前的基準測試。為了達到這個目標,在執行 go test 時需要新增 -c 標記以儲存測試的二進位制檔案 -- 同時我還經常將生成的二進位制檔案.text 重新命名為.golden

% go test -c
mv fib.test fib.golden

先前的 Fib 函式具有斐波那契數列中第 0 和第 1 個數字的硬編碼值。在之後使用遞迴呼叫了自身。稍後,我們將討論遞迴的成本,但目前,我們假設遞迴是有成本的,尤其是因為我們的演算法使用的是指數時間。

對此的簡單解決方法是對斐波那契數列中的另一個數字進行硬編碼,從而將每個可回溯呼叫的深度減少一個。

func Fib(n int) int {
    switch n {
    case 0:
        return 0
    case 1:
        return 1
    case 2:
        return 1
    default:
        return Fib(n-1) + Fib(n-2)
    }
}

該檔案還包含針對 Fib 的全面測試。 如果沒有通過驗證當前行為的測試,請勿嘗試提高基準。

為了能和我們的新版本進行比較,我們編譯一個新的測試的二進位制檔案並對其進行了基準測試,並使用 Benchstat 工具比較輸出。

% go test -c
% ./fib.golden -test.bench=. -test.count=10 > old.txt
% ./fib.test -test.bench=. -test.count=10 > new.txt
% benchstat old.txt new.txt
name     old time/op  new time/op  delta
Fib20-8  37.9µs ± 2%  24.1µs ± 3%  -36.26%  (p=0.000 n=10+10)

執行完上面的比較結果後,有 2 件事情需要確認:

  • 兩次執行基準間上下浮動的值。1-2% 是較好的,3-5% 還可以,高於 5% 時就需要考慮你程式的穩定性了。要當心當差異較大時,請不要貿然改進效能。
  • 樣本缺失。benchstat 工具將報告有多少有效的樣本資料。有時即使你執行了 10 次,但也可能只發現了 9 個樣本。10% 或更低的拒絕率是可以接受的,高於 10% 可能表明您的設定不穩定,並且你可能比較的樣本太少。

1.3.2 注意 p 值

低於 0.05 的 p 值可能具有統計學意義。 p 值大於 0.05 表示基準可能沒有統計意義。

1.4 避免基準測試的啟動耗時

有時候你的基準測試每次執行的時候會有一次啟動配置耗時。b.ResetTimer() 函式可以用於忽略啟動的累積耗時。

func BenchmarkExpensive(b *testing.B) {
    boringAndExpensiveSetup()
    b.ResetTimer()
    for n := 0; n < b.N; n++ {
        //function under test
    }
}

在上例程式碼中,使用 b.ResetTimer() 函式重置了基準測試的計時器

如果在每次迴圈迭代中,你有一些費時的配置邏輯,要使用 b.StopTimer() 和 b.StartTimer() 函式來暫定基準測試計時器。

func BenchmarkComplicated(b *testing.B) {
    for n := 0; n < b.N;n++ {
        b.StopTimer()
        complicatedSetup()
        b.StartTimer()
        //function under test
    }
}
  • 上例中,先使用 b.StopTimer() 暫停計時器
  • 然後執行完複雜的配置邏輯後,再使用 b.StartTimer() 啟動計時器

通過以上兩個函式,則可以忽略掉啟動配置所耗費的時間。

1.5 基準測試的記憶體分配

記憶體分配的次數和分配的大小和基準測試的執行時間強相關。你可以通過在程式碼中增加 b.ReportAllocs() 函式來告訴 testing 框架記錄記憶體分配的資料。

func BenchmarkRead(b *testing.B) {
    b.ReportAllocs()
    for n := 0; n < b.N; n++ {
        //function under test
    }
}

下面是使用 bufio 包中的基準測試的一個示例:

% go test -run=^$ -bench=. bufio
goos: darwin
goarch: amd64
pkg: bufio
BenchmarkReaderCopyOptimal-8            12999212                78.6 ns/op
BenchmarkReaderCopyUnoptimal-8           8495018               133 ns/op
BenchmarkReaderCopyNoWriteTo-8            360471              2805 ns/op
BenchmarkReaderWriteToOptimal-8          3839959               291 ns/op
BenchmarkWriterCopyOptimal-8            13878241                82.7 ns/op
BenchmarkWriterCopyUnoptimal-8           9932562               117 ns/op
BenchmarkWriterCopyNoReadFrom-8           385789              2681 ns/op
BenchmarkReaderEmpty-8                   1863018               640 ns/op            4224 B/op          3 allocs/op
BenchmarkWriterEmpty-8                   2040326               579 ns/op            4096 B/op          1 allocs/op
BenchmarkWriterFlush-8                  88363759                12.7 ns/op             0 B/op          0 allocs/op
PASS
ok      bufio   13.249s

你也可以使用 go test -benchmem 標識來強制 testing 框架列印出所有基準測試的記憶體分配次數

%  go test -run=^$ -bench=. -benchmem bufio
goos: darwin
goarch: amd64
pkg: bufio
BenchmarkReaderCopyOptimal-8            13860543                82.8 ns/op            16 B/op          1 allocs/op
BenchmarkReaderCopyUnoptimal-8           8511162               137 ns/op              32 B/op          2 allocs/op
BenchmarkReaderCopyNoWriteTo-8            379041              2850 ns/op           32800 B/op          3 allocs/op
BenchmarkReaderWriteToOptimal-8          4013404               280 ns/op              16 B/op          1 allocs/op
BenchmarkWriterCopyOptimal-8            14132904                82.7 ns/op            16 B/op          1 allocs/op
BenchmarkWriterCopyUnoptimal-8          10487898               113 ns/op              32 B/op          2 allocs/op
BenchmarkWriterCopyNoReadFrom-8           362676              2816 ns/op           32800 B/op          3 allocs/op
BenchmarkReaderEmpty-8                   1857391               639 ns/op            4224 B/op          3 allocs/op
BenchmarkWriterEmpty-8                   2041264               577 ns/op            4096 B/op          1 allocs/op
BenchmarkWriterFlush-8                  87643513                12.5 ns/op             0 B/op          0 allocs/op
PASS
ok      bufio   13.430s

1.6 注意編譯器的優化

下面的示例來源於issue 14813

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
}

func BenchmarkPopcnt(b *testing.B) {
    for i := 0; i < b.N; i++ {
        popcnt(uint64(i))
    }
}

你認為這個基準測試的效能到底有多快呢?讓我看下面結果

% go test -bench=. ./examples/popcnt/
goos: darwin
goarch: amd64
pkg: high-performance-go-workshop/examples/popcnt
BenchmarkPopcnt-8       1000000000               0.278 ns/op
PASS
ok      high-performance-go-workshop/examples/popcnt    0.318s

0.278 納秒;基本上就是一個 cpu 時鐘的時間。即使假設每個時鐘週期中 CPU 有一些指令要執行,但這個數字看起來也有點不太合理。那到底發生了什麼?

想要了解到底發生了什麼,我們需要看下基準測試下的 popcnt 函式。popcnt 函式是一個葉子函式 - 即該函式沒有呼叫其他任何函式 - 所以編譯器可以內聯它。

因為該函式是行內函數,編譯器可以知道該函式沒有任何副作用。popcnt 函式不會影響任何全域性變數的狀態。因此,呼叫被消除。下面是編譯器看到的:

func BenchmarkPopcnt(b *testing.B) {
    for i := 0; i < b.N; i++ {
        //optimised away
    }
}

在我測試過的所有版本的 Go 編譯器上,仍然會生成迴圈。 但是英特爾 CPU 確實擅長優化迴圈,尤其是空迴圈。

1.6.1 練習,看彙編

在我們繼續之前,讓我們看下彙編以確定我們看到的

% go test -gcflags=-S
  • 說明:使用gcflags="-l-S"標識可以禁用內聯,那對彙編的輸出有什麼影響

優化是一件好的事情 值得注意的是,通過消除不必要的計算,使實際程式碼快速執行的優化與消除沒有明顯副作用的基準測試的優化是一樣的。 隨著 Go 編譯器的改進,這種情況會越來越普遍

1.6.2 修復基準測試

禁用內聯以使基準測試可以正常工作是不現實的。我們想在編譯器優化的基礎上編譯我們的程式碼。

為了修復這個基準測試,我們必須確保編譯器不能證明 BenchmarkPopcnt 的主體不會導致全域性狀態改變。(譯者注:即讓編譯器知道 BenchmarkPopcnt 函式有可能會改變全域性狀態,這樣編譯器就不用再將函式做內聯優化了

var Result uint64

func BenchmarkPopcnt(b *testing.B) {
    var r uint64
    for i := 0; i < b.N; i++ {
        r = popcnt(uint64(i))
    }
    Result = r
}

以上通過增加全域性變數 Result 是比較推薦的方式,以此來確保編譯器不會對迴圈主題進行優化。

首先,我們把 popcnt 函式的呼叫結果儲存在變數 r 中。其次,因為 r 是區域性變數,一旦基準測試結束,變數 r 的生命週期也將結束,所以最後我們把 r 的結果賦值給全域性變數 Result。

因為變數 Result 是全域性可見,所以編譯器不能確定其他匯入該包的程式碼是否也在使用該變數,因此編譯器不能對該賦值操作進行優化。

1.7 基準測試錯誤

在基準測試中,for 迴圈是至關重要的。

這裡是兩個錯誤的基準測試,你能解釋他們為什麼錯誤嗎?

func BenchmarkFibWrong(b *testing.B) {
    Fib(b.N)
}

func BenchmarkFibWrong2(b *testing.B) {
    for n := 0; n < b.N; n++ {
        Fib(n)
    }
}

執行上面的基準測試試一試,你將看到什麼?

1.8 基準測試中使用 math/rand

眾所周知,計算機非常擅長預測並快取(譯者注:即 cpu 的區域性性原理)。也許我們的 Popcnt 基準測試返回的是一個快取的結果。讓我們看一下下面的例子:

var Result uint64

func BenchmarkPopcnt(b *testing.B) {
    var r uint64
    for i := 0; i < b.N; i++ {
        r = popcnt(rand.Uint64())
    }

    Result = r
}

以上程式碼是可靠的嗎?如果不是,哪裡出錯了?

1.9 收集基準測試資料

該 testing 包內建了對生成 CPU,記憶體和模組配置檔案的支援。

  • -cpuprofile=$FILE 收集 CPU 效能分析到 $FILE 檔案
  • -memprofile=$FILE,將記憶體效能分析寫入到 $FILE 檔案,-memprofilerate=N 調節取樣頻率為 1/N
  • -blockprofile=$FILE,輸出內部 goroutine 阻塞的效能分析檔案資料到 $FILE

這些標識也同樣可以用於二進位制檔案

% go test -run=XXX -bench=. -cpuprofile=c.p bytes
% go tool pprof c.p

benchmark 小結

benchmark 是 go 語言中用於測試效能的一個工具。主要適用於在已知效能瓶頸在哪裡時的場景。該測試函式位於_test.go 為結尾的檔案中,效能測試函式名以 Benchmark 開頭,可以測試出被執行函式被執行的次數,平均每次執行所消耗的時間,以及 cpu 以及記憶體的效能資料。 同時,在執行基準測試時也需要注意執行環境的穩定性,執行的次數,求得的平均值越準確。

原文連結 https://dave.cheney.net/high-performance-go-workshop/gophercon-2019.html#benchmarking

更多原創文章乾貨分享,請關注公眾號
  • Go 高效能系列教程之一:基準測試
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章