分支對代碼性能的影響和優化


譯者注:原文<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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM