1. Go 性能調優之 —— 基準測試

原文連接: https://github.com/sxs2473/go...
本文使用 Creative Commons Attribution-ShareAlike 4.0 International 協議進行受權許可。

基準測試

本節重點討論如何使用 Go 測試框架構建一個有效的基準測試,並提供一些實用的技巧來避免性能缺陷。git

基準測試的基本規則

在進行基準測試以前,咱們必需要有一個穩定的環境來得到可重現的結果。github

  • 機器必須是空閒的——不要運行在共享硬件上,在長時間運行基準測試時不要進行其餘操做
  • 注意節電和熱縮放(主要指 CPU 受溫度影響致使頻率不穩定)
  • 避免虛擬機和共享雲託管; 它們太亂,沒法進行一致的測量。

若是你負擔得起,最好購買專用的性能測試硬件。並禁用全部電源管理和熱縮放,保持機器上的軟件版本不變。golang

對於其餘人,請使用先後樣本並屢次運行它們以得到一致的結果。正則表達式

使用測試包進行基準測試

testing 包已經內置了支持基準測試的能力. 好比你有一個簡單的函數:算法

// 此函數計算斐波那契數列中第 N 個數字
func Fib(n int) int {
        switch n {
        case 0:
                return 0
        case 1:
                return 1
        default:
                return Fib(n-1) + Fib(n-2)
        }
}

咱們可使用 testing 包以以下形式爲此函數寫一個基準測試。基準測試函數也寫在以 _test.go 結尾的文件裏,它和test函數共存.服務器

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

基準測試和普通單元測試相似。 惟一的區別是基準測試接收的參數是*testing.B 而不是 *testing.T。 這兩種類型都實現了 testing.TB 接口,這個接口提供了一些比較經常使用的方法 Errorf(), Fatalf(), and FailNow()架構

運行包的基準測試

由於基準測試使用testing 包,它們一樣經過 go test 命令執行。可是,默認狀況下,當你調用go test時,基準測試是不執行的。框架

要顯式地執行基準測試請使用 -bench 標識。 -bench 接收一個與待運行的基準測試名稱相匹配的正則表達式,所以,若是要運行包中全部的基準測試,最多見的方法是這樣寫 -bench=.。例如:函數

% go test -bench=. ./examples/fib/
goos: darwin
goarch: amd64
BenchmarkFib20-8           30000             44514 ns/op
PASS
ok      _/Users/dfc/devel/gophercon2018-performance-tuning-workshop/2-benchmarking/examples/fib 1.795s

注意 : go test 會在運行基準測試以前以前執行包裏全部的單元測試,全部若是你的包裏有不少單元測試,或者它們會運行很長時間,你也能夠經過 go test-run 標識排除這些單元測試,不讓它們執行; 好比: go test -run=^$工具

基準測試的工做原理

基準測試函數會被一直調用直到b.N無效,它是基準測試循環的次數

b.N 從 1 開始,若是基準測試函數在1秒內就完成 (默認值),則 b.N 增長,並再次運行基準測試函數。

b.N 在近似這樣的序列中不斷增長;1, 2, 3, 5, 10, 20, 30, 50, 100 等等。 基準框架試圖變得聰明,若是它看到當b.N較小並且測試很快就完成的時候,它將讓序列增長地更快。

看上面的例子, BenchmarkFib20-8 發現約 30000 次迭代只須要1秒鐘。 From there the benchmark framework computed that

注意 : The -8 後綴和用於運行次測試的 GOMAXPROCS 值有關。 與GOMAXPROCS同樣,此數字默認爲啓動時Go進程可見的CPU數。 你可使用-cpu標識更改此值,能夠傳入多個值以列表形式來運行基準測試。

% go test -bench=. -cpu=1,2,4 ./examples/fib/
goos: darwin
goarch: amd64
BenchmarkFib20             30000             44644 ns/op
BenchmarkFib20-2           30000             44504 ns/op
BenchmarkFib20-4           30000             44848 ns/op
PASS

