分支預測是什麼?爲什麼有序數組比無序數組快?

1. 背景

import java.util.Arrays;
import java.util.Random;
public class Main
{
    public static void main(String[] args)
    {
        // Generate data
        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;
        // !!! With this, the next loop runs faster
        Arrays.sort(data);
        // Test
        long start = System.nanoTime();
        long sum = 0;
        for (int i = 0; i < 100000; ++i)
        {
            // Primary loop
            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);
    }
}

在intellij idea中運行結果:

# 1. 先排序後計算
5.549553
sum = 155184200000
# 2. 不排序直接結算
15.527867
sum = 155184200000

1.1. 問題的提出

以上代碼在數組填充時已經加入了分區函數,充分保證填充值的隨機性,計算時也是按一半的元素來求和,所以不存在特例情況。而且,計算也完全不涉及到數據的有序性,即數組是否有序理論上對計算不會產生任何作用。在這樣的前提下,爲什麼排序後的數組要比未排序數組運行快3倍以上?

2. 前情提示

2.1. 流水線

先簡單說明一下CPU的instruction pipeline(指令流水線),以下簡稱pipeline。 Pipieline假設程序運行時有一連串指令要被運行,將程序運行劃分成幾個階段,按照一定的順序並行處理之,這樣便能夠加速指令的通過速度。
絕大多數pipeline都由時鐘頻率(clock)控制,在數字電路中,clock控制邏輯門電路(logical cicuit)和觸發器(trigger), 當受到時鐘頻率觸發時,觸發器得到新的數值,並且邏輯門需要一段時間來解析出新的數值,而當受到下一個時鐘頻率觸發時觸發器又得到新的數值,以此類推。
而藉由邏輯門分散成很多小區塊,再讓觸發器鏈接這些小區塊組,使邏輯門輸出正確數值的時間延遲得以減少,這樣一來就可以減少指令運行所需要的週期。 這對應Pipeline中的各個stages。
取指令 - 指令譯碼 - 執行指令  這認爲是一個三級流水線
如果我們進一步把“執行指令”拆分成“ALU 計算(指令執行)- 內存訪問 - 數據寫回”,那麼它就會變成一個五級的流水線
像我們現代的 ARM 或者 Intel 的 CPU,流水線級數都已經到了 14 級。
流水線的級數越高越好嗎?
每一級流水線對應的輸出,都要放到流水線寄存器(Pipeline Register)裏面,然後在下一個時鐘週期,交給下一個流水線級去處理。所以,每增加一級的流水線,就要多一級寫入到流水線寄存器的操作。雖然流水線寄存器非常快,比如只有 20 皮秒(ps,10−12 秒)
如果我們指令的執行有 3 納秒,也就是 3000 皮秒。我們需要 20 級的流水線,那流水線寄存器的寫入就需要花費 400 皮秒,佔了超過 10%。如果我們需要 50 級流水線,就要多花費 1 納秒在流水線寄存器上,佔到 25%。這也就意味着,單純地增加流水線級數,不僅不能提升性能,反而會有更多的 overhead 的開銷。

所有的流水線停頓操作都要從指令執行階段開始。流水線的前兩個階段,也就是取指令(IF)和指令譯碼(ID)的階段,是不需要停頓的。CPU 會在流水線裏面直接去取下一條指令,然後進行譯碼。
取指令和指令譯碼不會需要遇到任何停頓,這是基於一個假設。這個假設就是,所有的指令代碼都是順序加載執行的。不過這個假設,在執行的代碼中,一旦遇到 if…else 這樣的條件分支,或者 for/while 循環,就會不成立。

可以看到,在 jmp 指令發生的時候,CPU 可能會跳轉去執行其他指令。jmp 後的那一條指令是否應該順序加載執行,在流水線裏面進行取指令的時候,我們沒法知道。要等 jmp 指令執行完成,去更新了 PC 寄存器之後,我們才能知道,是否執行下一條指令,還是跳轉到另外一個內存地址,去取別的指令。
有什麼優化的手段呢

2.2. 分支預測器

2.2.1. 久遠的歷史

想象一個鐵路分叉道口。

