1. 程式人生 > >分支預測淺談

分支預測淺談

背景

問題可以抽象如下:

將一組不大小超過256的隨機正整數填入陣列中, 然後將陣列中大於128的元素求和。但是會將程式分為兩個參考組, 其一是在求和前對陣列進行一次排序, 另一組則在求和前不進行排序,最後對比觀察結果和運算時間。

#include <algorithm>
#include <ctime>
#include <iostream>

int main()
{
    const unsigned arrSize = 32768;
    int data[arrSize] = {0};

    // 隨機填充0~256間的整數,為了方便結果對比此處未加入隨機種子
for (unsigned int idx = 0; idx < arrSize; ++idx) data[idx] = std::rand() % 256; // 觀察是否排序對求和計算的影響 std::sort(data, data + arrSize); //開始驗證 unsigned long long sum = 0; clock_t startTime = clock(); //此處為了加強對比效果,將求和計算迴圈執行了10萬次 for (unsigned int i = 0; i < 100000; ++i) { for
(unsigned int idx = 0; idx < arrSize; ++idx) { if (data[idx] >= 128) sum += data[idx]; } } // 將執行時間轉化為秒數 double elapsedTime = static_cast<double>(clock() - startTime) / CLOCKS_PER_SEC; std::cout << "執行時間: " << elapsedTime << std
::endl; std::cout << "數值總和: " << sum <<std::endl; return 0; }

將其在Red-Hat GCC上編譯並執行得到:

g++ branch_predic.cpp
./a.cout

===============
# 排序後計算
執行時間: 10.4
數值總和: 31493160000

===============
# 未排序,直接計算
執行時間: 23.5
數值總和: 31493160000

通過該次結果,我們可以發現排序後進行計算要相比未排序的情形,執行效率要提升一倍以上。

那麼問題來了,僅僅只是對一組資料進行不涉及有序性的累加操作,為何排序後的陣列執行起來要快於未排序陣列一倍以上? 這就涉及到處理器的分支判斷機制了。

分支預測

分支預測器是現代指令流水線式CPU中非常關鍵的技術,它在分支指令執行結束前猜測哪路分支將會被執行,以此來提高處理器指令流水線的效率。

一個較好的例子是將CPU指令流水線比作一列滿載的火車和鐵軌操作人員:
如果操作員需要火車在每個分支口都停下來去確認接下來的路口方向的話,就會耗費巨大的時間在火車的啟動和停止上。為了優化這個情況,於是便可以嘗試由操作員去猜測接來下列車的方向,這樣一來便會出現如下情況:

如果猜測總是正確,那列車便可以一路暢行;

如果總是猜錯,那麼列車反而需要花費時間來停止,倒退,再次啟動。現代CPU趨於採用非常長的流水線,它的啟停同列車一樣需要耗費大量的時鐘週期。

我們可以繼續使用上述例子進行論述:

if (data[idx] >= 128)
    sum += data[idx];

將其轉化為彙編碼得到:

cmp edx, 128
jl SHORT $LN3@main
add rbx, rdx
$LN3@main

可以看到,此段條件分支被分為兩段後續執行分支,如果條件判斷為真則執行數值相加操作,反之則跳轉至另外一個記憶體去執行對應的指令。而是否去條件跳轉只有該分支指令在指令流水線中通過了執行階段(execution stage)後才能確定。

所以如果沒有分支預測器,那麼CPU會等到分支指令通過流水線的執行階段後才會將下一個指令送入流水線的第一個階段,即取指令階段(fetch stage)。這樣一來就會造成處理器的處理週期空轉,我們稱之為流水線冒泡(bubbling)或流水線打嗝(hiccup)。

分支預測器便是提前猜測條件分支的結果,然後推測執行對應分支的指令,以此來避免流水線的停頓。這種方法同列車的分叉路口判斷一樣:

如果每次都猜對,則處理器無需停頓一直執行;

如果經常猜錯,那麼處理器得將之前推測執行的結果全部放棄,重新獲得正確分支的指令開始執行。分支預測失敗浪費的時間是從取指令到執行完指令的流水線的級數,所以現代處理器出現預測失敗的話可能會損失10-20個時鐘週期。

所以現代處理器為了儘可能的避免預測錯誤後的開銷,它會分析歷史執行記錄作為參考,來決定接下來的分支走向:
如果前10次對某一分支的判斷都為TRUE,那麼後一次分支預測器便有理由推測這次分支判斷結果也為TRUE;而如果後來這種分支結果為每三次為真之後下一次為FALSE,那麼分支預測器也會做出對應調整,對於現代處理器而言,一般分支預測的成功率能高達90%。

