分支對程式碼效能的影響和優化

PcDack發表於2022-03-26

譯者注:原文<How branches influence the performance of your code and what can you do about it?>

這是關於底層優化的第三篇文章,前面兩篇為:

我們已經涵蓋了與資料快取和函式呼叫優化有關的前兩個主題,接下來將討論有關於分支相關的內容。所以分支有什麼特別的嘛?

分支(亦或跳轉)是最常用的指令型別之一。在統計學上,每 5 條指令就會存在一個分支相關的指令。 分支可以有條件地或無條件地改變程式的執行流程。對於 CPU來說, 高效的分支實現對於良好的效能至關重要。

在我們解釋分支如何影響 CPU 效能之前,先簡單介紹一下 CPU 的內部組織形式。

CPU內部的組織形式

今天的許多現代處理器(但不是全部,特別是用於嵌入式系統的一些處理器)具有以下一些或全部特徵:

  • 指令流水( Pipline ):管道允許CPU同時執行一條以上的指令。能實現這一效果的原因是因為 CPU 將每條指令的執行分成幾個階段,每條指令處於不同的執行階段。汽車工廠也採用同樣的原則:在任何時候,工廠都有50輛汽車在同時生產,例如,一輛車正在噴漆,發動機正在安裝在另一輛車上,車燈正在安裝在第三輛車上,等等。指令流水可以很短,只有幾個階段(如三個階段),也可以很長,有很多階段(如二十個階段)。(我在我的一篇部落格中有用到指令流水來優化程式,不太明確的可以看下)。
  • 失序執行(Out of order execution) :在程式設計師的視角下,程式指令是一個接一個執行的。但是在 CPU 視角下情況是完全不同的:CPU 不需要按照指令在記憶體中出現的順序執行指令。在執行期間,CPU的一些指令將被阻塞,等待來自記憶體的資料或等待其他指令的資料。CPU 跑去執行後面那些沒被阻塞的指令。當阻塞的指令被啟用後,那些沒被阻塞的指令已經執行完畢。這樣可以節省CPU週期。
  • 推測性執行(Speculative execution) :CPU 可以預先執行一些指令,即使它不是 100% 確定這些指令需要被執行。例如,它將猜測一個條件性分支指令的結果,然後在完全確定將進行分支之前開始執行分支目的地的指令。如果後來CPU發現猜測(推測)是錯誤的,它將取消預先執行指令的結果,一切都將以沒有推測的方式出現。
  • *分支機構預測(Branch prediction) *:現代的 CPU 有特殊的電路,對於每個分支指令都會記住其先前的結果:已跳轉的分支或未跳轉的分支。當下一次執行相同的分支指令時,CPU 將利用這些資訊來猜測分支的目的地,然後在分支目的地開始預測性地執行指令。在分支預測器是正確的情況下,這會提高程式效能。

所有現代處理器都有指令流水系統,以便更好地利用 CPU 的資源。而且大多數處理器都有分支預測和推測執行。 就失序執行而言,大多數低端的低功耗處理器都沒有這個功能,因為它消耗了大量功耗,而且速度的提高並不巨大。 但不要太看重這些,因為這些資訊可能在幾年後就會過時。

你可以閱讀 Jason Robert Carey Patterson: Modern Microprocessors – a 90 minutes guide來了解更多現代處理器的特性。

現在讓我們來談談這些 CPU 的特性是如何影響分支的。

CPU如何處理分支的?

從 CPU 的角度看,分支的代價是高昂的。

當一條分支指令進入處理器流水時,在解碼和計算其目的地之前,並不知道分支目的地。分支指令後面的指令可以是:1)直接跟在分支後面的指令;2)分支目的地的指令。

對於有指令流水的處理器來說,這就是一個問題。為了保持指令流水飽和並避免減速,處理器需要在處理器解碼分支指令之前就知道分支目的地。取決於處理器的設計方式,它可以:

  1. 暫停指令流水(專業技術名稱stall the pipeline),停止解碼指令,直到解碼完分支指令並知道分支目的地。然後繼續執行指令流水。
  2. 緊隨分支之後的載入指令。如果後來發現這是一個錯誤的選擇,處理器將需要重新整理流水線並開始從分支目的地載入正確的指令。
  3. 分支預測器是否應該載入緊隨分支之後的指令或分支目的地的指令。分支預測器還需要告訴指令流水哪裡是分支的目的地 (否則,需要等到流水解決了分支的目的地之後,才新指令載入到流水中)。

現在,除了一些非常低端的嵌入式處理器之外,很少有人會採用這種 1) 這種方法。只是讓處理器什麼都不做是對其資源的浪費,所以大多數處理器會做2)。具有2)處理方式的處理器在低端嵌入式系統和麵向低端市場處理器中很常見。常見的桌上型電腦和膝上型電腦CPU都採取 3)處理方式。

帶有分支預測器的CPU上的分支

如果處理器有一個分支預測器和推測執行,如果分支預測器是正確的,那麼分支預測有較小的代價。萬一不是的話,分支預測具有較高的代價。這對於具有較長的指令流水的 CPU 來說尤其如此,在這種情況下,CPU 需要在預測錯誤的情況下重新整理許多指令。錯誤預測的準確代價是不一樣的,但是一般的規則是:CPU 越貴,分支預測錯誤的代價越高。

有一些分支很容易預測,當然也有一些分支則很難預測。為了說明這一點,想象一下一個演算法,該演算法在一個陣列中迴圈並找到最大的元素。條件 if (a[i] < max) max = a[i] 對於一個有隨機元素的陣列來說,大多數時候條件都為假。 現在想象一下第二個演算法,計算小於陣列平均值的元素數量。 if (a[i] < mean) cnt++ 分支預測器在隨機陣列中很難預測的。

關於推測性執行的一個簡短說明。推測性執行是一個更廣泛的術語,但在分支的背景下,它意味著對分支的條件進行推測(猜測)。現在經常出現的情況是,分支條件不能被推測,因為 CPU 正在等待資料或者正在等待其他指令的完成。推測性執行將允許 CPU 至少執行幾條在分支主體內的指令。當分支條件最終被評估時,這項工作可能會變成有用的,從而使 CPU 節省了一些週期,或者是沒用的,CPU 會把預測的相關內容清空。

