聊一聊分支預測,思考爲什麼使用 if/else 語句會降低程序效率

寫在前面

如果覺得寫得好,有所收穫,記得點個關注和點個贊哦,感謝支持。
在Stack Overflow上看到了這樣的一個帖子,覺得挺值得學習的,這個帖子是關於討論爲什麼處理排序數組比處理未排序數組快?看完後面的回答,然後得到了一個概念,就是“分支預測”,然後針對分支預測查看了許多資料和論文,覺得收穫挺多的,所以寫一篇博文記錄一下。

引出問題

可能有很多人沒有接觸過“分支預測”,不要着急,我們在正式講解分支預測之前,我們先來探討一下上面的問題,爲什麼處理排序數組比處理未排序數組快?我們先來看一下下面這樣一段代碼

import java.util.Arrays;
import java.util.Random;

public class Main{
    public static void main(String[] args){
        int arraySize = 32768;
        int data[] = new int[arraySize];

        Random rnd = new Random(0);
        for (int c = 0; c < arraySize; ++c)
            data[c] = rnd.nextInt() % 256;

        // !!! 第一次運行的時候不進行排序,即註釋掉下面的這個,第二次運行的時候排序
        Arrays.sort(data);

        long start = System.nanoTime();
        long sum = 0;

        for (int i = 0; i < 100000; ++i){
            for (int c = 0; c < arraySize; ++c){
                if (data[c] >= 128)
                    sum += data[c];
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);
    }
}

上面的這段代碼我們運行兩次,第一次把排序的那行註釋掉,第二次進行排序,可以觀察一下運行的結果,我這裏運行的
在這裏插入圖片描述在這裏插入圖片描述
左邊的是未排序的,右邊的是排序的,發現兩種同樣的遍歷,排序和未排序之間的結果相差十秒之多,這是爲什麼?要知道,因爲我們上面的代碼是直接new出來的數組,所以排除數據被帶入緩存的因素,當然如果你覺得會不會是編譯器的因素,你大可以換編譯器嘗試一下,結果都是相似的,更甚者你可以使用C++或者其他語言進行嘗試,其結果都是相似的結果,那麼這到底是是爲什麼呢?其實這就涉及到計算機科學中一個非常重要的概念,就是分支預測。下面我們就來了解這個概念。

分析問題的原因

首先我們仔細看一下代碼,在for循環中,我們是使用if分支進行判斷操作,現在我們來這樣考慮一個if語句,在處理器級別,它是一條分支指令。這樣想,如果你是處理器,並且看到一個分支。你不知道它將走哪條路。你會怎麼做?你應該停止執行當前的操作並等待之前的指令完成,然後再沿着正確的路徑繼續進行下一步操作。現代處理器很複雜,而且流程很長。因此,他們需要一直進行“熱身”和“放慢腳步”的操作。那有沒有更好的辦法來解決這個問題呢?其中一個辦法就是,通過猜測分支將會朝哪個方向前進,也就是進行結果預測。

