Go十大常見錯誤第2篇:benchmark效能測試的坑

coding進階 發表於 2022-06-18
Go

前言

這是Go十大常見錯誤系列的第二篇:benchmark效能測試的坑。素材來源於Go佈道者,現Docker公司資深工程師Teiva Harsanyi

本文涉及的原始碼全部開源在:Go十大常見錯誤原始碼,歡迎大家關注公眾號,及時獲取本系列最新更新。

場景

go test支援benchmark效能測試,但是你知道這裡可能有坑麼?

一個常見的坑是編譯器內聯優化,我們來看一個具體的例子:

func add(a int, b int) int {
    return a + b
}

現在我們要對add函式做效能測試,可能會編寫如下測試程式碼:

func BenchmarkWrong(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        add(1000000000, 1000000001)
    }
}

這裡可能有什麼坑呢?對於編譯器而言,add函式是一個葉子函式(leaf function),即add函式本身沒有呼叫其它函式,所以編譯器會對add函式的呼叫做內聯(inline)優化,這會導致效能測試的結果不準確。因為我們通常要測試的是自己程式本身的執行效率,而不是編譯器做了優化後的執行效率,這樣才方便我們對程式的效能有一個正確的認知,而且你做go test測試時編譯器的優化效果和實際生產環境執行時編譯器的優化效果可能也不一樣

那怎麼知道執行go test的時候編譯器是否做了內聯優化呢?很簡單,給go test增加-gcflags="-m"引數,-m表示列印編譯器做出的優化決定。

$ go test -gcflags="-m" -v -bench=BenchmarkWrong -count 1
# example.com/benchmark [example.com/benchmark.test]
./go_util.go:3:6: can inline add
./go_bench_test.go:19:6: inlining call to add
./go_bench_test.go:16:21: b does not escape
# example.com/benchmark.test
/var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build2365344599/b001/_testmain.go:33:6: can inline init.0
/var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build2365344599/b001/_testmain.go:41:24: inlining call to testing.MainStart
/var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build2365344599/b001/_testmain.go:41:42: testdeps.TestDeps{} escapes to heap
/var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build2365344599/b001/_testmain.go:41:24: &testing.M{...} escapes to heap
goos: darwin
goarch: amd64
pkg: example.com/benchmark
cpu: Intel(R) Core(TM) i5-5250U CPU @ 1.60GHz
BenchmarkWrong
BenchmarkWrong-4        1000000000               0.4601 ns/op
PASS
ok      example.com/benchmark   0.605s

上面的執行結果的./go_bench_test.go:19:6: inlining call to add就表示編譯器對BenchmarkWrong裡的add函式呼叫做了內聯優化。

備註: -gcflags 的所有引數值可以執行go tool compile --help進行檢視。

最佳實踐

那在效能測試的時候怎麼禁用編譯期的內聯優化呢?有2個方案:

-gcflags="-l"

第一種方案,執行go test的時候,增加-gcfloags="-l"引數,-l表示禁用編譯器的內聯優化。

$ go test -gcflags="-m -l" -v -bench=BenchmarkWrong -count 3
# example.com/benchmark [example.com/benchmark.test]
./go_bench_test.go:16:21: b does not escape
# example.com/benchmark.test
/var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build2785655381/b001/_testmain.go:41:42: testdeps.TestDeps{} escapes to heap
goos: darwin
goarch: amd64
pkg: example.com/benchmark
cpu: Intel(R) Core(TM) i5-5250U CPU @ 1.60GHz
BenchmarkWrong
BenchmarkWrong-4        476215998                2.447 ns/op
BenchmarkWrong-4        492860170                2.404 ns/op
BenchmarkWrong-4        483547294                2.388 ns/op
PASS
ok      example.com/benchmark   4.568s

通過上面的輸出結果可以看出,並沒有inlining call字樣,這就證明了使用-gcflags="-l"引數後,編譯器沒有做內聯優化了。

對比下編譯期內聯優化禁用前後的結果,效能差了將近5倍。

  • 開啟內聯優化,耗時:0.4601 ns/op
  • -gcflags="-l"關閉內聯優化,耗時大概:2.4 ns/op

go:noinline

第二種方案,使用//go:noinline編譯器指令(compiler directive),編譯器在編譯時會識別到這個指令,不做內聯優化。

//go:noinline
func add(a int, b int) int {
    return a + b
}

通過這種方式修改程式碼後,我們就不需要使用-gcflags="-l"引數了,我們來看看效能測試結果:

$ go test -gcflags="-m" -v -bench=BenchmarkWrong -count 3
# example.com/benchmark [example.com/benchmark.test]
./go_bench_test.go:16:21: b does not escape
# example.com/benchmark.test
/var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build1050705055/b001/_testmain.go:33:6: can inline init.0
/var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build1050705055/b001/_testmain.go:41:24: inlining call to testing.MainStart
/var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build1050705055/b001/_testmain.go:41:42: testdeps.TestDeps{} escapes to heap
/var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build1050705055/b001/_testmain.go:41:24: &testing.M{...} escapes to heap
goos: darwin
goarch: amd64
pkg: example.com/benchmark
cpu: Intel(R) Core(TM) i5-5250U CPU @ 1.60GHz
BenchmarkWrong
BenchmarkWrong-4        482026485                2.422 ns/op
BenchmarkWrong-4        495307399                2.413 ns/op
BenchmarkWrong-4        407674614                2.613 ns/op
PASS
ok      example.com/benchmark   4.439s

通過上面的輸出結果,同樣可以看出編譯器沒有做內聯優化了,最終的執行效率和第一種方案基本一致。

測試原始碼地址:benchmark效能測試原始碼,大家可以下載到本地進行測試。

備註: 網上有些文章的說法是把函式呼叫的結果賦值給一個區域性變數,然後使用一個全域性變數來承接這個區域性變數的值就可以避免編譯器的內聯優化。這個說法實際上是錯誤的,原作者Teiva Harsanyi在這方面也犯了錯誤。要判斷編譯器是否做了內聯優化,參考本文寫的方式驗證即可。

開源地址

文章和示例程式碼開源在GitHub: Go語言初級、中級和高階教程

公眾號:coding進階。關注公眾號可以獲取最新Go面試題和技術棧。

個人網站:Jincheng's Blog

知乎:無忌

References