CPU 的分支預測
如下這樣一個簡單的程式碼:
相信大家都能看懂。這段程式碼對data陣列中所有大於等於128的值進行求和。這樣的求和操作運行了 10 萬輪。
下面,我們來看一下這段程式碼的效能。我們這樣隨機生成一個數組:
使用這個隨機生成的陣列,測試上面的程式碼。在我的計算機上,整體耗時是8.5 秒左右。
下面問題來了。如果,我對這個隨機的陣列進行一遍排序。對排序後的陣列執行上面的程式碼,效能會有怎樣的影響?
可能很多同學都會認為,效能是差不多的。
這是因為,上面的程式碼過程,只是從頭到尾掃描陣列,對於陣列中的每一個元素,判斷其是否大於等於128,如果是,就加入到sum中。
整個演算法邏輯,和陣列是否有序無關。有序的陣列不會提前終止任何操作。不管是有序的陣列,還是無序的陣列,執行的運算元量是一樣多的。
甚至,為了保持公平,我為隨機數生成器添加了種子。所以,兩次測試的陣列中,大於等於128的元素個數都是一樣的。這就意味著sum += data[c];這個指令的執行次數是一致的。
區別只有:第二次執行,我先對陣列進行了排序!
可是,實際結果卻是這樣的:
大家可以看到,由於測試資料是一樣的,所以最終的sum結果是一樣的。但是第二次,針對有序的陣列做實驗,消耗的時間僅僅是2.8 秒左右,比無序的情況快了有 3 倍之多!
大家可能會覺得,這是不是 JVM 在搞什麼鬼?那麼,同樣的程式碼邏輯,我們嘗試用 C++ 實驗一遍!
這段程式碼,我使用無序的陣列測試,在我的計算機上,執行時間大概是18.8 秒左右。(Debug 模式)
但是,當我將陣列進行排序以後,執行時間則變成了5.7 秒!(Debug 模式)
看來,這不是 JVM 的問題,而是有更加底層的優化機制在起作用。
這個機制,就是CPU 的分支預測(Branch prediction)。
在具體講解什麼是 CPU 的分支預測之前,我們先來看一下什麼是 CPU 指令執行的流水線(Pipeline)。
簡單來說,一條指令的執行,在 CPU 內部,需要經過若干步驟。
比如,一個常見的模型,是 4 階段流水線。即一條指令在 CPU 內部的執行,需要有 4 步:
-
fetch(獲取指令)
-
decode(解碼指令)
-
execute(執行指令)
-
write-back(寫回資料)
經過這四個階段,才叫完全執行完了一條指令。
我們可以類比這樣的一個例子。
我們去很多旅遊景區吃飯,餐廳會使用半自助的形式由遊客來選餐。遊客進入選餐隊伍之後,需要完成以下的事情,才能真正的執行完“買飯”這件事情,開始享用香噴噴的午餐:
-
選擇一個主菜
-
選擇一個配菜
-
選擇一個飲料
-
去結賬!
對於這個流程的執行,我們當然可以等 A 同學選好他的午飯:主菜,配菜和飲料,並且結完賬,然後 B 同學再去選擇他的午飯。▼
相信同學們都明白,這樣做是低效的。
在 A 同學選擇完主菜,去選擇配菜的時候,B 同學就已經可以上去選擇他的主菜了。▼
當 A 同學開始選擇飲料的時候,B 同學已經可以選擇配菜了,而 C 同學,此時就可以開始選擇主菜了。▼
這樣做,當 A 同學結完賬的時候,E 同學都已經開始選主菜了。▼
很顯然,這樣做效率更高。
這就叫流水線。一個同學不需要等前一個同學完成所有選餐的步驟再去選餐,而只要完成一步,下一個同學就可以跟進。
CPU 的流水線完全同理。因為執行每一條指令需要 4 步。所以,在執行 A 指令的時候,一旦完成了 A 指令的 fetch 操作,進入 A 指令的 decode 階段,就可以對下一條 B 指令執行 fetch 操作了。▼
當 A 指令 decode 完成,進入 execute 階段,就可以開始對 B 指令進行 decode 了,同時,B 指令的下一條 C 指令,就可以開始 fetch 了。▼
那麼問題來了,現在,如果一條指令是if,怎麼辦?
為什麼if指令會出問題?因為對於if指令,我們必須等它執行完,才能知道下一條指令是什麼!下一條指令是根據if表示式中的結果是真還是假來決定的!
而實際情況,可能不是簡單的一條指令的問題。因為if表示式的計算,可能涉及多個操作。
比如上面程式碼中,就算是if(data[c] >= 128)這個簡單的邏輯,我們也需要先解析出c的值,再拿出data,再從data中拿出c這個索引對應的元素,再去比較這個元素是不是大於等於128。
可以想象,後面的指令就停在這裡了。需要等這一系列if判斷相關的指令都執行完,計算出最終結果,才能決定下面把哪條指令放入流水線。
這顯然會對效能產生影響。於是,現代 CPU 對於這種情況,都設計了一個機制,叫做分支預測(Branch Prediction)。
簡單來說,分支預測就是針對這種if指令,不等它執行完畢,先預測一下執行的結果可能是true還是false,然後將對應條件的指令放進流水線。
如果等if語句執行完畢,發現最初預測錯了,那麼我們把這些錯誤的指令計算結果扔掉就好了,轉而重新把正確的指令放到流水線中執行。
這種情況,雖然也會損失一些效能,但可以接受。因為反正如果不做預測,時間也會空耗,對應就是 CPU 的時鐘週期空轉。
但一旦預測對了,那就是一個巨大的效能提升。因為後續指令已經進入流水線,執行起來了。我們直接繼續這個過程就好。
這就是 CPU 的分支預測,是不是很簡單?
具體 CPU 的分支預測是如何實現的?不同的體系架構,包括同一體系架構 CPU 的不同版本,會有不同的策略。
但是,整體上,一個重要的策略,是參考某條if指令執行過程中判斷為true或者false的歷史記錄。
這應用了在計算機領域經常使用的一個原理:區域性性原理。
通常在作業系統課程中,都會介紹這個重要的原理。很多演算法或者資料結構的設計,也是基於這個原理的。
比如,計算機體系結構設計,都是分層的。從外存;到記憶體;到一級快取,二級快取;到暫存器。儲存容量逐漸減小;但是,運算速度越來越快。
作業系統在執行的過程中,就需要做一個重要的排程:決定把什麼資料放到更高層次的快取中,以提升程式執行的效率。
區域性性原理說的就是:
如果一個資訊正在訪問,那麼近期很有可能會再次訪問,這叫時間區域性性;
如果一個資訊正在訪問,那麼近期訪問的其他資訊,大概率在空間地址上,和這個資訊的空間地址鄰近,這叫空間區域性性。
這樣的區域性性原理同樣被應用在了 CPU 對if的條件分支預測上。一個if現在被判為true,下次,會更高概率的判為true。當然,實際的預測邏輯會更復雜,但是,區域性性原理是一個重要的參考。
我稱之為:if 區域性性原理。
(我瞎編的,聽說多使用這種讓人摸不到頭腦的術語,會顯得文章更加高大上。)
現在,大家應該明白了。對於文章開始討論的程式碼,如果資料經過了排序,那麼,所有小於128的資料就都在陣列的前面;所有大於等於128的資料,就都在陣列的後面。
那麼,在下面的執行過程中,CPU 根據歷史記錄對if進行分支預測,就會高概率命中,提升效能。
而對於完全隨機的陣列,資料是否大於等於128是完全隨機的,這就導致 CPU 的分支預測總是失效,從而,降低了效能。
好了,原理解釋清楚了。下面,我們看一下,在這個程式中,我們可不可以避免這種分支預測經常失敗導致的效能問題?
答案是,可以!我們需要想辦法去掉if判斷。
怎麼去除?在這個程式中,我們可以使用這樣的方式:
注意上面的程式碼中,紅框的部分,代替了原來的if邏輯。
為什麼這是等價的?我們可以簡單分析一下。
首先看變數t的值。他是data[c] - 128的結果右移31位。
大家可以想象:
如果data[c] - 128是非負數,右移補零,符號位也是0。右移31位的結果是0x0000;
如果data[c] - 128是負數,右移補一,符號位也是1。右移31位的結果,是0xffff。
在下面的sum計算中,先對t取反。
那麼如果data[c] - 128是非負數,即data[c] >= 128,t就是0x0000,取反的結果是0xffff。0xffff每一位都是1,和data[c]做與運算,結果還是data[c]自身。此時,相當於把data[c]加入了sum中。
如果data[c] - 128是負數,即data[c] < 128,t就是0xffff。此時對t取反,結果為0。0和data[c]做與運算,結果還是0。此時,相當於sum什麼都沒有加。
所以,這和判斷一下data[c]是否大於等於128,如果大於等於,再做加法運算,是等價的。但是,我們去掉了 if 判斷。
這個程式碼的效能是怎樣的?在我的計算機上,不做排序的話,只需要1.7 秒(對比之前的 8.4 秒)。
更重要的是,這個程式碼的效能,不再受原始陣列是否排序而影響。當排序以後,執行時間,也是同一個數量級的。
使用 C++ 測試,結果是類似的。
怎麼樣,是不是很酷?
關於分支預測,有興趣的同學,可以參考維基百科的Branch predictor詞條,瞭解更多。
參考文獻: