benchmark 基準測試

hickey發表於2022-04-08

轉載自:benchmark 基準測試

1 穩定的測試環境

當我們嘗試去最佳化程式碼的效能時,首先得知道當前的效能怎麼樣。Go 語言標準庫內建的 testing 測試框架提供了基準測試(benchmark)的能力,能讓我們很容易地對某一段程式碼進行效能測試。

效能測試受環境的影響很大,為了保證測試的可重複性,在進行效能測試時,儘可能地保持測試環境的穩定。

  • 機器處於閒置狀態,測試時不要執行其他任務,也不要和其他人共享硬體資源。
  • 機器是否關閉了節能模式,一般筆記本會預設開啟這個模式,測試時關閉。
  • 避免使用虛擬機器和雲主機進行測試,一般情況下,為了儘可能地提高資源的利用率,虛擬機器和雲主機 CPU 和記憶體一般會超分配,超分機器的效能表現會非常地不穩定。

超分配是針對硬體資源來說的,商業上對應的就是雲主機的超賣。虛擬化技術帶來的最大直接收益是伺服器整合,透過 CPU、記憶體、儲存、網路的超分配(Overcommitment)技術,最大化伺服器的使用率。例如,虛擬化的技能之一就是隨心所欲的操控 CPU,例如一臺 32U(物理核心)的伺服器可能會建立出 128 個 1U(虛擬核心)的虛擬機器,當物理伺服器資源閒置時,CPU 超分配一般不會對虛擬機器上的業務產生明顯影響,但如果大部分虛擬機器都處於繁忙狀態時,那麼各個虛擬機器為了獲得物理伺服器的資源就要相互競爭,相互等待。Linux 上專門有一個指標,Steal Time(st),用來衡量被虛擬機器監視器(Hypervisor)偷去給其它虛擬機器使用的 CPU 時間所佔的比例。

2 benchmark 的使用

2.1 一個簡單的例子

Go 語言標準庫內建了支援 benchmark 的 testing 庫,接下來看一個簡單的例子:

使用 go mod init example 初始化一個模組,新增 fib.go 檔案,實現函式 fib,用於計算第 N 個菲波那切數。

// fib.go
package main

func fib(n int) int {
    if n == 0 || n == 1 {
        return n
    }
    return fib(n-2) + fib(n-1)
}

接下來,我們在 fib_test.go 中實現一個 benchmark 用例:

// fib_test.go
package main

import "testing"

func BenchmarkFib(b *testing.B) {
    for n := 0; n < b.N; n++ {
        fib(30) // run fib(30) b.N times
    }
}
  • benchmark 和普通的單元測試用例一樣,都位於 _test.go 檔案中。
  • 函式名以 Benchmark 開頭,引數是 b *testing.B。和普通的單元測試用例很像,單元測試函式名以 Test 開頭,引數是 t *testing.T

2.2 執行用例

go test <module name>/<package name> 用來執行某個 package 內的所有測試用例。

  • 執行當前 package 內的用例:go test examplego test .
  • 執行子 package 內的用例: go test example/<package name>go test ./<package name>
  • 如果想遞迴測試當前目錄下的所有的 package:go test ./...go test example/...

go test 命令預設不執行 benchmark 用例的,如果我們想執行 benchmark 用例,則需要加上 -bench 引數。例如:

$ go test -bench .
goos: darwin
goarch: amd64
pkg: example
BenchmarkFib-8               200           5865240 ns/op
PASS
ok      example 1.782s

-bench 引數支援傳入一個正規表示式,匹配到的用例才會得到執行,例如,只執行以 Fib 結尾的 benchmark 用例:

$ go test -bench='Fib$' .
goos: darwin
goarch: amd64
pkg: example
BenchmarkFib-8               202           5980669 ns/op
PASS
ok      example 1.813s

2.3 benchmark 是如何工作的

benchmark 用例的引數 b *testing.B,有個屬性 b.N 表示這個用例需要執行的次數。b.N 對於每個用例都是不一樣的。

那這個值是如何決定的呢?b.N 從 1 開始,如果該用例能夠在 1s 內完成,b.N 的值便會增加,再次執行。b.N 的值大概以 1, 2, 3, 5, 10, 20, 30, 50, 100 這樣的序列遞增,越到後面,增加得越快。我們仔細觀察上述例子的輸出:

BenchmarkFib-8               202           5980669 ns/op

BenchmarkFib-8 中的 -8GOMAXPROCS,預設等於 CPU 核數。可以透過 -cpu 引數改變 GOMAXPROCS-cpu 支援傳入一個列表作為引數,例如:

