翻譯原文連接 轉帖/轉載請註明出處php
原文連接@hashrocket.com 發表於2015/12/28linux
在開發pgx(一個針對Go語言的PostgreSQL driver)的時候,有好幾回我都須要在20多個代碼分支間跳轉。一般我會選用switch語句。還有個更加可讀的實現方法是使用函數map。我一開始認爲用switch語句進行分支跳轉比一個map查找和函數調用更快。數據庫驅動(database driver)的性能是一個很重要的考量,因此在作任何改動前,有必要對它們的影響作一下慎重地研究。git
性能測試顯示它們有很大的差別。但最終的答案是它們對整個程序來講多是可有可無的。若是你想了解得出這個結論而作的測試,那麼請繼續閱讀。github
在互聯網上沒有找到有用的信息。我找到的幾個帖子都認爲map在有足夠多跳轉分支時會更快。一個在2012年對switch優化的討論包括了Ken Thompson的觀點。他認爲沒有太多優化的空間。我決定寫一個benchmark來測試它們在Go語言裏的性能。golang
取得下面結果的系統配置是:Intel i7-4790K,Ubuntu 14.04,運行的是go1.5.1 linux/amd64。測試的源代碼和結果在Github上。數據庫
下面是一個對switch的基本測試:微信
func BenchmarkSwitch(b *testing.B) { var n int for i := 0; i < b.N; i++ { switch i % 4 { case 0: n += f0(i) case 1: n += f1(i) case 2: n += f2(i) case 3: n += f3(i) } } // n will never be < 0, but checking n should ensure that the entire benchmark loop can't be optimized away. if n < 0 { b.Fatal("can't happen") }
衆所周知,像這樣的測試要達到它的目的一般是很困難的。好比,編譯優化器會把一段不產生任何效果的代碼徹底忽略掉。這裏的n就是用來防止這整段代碼不被優化掉。接下來的文章裏還會提到其它幾個須要注意的地方。app
下面是一個函數map的測試代碼:函數
func BenchmarkMapFunc4(b *testing.B) { var n int for i := 0; i < b.N; i++ { n += Funcs[i%4](i) } // n will never be < 0, but checking n should ensure that the entire benchmark loop can't be optimized away. if n < 0 { b.Fatal("can't happen") } }
咱們使用Ruby erb模版來生成包含4,8,16,32,64,128,256和512個跳轉分支的測試。結果顯示map版本在4個分支的狀況下比switch版本慢了25%。在8個分支的狀況下它們的性能至關。map版本在分支越多的狀況下越快,在512個分支的測試裏它會比switch版本快50%。工具
以前的測試給出了一些結果,可是它們並不充分。有好幾個影響測試的因素都沒有考慮進去。首先是函數是否被內聯。一個函數能夠在switch語句裏被內聯,可是函數map就不會。咱們有必要測試一下函數內聯對性能的影響。
下面這個函數作了一些毫無心義的工做,它能保證整個函數內容不會被優化掉,可是Go語言的編譯器會把整個函數內聯。
func f0(n int) int { if n%2 == 0 { return n } else { return 0 } }
在寫這篇文章的時候,Go編譯器還不能內聯包含panic的函數。下面這個函數包含了一個不可能被執行到的panic調用,從而防止了函數被內聯。
func noInline0(n int) int { if n < 0 { panic("can't happen - but should ensure this function is not inlined") } else if n%2 == 0 { return n } else { return 0 } }
當函數不能被內聯時,性能有了很大的變化。map版本的代碼比switch版本在4個分支的測試裏快了大約30%,在512個分支的測試裏快了300%。
上面的測試根據循環的次數來決定跳轉分支。
for i := 0; i < b.N; i++ { switch i % 4 { // ... } }
這保證咱們測試的僅僅是分支跳轉的性能。在現實世界中,分支跳轉的選擇一般會致使一個內存的讀取。爲了模擬這個行爲,咱們使用一個簡單的查找來決定跳轉分支。
var ascInputs []int func TestMain(m *testing.M) { for i := 0; i < 4096; i++ { ascInputs = append(ascInputs, i) } os.Exit(m.Run()) } func BenchmarkSwitch(b *testing.B) { // ... for i := 0; i < b.N; i++ { switch ascInputs[i%len(ascInputs)] % 4 { // ... } // ... }
這個改變大大的下降了性能。表現最好的switch測試性能從1.99 ns/op降低到了8.18 ns/op。表現最好的map測試性能從2.39 ns/op降低到了10.6 ns/op。具體的數據在不一樣的測試中會有一些差異,可是查找操做增長了大約7 ns/op。
厲害的讀者確定已經注意到了,這些測試裏的分支跳轉是高度可預測的,這不符合現實。它老是按照分支0,而後分支1,而後分支2的順序來。爲了解決這個問題,分支跳轉的選擇被隨機化了。
var randInputs []int func TestMain(m *testing.M) { for i := 0; i < 4096; i++ { randInputs = append(randInputs, rand.Int()) } os.Exit(m.Run()) } func BenchmarkSwitch(b *testing.B) { // ... for i := 0; i < b.N; i++ { switch randInputs[i%len(ascInputs)] % 4 { // ... } // ... }
這個改變繼續下降了性能。在32個跳轉分支的測試裏,map查找延遲從11ns漲到了22ns。具體的數據根據跳轉分支的數目以及函數是否被內聯會有變化,可是性能基本都降低了一半。
從計算跳轉分支目的地到查找跳轉目的地的性能損失是在預料之中的,由於有了額外的內存讀取。可是從順序跳轉到隨機跳轉的性能影響卻出乎意料。爲了瞭解其中的緣由,咱們使用Linux perf工具。它能夠提供例如cache miss和分支跳轉預測錯誤(branch-prediction misses)等CPU層面的統計。
爲了不對測試程序編譯過程的profiling,能夠將測試代碼預先編譯好。
go test -c
而後咱們讓perf工具爲咱們提供其中一個順序查找測試的統計數據。
$ perf stat -e cache-references,cache-misses,branches,branch-misses ./go_map_vs_switch.test -test.bench=PredictableLookupMapNoInlineFunc512 -test.benchtime=5s
輸出結果裏有意思的部分是分支跳轉預測錯誤的統計:
9,919,244,177 branches 10,675,162 branch-misses # 0.11% of all branches
因此當跳轉順序可預測時分支跳轉預測工做得很是好。但不可預測分支跳轉測試裏的結果就大相徑庭。
$ perf stat -e cache-references,cache-misses,branches,branch-misses ./go_map_vs_switch.test -test.bench=UnpredictableLookupMapNoInlineFunc512 -test.benchtime=5s
相關的輸出:
3,618,549,427 branches 451,154,480 branch-misses # 12.47% of all branches
分支跳轉預測的錯誤率漲了100倍。
我最初想回答的問題是,用函數map來替換switch語句對性能是否會有影響。我假設switch語句會快一點。可是我錯了。Map一般會更快,並且快好幾倍。
這是否意味着咱們應該選擇使用map而不是switch語句呢?不!雖然從百分比來看改變很是大,但絕對的時間差別其實很小。只有在每秒鐘執行上百萬次分支跳轉而沒有其它實際工做量時,這個差別纔會顯現出來。即便是這樣,內存訪問和分支跳轉的成功率對性能影響更大,而不是選擇switch語句或者map。
對switch語句或者map的選擇標準應該是誰能產生最乾淨的代碼,而不是性能。
若是你喜歡咱們的文章,請關於微信公衆號【曼託斯】