最近幾天在蒐集一些關於 JavaScript 函數語言程式設計的效能測試用例,還有記憶體佔用情況分析。
我在一年前(2017年1月) 曾寫過一篇文章《JavaScript 函數語言程式設計存在效能問題麼?》,在文中我對陣列高階函式以及 for-loop 進行了基準測試,得到的結果是 map
`reduce` 這些函式比原生的 for-loop 大概有 20 倍的效能差距。
不過一年半過去了,V8 引擎也有了很大的改進,其中就包括了對陣列高階函式的效能改進,並取得了很好的效果。
可以看到兩者已經相當接近了。
但是我卻在 jsperf 發現了一個很有意思的測試用例: https://jsperf.com/sorted-loop/2
// 對整形陣列 data 進行累加求和
function perf(data) {
var sum = 0;
for (var i = 0; i < len; i++) {
if (data[i] >= 128) {
sum += data[i];
}
}
return sum;
}
複製程式碼
兩個 test case 都使用這個函式,唯一不同的是陣列(引數):一個是有序的,另一個是無序的。結果兩者的效能差了 4 倍。
我們都知道如果對一個有序陣列進行搜尋,我們可以二分查詢演算法獲得更好的效能。不過二分查詢和普通查詢是兩個截然不同的演算法,因此效能有差距是正常的。但是這個測試用例不同,兩者的演算法完全一模一樣,因為都是同一個函式。兩者生成的二進位制機器碼也一樣。為什麼還有這麼大的效能差距呢?
於是我以 fast array sorted
為關鍵字在 Google 搜尋了一下,果然找到了 stackoverflow 的結果,問題和答案都獲得了 2 萬多贊,應該值得一看。雖然原文使用 C++ 和 Java 寫的,但是應該有共通性。
原來兩者的程式碼雖然一模一樣,但是當 CPU 執行時卻不一樣,原因就在於 CPU 的一個優化特性:Branch Prediction(分支預測)。
為了便於理解,答者用了一個比喻:
考慮一個鐵軌的分叉路口:
(圖片作者 Mecanismo,來源 Wikimedia,授權許可 CC-By-SA 3.0)
假設我們是在 19 世紀,而你負責為火車選擇一個方向,那時連電話和手機還沒有普及,當火車開來時,你不知道火車往哪個方向開。於是你的做法(演算法)是:叫停火車,此時火車停下來,你去問司機,然後你確定了火車往哪個方向開,並把鐵軌扳到了對應的軌道。
還有一個需要注意的地方是,火車的慣性是非常大的,所以司機必須在很遠的地方就開始減速。當你把鐵軌扳正確方向後,火車從啟動到加速又要經過很長的時間。
那麼是否有更好的方式可以減少火車的等待時間呢?
有一個非常簡單的方式,你提前把軌道扳到某一個方向。那麼到底要扳到哪個方向呢,你使用的手段是——“瞎蒙”:
- 如果蒙對了,火車直接通過,耗時為 0。
- 如果蒙錯了,火車停止,然後倒回去,你將鐵軌扳至反方向,火車重新啟動,加速,行駛。
如果你很幸運,每次都蒙對了,火車將從不停車,一直前行!(你可以去買彩票了)
如果不幸你蒙錯了,那麼將浪費很長的時間。
那現在我們思考一個 if
語句。if
語句會產生一個“分支”,類似前面的鐵軌:
有很多人覺得,CPU 怎麼會像火車一樣呢?CPU 也需要減速和後退嗎?難道不是遇到中斷就直接跳轉了嗎?
現代化的 CPU 晶片是非常複雜的,為了提升效能大部分晶片使用了指令流水線(instruction pipeline)技術,通常有幾個主要步驟:
讀取指令(IF) -> 解碼(ID) -> 執行(EX) -> 儲存器訪問(MEM) -> 寫回暫存器(WB)
複製程式碼
這樣就大大提升了指令的通過速度(單位時間內被執行的指令數量)。當第一條指令執行完成後,第二條指令已經完成解碼了,並且可以立即執行。
那麼 CPU 如何做分支預測呢?一個最簡單的方式就是根據歷史。如果過去 99% 的次數都是在某個分支執行,那麼 CPU 就會猜測:下一次可能還會在此分支執行,因此可以提前將這個分支的程式碼裝載到流水線上。如果預測失敗,則需要清空流水線並重新載入,可能會損失 20 個左右的時鐘週期時間。
如果陣列是按某個順序排列的,那麼 CPU 的預測會非常準確,就像我們前面的程式碼,data[i] >= 128
,不論陣列是升序的還是降序的,在 128 這個分隔點之前和之後,CPU 的分支預測都能得到正確的結果。如果陣列是亂序的,那麼 CPU 流水線將會不停的預測失敗並重新載入指令。
那麼我們如果已經知道了我們的陣列是亂序的,並有很大可能使分支預測失敗,那麼能不能進行程式碼優化,避免 CPU 的分支預測?
答案是肯定的。我們可以把分支語句去掉,這樣 CPU 就可以直接在指令流水線上裝載指令,而無需依賴分支預測功能。在此使用一個位運算的技巧。我們觀察之前的程式碼:
if (data[i] >= 128) {
sum += data[i];
}
複製程式碼
把所有大於 128 的數累加。
因為位運算只對 32 位的整數有效,因此我們可以使用右移來判斷一個數。
對於有符號數:
- 非負數右移 31 位為一定為
0
- 負數右移 31 位為一定為
-1
,也就是0xffff
因為 -1
的二進位制表示是所有位都是 1
,既:
0b1111_1111_1111_......_1111_1111_1111
// 32個
複製程式碼
因此,-1
與任何數進行與運算其值不變。
-1 & x === x
複製程式碼
0
與 -1
正好相反,32 位全部為 0
:
0b0000_0000_0000_......_0000_0000_0000
// 32個
複製程式碼
可以看到,對應數字 0
與 -1
,每個 bit 位都是相反的,於是我們可以按位取反:
~ -1 === 0
~ 0 === -1
複製程式碼
如此一來我們可以分析前面的程式碼,“如果大於 128 則累加”,我們拆解一下:
- 我們把這個數減去
128
,那麼只有 2 種結果:正數(0)和負數 - 右移 31 位,得到
0
或-1
我們需要把所有的結果為 0
(大於128) 的值相加:
- 按位取反,把大於
128
的數變為-1
,小於128
的變為0
- 與原數進行與運算
程式碼為:
const t = (data[i] - 128) >> 31
sum += ~t & data[i];
複製程式碼
這樣就可以避免分支預測失敗的情況。效能測試:
可以看到兩者有幾乎相同的效能,而且效能明顯高於之前使用 if
分支的亂序陣列。但是我們也看到了兩者的效能和有序陣列的 if
分支程式碼相比,效能要差了不少,是不是因為位運算沒有使用分支預測,因而比有序陣列的分支預測程式碼效能要差一些呢?並不是。
即使有序陣列的分支預測成功率非常高,但是在經歷 128
這個分支臨界點時,CPU 依然會預測失敗,並損失很長的時鐘週期時間。除非陣列裡面所有的陣列都是大於 128 或者都是小於 128 的。而使用位運算則完全不需要 CPU 停頓。
位運算比 if
分支要慢,這也和很多開發者的心理預期不一樣,很多人覺得位運算理所應當是最快的。其實我很早之前就寫過一篇文章:
上面程式碼之所以位運算比 if
分支要慢,是因為位運算實現這個功能比較繁瑣,生成的二進位制機器碼也比較長,因此需要更長得指令週期才能執行完,因此要比 if
分支的程式碼慢。
最後做個總結吧。
位運算由於消除了分支,因此效能更加穩定,但是可讀性也更差。甚至有人說:“所有在業務程式碼裡面使用位運算的行為都是裝逼”、“程式碼是寫給人看的,位運算是寫給機器看的”。