瞭解分支彙編

C 和 C++ 中的分支由一個需要判斷的條件和一系列需要在條件滿足的情況下執行的命令組成。在彙編層面,條件判斷和分支通常是兩條指令。請看下面這個 C 語言的小例子:

if (p != nullptr) {
    transform(p);
} else {
    invalid++;
}

彙編程式只有兩類指令:比較指令和使用比較結果的跳轉指令。所以上面的C++例子大致對應於下面的偽彙編程式。

    p_not_null = (p != nullptr)
    if_not (p_not_null) goto ELSE;
    transform(p);
    goto ENDIF;
ELSE:
    invalid++;
ENDIF:

判斷原有的C條件(p != nullptr),如果它是假的,則執行對應於else分支的指令。否則,執行與if分支的主體相對應的指令。

同樣的行為可以用稍微不同的方式實現。將原本跳轉到 ELSE 的部分和 if 部分進行調換。像下面這樣:

    p_not_null = (p != nullptr)
    if (p_not_null) goto IF:
    invalid++;
    goto ENDIF;
IF:
    transform(p);
ENDIF: 

大多數時候,編譯器將為原始的 C++ 程式碼生成如同第一個程式碼的彙編,但開發者可以使用 GCC 內建程式來影響這一點。我們將在後面討論如何告訴編譯器要生成什麼樣的程式碼。

你也許會有疑問,為什麼需要做上述的操作?在一些 CPU 上跳轉的代價比不跳轉的代價昂貴。在這些場景下,告訴編譯器如何構建程式碼可以帶來更好的效能。

分支和向量化

分支影響你的程式碼效能的方式比你能想到的要多。我們先來談談向量化的問題(你可以在這裡找到更多關於向量化和分支的資訊)。大多數現代 CPU 都有特殊的向量指令,可以處理同一型別的多個資料(AVX)。例如,有一條指令可以從記憶體中載入 4 個整數,另一條指令可以做4個加法,還有一條指令可以將 4 個結果存回記憶體。

向量程式碼可以比其標量程式碼快幾倍。編譯器知道這一點,通常可以在一個稱為自動向量化的過程中自動生成向量指令。但自動向量化有一個限制,這個限制是由於分支結構的存在。考慮一下下面的程式碼:

for (int i = 0; i < n; i++) {
    if (a[i] > 0) {
        a[i]++;
    } else {
        a[i]--;
    }
}

這個迴圈對於編譯器來說很難向量化,因為處理的型別取決於變數:如果值 a[i] 是正的,我們做加法;否則,我們做減法。不存在指令對正數做加法,對負數做減法。處理的型別根據資料值的不同而不同,這種程式碼很難被向量化。

一句話:若在編譯器支援向量化的情況下,迴圈內部的分支使編譯器的自動向量化難以實現或完全無法實現。而在迴圈內的不進行分支可以帶來很大的速度改進。

一些優化程式的技巧

在談論技術之前,讓我們先定義兩件事。當我們說條件概率時,實際上我們的意思是條件為真的機率是多少。 有的條件大部分是真的,有的條件大部分是假的。還有一些條件,其真或假的機會是相等的。

具有分支預測功能的 CPU 很快就能弄清哪些條件大多是真的,哪些是假的,你不應該指望在這方面有任何效能退步。然而,當涉及到難以預測的條件時,分支預測器預測正確的概率為50%。這部分是隱藏的潛在的優化空間。

另外一件事,我們將使用一個術語計算密集高代價的條件。這個術語實際上可以意味著兩件事:1)需要大量的指令來計算它,或者2)計算所需的資料不在快取中,因此一條指令需要很多時間才能完成。第一個對計數指令(PC)可見,第二個則不可見,但也非常重要。如果我們以隨機的方式訪問記憶體,資料很可能不在快取中,這將導致指令流水停滯和效能降低。

現在轉到程式設計技巧。這裡有幾個技巧,通過重寫程式的關鍵部分,使你的程式執行得更快。 但是請注意,這些技巧也可能使你的程式執行速度變慢,這將取決於:1)你的CPU是否支援分支預測。2)你的CPU是否必須等待來自記憶體的資料。因此,請做基準測試!

加入條件--高代價和低代價的條件

加入條件是 (cond1 && cond2) 或 (cond1 || cond2) 型別的條件。根據 C 和 C++ 標準,在 (cond1 && cond2) 的情況下,如果 cond1 是假的,cond2 將不會被評估。同樣地,在 (cond1 || cond2) 的情況下,如果 cond1 為真,cond2 將不會被評估(短路斷路)。

因此,如果你有兩個條件,其中一個條件比較簡單,另一個條件比較複雜,那麼就把簡單的條件放在前面,複雜的條件放在後面。這將確保複雜的條件不會被無謂地評估。

優化 if/else 指令鏈

如果你在程式碼的關鍵部分有一連串的 if/else 命令,你將需要檢視條件概率和條件計算強度,以便優化該鏈。比如說:

if (a > 0) { 
    do_something();
} else if (a == 0) { 
   do_something_else();
} else {
    do_something_yet_else();
}

現在想象一下,(a < 0 ) 的概率為 70%,(a > 0) 為 20%,(a == 0) 為 10%。在這種情況下,最合理的做法是將上述程式碼重新編排成這樣。

if (a < 0) { 
    do_something_yet_else();
} else if (a > 0) { 
   do_something();
} else {
    do_something_else();
}

使用查詢表來代替switch

當涉及到刪除分支時,查詢表(LUT)會很方便。不幸的是,在 switch 語句中,大多數時候分支是很容易預測的,所以這種優化可能會變成沒有任何效果。儘管如此,這裡還是需要提一下:

switch(day) {
    case MONDAY: return "Monday";
    case TUESDAY: return "Tuesday";
   ...
    case SUNDAY: return "Sunday";
    default: return "";
};

