這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
翻譯原文連結 轉帖/轉載請註明出處
原文連結@hashrocket.com 發表於2015/12/28
在開發pgx(一個針對Go語言的PostgreSQL driver)的時候,有好幾次我都需要在20多個代碼分支間跳轉。通常我會選用switch語句。還有個更加可讀的實現方法是使用函數map。我一開始認為用switch語句進行分支跳轉比一個map尋找和函數調用更快。資料庫驅動(database driver)的效能是一個很重要的考量,所以在做任何改動前,有必要對它們的影響做一下謹慎地研究。
摘要
效能測試顯示它們有很大的差異。但最終的答案是它們對整個程式來說可能是無關緊要的。如果你想瞭解得出這個結論而做的測試,那麼請繼續閱讀。
初步調查
在互連網上沒有找到有用的資訊。我找到的幾個文章都認為map在有足夠多跳轉分支時會更快。一個在2012年對switch最佳化的討論包括了Ken Thompson的觀點。他認為沒有太多最佳化的空間。我決定寫一個benchmark來測試它們在Go語言裡的效能。
最基本的測試
取得下面結果的系統配置是: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就是用來防止這整段代碼不被最佳化掉。接下來的文章裡還會提到其它幾個需要注意的地方。
下面是一個函數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%。
內嵌函式(Inlineable Functions)
之前的測試給出了一些結果,但是它們並不充分。有好幾個影響測試的因素都沒有考慮進去。首先是函數是否被內聯。一個函數可以在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 []intfunc 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 []intfunc 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 branches10,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的選擇標準應該是誰能產生最乾淨的代碼,而不是效能。
如果你喜歡我們的文章,請關於公眾號【曼托斯】