最近幾天在蒐集一些關於 JavaScript 函數式編程的性能測試用例,還有內存佔用狀況分析。算法
我在一年前(2017年1月) 曾寫過一篇文章《JavaScript 函數式編程存在性能問題麼?》,在文中我對數組高階函數以及 for-loop 進行了基準測試,獲得的結果是 map
`reduce` 這些函數比原生的 for-loop 大概有 20 倍的性能差距。編程
不過一年半過去了,V8 引擎也有了很大的改進,其中就包括了對數組高階函數的性能改進,並取得了很好的效果。數組
能夠看到二者已經至關接近了。jsp
可是我卻在 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 倍。函數
咱們都知道若是對一個有序數組進行搜索,咱們能夠二分查找算法得到更好的性能。不過二分查找和普通查找是兩個大相徑庭的算法,所以性能有差距是正常的。可是這個測試用例不一樣,二者的算法徹底如出一轍,由於都是同一個函數。二者生成的二進制機器碼也同樣。爲何還有這麼大的性能差距呢?oop
因而我以 fast array sorted
爲關鍵字在 Google 搜索了一下,果真找到了 stackoverflow 的結果,問題和答案都得到了 2 萬多贊,應該值得一看。雖然原文使用 C++ 和 Java 寫的,可是應該有共通性。性能
原來二者的代碼雖然如出一轍,可是當 CPU 執行時卻不同,緣由就在於 CPU 的一個優化特性:Branch Prediction(分支預測)。測試
爲了便於理解,答者用了一個比喻:優化
考慮一個鐵軌的分叉路口:
(圖片做者 Mecanismo,來源 Wikimedia,受權許可 CC-By-SA 3.0)
假設咱們是在 19 世紀,而你負責爲火車選擇一個方向,那時連電話和手機尚未普及,當火車開來時,你不知道火車往哪一個方向開。因而你的作法(算法)是:叫停火車,此時火車停下來,你去問司機,而後你肯定了火車往哪一個方向開,並把鐵軌扳到了對應的軌道。
還有一個須要注意的地方是,火車的慣性是很是大的,因此司機必須在很遠的地方就開始減速。當你把鐵軌扳正確方向後,火車從啓動到加速又要通過很長的時間。
那麼是否有更好的方式能夠減小火車的等待時間呢?
有一個很是簡單的方式,你提早把軌道扳到某一個方向。那麼到底要扳到哪一個方向呢,你使用的手段是——「瞎蒙」:
若是你很幸運,每次都蒙對了,火車將從不停車,一直前行!(你能夠去買彩票了)
若是不幸你蒙錯了,那麼將浪費很長的時間。
那如今咱們思考一個 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 位的整數有效,所以咱們可使用右移來判斷一個數。
對於有符號數:
0
-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)和負數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
分支的代碼慢。
最後作個總結吧。
位運算因爲消除了分支,所以性能更加穩定,可是可讀性也更差。甚至有人說:「全部在業務代碼裏面使用位運算的行爲都是裝逼」、「代碼是寫給人看的,位運算是寫給機器看的」。