上述語句可以用LUT來實現:

if (day < MONDAY || day > SUNDAY) return "";
char* days_to_string = { "Monday", "Tuesday", ... , "Sunday" };
return days_to_string[day - MONDAY];

通常情況下,編譯器可以為你做這項工作,通過用查詢表代替 switch。然而,不能保證這種情況會發生,你需要看一下編譯器的向量化報告。

還有一個叫做計算標籤的 GNU 語言擴充套件,允許你使用儲存在陣列中的標籤來實現查詢表。它對實現解析器非常有用。例如下面程式碼所示:

static const void* days[] = { &&monday, &&tuesday, ..., &&sunday };
goto days[day];
monday:
    return "Monday";
tuesday:
    return "Tuesday";
...
sunday:
    return "Sunday";

將最常見的情況從 switch 中移出

如果你正在使用 switch 命令,而且有一種情況似乎是最常見的,你可以把它從 switch 中移出來,給它一個特殊的處理。繼續上一節的例子:

day get_first_workday() {
     std::chrono::weekday first_workday = read_first_workday();
    if (first_workday == Monday) { return day::Monday; }
    switch(first_workday) { 
        case Tuesday: return day::Tueasday;
        ....
    };
}

重寫加入條件

如前所述,在連線條件的情況下,如果第一個條件有一個特定的值,第二個條件根本不需要被評估。編譯器是如何做到這一點的呢?以下面這個函式為例:

if (a[i] > x && a[i] < y) {
    do_something();
}

現在假設 a[i]>x 和 a[i]<y ,判斷起來很簡單(所有資料都在暫存器或快取中),但很難預測。這個程式碼將轉化為以下偽彙編程式。

if_not (a[i] > x) goto ENDIF;
if_not (a[i] < y) goto ENDIF;
do_something;
ENDIF

你在這裡得到的是兩個難以預測的分支。如果我們用 & 而不是 && 連線兩個條件,我們會:

  1. 強制一次評估兩個條件:&操作符是算術和操作,它必須評估兩邊。
  2. 讓條件更容易預測,從而降低分支錯誤預測率:兩個完全獨立的條件,概率為50%,若是一個聯合條件,其真實概率為25%。
  3. 兩個分支合二為一變成一個分支。

操作符 & 評估兩個條件,在生成的彙編中,只有一個分支,而不是兩個。同樣的情況也適用於運算子 || 和其孿生運算子 | 。

請注意:根據C++標準,bool型別的值為0表示假,任何其他值表示真。C++ 標準保證邏輯運算和算術比較的結果永遠是 0 或 1,但不能保證所有的 bool 型別的變數都只有這兩個值。 你可以通過對其應用!!操作符來規範化 bool 變數。

告訴編譯器哪個分支有更高的概率

GCC和CLANG提供了一些關鍵字,程式設計師可以用這些關鍵字來告訴他們哪些分支的概率更高。例如:

#define likely(x)      __builtin_expect(!!(x), 1)
#define unlikely(x)    __builtin_expect(!!(x), 0)
if (likely(ptr)) {
    ptr->do_something();
}

通常我們通過巨集 likely 和 unlikely 來使用 __builtin_expect,因為它們的語法很麻煩,不方便使用。當這樣註釋時,編譯器將重新安排 if 和 else 分支中的指令,以便最優化地使用底層硬體。請確保條件概率是正確的,否則就會出現效能下降。

使用無分支演算法

一些演算法可以通過一些技巧轉化為無分支的演算法。例如,下面的一個函式 abs 使用一個技巧來計算一個數字的絕對值。你能猜到是什麼技巧嘛?

int abs(int a) {
  int const mask = 
        a >> sizeof(int) * CHAR_BIT - 1;
    return  = (a + mask) ^ mask;
}

有一大堆無分支的演算法,這個列表在網站 Bit Twiddling Hacks

使用條件載入(conditional loads)而不是分支

許多 CPU 都支援有條件移動指令(conditional move),可以用來刪除分支。下面是一個例子:

if (x > y) {
    x++;
}

可以改寫為

int new_x = x + 1;
x = (x > y) ? new_x : x; // the compiler should recognize this and emit a conditional branch

編譯器會將第 2 行的命令,寫成對變數 x 的條件性載入,併發出條件性移動指令。不幸的是,編譯器對何時發出條件分支有自己的內部邏輯,而這並不總是如開發者所期望的。你可以通過內聯彙編的方式來強制條件載入(後面會有介紹)。

但是要注意,無分支版本做了更多的操作。無論 x 是否大於 y ,x 都會執行加一操作。 加法是一個代價很低的操作,但對於其他代價高的操作(如除法),這種優化可能造成效能下降。

用算術運算來實現無分支

有一種方法可以通過巧妙地使用算術運算來實現無分支。例子:

// 使用分支
if (a > b) {
    x += y;
}
// 不使用分支
x += -(a > b) & y; 

在上面的例子中,表示式-(a > b) 將建立一個掩碼,若條件不成立的時候,掩碼為 0,當條件成立的時候掩碼為 1。

條件性賦值的一個例子:

// 使用分支
x = (a > b) ? val_a : val_b;
// 不使用分支
x = val_a;
x += -(a > b) & (val_b - val_a);

上述所有的例子都使用算術運算來避免分支。當然,根據你的 CPU 的分支預測錯誤懲罰和資料快取命中率,這也可能不會帶來效能提升。

一個在迴圈佇列中移動索引的例子:

// 帶分支
int get_next_element(int current, int buffer_len) {
    int next = current + 1;
    if (next == buffer_len) {
        return 0;
    }
    return next;
}
// 不帶分支
int get_next_element_branchless(int current, int buffer_len) {
    int next = current + 1;
    return (next < buffer_len) * next;
}

重新組織你的程式碼,以避免分支的出現

如果你正在編寫需要高效能的軟體,你肯定應該看一下 面向資料的設計原則。下面將簡單敘述一個技巧。