爲了論證此問題,讓我們回到19世紀,那個遠距離無線通信還未普及的年代。你是鐵路交叉口的扳道工。當聽到火車快來了的時候,你無法猜測它應該朝哪個方向走。於是你叫停了火車,上前去問火車司機該朝哪個方向走,以便你能正確地切換鐵軌。
要知道,火車是非常龐大的,切急速行駛時有巨大的慣性。爲了完成上述停車-問詢-切軌的一系列動作,火車需耗費大量時間減速,停車,重新開啓。
既然上述過車非常耗時,那是否有更好的方法?當然有!當火車即將行駛過來前,你可以猜測火車該朝哪個方向走。

  • 如果猜對了,它直接通過,繼續前行。
  • 如果猜錯了,車頭將停止,倒回去,你將鐵軌扳至反方向,火車重新啓動,駛過道口。

如果你不幸每次都猜錯了,那麼火車將耗費大量時間停車-倒回-重啓。
如果你很幸運,每次都猜對了呢?火車將從不停車,持續前行!

2.2.2. 在現代CPU中的情況

分支預測器是一種數字電路,在分支指令執行前,猜測哪一個分支會被執行,能顯著提高pipelines的性能。
條件分支通常有兩路後續執行分支,not token時,跳過接下來的JMP指令,繼續執行, token時,執行JMP指令,跳轉到另一塊程序內存去執行。
爲了說明這個問題,我們先考慮如下問題。

2.2.2.1. 沒有分支預測器會怎樣?

加入沒有分支預測器,處理器會等待分支指令通過了pipeline的執行階段(execuate stage)才能把下一條指令送入pipeline的fetch stage。
這會造成流水線停頓(stalled)或流水線冒泡(bubbling)或流水線打嗝(hiccup),即在流水線中生成一個沒有實效的氣泡, 如下圖所示:

圖中一個氣泡在編號爲3的始終頻率中產生,指令運行被延遲。
Stream hiccup現象在早期的RISC體系結構處理器中常見。

2.2.2.2. 有分支預測期的pipeline

我們來看分支預測器在條件分支跳轉中的應用。
條件分支通常有兩路後續執行分支,not token時,跳過接下來的JMP指令,繼續執行, token時,執行JMP指令,跳轉到另一塊程序內存去執行。
加入分支預測器後,爲避免pipeline停頓(stream stalled),其會猜測兩路分支哪一路最有可能執行,然後投機執行,如果猜錯,則流水線中投機執行中間結果全部拋棄,重新獲取正確分支路線上的指令執行。可見,錯誤的預測會導致程序執行的延遲。
由前面可知,Pipeline執行主要涉及Fetch, Decode, Execute, Write-back幾個stages, 分支預測失敗會浪費Write-back之前的流水線級數。現代CPU流水線級數非常長,分支預測失敗可能會損失20個左右的時鐘週期,因此對於複雜的流水線,好的分支預測器非常重要。

2.2.2.3. 常見的分支預測器

  • 靜態分支預測器

最簡單的分支預測技術,叫作“假裝分支不發生”。顧名思義,自然就是仍然按照順序,把指令往下執行。其實就是 CPU 預測,條件跳轉一定不發生。這樣的預測方法,其實也是一種靜態預測技術。就好像猜硬幣的時候,你一直猜正面,會有 50% 的正確率。
如果分支預測是正確的,我們自然賺到了。這個意味着,我們節省下來本來需要停頓下來等待的時間。如果分支預測失敗了呢?那我們就把後面已經取出指令已經執行的部分,給丟棄掉。這個丟棄的操作,在流水線裏面,叫作 Zap 或者 Flush。CPU 不僅要執行後面的指令,對於這些已經在流水線裏面執行到一半的指令,我們還需要做對應的清除操作。比如,清空已經使用的寄存器裏面的數據等等,這些清除操作,也有一定的開銷。所以,CPU 需要提供對應的丟棄指令的功能,通過控制信號清除掉已經在流水線中執行的指令。只要對應的清除開銷不要太大,我們就是划得來的。

  • 雙模態預測器(bimodal predictor)