$ go test -bench='Fib$' -cpu=2,4 .
goos: darwin
goarch: amd64
pkg: example
BenchmarkFib-2               206           5774888 ns/op
BenchmarkFib-4               205           5799426 ns/op
PASS
ok      example 3.563s

在這個例子中,改變 CPU 的核數對結果幾乎沒有影響,因為這個 Fib 的呼叫是序列的。

2025980669 ns/op 表示用例執行了 202 次,每次花費約 0.006s。總耗時比 1s 略多。

2.4 提升準確度

對於效能測試來說,提升測試準確度的一個重要手段就是增加測試的次數。我們可以使用 -benchtime-count 兩個引數達到這個目的。

benchmark 的預設時間是 1s,那麼我們可以使用 -benchtime 指定為 5s。例如:

$ go test -bench='Fib$' -benchtime=5s .
goos: darwin
goarch: amd64
pkg: example
BenchmarkFib-8              1033           5769818 ns/op
PASS
ok      example 6.554s

實際執行的時間是 6.5s,比 benchtime 的 5s 要長,測試用例編譯、執行、銷燬等是需要時間的。

-benchtime 設定為 5s,用例執行次數也變成了原來的 5倍,每次函式呼叫時間仍為 0.6s,幾乎沒有變化。

-benchtime 的值除了是時間外,還可以是具體的次數。例如,執行 30 次可以用 -benchtime=30x

$ go test -bench='Fib$' -benchtime=50x .
goos: darwin
goarch: amd64
pkg: example
BenchmarkFib-8                50           6121066 ns/op
PASS
ok      example 0.319s

呼叫 50 次 fib(30),僅花費了 0.319s。

-count 引數可以用來設定 benchmark 的輪數。例如,進行 3 輪 benchmark。

$ go test -bench='Fib$' -benchtime=5s -count=3 .
goos: darwin
goarch: amd64
pkg: example
BenchmarkFib-8               975           5946624 ns/op
BenchmarkFib-8              1023           5820582 ns/op
BenchmarkFib-8               961           6096816 ns/op
PASS
ok      example 19.463s

2.5 記憶體分配情況

-benchmem 引數可以度量記憶體分配的次數。記憶體分配次數也效能也是息息相關的,例如不合理的切片容量,將導致記憶體重新分配,帶來不必要的開銷。

在下面的例子中,generateWithCapgenerate 的作用是一致的,生成一組長度為 n 的隨機序列。唯一的不同在於,generateWithCap 建立切片時,將切片的容量(capacity)設定為 n,這樣切片就會一次性申請 n 個整數所需的記憶體。

// generate_test.go
package main

import (
    "math/rand"
    "testing"
    "time"
)

func generateWithCap(n int) []int {
    rand.Seed(time.Now().UnixNano())
    nums := make([]int, 0, n)
    for i := 0; i < n; i++ {
        nums = append(nums, rand.Int())
    }
    return nums
}

func generate(n int) []int {
    rand.Seed(time.Now().UnixNano())
    nums := make([]int, 0)
    for i := 0; i < n; i++ {
        nums = append(nums, rand.Int())
    }
    return nums
}

func BenchmarkGenerateWithCap(b *testing.B) {
    for n := 0; n < b.N; n++ {
        generateWithCap(1000000)
    }
}

func BenchmarkGenerate(b *testing.B) {
    for n := 0; n < b.N; n++ {
        generate(1000000)
    }
}

執行該用例的結果是:

go test -bench='Generate' .
goos: darwin
goarch: amd64
pkg: example
BenchmarkGenerateWithCap-8            44          24294582 ns/op
BenchmarkGenerate-8                   34          30342763 ns/op
PASS
ok      example 2.171s

可以看到生成 100w 個數字的隨機序列,GenerateWithCap 的耗時比 Generate 少 20%。

我們可以使用 -benchmem 引數看到記憶體分配的情況:

goos: darwin
goarch: amd64
pkg: example
BenchmarkGenerateWithCap-8  43  24335658 ns/op  8003641 B/op    1 allocs/op
BenchmarkGenerate-8         33  30403687 ns/op  45188395 B/op  40 allocs/op
PASS
ok      example 2.121s

Generate 分配的記憶體是 GenerateWithCap 的 6 倍,設定了切片容量,記憶體只分配一次,而不設定切片容量,記憶體分配了 40 次。

2.6 測試不同的輸入

不同的函式複雜度不同,O(1),O(n),O(n^2) 等,利用 benchmark 驗證複雜度一個簡單的方式,是構造不同的輸入。對剛才的 benchmark 稍作改造,便能夠達到目的。