提升基準測試的精度

fib 函數是一個模擬的例子 — 除非你編寫 TechPower 服務器基準測試來驗證,不然你的業務不太多是你計算斐波那契數列中第20個數字的速度。 可是,基準確實展示了我認爲有效的基準。

具體來講,當你的基準測試運行幾千次迭代的時候,咱們能夠認爲得到了一個每次運行的平均值,而若是基準測試只運行幾十次,那麼這個平均值極可能不穩定,也就不能說明問題。

要增長迭代次數,可使用-benchtime標識增長運行時間,例如

% go test -bench=. -benchtime=10s ./examples/fib/
goos: darwin
goarch: amd64
BenchmarkFib20-8          300000             44616 ns/op

運行一個相同的基準測試,直到它到達b.N的值,運行時間超過10秒。當咱們運行時間是10倍的時候,迭代次數也會增長到10倍。然而每一次執行的結果卻沒有什麼變化,這正是咱們所預期的。

若是你有一個基準測試,它運行數百萬次或數十億次迭代,每次操做的時間都在微秒或納秒級,那麼你可能會發現基準測試結果不穩定,由於熱縮放、內存局部性、後臺處理、gc活動等等。

對於每次操做是以10或個位數納秒爲單位計算的函數來講,指令從新排序和代碼對齊的相對效應都將對結果產生影響。

可使用-count 標識屢次運行基準測試來解決這個問題:

% go test -bench=Fib1 -count=10 ./examples/fib/
goos: darwin
goarch: amd64
BenchmarkFib1-8         2000000000               1.99 ns/op
BenchmarkFib1-8         1000000000               1.95 ns/op
BenchmarkFib1-8         2000000000               1.99 ns/op
BenchmarkFib1-8         2000000000               1.97 ns/op
BenchmarkFib1-8         2000000000               1.99 ns/op
BenchmarkFib1-8         2000000000               1.96 ns/op
BenchmarkFib1-8         2000000000               1.99 ns/op
BenchmarkFib1-8         2000000000               2.01 ns/op
BenchmarkFib1-8         2000000000               1.99 ns/op
BenchmarkFib1-8         1000000000               2.00 ns/op

得出Fib(1)的基準測試在2納秒左右,方差爲正負2%.

提示 : 若是你發現須要針對特定的包調整不一樣的默認值,我建議使用Makefile中完成這些設定,這樣每一個想要運行基準測試的人均可以使用相同的配置進行編碼。

Benchstat

在上一節中,我建議屢次運行基準測試以得到更多的平均數據。對於任何基準測試來講,這都是一個很好的建議,由於測試過程會受到電源管理、後臺進程和熱管理的影響,這個問題我在本章的開頭已經提到過。

下面我將介紹一個由 Russ Cox 編寫的測試工具 benchstat

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

Benchstat 能夠獲取一組基準測試數據,並告訴你它的穩定性如何。如下是使用電池時的數據:

% go test -bench=Fib20 -count=10 ./examples/fib/ | tee old.txt
goos: darwin
goarch: amd64
BenchmarkFib20-8           30000             46295 ns/op
BenchmarkFib20-8           30000             41589 ns/op
BenchmarkFib20-8           30000             42204 ns/op
BenchmarkFib20-8           30000             43923 ns/op
BenchmarkFib20-8           30000             44339 ns/op
BenchmarkFib20-8           30000             45340 ns/op
BenchmarkFib20-8           30000             45754 ns/op
BenchmarkFib20-8           30000             45373 ns/op
BenchmarkFib20-8           30000             44283 ns/op
BenchmarkFib20-8           30000             43812 ns/op
PASS
ok      _/Users/dfc/devel/gophercon2018-performance-tuning-workshop/2-benchmarking/examples/fib 17.865s
% benchstat old.txt
name     time/op
Fib20-8  44.3µs ± 6%