總而言之,分支預測器會收集之前若干次的判斷結果,並總結出規律以供下次預測時作參考。

但是遇到一些難以預測的,同時也無法識別出其模式的判斷分支時,分支預測器便無用武之地了。在此例中的if-statement便是罪魁禍首。

data陣列中的元素趨近均勻的分佈在0-255之間,當陣列有序時, 陣列的前一半元素均不會進入if-statement的流程中,而後一半則全部進入。這樣有朝向的,有規律的迭代對於分支預測器而言是非常友好的,很易於做出判斷, 簡單描述如下:

//T: 該分支被選中(taken)
//N: 分支未被選中(not taken)

data[] = 0, 1, 2, 3, 4, ... 126, 127, 128, 129, ..., 251, 252, 253,...
branch = N  N  N  N  N  ...  N    N    T    T   ...   T    T    T  ...

       =   NNNNN...NNNNTTTTT...TTTTT   (easy to predict)

而當陣列無序且隨機分佈時:

data[] = 121, 56, 180, 101, 144, 162, 183, 11, 210, 177, 90, 45, 67,...
branch =  N   N   T   N   T   T   T   T   N   T   T   N   N   N  ...
       =   NNTNTTTTNTTNNN...  (completely random - hard to predict)  

可以看到,在面對無法有效識別出的判斷模式時,分支預測器的預測機制也就成為了隨機猜測了,因此只有近似50%的預測成功率。

改進方案

當面對可能出現大量隨機變數的分支判斷時,為了提高運算效率,可以採用位運算的方法來避免分支判斷帶來的開銷。
將上述進行的分支判斷語句:

if (data[idx] >= 128)
    sum += data[idx]; 

替換為:

int val = (data[idx] - 128) >> 31;
sum += ~val & data[sum];

其基本原理如下:

|x| >> 31 == 0   //將一個非負的整形數右移31位後得到的值一定為0
-|x| >> 31 == -1 //將一個整形的負數右移31位後得到的值一定為-1,即0xffffffff

~ 0 == -1 // 0取反後為-1,反之亦成立

-1 & x = x // 任何整形數和-1求與後數值不變(因為-1作為掩碼是全f)

所以,上述的位運算就變成了:

如果data[idx] < 128,位移後的值取反為0,接下來與data[idx]求與後得到0,所以sum不累加;反之,如果data[idx] >= 128,位移取反後為-1,所以sum要加上求與後得到的data[idx]

然後進行位運算的程式分別在有序和無序陣列中執行,得到幾乎相同的結果。

更多位運算的Hack操作參見BitHacks.

補充: 指令流水線

指令流水線是一種增加指令吞吐量(throughput)的方法,它通過將基本程式執行流水線拆分為若干連續、獨立的步驟來提升單位時間內同時執行的指令數。
一般而言,指令流水線分為四個階段進行,

取指令(Fetch) -> 解碼指令(Decode) -> 執行指令(Execute) -> 寫回執行結果(Write-back)

簡要圖示如下:

cpu_pipeline1

圖中,上方灰色區域為尚未執行的指令, 而下方灰色則表示已經執行完成的指令, 中間區域即為指令流水線,可以看到,被區分為四種顏色的指令順序地進入流水線中分步執行。

為了保證圖中指令能夠連續、緊湊的通過流水線,那麼就必須要確定要執行指令的序列和順序。而當處理器遇到分支條件跳轉時,如果處理器無法確認接下來執行的分支,則會等到當前指令通過了流水線的執行階段(Execute Stage)後才會去取下一條指令。

沒有分支預測器的情況:

cpu_pipeline2

可以看到,圖中氣泡在時鐘週期3的時候產生,且一直存在直到它走完流水線。

有分支預測器的情況:

在有分支預測器時,處理器遇到分支條件跳轉時,會由分支預測器提前猜測最可能執行的分支,這樣便可以讓下一條指令連續的進入指令流水線,從而避免了流水線的冒泡。

但是,如果分支預測器猜測錯誤的話,處理器會丟掉該跳轉指令後全部進入流水線的指令,並從正確位置的起始位置重新填充流水線。對於現代CPU而言,由於流水線級數非常長,預測失敗通常會損失20-40個時鐘週期,所以高效的分支預測器對處理器是至關重要的。

總結

本文通過一個對有序、無序數列進行判斷求和程式效率問題的討論進行引申, 分析處理器的分支預測對不同資料分佈的程式執行效能影響,同時給出了利用位運算的優化方案。最後簡要的介紹了處理器指令流水線的運作方式和分支預測器的作用。