假設你有一個叫做animation的類,它可以是可見的或隱藏的。處理一個可見的animation與處理一個隱藏的animation是完全不同的。有一個包含animation的列表叫animation_list,你的處理方式看起來像這樣:

for (const animation& a: animation_list) {
   a.step_a();
   if (a.is_visible()) {
      a.step_av();
   }
   a.step_b();
   if (a.is_visible) {
       a.step_bv();
}

分支預測器真的很難處理上述程式碼,除非animation是按照可見度排序的。有兩種方法來解決這個問題。一個是根據 is_visible()animation_list中的動畫進行排序。第二種方法是建立兩個列表,animation_list_visibleanimation_list_hidden,然後重寫程式碼如下:

for (const animation& a: animation_list_visible) {
   a.step_a();
   a.step_av();
   a.step_b();
   a.step_bv();
}
for (const animation& a: animation_list_hidden) {
   a.step_a();
   a.step_b();
}

所有的條件分支都消失了。

使用模板來刪除分支

如果一個布林值被傳遞給函式,並且在函式內部作為引數使用,你可以通過把它作為模板引數傳遞來刪除這個布林值。例如:

int average(int* array, int len, bool include_negatives) {
    int average = 0;
    int count = 0;
    for (int i = 0; i < n; i++) {
        if (include_negatives) {
            average += array[i];
        } else {
            if (array[i] > 0) {
                average += array[i];
                count++;
            }
        }
    }
    if (include_negatives) {
         return average / len;
    } else {
        return average / count;
    }
}

在這個函式中, include_negatives 的這個條件被多次判斷。要刪除判斷,可以將引數作為模板引數而不是函式引數傳遞。

template <bool include_negatives>
int average(int* array, int len) {
    int average = 0;
    int count = 0;
    for (int i = 0; i < n; i++) {
        if (include_negatives) {
            average += array[i];
        } else {
            if (array[i] > 0) {
                average += array[i];
                count++;
            }
        }
    }
    if (include_negatives) {
         return average / len;
    } else {
        return average / count;
    }
}

通過這種實現方式,編譯器將生成兩個版本的函式,一個包含 include_negatives,另一個不包含(以防對該引數有不同值的函式的呼叫)。分支完全消失了,而未使用的分支中的程式碼也不見了。

但是你呼叫函式的方法有一些區別,如下所示:

int avg;
bool should_include_negatives = get_should_include_negatives();
if (should_include_negatives) {
    avg = average<true>(array, len);
} else {
    avg = average<false>(array, len);
}

這實際上是一種叫做分支優化的編譯器優化。 如果在編譯時知道 include_negatives 的值,並且編譯器決定行內函數,它將刪除分支和未使用的程式碼。我們用模板的版本保證了這一點,而未使用模板的原始版本則不一定能做到這點。

編譯器通常可以為你做這種優化。如果編譯器能夠保證 include_negatives 這個值在迴圈執行過程中不會改變它的值,它可以建立兩個版本的迴圈:一個是它的值為真的迴圈,另一個是它的值為假的迴圈。這種優化被稱為 *loop invariant code motion *,你可以在我們關於 迴圈優化 的帖子中瞭解更多資訊。使用模板可以保證這種優化發生。

其他一些避免分支的技巧

如果你在程式碼中多次檢查一個條件,你可以通過檢查一次該條件,然後多做一些程式碼複製來達到更好的效能。例如:

if (is_visible) {
    hide();
}
process();
if (is_active) {
    display();
}

可替換為:

if (is_visible) {
    hide();
    process();
    display();
} else {
    process();
}

我們也可以引入一個兩個元素陣列,一個用來儲存條件為真時的結果,另一個用來儲存條件為假時的結果。例如:

int larger = 0;
for (int i = 0; i < n; i++) {
    if (a[i] > m) {
        larger++;
    }
}
return larger;

可以被替換為:

int result[] = { 0, 0 };
for (int i = 0; i < n; i++) {
    result[a>i]++;
}
return result[1];

實驗

現在讓我們來看看最有趣的部分:實驗。我們決定做兩個實驗,一個是與遍歷一個陣列並計算具有某些屬性的元素有關。這是一種快取友好的演算法,因為硬體預取器可以很好的預取資料。

第二種演算法是我們在關於[[快取友好程式設計指南 id=22260e6c-fd75-4db0-9f05-98dc201b30fb]]文章中介紹的經典的二分查詢演算法。由於二分查詢的性質,這種演算法對緩衝區完全不友好,大部分的速度上的瓶頸來自於對資料的等待。

為了測試,我們使用了三種不同架構的晶片:

  • AMD A8-4500M quad-core x86-64 處理器,每個單獨的核心有16 kB的L1 資料快取,一對核心共享 2M 的 L2 快取。這是一個現代指令流水處理器,具有分支預測、推測執行和失序執行功能。根據技術規格,該 CPU 的錯誤預測懲罰(misprediction penalty)約為 20 個週期。
  • **Allwinner sun7i A20 dual-core ARMv7 **處理器,每個核心有 32kB 的 L1 資料快取和 256kB 的 L2 共享快取。 這是一個廉價的處理器,旨在為嵌入式裝置提供分支預測和推測執行,但沒有失序執行。
  • **Ingenic JZ4780 dual-core MIPS32r2 **處理器,每個核心有 32kB 的 L1 資料快取和 512kB 的 L2 共享資料快取。這是一個用於嵌入式裝置帶指令流水的處理器,有一個簡單的分支預測器。根據技術規範,分支預測錯誤的懲罰約為3個週期。

計算例項

為了證明程式碼中分支的影響,我們寫了一個非常小的演算法,計算一個陣列中大於給定給定元素的數量。程式碼可在我們的Github倉庫中找到,只需在 2020-07-branches 目錄中輸入 make counting 。

這裡是最重要的函式:

int count_bigger_than_limit_regular(int* array, int n, int limit) {
    int limit_cnt = 0;
    for (int i = 0; i < n; i++) {
        if (array[i] > limit) {
            limit_cnt++;
        }
    }
    return limit_cnt;
}

如果讓你來寫這個演算法,你可能會想出上面的辦法。

為了能夠進行恰當的測試,我們用優化級別 -O0 編譯了所有的函式。在所有其他的優化級別中,編譯器會用算術來代替分支,並做一些繁重的迴圈處理,並掩蓋了我們想要看到的東西。

分支錯誤預測的代價

讓我們首先測試一下分支錯誤預測給我們帶來了多少損失。 我們剛才提到的演算法是計算陣列中所有大於 limit 的元素。因此,根據陣列的值和 limit 的值,我們可以在 if (array[i] > limit) { limit_cnt++ }中調整(array[i] > limit)為真的概率。

我們生成的輸入陣列的元素在 0 和陣列的長度(arr_len)之間均勻分佈。然後,為了測試錯誤預測的代價,我們將 limit 的值設定為 0(條件永遠為真),arr_len / 2(條件在50%的時間內為真,難以預測)和 arr_len(條件永遠為假)。下面是測量結果:

Condition always true Condition unpredictable Condition false
Runtime (ms) 5533 14176 5478
Instructions 14G 13.5G 13G
Instructions per cycle 1.36 0.50 1.27
Branch misspredictions (%) 0% 32.96% 0%

上表的資料為:陣列長度=1M,在AMD A8-4500M上查詢1000個。

在 x86-64 上,不可預測條件的程式碼版本的速度要慢三倍。發生這種情況是因為每次分支被錯誤預測時,指令流水都要被重新整理。

下面是ARM和MIPS晶片的執行時間:

Condition always true Condition unpredictable Condition always false
ARM 30.59s 32.23s 25.89s
MIPS 37.35s 35.59s 31.55s

上表為在MIPS和ARM晶片上的執行時間,陣列長度為1M,查詢量為1000。

根據我們的測量,MIPS晶片沒有錯誤預測的懲罰(和規格上的描寫不同)。在 ARM 晶片上有一個小的懲罰,但肯定不會像 x86-64 晶片那樣急劇。

我們能解決這個問題嗎?向下閱讀。

使用無分支方法

現在讓我們根據我們之前給你的建議重寫條件。下面是三個重寫了條件的實現:

int count_bigger_than_limit_branchless(int* array, int n, int limit) {
    int limit_cnt[] = { 0, 0 };
    for (int i = 0; i < n; i++) {
        limit_cnt[array[i] > limit]++;
    }
    return limit_cnt[1];
}
int count_bigger_than_limit_arithmetic(int* array, int n, int limit) {
    int limit_cnt = 0;
    for (int i = 0; i < n; i++) {
        limit_cnt += (array[i] > limit);
    }
    return limit_cnt;
}
int count_bigger_than_limit_cmove(int* array, int n, int limit) {
    int limit_cnt = 0;
    int new_limit_cnt;
    for (int i = 0; i < n; i++) {
        new_limit_cnt = limit_cnt + 1;
        // The following line is pseudo C++, originally it is written in inline assembly
        limit_cnt = conditional_load_if(array[i] > limit, new_limit_cnt);
    }
    return limit_cnt;
}

我們的程式碼有三個版本:

  • count_bigger_than_limit_branchless 在(如上文其他一些避免分支的技巧) 內部使用一個小的兩元素陣列來計算當陣列中的元素大於和小於 limit 時的情況。
  • count_bigger_than_limit_arithmetic利用表示式(array[i] > limit)只能有 0 或 1 的值這一事實,用表示式的值來增加計數器。
  • count_bigger_than_limit_cmove計算新值,然後使用條件移動來載入它,如果條件為真。我們使用內聯彙編來確保編譯器會發出 cmov 指令。

請注意所有版本的一個共同點。 在分支內部,有一項我們必須做的工作。當我們刪除分支時,我們仍然在做這項工作,但這次即使我們不需要這項工作的情況下仍然去做這項工作。這使得我們的CPU執行更多的指令,但我們希望通過減少分支錯誤預測和提高每週期的指令比率來獲得效能提升。

在x86-64架構上測試無分支程式碼

我們的三種不同的策略是如何在效能上顯示出避免分支的?以下是可預測條件下的結果。

Regular Branchless Arithmetic Conditional Move
Runtime (ms) 5502 7492 6100 9845
Instructions executed 14G 19G 15G 19G
Instructions per cycle 1.37 1.37 1.33 1.04

上表是陣列長度=1M,在AMD A8-4500M上查詢1000個可預測的分支的結果。

正如你所看到的,當分支是可預測的,Regular 的實現是最好的。這種實現方式還具有最小的執行指令數量和最佳的週期指令比。

始終錯誤條件的執行時間與始終正確條件的執行時間差別不大,這適用於所有四個實現。除常規實現外,所有使用其他方法實現的效能都是一樣的。在Regular 實現中,每週期指令數降低,但執行的指令數也降低,沒有觀察到速度上的差異。

當分支無法預測時,會發生什麼?效能看起來會完全不同。

Regular Branchless Arithmetic Conditional Move
Runtime (ms) 14225 7427 6084 9836
Instructions executed 13.5G 19G 15G 19G
Instructions per cycle 0.5 1.38 1.32 1.04

上表是陣列長度=1M,在AMD A8-4500M上查詢 1000 個不可預測的分支的結果。

Regular 實現的效能最差。每週期的指令數要差很多,因為由於分支預測錯誤,指令流水必須被重新重新整理。對於其他方法實現的程式碼,效能和上表幾乎沒有任何變化。

有一件值得注意的事情。 如果我們用 -O3 編譯選項編譯這個程式,編譯器不會按照 Regular 實現去實現。因為分支錯誤預測率很低,執行時間和 Arithmetic 實現的執行時間非常接近。

在ARMv7上測試

在 ARM 晶片的情況下,效能看起來又有所不同。由於作者不熟悉 ARM 的彙編程式,所以我們沒有顯示條件移動(Conditional Move)實現的結果。

Condition predictability Regular Arithmetic Branchless
Always true 3.059s 3.385s 4.359s
Unpredictable 3.223s 3.371s 4.360s
Always false 2.589s 3.370s 4.360s

這裡,Regular 版本是最快的。Arithmetic 版和 Branchless 版並沒有帶來任何速度上的提高,它們實際上更慢。

請注意,具有不可預測條件的版本的效能最差的。這表明該晶片有某種分支預測功能。然而,錯誤預測的代價很低,否則我們會看到在這種情況下,其他的實現方式會更快。

在MIPS32r2上測試

下面是MIPS的結果:

Condition predictability Regular Arithmetic Branchless Cmov
Always true 37.352s 37.333s 41.987s 39.686s
Unpredictable 35.590s 37.353s 42.033s 39.731s
Always false 31.551s 37.396s 42.055s 39.763s

從這些數字來看,MIPS 晶片似乎沒有任何分支錯誤預測,因為執行時間完全取決於常規執行的指令數量(與技術規範相反)。對於 Regular 執行來說,條件為真的次數越少,程式就越快。

另外,分支代價似乎是相對較低的,因為在條件總是真的情況下,Arithmetic 實現和普通實現有相同的效能。其他的實現方式會慢一些,但不會太多。

用 likely 的和 unlikely 的來註釋分支

我們想測試的下一件事是,用 "likely"和 "unlikely"註釋分支是否對分支的效能有任何影響。我們使用了與之前相同的函式,但我們對臨界條件做了這樣的註釋,if(likely(a[i] > limit) limit_cnt++。我們使用優化級別 O3 來編譯這些函式,因為在非生產優化級別上測試註釋的行為沒有意義。

使用 GCC 7.5 的 AMD A8-4500M 給出了一些意外的結果。下面是這些結果。

條件預測 Likely Unlikely 不宣告
總是true 904ms 1045ms 902ms
總是false 906ms 1050ms 903ms

在條件被標記為可能的情況下,總是比條件被標記為不可能的情況快。仔細想想這並不完全出乎意料,因為這個 CPU 有一個好的分支預測器。Unlikely的版本只是引入了額外的指令,沒有必要。

在我們使用 GCC 6.3 的 ARMv7 晶片上,如果我們使用 likely 或 Unlikely 的分支註解,則完全沒有效能差異。編譯器確實為兩種實現方式生成了不同的程式碼,但兩種方式的週期數和指令數大致相同。我們的猜測是,如果不採取分支,這個CPU不會效能提升,這就是為什麼我們看到效能既沒有增加也沒有減少的原因。

在我們的 MIPS 晶片和 GCC 4.9 上也沒有效能差異。GCC 為 likely 和 Unlikely 的函式版本生成了相同的彙編。

結論:就 likely 和 Unlikely 的巨集而言,我們的調查表明,它們在有分支預測器的處理器上沒有任何幫助。不幸的是,我們沒有一個沒有分支預測器的處理器來測試那裡的行為。

聯合條件

為了測試 if 子句中的聯合條件,我們這樣修改我們的程式碼。

int count_bigger_than_limit_joint_simple(int* array, int n, int limit) {
    int limit_cnt = 0;
    for (int i = 0; i < n/2; i+=2) {
        // The two conditions in this if can be joined with & or &&
        if (array[i] > limit && array[i + 1] > limit) {
            limit_cnt++;
        }
    }
    return limit_cnt;
}

基本上,這是一個非常簡單的修改,兩個條件都很難預測。唯一不同的就是第四行程式碼if (array[i] > limit && array[i + 1] > limit) 。 我們想測試一下,使用操作符 && 和操作符 & 來連線條件是否有區別。我們稱第一個版本為 simple,第二個版本為 arithmetic

我們用 -O0 編譯上述函式,因為當我們用 -O3 編譯它們時,算術版本在 x86-64 上非常快,而且沒有分支錯誤預測。這表明編譯器已經完全優化掉了這個分支。

下面是所有三種架構的結果,以防這兩種條件都難以用來優化預測:

Joint simple Joint arithmetic
x86-64 5.18s 3.37s
ARM 12.756s 15.317s
MIPS 13.221s 15.337s

上述結果表明,對於具有分支預測器和高錯誤預測懲罰的 CPU 來說,使用 & 要快得多。但是,對於錯誤預測懲罰較低的 CPU 來說,使用 && 的速度更快,僅僅是因為它執行的指令更少。

二分查詢

為了進一步測試分支的行為,我們採用了我們在關於快取友好程式設計指南文章中用來測試緩衝區預取的二進位制查詢演算法。原始碼在github 倉庫裡, 只要在 2020-07-branches 目錄下輸入 make binary_search 即可執行。

這裡是實現二分查詢的核心程式碼:

int binary_search(int* array, int number_of_elements, int key) {
    int low = 0, high = number_of_elements-1, mid;
    while(low <= high) {
        mid = (low + high)/2;
        if (array[mid] == key) {
            return mid;
        }
        if(array[mid] < key) {
            low = mid + 1; 
        } else {
            high = mid-1;
        }
    }
    return -1;
}

上述演算法是一種經典的二進位制查詢演算法。我們將其稱為 *regular *實現。注意: 在第8-12行有一個重要的if/else條件,決定了查詢的流程。由於二進位制查詢演算法的性質,Array[mid]< key的條件很難預測。另外,對陣列 [mid] 訪問的代價很高,因為這些資料通常不在資料快取中。我們用兩種方法消除了這個分支,使用條件移動和使用算術運算。下面是這兩個版本。

// Conditional move implementation
int new_low = low + 1;
int new_high = high - 1;
bool condition = array[mid] > key;
// The bellow two lines are pseudo C++, the actual code is written in assembler
low = conditional_move_if(new_low, condition);
high = conditional_move_if_not(new_high, condition);
// Arithmetic implementation
int new_low = mid + 1;
int new_high = mid - 1;
int condition = array[mid] < key;
int condition_true_mask = -condition;
int condition_false_mask = -(1 - condition);
low += condition_true_mask & (new_low - low);
high += condition_false_mask & (new_high - high); 

條件移動的實現使用 CPU 提供的指令來有條件地載入準備好的值。

算術實現使用巧妙的條件操作來生成 condition_true_mask 和 condition_false_mask 。根據這些掩碼的值,它將向變數 low 和 high 載入適當的值。

x86-64 上的二進位制查詢演算法

下面是x86-64 CPU的效能比較,在工作集很大,不適合快取的情況下。我們測試了使用 __builtin_prefetch 的顯式資料預取和不使用的演算法版本。

Regular Arithmetic Conditional move
No data prefetching 2.919s 3.623s 3.243s
With data prefetching 2.667s 2.748s 2.609s

上面的表格顯示了一些非常有趣的東西。我們的二進位制查詢中的分支不能被很好地預測,當沒有資料預取時,我們的常規演算法表現得最好。為什麼? 因為分支預測、推測性執行和失序執行使 CPU 在等待資料從記憶體到達時有事情可做。為了不佔用這裡的文字,我們將在以後再談。

下面是工作集完全適合 L1 快取時同一演算法的結果:

Regular Arithmetic Conditional move
No data prefetching 0.744s 0.681s 0.553s
With data prefetching 0.825s 0.704s 0.618s

與之前的實驗相比,這些數字是不同的。當工作集完全適合L1資料快取時, Conditional move 版本的演算法是最快的,差距很大,其次是 Arithmetic 版本的演算法。由於許多分支預測錯誤,regular 版本的表現很差。在工作集較小的情況下,預取並沒有幫助。所有的資料都已經在快取中,預取指令只是執行更多的指令,沒有任何額外的好處。

ARM和MIPS上的二進位制查詢演算法

對於 ARM 和 MIPS 晶片,預取演算法比非預取演算法要慢,所以我們不考慮預取。

下面是 ARM 和 MIPS 晶片在 4M 元素的陣列上的二分查詢執行時間。

Regular Arithmetic Conditional Move
ARM 10.85s 11.06s
MIPS 11.79s 11.80s 11.87s

在 MIPS 上,所有三種型別的數字都大致相同。在 ARM 上,Regular 版本比Arithmetic 版本稍快一些。

下面是 ARM 和 MIPS 晶片在 10k 元素的陣列上的執行時間:

Regular Arithmetic Conditional Move
ARM 1.71s 1.79s
MIPS 1.42s 1.48s 1.51s

工作集的大小並不改變效能的相對比例。在這些晶片上,與分支有關的優化並不產生速度的提高。

為什麼在 x86-64 的大工作集上,帶分支的二進位制搜尋最快?

現在讓我們回到這個問題。在 x86-64 晶片中,我們看到,如果工作集很大,regular 版本是最快的。在工作集很小的情況下,Conditional Move 版本是最快的。當我們引入軟體預取以提高快取命中率時,我們看到 regular 版本的優勢正在消失。為什麼?

失序執行的侷限性

為了解釋這一點,請記住,我們正在談論的 CPU 是高階 CPU,具有分支預測、推測執行和失序執行功能。所有這些都意味著,CPU 可以並行地執行幾條指令,但它一次可以執行的指令數量是有限的。這一限制是由兩個因素造成的:

  • 處理器中的資源數量是有限的。例如,一個典型的高階處理器可能同時處理四條簡單的算術指令、兩條載入指令、兩條儲存指令或一條複雜的算術指令。當指令執行完畢後(技術術語是指令retire),資源變得可用,因此處理器可以處理新指令。
  • 指令之間存在著資料依賴性。如果當前指令的輸入引數依賴於前一條指令的結果,那麼在前一條指令完成之前,當前指令不能被處理。它被卡在處理器中佔用資源,阻止其他指令進入。

所有的程式碼都有資料依賴性,有資料依賴性的程式碼不一定是壞的。但是,資料依賴性降低了處理器每個週期所能執行的指令數量。

在順序執行的 CPU 中,如果當前指令依賴於前一條指令,並且前一條指令還沒有完成,那麼流水線就會被停滯。如果是失序執行的 CPU,處理器將嘗試載入被阻止的指令之後的其他指令。 如果這些指令不依賴於前面的指令,它們可以安全地執行。這是讓CPU利用閒置資源的方法。

解釋帶分支的二分查詢的效能

那麼,這與我們的二進位制搜尋的效能有什麼關係呢?下面將 regular 的核心部分改寫為偽彙編程式:

    element = load(base_address = array, index = mid)
    if_not (element < key) goto ELSE
    low = mid + 1
    goto ENDIF
ELSE:
    high = mid - 1
ENDIF:
    // These are the instructions at the beginning of the next loop
    mid = low + high
    mid = mid / 2

讓我們做一些假設:操作 element = load(base_address = array, index = mid) 如果 array[mid] 不在資料快取中,需要 300 個週期完成,若在快取中只需要 3 個週期。分支條件element < key將在 50% 的時間會被正確預測(最壞情況下的分支預測)。分支錯誤預測的代價是 15 個週期。

讓我們分析一下我們的程式碼是如何被執行的。處理器需要等待 300 個週期來執行第1行的 load 。由於它有 OOE(亂序執行),它開始在執行第 2 行的分支。第 2 行的分支依賴於第一行的資料,所以 CPU 不能執行它。然而,CPU 進行猜測並開始執行第 3、4、9 和 10 行的指令。若猜測正確,那麼整個程式執行只花費 300 個機械週期。若猜測錯誤,需要額外的加上處理指令 6、9和10的時間,又多增加了15個週期。

解釋帶有條件移動(conditional move)的二分查詢的效能

條件性移動的實現情況如何?下面是偽彙編:

    element = load(base_address = array, index = mid)
    load_low =  element < key
    new_low = mid + 1
    new_high = mid - 1
    low = move_if(condition = load_low, value = new_low)
    high = move_if_not(condition = load_low, value = new_high)
    // These are the instructions at the beginning of the next loop
    mid = low + high
    mid = mid / 2

這裡沒有分支,因此沒有分支錯誤預測的懲罰。讓我們與 regular 實現有相同的假設。(操作element = load(base_address = array, index = mid)如果array[mid]不在資料快取中,需要300個週期來完成,否則需要3個週期)。

這段程式碼的執行情況如下:處理器需要等待300個週期來執行第1行的載入。因為 CPU 具有OOE(亂序執行),它將會嘗試執行第二行,很快 CPU 發現第二行依賴第一行的資料。因此,CPU 會繼續向下探索執行第 3 和 4 行。CPU 無法執行第 5 和 6 行因為它們都依賴於第 2 行的資料。第 9 行的指令也無法執行,因為它依賴於第 5 行和第 6 行。第 10 行指令依賴於第 9 行,所以也無法執行。由於這裡沒有涉及到推測,到達指令 10 需要 300 個週期外加上執行指令2、5、6 和 9 的一些時間。

分支與條件移動的效能比較

現在讓我們做一些簡單的數學計算。在帶有分支預測的二分查詢的情況下,執行時間為:

MISSPREDICTION_PENALTY = 15 cycles
INSTRUCTIONS_NEEDED_TO_EXECUTE_DUE_MISSPREDICTION = 50 cycles

RUNTIME = (RUNTIME_PREDICTION_CORRECT + RUNTIME_PREDICTION_NOTCORRECT) / 2
RUNTIME_PREDICTION_CORRECT = 300 cycles
RUNTIME_PREDICTION_NOTCORRECT = 300 cycles + MISSPREDICTION_PENALTY + INSTRUCTIONS_NEEDED_TO_EXECUTE_DUE_MISSPREDICTION = 365 cycles

RUNTIME = 332.5 cycles

如果是條件移動的版本,執行時間是:

INSTRUCTIONS_BLOCKED_WAITING_FOR_DATA = 50 cycles

RUNTIME = 300 cycles + INSTRUCTIONS_BLOCKED_WAITING_FOR_DATA = 350 cycles

正如你所看到的,若從記憶體中載入資料需要等待 300 個週期的情況下,分支預測(regular)版本平均快 17.5 個週期。

最後說一下

目前的處理器不對條件性移動(conditional moves)進行推測,只對分支進行推測。分支猜測使其能夠掩蓋緩慢的記憶體訪問所帶來的一些懲罰。條件性移動(conditional moves)(和其他去除分支的技術)消除了分支錯誤預測的懲罰,但引入了資料依賴性懲罰。處理器將更經常地被阻塞,並且可以推測地執行更少的指令。在快取記憶體失誤率較低的情況下,資料依賴性的懲罰比分支錯誤預測的懲罰要昂貴得多。因此,結論是:分支預測機制打破了一些資料的依賴性,有效地掩蓋了 CPU 需要從記憶體中等待資料的時間。如果分支預測器的猜測是正確的,那麼當資料從儲存器到達時,很多工作已經完成。對於無分支的程式碼來說,情況並非如此。

總結

當我第一次開始寫這篇文章時,我以為是一篇簡單明瞭的文章,結論很短。孩子,我錯了 ? 讓我們從感恩開始。

首先為編譯器開發者喝彩。這個經驗告訴我,編譯器是使分支快速化的大師。他們知道每條指令的時間,他們可以使一般的分支具有良好效能。

第二個讚譽要歸功於現代處理器的硬體設計師。在分支預測正確的情況下,硬體設計使分支成為代價相當低的指令之一。大多數時候,分支預測工作良好,這使得我們的程式執行順暢。程式設計師可以專注於更重要的事情。

而第三個讚美之詞又是給現代處理器的硬體設計師的。為什麼?因為失序執行(OOE)。我們在二分查詢例子中的實驗表明,即使在分支錯誤預測率很高的情況下,等待資料然後執行分支比預測性地執行分支然後在錯誤預測的情況下重新整理指令流水更昂貴。

關於分支優化的一般說明

我們在這裡提出了一些建議,這些建議有些是通用的,每次都能在每個硬體上發揮作用,比如優化if/else命令鏈,或者重新組織你的程式碼,以避免分支。然而,這裡介紹的其他技術比較有限,只能在某些條件下推薦使用。

要優化你的分支,你首先需要了解的是,編譯器在優化它們方面做得很好。因此,我的建議是,這些優化在大多數時候是不值得的。讓你的程式碼簡單易懂,編譯器會盡最大努力生成最好的程式碼,無論是現在還是將來。

第二件事也很重要:在優化分支之前,你需要確保你的程式以最佳方式使用資料快取。在許多快取記憶體的情況下,分支實際上是CPU效能的捍衛者。移除它們,你會得到不好的結果。首先改善資料快取的使用,然後再處理分支。

你唯一需要關注的是你程式碼中的某一個或者兩個關鍵程式碼,這一兩個方法將會執行在特定的計算機上。我們的經驗顯示,在有些地方,從分支程式碼切換到無分支程式碼會帶來更多的效能,但具體數字取決於你的CPU,資料快取利用率,以及可能還有其他因素。 因此需要進行仔細的測試。

我還建議你使用內聯彙編程式碼的形式編寫分支的關鍵部分,因為這將保證你編寫的程式碼不會被編譯器的優化所破壞。當然,關鍵是你要測試你的程式碼的效能迴歸,因為這些似乎都是脆弱的優化。

分支的未來

我測試了兩個便宜的處理器,它們都有分支預測器。如今,很難找到一款不帶分支預測器的處理器。在未來,我們應該期待更復雜的處理器設計,即使在低端 CPU 中也是如此。隨著越來越多的CPU採用失序執行,分支錯誤預測的懲罰將變得越來越高。 對於一個有效能意識的開發者來說,關注好分支將變得越來越重要。

擴充套件閱讀

Agner’s Optimizing Software in C++: chapter 7.5 Booleans, chapter 7.12 Branches and switch statements

CPW: Avoiding Branches

Power and Performance: Software Analysis and Optimization by Jim Kukunas, Chapter 13: Branching

相關文章