// generate_test.go
package main

import (
    "math/rand"
    "testing"
    "time"
)

func generate(n int) []int {
    rand.Seed(time.Now().UnixNano())
    nums := make([]int, 0)
    for i := 0; i < n; i++ {
        nums = append(nums, rand.Int())
    }
    return nums
}
func benchmarkGenerate(i int, b *testing.B) {
    for n := 0; n < b.N; n++ {
        generate(i)
    }
}

func BenchmarkGenerate1000(b *testing.B)    { benchmarkGenerate(1000, b) }
func BenchmarkGenerate10000(b *testing.B)   { benchmarkGenerate(10000, b) }
func BenchmarkGenerate100000(b *testing.B)  { benchmarkGenerate(100000, b) }
func BenchmarkGenerate1000000(b *testing.B) { benchmarkGenerate(1000000, b) }

這裡,我們實現一個輔助函式 benchmarkGenerate 允許傳入引數 i,並構造了 4 個不同輸入的 benchmark 用例。執行結果如下:

$ go test -bench .                                                       
goos: darwin
goarch: amd64
pkg: example
BenchmarkGenerate1000-8            34048             34643 ns/op
BenchmarkGenerate10000-8            4070            295642 ns/op
BenchmarkGenerate100000-8            403           3230415 ns/op
BenchmarkGenerate1000000-8            39          32083701 ns/op
PASS
ok      example 6.597s

透過測試結果可以發現,輸入變為原來的 10 倍,函式每次呼叫的時長也差不多是原來的 10 倍,這說明覆雜度是線性的。

3 benchmark 注意事項

3.1 ResetTimer

如果在 benchmark 開始前,需要一些準備工作,如果準備工作比較耗時,則需要將這部分程式碼的耗時忽略掉。比如下面的例子:

func BenchmarkFib(b *testing.B) {
    time.Sleep(time.Second * 3) // 模擬耗時準備任務
    for n := 0; n < b.N; n++ {
        fib(30) // run fib(30) b.N times
    }
}

執行結果是:

$ go test -bench='Fib$' -benchtime=50x .
goos: darwin
goarch: amd64
pkg: example
BenchmarkFib-8                50          65912552 ns/op
PASS
ok      example 6.319s

50次呼叫,每次呼叫約 0.66s,是之前的 0.06s 的 11 倍。究其原因,受到了耗時準備任務的干擾。我們需要用 ResetTimer 遮蔽掉:

func BenchmarkFib(b *testing.B) {
    time.Sleep(time.Second * 3) // 模擬耗時準備任務
    b.ResetTimer() // 重置定時器
    for n := 0; n < b.N; n++ {
        fib(30) // run fib(30) b.N times
    }
}

執行結果恢復正常,每次呼叫約 0.06s。

$ go test -bench='Fib$' -benchtime=50x .
goos: darwin
goarch: amd64
pkg: example
BenchmarkFib-8                50           6187485 ns/op
PASS
ok      example 6.330s

3.2 StopTimer & StartTimer

還有一種情況,每次函式呼叫前後需要一些準備工作和清理工作,我們可以使用 StopTimer 暫停計時以及使用 StartTimer 開始計時。

例如,如果測試一個冒泡函式的效能,每次呼叫冒泡函式前,需要隨機生成一個數字序列,這是非常耗時的操作,這種場景下,就需要使用 StopTimerStartTimer 避免將這部分時間計算在內。

例如:

// sort_test.go
package main

import (
    "math/rand"
    "testing"
    "time"
)

func generateWithCap(n int) []int {
    rand.Seed(time.Now().UnixNano())
    nums := make([]int, 0, n)
    for i := 0; i < n; i++ {
        nums = append(nums, rand.Int())
    }
    return nums
}

func bubbleSort(nums []int) {
    for i := 0; i < len(nums); i++ {
        for j := 1; j < len(nums)-i; j++ {
            if nums[j] < nums[j-1] {
                nums[j], nums[j-1] = nums[j-1], nums[j]
            }
        }
    }
}

func BenchmarkBubbleSort(b *testing.B) {
    for n := 0; n < b.N; n++ {
        b.StopTimer()
        nums := generateWithCap(10000)
        b.StartTimer()
        bubbleSort(nums)
    }
}

執行該用例,每次排序耗時約 0.1s。

$ go test -bench='Sort$' .
goos: darwin
goarch: amd64
pkg: example
BenchmarkBubbleSort-8                  9         113280509 ns/op
PASS
ok      example 1.146s
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章