  • 如果猜對了,則繼續執行。
  • 如果猜錯了,則需要刷新當前的操作緩存並回滾到分支。然後可以沿着正確的路徑重新啓動。

這就是分支預測,要知道,在計算機中,處理器直到最後一刻才知道分支的方向。所以進行分支預測式非常有效的一種方式,我們只要通過過去大量的分支行爲來進行預測, 就可以提高預測的效率。換句話說,我們可以嘗試識別一個模式並遵循這個模式,這或多或少是分支預測變量的工作方式。在現實開發中,大多數應用程序都具有行爲良好的分支(這裏的行爲良好指的是分支的行爲結果存在規律性)。因此,現代分支預測器通常將達到90%以上的命中率。但是,當面對沒有可識別模式的不可預測分支時,分支預測變量實際上是沒有用的。如果感興趣想要深入的話,可以看這篇文章。到這裏,我們就可以知道一件事情,上面排序和未排序之所以會有的差異的原因,是在於if分支,也就是下面這一段代碼

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

我們可以知道,數據在0到255之間均勻分佈,如果對數據進行排序時,大約前半部分的迭代將不會進入if語句。而後半部分都會進入if語句。這種情況就是上面所說的“規律性”,這對分支預測器非常友好,因爲分支連續多次朝同一方向前進。即使是簡單的飽和計數器也可以正確預測分支。我們來舉個例子,假設排序之後的數據是如下這樣的,我們將它可視化一下
在這裏插入圖片描述
當數據完全隨機時,分支預測器將變得沒有什麼用,因爲它無法預測隨機數據。因此,可能會有大約50%的錯誤預測(沒有比隨機猜測好)同樣我們可視化一下數據。
在這裏插入圖片描述

如何提高程序效率

通過上面的分析我們知道了因爲分支的預測性,導致程序的效率下降的問題,我們怎麼來解決這個問題呢?最直接的就是將數據進行規律性整理,保證分支預測的準確率,從而提高程序的效率。當然,如果編譯器將數據進行規劃性整理比較困難,我們還有一種方法,就是可以稍微犧牲一點程序的可讀性來提高效率,即通過二進制位運算來解決。比如我們可以進行這樣的替換

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

將上面的這段代碼換成下面這樣的

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

這樣做直接消除了分支,並用一些按位運算將其替換(請注意,這種解決辦法只是舉個例子,在本例中並不完全等同於原始的if語句。但是在這種情況下,它對於的所有輸入值均有效data[])。簡而言之,就是分支可能會影響程序的效率,我們可以通過一些方式,避免使用分支,或者提高分支的效率(如排序),下面我把數據排序與未排序,分支和未分支的時間貼出來

//  分支+亂序
seconds = 16.93293813
//  分支+排序
seconds = 6.643797077
//  分支+亂序
seconds = 3.113581453
//  未分支+排序
seconds = 3.186068823

進一步講講分支預測

條件分支指令通常具有兩路後續執行分支。即不採取(not taken)跳轉,順序執行後面緊挨JMP的指令,以及採取(taken)跳轉到另一塊程序內存去執行那裏的指令。是否需要跳轉,只有到真正執行時才能確定。如果沒有分支預測器,處理器將會等待分支指令通過了指令流水線的執行階段,才把下一條指令送入流水線的第一個階段—取指令階段(fetch stage),這種技術叫做 流水線停頓。分支預測器就是猜測條件判斷會走哪一路,如果猜對,就避免流水線停頓造成的時間浪費。如果猜錯,那麼流水線中推測執行的那些中間結果全部放棄,重新獲取正確的分支路線上的指令開始執行,這導致了程序執行的延遲。

什麼是指令流水線?

開發計算機程序,本質上是編寫一組期望計算機順序執行的命令。早期的計算機一次僅執行一條命令。這意味着每個命令都會加載到內存中,執行完成後再加載下一個命令。指令流水線是一種改進。處理器會將工作分解成多個部分,對不同的部分並行執行。這樣,處理器能夠在加載下一條的同時執行一條命令。處理器內部的指令流水線越長,不僅可以簡化還能並行執行更多的部分。這樣能夠提高系統的整體性能。例如下面這個簡單的程序:

int a = 0;
a += 1;
a += 2;
a += 3;

程序會按照下面的流水線處理:Fetch(提取)、Decode(解碼)、Execute(執行)、Store(存儲):
在這裏插入圖片描述
這裏可以看到四個命令如何並行處理,整體執行速度更快。處理器執行某些命令時會導致流水線問題。流水線中部分指令執行時依賴於之前的指令,但是前面的指令可能還沒有執行。分支是一種危險。分支會挑選兩個執行方向之一執行,但只有在解析後才能確定是哪個方向。這意味着通過分支加載命令都是不安全的,因爲無法知道從哪裏加載命令。修改上面的程序加入分支:

int a = 0;
a += 1;
if (a < 10) {
  a += 2;
}
a += 3;

運行結果與之前相同,但其中加入了 if語句。在解析前,雖然計算機能看到這些指令,但不能加載 if 後面的指令。因此,執行的順序看起來像下面這樣:
在這裏插入圖片描述
現在可以立刻看到加入分支對程序執行造成的影響,得到相同結果所需的時鐘週期。分支預測是對上面的一種改進,計算機會嘗試預測分支的執行路徑,然後採取相應的動作。在上面的示例中,處理器會預測if(a <10)爲 true,因此把 a += 2 作爲下一條待執行指令。這將導致執行的順序變成這樣:
在這裏插入圖片描述
可以看到程序的性能立即得到了提升:現在只要9個時鐘週期而不是之前的11個,速度提升了19%。但是,這樣做也並非沒有風險。如果分支預測出錯,那麼將對不應該執行的指令排隊。發生這種情況時,計算機要丟棄這些指令重新開始。修改判斷條件改爲false:

int a = 0;
a += 1;
if (a > 10) {
  a += 2;
}
a += 3;

可能會像下面這樣執行:
在這裏插入圖片描述
現在,即使處理的指令更少,執行卻比之前慢!處理器錯誤地預測分支等於 true,把 a += 2 指令排隊。接着發現分支等於 false,必須丟棄已排隊的指令,然後重新執行。到此我們就餓差不多瞭解了分支預測的概念,這裏多提一句,如果我們不能去掉分支,我們可以保證分支的順序,if/else 語句的分支順序很重要。也就是說,下面這樣的分支安排性能會更好:

if (mostLikely) {
  // Do something
} else if (lessLikely) {
  // Do something
} else if (leastLikely) {
  // Do something
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章