golang benchmark 源碼分析

testing 包提供了對 Go 包的自動測試支持。這是和 go test 命令相呼應的功能, go test 命令會自動執行所以符合格式

func TestXXX(t *testing.T)

當帶着 -bench=“.” ( 參數必須有!)來執行 * go test 命令的時候性能測試程序就會被順序執行。符合下面格式的函數被認爲是一個性能測試程序,

func BenchmarkXxx(b *testing.B)

執行 go test -bench=”.” 後 結果 :

// 表示測試全部通過

>PASS                       
// Benchmark 名字 - CPU            循環次數             平均每次執行時間 
BenchmarkLoops-2                  100000             20628 ns/op     
BenchmarkLoopsParallel-2          100000             10412 ns/op   
//      哪個目錄下執行go test         累計耗時
ok      swap/lib                   2.279s

源碼包位置:src/testing/benchmark.go

testing 目錄下的文件有

allocs.go               helper_test.go          quick
allocs_test.go          helperfuncs_test.go     run_example.go
benchmark.go            internal                run_example_js.go
benchmark_test.go       iotest                  sub_test.go
cover.go                match.go                testing.go
example.go              match_test.go           testing_test.go
export_test.go          panic_test.go

testing.T

判定失敗接口

Fail 失敗繼續
FailNow 失敗終止

打印信息接口

Log 數據流 (cout 類似)
Logf format (printf 類似)
SkipNow 跳過當前測試
Skiped 檢測是否跳過

綜合接口產生:

Error / Errorf 報告出錯繼續 [ Log / Logf + Fail ]
Fatel / Fatelf 報告出錯終止 [ Log / Logf + FailNow ]
Skip / Skipf 報告並跳過 [ Log / Logf + SkipNow ]

testing.B

首先 , testing.B 擁有 testing.T 的全部接口。

SetBytes( i uint64) 統計內存消耗, 如果你需要的話。
SetParallelism(p int) 制定並行數目。
StartTimer / StopTimer / ResertTimer 操作計時器
testing.PB
Next() 接口 。判斷是否繼續循環

下面帶着三個問題去閱讀源碼:

B 定義了性能測試的數據結構,我們提取其比較重要的一些成員進行分析:

type B struct {
  common                         // 與testing.T共享的testing.common,負責記錄日誌、狀態等
  importPath       string // import path of the package containing the benchmark
  context          *benchContext
  N                int            // 目標代碼執行次數,不需要用戶瞭解具體值,會自動調整
  previousN        int           // number of iterations in the previous run
  previousDuration time.Duration // total duration of the previous run
  benchFunc        func(b *B)   // 性能測試函數
  benchTime        time.Duration // 性能測試函數最少執行的時間,默認爲1s,可以通過參數'-benchtime 10s'指定
  bytes            int64         // 每次迭代處理的字節數
  missingBytes     bool // one of the subbenchmarks does not have bytes set.
  timerOn          bool // 是否已開始計時
  showAllocResult  bool
  result           BenchmarkResult // 測試結果
  parallelism      int // RunParallel creates parallelism*GOMAXPROCS goroutines
  // The initial states of memStats.Mallocs and memStats.TotalAlloc.
  startAllocs uint64  // 計時開始時堆中分配的對象總數
  startBytes  uint64  // 計時開始時時堆中分配的字節總數
  // The net total of this test after being run.
  netAllocs uint64 // 計時結束時,堆中增加的對象總數
  netBytes  uint64 // 計時結束時,堆中增加的字節總數
}

啓動計時:B.StartTimer()

StartTimer() 負責啓動計時並初始化內存相關計數,測試執行時會自動調用,一般不需要用戶啓動。

func (b *B) StartTimer() {
  if !b.timerOn {
    runtime.ReadMemStats(&memStats)     // 讀取當前堆內存分配信息
    b.startAllocs = memStats.Mallocs    // 記錄當前堆內存分配的對象數
    b.startBytes = memStats.TotalAlloc  // 記錄當前堆內存分配的字節數
    b.start = time.Now()                // 記錄測試啓動時間
    b.timerOn = true                   // 標記計時標誌
  }
}

StartTimer()負責啓動計時,並記錄當前內存分配情況,不管是否有 “-benchmem” 參數,內存都會被統計,參數只決定是否要在結果中輸出。

停止計時:B.StopTimer()

StopTimer() 負責停止計時,並累加相應的統計值。

func (b *B) StopTimer() {
  if b.timerOn {
    b.duration += time.Since(b.start)                   // 累加測試耗時
    runtime.ReadMemStats(&memStats)                     // 讀取當前堆內存分配信息
    b.netAllocs += memStats.Mallocs - b.startAllocs     // 累加堆內存分配的對象數
    b.netBytes += memStats.TotalAlloc - b.startBytes    // 累加堆內存分配的字節數
    b.timerOn = false                                  // 標記計時標誌
  }
}

需要注意的是,StopTimer() 並不一定是測試結束,一個測試中有可能有多個統計階段,所以其統計值是累加的。

重置計時:B.ResetTimer()

ResetTimer() 用於重置計時器,相應的也會把其他統計值也重置。

func (b *B) ResetTimer() {
  if b.timerOn {
    runtime.ReadMemStats(&memStats)     // 讀取當前堆內存分配信息
    b.startAllocs = memStats.Mallocs    // 記錄當前堆內存分配的對象數
    b.startBytes = memStats.TotalAlloc  // 記錄當前堆內存分配的字節數
    b.start = time.Now()                // 記錄測試啓動時間
  }
  b.duration = 0                          // 清空耗時
  b.netAllocs = 0                         // 清空內存分配對象數
  b.netBytes = 0                          // 清空內存分配字節數
}