benchstat 告訴咱們,平均值爲44.3微秒,樣本間的波動區間爲正負 6%。 這對電池電量來講在乎料之中。

  • 第一次運行是最慢的,由於操做系統的 CPU 時鐘頻率已經下降以節省功耗。
  • 接下來的兩次運行是最快的,由於操做系統識別到有一個較大的工做負載加入,就會提升 CPU 時鐘速度,以儘快經過工做。
  • 剩下的是當 CPU 高速運轉發熱,由於功耗致使又被限制,因此又慢了下來。

對比標準 benchmarks 和 benchstat

肯定兩組基準測試結果之間的差別多是單調乏味且容易出錯的。  Benchstat 能夠幫助咱們解決這個問題。

提示 : 保存基準運行的輸出頗有用,但你也能夠保存生成它的二進制文件。 爲此,請使用-c標誌來保存測試二進制文件;我常常將這個二進制文件從.test重命名爲.golden

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

提高 Fib 性能

先前的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)
        }
}

爲了比較咱們的新版本,咱們編譯了一個新的測試二進制文件並對它們都進行了基準測試,並使用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  44.3µs ± 6%  25.6µs ± 2%  -42.31%  (p=0.000 n=10+10)

比較基準測試時須要檢查三件事

  • 新老兩次的方差。1-2% 是不錯的, 3-5% 也還行,可是大於5%的話,可能不太可靠。 在比較一方具備高差別的基準時要當心,您可能看不到改進。
  • p值。p值低於0.05是比較好的狀況,大於0.05則意味着基準測試結果可能沒有統計學意義。
  • 樣本不足。benchstat將報告它認爲有效的新舊樣本的數量,有時你可能只發現9個報告,即便你設置了-count=10。拒絕率小於10%通常是沒問題的,而高於10%可能代表你的設置是不穩定的,也多是比較的樣本太少了。

避免基準測試的啓動成本

有時候每次基準測試運行前都有一些初始化操做。 b.ResetTimer()將讓你跳過這些運行時間。

func BenchmarkExpensive(b *testing.B) {
        boringAndExpensiveSetup()
        b.ResetTimer() // HL
        for n := 0; n < b.N; n++ {
                // 被測試的功能
        }
}

若是每次循環迭代內部都有一些高成本的其餘邏輯,請使用b.StopTimer()b.StartTimer()來暫停基準計時器。

func BenchmarkComplicated(b *testing.B) {
        for n := 0; n < b.N; n++ {
                b.StopTimer() // HL
                complicatedSetup()
                b.StartTimer() // HL
                // 被測試的功能
        }
}

內存分配的基準測試

分配計數和大小與基準測試的執行時間密切相關。 你能夠告訴測試框架記錄被測代碼所作的分配數量。

func BenchmarkRead(b *testing.B) {
        b.ReportAllocs()
        for n := 0; n < b.N; n++ {
                // 被測試的功能
        }
}

如下是使用bufio軟件包基準測試的示例:

% go test -run=^$ -bench=. bufio
goos: darwin
goarch: amd64
pkg: bufio
BenchmarkReaderCopyOptimal-8            20000000               103 ns/op
BenchmarkReaderCopyUnoptimal-8          10000000               159 ns/op
BenchmarkReaderCopyNoWriteTo-8            500000              3644 ns/op
BenchmarkReaderWriteToOptimal-8          5000000               344 ns/op
BenchmarkWriterCopyOptimal-8            20000000                98.6 ns/op
BenchmarkWriterCopyUnoptimal-8          10000000               131 ns/op
BenchmarkWriterCopyNoReadFrom-8           300000              3955 ns/op
BenchmarkReaderEmpty-8                   2000000               789 ns/op            4224 B/op          3 allocs/op
BenchmarkWriterEmpty-8                   2000000               683 ns/op            4096 B/op          1 allocs/op
BenchmarkWriterFlush-8                  100000000               17.0 ns/op             0 B/op          0 allocs/op