上面的靜態預測策略,看起來比較簡單,預測的準確率也許有 50%。但是如果運氣不好,可能就會特別差。於是,工程師們就開始思考,我們有沒有更好的辦法呢?比如,根據之前條件跳轉的比較結果來預測,是不是會更準一點?我們日常生活裏,最經常會遇到的預測就是天氣預報。如果沒有氣象臺給你天氣預報,你想要猜一猜明天是不是下雨,你會怎麼辦?有一個簡單的策略,就是完全根據今天的天氣來猜。如果今天下雨,我們就預測明天下雨。如果今天天晴,就預測明天也不會下雨。這是一個很符合我們日常生活經驗的預測。因爲一般下雨天,都是連着下幾天,不斷地間隔地發生“天晴 - 下雨 - 天晴 - 下雨”的情況並不多見。那麼,把這樣的實踐拿到生活中來是不是有效呢?我在這裏給了一張 2019 年 1 月上海的天氣情況的表格。

我們用前一天的是不是下雨,直接來預測後一天會不會下雨。這個表格裏一共有 31 天,那我們就可以預測 30 次。你可以數一數,按照這種預測方式,我們可以預測正確 23 次,正確率是 76.7%,比隨機預測的 50% 要好上不少。

而同樣的策略,我們一樣可以放在分支預測上。這種策略,我們叫一級分支預測(One Level Branch Prediction),或者叫** 1 比特飽和計數**(1-bit saturating counter)。這個方法,其實就是用一個比特,去記錄當前分支的比較情況,直接用當前分支的比較情況,來預測下一次分支時候的比較情況。只用一天下雨,就預測第二天下雨,這個方法還是有些“草率”,我們可以用更多的信息,而不只是一次的分支信息來進行預測。於是,我們可以引入一個狀態機(State Machine)來做這個事情。如果連續發生下雨的情況,我們就認爲更有可能下雨。之後如果只有一天放晴了,我們仍然認爲會下雨。在連續下雨之後,要連續兩天放晴,我們纔會認爲之後會放晴。
這個狀態機裏,我們一共有 4 個狀態,所以我們需要 2 個比特來記錄對應的狀態。這樣這整個策略,就可以叫作 2 比特飽和計數,或者叫雙模態預測器(Bimodal Predictor)。

圖左邊兩個狀態爲不採納(not token),右邊兩個爲採納(token)。由not token到token中間有兩個漸變狀態。由紅色到藍色翻轉需要連續兩次分支選擇。
技術實現上可用兩個二進制位來表示,00, 01, 10, 11分別對應strongly not token, weakly not token, weakly token, strongly token。 一個判斷兩個分支預測規則是否改變的簡單方法便是判斷這個二級制狀態高位是否跳變。高位從0變爲1, 強狀態發生翻轉,則下一個分支指令預測從not token變爲token,反之亦然。
據評測,雙模態預測器的正確率可達到93.5%。預測期一般在分支指令解碼前起作用。
其它常見分支預測器如兩級自適應預測器,局部/全局分支預測器,融合分支預測器,Agree預測期,神經分支預測器等。

3. 分析

原程序中:

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

彙編碼:

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

讓我們回到文章開頭的問題。現在假設你是處理器,當看到上述分支時,當你並不能決定該如何往下走,該如何做?只能暫停運行,等待之前的指令運行結束。然後才能繼續沿着正確地路徑往下走。
要知道,現代編譯器是非常複雜的,運行時有着非常長的流水線, 減速和熱啓動將耗費巨量的時間。
那麼,有沒有好的辦法可以節省這些狀態切換的時間呢?你可以猜測分支的下一步走向!

  • 如果猜錯了,處理器要清楚剛剛執行的中間結果, 回滾到之前的分支,然後重新熱啓動,選擇另一條路徑。
  • 如果猜對了,處理器不需要暫停,繼續往下執行。

如果每次都猜錯了,處理器將耗費大量時間在停止-回滾-熱啓動這一週期性過程裏。
如果僥倖每次都猜對了,那麼處理器將從不暫停,一直運行至結束。
上述過程就是分支預測(branch prediction)。雖然在現實的道口鐵軌切換中,可以通過一個小旗子作爲信號來判斷火車的走向,但是處理器卻無法像火車那樣去預知分支的走向–除非最後一次指令運行完畢。
那麼處理器該採用怎樣的策略來用最小的次數來儘量猜對指令分支的下一步走向呢?答案就是分析歷史運行記錄: 如果火車過去90%的時間都是走左邊的鐵軌,本次軌道切換,你就可以猜測方向爲左,反之,則爲右。如果在某個方向上走過了3次,接下來你也可以猜測火車將繼續在這個方向上運行…
換句話說,你試圖通過歷史記錄,識別出一種隱含的模式並嘗試在後續鐵道切換的抉擇中繼續應用它。這和處理器的分支預測原理或多或少有點相似。
大多數應用都具有狀態良好的(well-behaved)分支,所以現代化的分支預測器一般具有超過90%的命中率。但是面對無法預測的分支,且沒有識別出可應用的的模式時,分支預測器就無用武之地了。
關於分支預測期,可參考維基百科相關詞條"Branch predictor" article on Wikipedia..
文首導致非排序數組相加耗時顯著增加的罪魁禍首便是if邏輯:

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