ResetTimer() 比較常用,典型使用場景是一個測試中,初始化部分耗時較長,初始化後再開始計時。

設置處理字節數:B.SetBytes(n int64)

// SetBytes records the number of bytes processed in a single operation.

// If this is called, the benchmark will report ns/op and MB/s.

func (b *B) SetBytes(n int64) {
  b.bytes = n
}

這是一個比較含糊的函數,通過其函數說明很難明白其作用。

其實它是用來設置單次迭代處理的字節數,一旦設置了這個字節數,那麼輸出報告中將會呈現 “xxx MB/s” 的信息,用來表示待測函數處理字節的性能。待測函數每次處理多少字節數只有用戶清楚,所以需要用戶設置。

報告內存信息:

func (b *B) ReportAllocs() {
  b.showAllocResult = true
}

ReportAllocs() 用於設置是否打印內存統計信息,與命令行參數 “-benchmem” 一致,但本方法只作用於單個測試函數。

性能測試是如何啓動的

性能測試要經過多次迭代,每次迭代可能會有不同的 b.N 值,每次迭代執行測試函數一次,跟據此次迭代的測試結果來分析要不要繼續下一次迭代。

我們先看一下每次迭代時所用到的方法,runN():

func (b *B) runN(n int) {
  b.N = n                       // 指定B.N
  b.ResetTimer()                // 清空統計數據
  b.StartTimer()                // 開始計時
  b.benchFunc(b)                // 執行測試
  b.StopTimer()                 // 停止計時
}

該方法指定 b.N 的值,執行一次測試函數。

與 T.Run() 類似,B.Run() 也用於啓動一個子測試,實際上用戶編寫的任何一個測試都是使用 Run() 方法啓動的,我們看下 B.Run() 的僞代碼:

func (b *B) Run(name string, f func(b *B)) bool {
  sub := &B{                          // 新建子測試數據結構
    common: common{
      signal:  make(chan bool),
      name:    name,
      parent:  &b.common,
    },
    benchFunc:  f,
  }
  if sub.run1() { // 先執行一次子測試,如果子測試不出錯且子測試沒有子測試的話繼續執行sub.run()
  sub.run()       // run()裏決定要執行多少次runN()
  }
  b.add(sub.result) // 累加統計結果到父測試中
  return !sub.failed
}

所有的測試都是先使用 run1() 方法執行一次測試, run1() 方法中實際上調用了 runN(1),執行一次後再決定要不要繼續迭代。

測試結果實際上以最後一次迭代的數據爲準,當然,最後一次迭代往往意味着 b.N 更大,測試準確性相對更高。

B.N 是如何調整的?

B.launch() 方法裏最終決定 B.N 的值。我們看下僞代碼:

func (b *B) launch() { // 此方法自動測算執行次數,但調用前必須調用run1以便自動計算次數
  d := b.benchTime
  for n := 1; !b.failed && b.duration < d && n < 1e9; { // 最少執行b.benchTime(默認爲1s)時間,最多執行1e9次
    last := n
    n = int(d.Nanoseconds()) // 預測接下來要執行多少次,b.benchTime/每個操作耗時
    if nsop := b.nsPerOp(); nsop != 0 {
      n /= int(nsop)
    }
    n = max(min(n+n/5, 100*last), last+1) // 避免增長較快,先增長20%,至少增長1次
    n = roundUp(n) // 下次迭代次數向上取整到10的指數,方便閱讀
    b.runN(n)
  }
}

不考慮程序出錯,而且用戶沒有主動停止測試的場景下,每個性能測試至少要執行 b.benchTime 長的秒數,默認爲 1s。先執行一遍的意義在於看用戶代碼執行一次要花費多長時間,如果時間較短,那麼 b.N 值要足夠大才可以測得更精確,如果時間較長,b.N 值相應的會減少,否則會影響測試效率。

最終的 b.N 會被定格在某個 10 的指數級,是爲了方便閱讀測試報告。

內存是如何統計的?

我們知道在測試開始時,會把當前內存值記入到 b.startAllocs 和 b.startBytes 中,測試結束時,會用最終內存值與開始時的內存值相減,得到淨增加的內存值,並記入到 b.netAllocs 和 b.netBytes 中。

每個測試結束,會把結果保存到 BenchmarkResult 對象裏,該對象裏保存了輸出報告所必需的統計信息:

type BenchmarkResult struct {
  N         int           // 用戶代碼執行的次數
  T         time.Duration // 測試耗時
  Bytes     int64         // 用戶代碼每次處理的字節數,SetBytes()設置的值
  MemAllocs uint64        // 內存對象淨增加值
  MemBytes  uint64        // 內存字節淨增加值
}

其中 MemAllocs 和 MemBytes 分別對應 b.netAllocs 和 b.netBytes。

那麼最終統計時只需要把淨增加值除以 b.N 即可得到每次新增多少內存了。

每個操作內存對象新增值:

func (r BenchmarkResult) AllocsPerOp() int64 {
  return int64(r.MemAllocs) / int64(r.N)
}

每個操作內存字節數新增值:

func (r BenchmarkResult) AllocedBytesPerOp() int64 {
  if r.N <= 0 {
    return 0
  }
  return int64(r.MemBytes) / int64(r.N)
}
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/LcIIiFdl-oXEAiH5iI4Urw