注意 : 想對全部基準測試都生效,你也可使用go test -benchmem標識。

% go test -run=^$ -bench=. -benchmem bufio
goos: darwin
goarch: amd64
pkg: bufio
BenchmarkReaderCopyOptimal-8            20000000                93.5 ns/op            16 B/op          1 allocs/op
BenchmarkReaderCopyUnoptimal-8          10000000               155 ns/op              32 B/op          2 allocs/op
BenchmarkReaderCopyNoWriteTo-8            500000              3238 ns/op           32800 B/op          3 allocs/op
BenchmarkReaderWriteToOptimal-8          5000000               335 ns/op              16 B/op          1 allocs/op
BenchmarkWriterCopyOptimal-8            20000000                96.7 ns/op            16 B/op          1 allocs/op
BenchmarkWriterCopyUnoptimal-8          10000000               124 ns/op              32 B/op          2 allocs/op
BenchmarkWriterCopyNoReadFrom-8           500000              3219 ns/op           32800 B/op          3 allocs/op
BenchmarkReaderEmpty-8                   2000000               748 ns/op            4224 B/op          3 allocs/op
BenchmarkWriterEmpty-8                   2000000               662 ns/op            4096 B/op          1 allocs/op
BenchmarkWriterFlush-8                  100000000               16.9 ns/op             0 B/op          0 allocs/op
PASS
ok      bufio   20.366s

注意編譯優化

這個例子來自 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
BenchmarkPopcnt-8       2000000000               0.30 ns/op
PASS

0.3 納秒,這基本上是一個時鐘週期。即便假設CPU每一個時鐘週期內會執行多條指令,這個數字彷佛也不合理地低。 發生了什麼?

要了解發生了什麼,咱們必須看看benchmark下的函數popcnt。  popcnt是一個葉子函數 - 它不調用任何其餘函數 - 所以編譯器能夠內聯它。

由於函數是內聯的,因此編譯器如今能夠看到它沒有反作用。  popcnt不會影響任何全局變量的狀態。 這樣,調用就被消除了。 這是編譯器看到的:

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

在全部版本的Go編譯器上,仍然會生成循環。 可是英特爾CPU很是擅長優化循環,尤爲是空循環。

優化是一件好事

須要去掉的是,經過刪除沒必要要的計算使真正的代碼快速運行的優化,與刪除沒有明顯反作用的基準測試的優化是相同的。

隨着Go編譯器的改進,這隻會變得更加廣泛。

修復基準測試

要修復此基準測試,咱們必須確保編譯器沒法檢驗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
}

這是確保編譯器沒法優化循環體的推薦方法。

首先,咱們經過將調用popcnt的結果存儲在r中。 而後,當測試基準結束時,rBenchmarkPopcnt的範圍內被聲明,r的結果對於程序的另外一部分是不可見的,因此最終,咱們將r值賦給包級別的公共變量Result

由於Result是公共的,因此編譯器沒法證實導入此類的另外一個包將沒法看到Result隨時間變化的值,所以它沒法優化致使其賦值的任何操做。

錯誤的基準測試

for 循環對基準測試的執行很是重要

下面是兩個錯誤的的基準測試例子:

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

結果是,它們會一直執行下去

分析基準測試的結果

testing包內置了支持生成CPU,內存和塊的profile文件。

  • -cpuprofile=$FILE 將 CPU 分析結果寫入 $FILE.
  • -memprofile=$FILE 將內存分析結果寫入 $FILE, -memprofilerate=N 調整記錄速率爲 1/N.
  • -blockprofile=$FILE, 將塊分析結果寫入 $FILE.

使用這些標識中的任何一個同時都會保留二進制文件。

% go test -run=XXX -bench=. -cpuprofile=c.p bytes
% go tool pprof c.p
相關文章
相關標籤/搜索