1. 程式人生 > 實用技巧 >CPU 的分支預測

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 步:

  1. fetch(獲取指令)

  2. decode(解碼指令)

  3. execute(執行指令)

  4. write-back(寫回資料)

經過這四個階段,才叫完全執行完了一條指令。

我們可以類比這樣的一個例子。

我們去很多旅遊景區吃飯,餐廳會使用半自助的形式由遊客來選餐。遊客進入選餐隊伍之後,需要完成以下的事情,才能真正的執行完“買飯”這件事情,開始享用香噴噴的午餐:

  1. 選擇一個主菜

  2. 選擇一個配菜

  3. 選擇一個飲料

  4. 去結賬!

對於這個流程的執行,我們當然可以等 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詞條,瞭解更多。

參考文獻:

https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array

https://en.wikipedia.org/wiki/Branch_predictor