注意到data數組裏的元素是按照0-255的值被均勻存儲的(類似均勻的分桶)。數組data有序時,前面一半元素的迭代將不會進入if-statement, 超過一半時,元素迭代將全部進入if-statement.
這樣的持續朝同一個方向切換的迭代對分支預測器來說是非常友好的,前半部分元素迭代完之後,後續迭代分支預測器對分支方向的切換預測將全部正確。
簡單地分析一下:
有序數組的分支預測流程:

T = 分支命中
N = 分支沒有命中
data[] = 0, 1, 2, 3, 4, ... 126, 127, 128, 129, 130, ... 250, 251, 252, ...
branch = N  N  N  N  N  ...   N    N    T    T    T  ...   T    T    T  ...
       = NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT  (非常容易預測)
無序數組的分支預測流程:
data[] = 226, 185, 125, 158, 198, 144, 217, 79, 202, 118,  14, 150, 177, 182, 133, ...
branch =   T,   T,   N,   T,   T,   T,   T,  N,   T,   N,   N,   T,   T,   T,   N  ...
       = TTNTTTTNTNNTTTN ...   (完全隨機--無法預測)

在本例中,由於data數組元素填充的特殊性,決定了分支預測器在未排序數組迭代過程中將有50%的錯誤命中率,因而執行完整個sum操作將會耗時更多。

4. 結論

  • 使用分支預測: 是否排序嚴重影響performance
  • 使用bithack: 是否排序對performance無顯著影響

這個例子告訴給我們啓示: 在大規模循環邏輯中要儘量避免數據強依賴的分支(data-dependent branching).
switch對分支預測不友好,dubbo中也有利用分支預測的寫法

public void benchIfAndSwitch(ExecutionPlan plan, Blackhole bh) {
    int result = 0;
    for (int i = 0; i < plan.size; ++i) {
        ChannelState state = plan.states[i];
        if (state == ChannelState.RECEIVED) {
            result += ChannelState.RECEIVED.ordinal();
        } else {
            switch (state) {
            case CONNECTED:
                result += ChannelState.CONNECTED.ordinal();
                break;
            case SENT:
                result += ChannelState.SENT.ordinal();
                break;
            case DISCONNECTED:
                result += ChannelState.DISCONNECTED.ordinal();
                break;
            case CAUGHT:
                result += ChannelState.CAUGHT.ordinal();
                break;
            }
        }
    }
    bh.consume(result);
}}
public void benchSiwtch(ExecutionPlan plan, Blackhole bh) {
        int result = 0;
        for (int i = 0; i < plan.size; ++i) {
            switch (plan.states[i]) {
            case CONNECTED:
                result += ChannelState.CONNECTED.ordinal();
                break;
            case DISCONNECTED:
                result += ChannelState.DISCONNECTED.ordinal();
                break;
            case SENT:
                result += ChannelState.SENT.ordinal();
                break;
            case RECEIVED:
                result += ChannelState.RECEIVED.ordinal();
                break;
            case CAUGHT:
                result += ChannelState.CAUGHT.ordinal();
                break;
            }
        }
        bh.consume(result);
    }
Result "io.github.hengyunabc.jmh.TestBenchMarks.benchSiwtch":
  576.745 ±(99.9%) 6.806 ops/s [Average]
  (min, avg, max) = (490.348, 576.745, 618.360), stdev = 20.066
  CI (99.9%): [569.939, 583.550] (assumes normal distribution)
# Run complete. Total time: 00:06:48
Benchmark                         (size)   Mode  Cnt     Score    Error  Units
TestBenchMarks.benchIfAndSwitch  1000000  thrpt  100  1535.867 ± 61.212  ops/s
TestBenchMarks.benchSiwtch       1000000  thrpt  100   576.745 ±  6.806  ops/s

可以明顯看到if前置的每秒操作數更多,效率更高

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章