轉載自: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 example
或go 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 中的 -8
即 GOMAXPROCS
,預設等於 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 的呼叫是序列的。
202
和 5980669 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
引數可以度量記憶體分配的次數。記憶體分配次數也效能也是息息相關的,例如不合理的切片容量,將導致記憶體重新分配,帶來不必要的開銷。
在下面的例子中,generateWithCap
和 generate
的作用是一致的,生成一組長度為 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
開始計時。
例如,如果測試一個冒泡函式的效能,每次呼叫冒泡函式前,需要隨機生成一個數字序列,這是非常耗時的操作,這種場景下,就需要使用 StopTimer
和 StartTimer
避免將這部分時間計算在內。
例如:
// 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 協議》,轉載必須註明作者和本文連結