一文告訴你CPU分支預測對性能影響有多大

來源於stackoverflow上的一個問題爲何處理有序數組比處理無需數組快,原文中已經有了一些探討,這裏咱們首先來複現下結果,而後再解釋下爲何! html

咱們有以下兩段代碼,代碼看起來都是差很少的,實際上邏輯也是同樣的,都是統計數組中小於THRESHOLD數的個數,惟一的區別是一個是在無序數組中統計,另外一個是在有序數組中統計。若是兩個數組數據源是一致的(數組大小、數據都是一致的),只是一個無序一個有序,你以爲兩個函數的性能差距會有多大?java

public static void countUnsortedArr() {
        int cnt = 0;
        for (int i = 0; i < MAX_LENGTH; i++) {
            if (arr[i] < THRESHLOD) {
                cnt++;
            }
        }
    }

    public static void countSortedArr() {
        int cnt = 0;
        for (int i = 0; i < MAX_LENGTH; i++) {
            if (arrSotred[i] < THRESHLOD) {
                cnt++;
            }
        }
    }

直覺上,兩段代碼的邏輯徹底同樣,由於數據源一致統計結果也是一致的,因此性能上不會有什麼差別,但真的是這樣嗎?咱們用OpenJdk中的基準測試工具JMH來測試下。macos

測試環境

MacBook Pro (13-inch, 2017)
CPU: 2.5 GHz Intel Core i7
JMH version: 1.21
VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
測試方式:預熱一輪,而後對每一個函數作三輪的壓測,每輪都是10s

結果以下,SCore表示執行一次這個函所須要的微秒數,數值越小性能越高。數組

Benchmark                              Mode  Cnt      Score      Error  Units
BranchPredictionTest.countSortedArr    avgt    3   5212.052 ± 7848.382  us/op
BranchPredictionTest.countUnsortedArr  avgt    3  31854.238 ± 5393.947  us/op

是否是很出乎意料,明顯有序數組中統計快的多,性能差距足足有 6倍 。並且通過我屢次測試,這個性能差距很是穩定。是否是感受不符合邏輯,大多數程序猿都是用高級語言編寫代碼,其實語言自己就封裝了不少底層的細節,事實上,CPU對分支跳轉指令是有優化的,這就是咱們標題中提到的CPU分支預測。在詳細分支預測前先申明一句,本文目標不是講清楚分支預測,而是告訴你分支預測對性能的影響,想了解更多關於CPU分支預測的內容,文末列出了幾篇參考資料。
在這裏插入圖片描述
要說分支預測,還得提到現代CPU的指令流水線模式。如上圖所示,現代CPU爲了提高指令的吞吐率,將單個指令切分紅多個階段,大體分爲__取指(fetch),譯碼(decode),執行(execute),回寫(write-back)__,一條指令沒必要等上一條徹底執行完成就能夠開始執行了,就比如工廠中的流水線,能夠大大提高指令執行的吞吐率。現代CPU實際上不止4個階段,像intel和arm的處理器基本上都有十多個階段,流水線吞吐率的優點更加明顯。 性能優化

但理想很美好,顯示很骨幹。不是說全部的指令多能夠再上一條指令執行完成前就開始執行,可能這條指令會依賴上一條指令的執行結果。爲了應對這種數據依賴狀況下致使到吞吐率降低,CPU的設計者提出了好多優化方式,好比指令冒險),指令亂序執行,預測,咱們今天提到的__分支預測__就屬於「預測」的一種。
在這裏插入圖片描述
分支預測的思路也很簡單,既然依賴的數據還沒算出來,那我就猜一個結果,而後提早開始執行指令,固然也不是隨機猜想,現代CPU的預測思路是看前幾回預測的結果,就比如前天下雨、昨天也下雨,那我能夠簡單粗暴的認爲今天也下雨,具體細節見文末參考資料。思路很簡單,但效果卻出奇的好,從Wikipedia的數據咱們能夠知道現代CPU的分支預測準確率能夠到90%以上。 dom

既然準確率不是100%,就意味着有失敗的時候。若是CPU發現預測錯誤會把全部預測以後的指令執行結果所有拋棄,而後從預測分支那從新開始執行,至關於不少指令白跑了,預測失敗的代價很高,正常一條指令執行須要10-20個指令週期,預測失敗的話可能額外多出30-40個指令週期。 回到咱們上面的測試代碼,我準備的數據是100w個從0-100w之間的數,而後統計小於50w的數的個數。無序的狀況下至關於會有50%的可能性分支預測失敗,有序狀況下100w次預測只會有一次失敗,分支預測失敗就是產生性能差距的緣由。函數

性能優化

知道了緣由,如何優化性能?既然有序數組最快,是否是咱們直接將數組排序,而後作遍歷就行?別忘了,額外作排序帶來的性能損失遠超過度支預測失敗帶來的性能損失。既然提高分支預測成功率的方式行不通咱們就乾脆直接幹掉會致使分支預測的邏輯,How?工具

優化1

對於我這個簡單的統計邏輯,能夠直接用位運算來完成。位運算看着複雜,但其實思路很簡單,就是將整數的符號位直接轉化成0和1,再加到cnt上。沒有了if小於判斷,生成的指令裏也就沒有了jmp指令,從而避免CPU分支預測錯誤致使的性能消耗。
代碼以下:性能

@Benchmark
    public static void count1() {
        int cnt = 0;
        for (int i = 0; i < MAX_LENGTH; i++) {
            cnt += (-~(THRESHLOD-arr[i]) >> 31);
        }
    }

優化2

既然我都對位運算標了優化1,那確定還有優化2,事實上用 ? : 三目運算符也能優化性能。測試

public static void count2() {
        int cnt = 0;
        for (int i = 0; i < MAX_LENGTH; i++) {
            cnt += arr[i] < THRESHLOD ? 1 : 0;
        }
    }

咱們把四種方式放一塊兒再看一下性能差距,位運算毫無疑問是最快的,使用?:三目運算符的方式也至關快,和有序數據統計差很少,能夠肯定三目運算符也成功避免了分支預測錯誤代碼來的性能損失。

Benchmark                              Mode  Cnt      Score       Error  Units
BranchPredictionTest.count1            avgt    3   3807.000 ± 16265.107  us/op
BranchPredictionTest.count2            avgt    3   4706.082 ± 19757.705  us/op
BranchPredictionTest.countSortedArr    avgt    3   4458.783 ±   107.975  us/op
BranchPredictionTest.countUnsortedArr  avgt    3  30719.090 ±  4517.611  us/op

?:三目運算爲何這麼快?

?:表達式裏有有小於判斷,爲何就沒有分支跳轉了?這個問題我也疑惑了很久,後來我用C語言代碼生成了if?:邏輯的彙編代碼,終於發現了其中的不一樣。

C代碼
int fun1(int x)
{
    int cnt = 0;
    if (x < 0)
    {
        cnt += 1;
    }
    return cnt;
}

int fun2(int x)
{
    int cnt = 0;
    cnt  += x > 0 ? 1 : 0;
    return cnt;
}
彙編代碼
.section    __TEXT,__text,regular,pure_instructions
    .build_version macos, 10, 14
    .globl    _fun1                   ## -- Begin function fun1
    .p2align    4, 0x90
_fun1:                                  ## @fun1
    .cfi_startproc
## %bb.0:
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register %rbp
    movl    %edi, -4(%rbp)
    movl    $0, -8(%rbp)
    cmpl    $0, -4(%rbp)
    jge    LBB0_2     ## 若是cmpl指令判斷的結果是大於等於,就跳轉到LBB0_2代碼塊  
## %bb.1:
    movl    -8(%rbp), %eax
    addl    $1, %eax
    movl    %eax, -8(%rbp)
LBB0_2:
    movl    -8(%rbp), %eax
    popq    %rbp
    retq
    .cfi_endproc
                                        ## -- End function
    .globl    _fun2                   ## -- Begin function fun2
    .p2align    4, 0x90
    
    
_fun2:                                  ## @fun2
    .cfi_startproc
## %bb.0:
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register %rbp
    xorl    %eax, %eax
    movl    %edi, -4(%rbp)
    movl    $0, -8(%rbp)
    movl    -4(%rbp), %edi
    cmpl    $0, %edi
    movl    $1, %edi
    cmovll    %edi, %eax
    addl    -8(%rbp), %eax
    movl    %eax, -8(%rbp)
    movl    -8(%rbp), %eax
    popq    %rbp
    retq
    .cfi_endproc
                                        ## -- End function

.subsections_via_symbols

從彙編代碼能夠看出,fun2中沒有jge指令(jump when greater or equal),這是一個典型的跳轉指令,它會根據cmpl指令的結果來跳轉到某個代碼塊,上文已經提到了,CPU只有在跳轉指令時纔會作分支預測。而?:的實現徹底不一樣,雖然也有cmpl指令,後面跟的是cmovll指令,這個指令會根據cmp的結果把值放到%eax寄存器中,後續全部的指令都是串行執行的,徹底沒有跳轉指令,因此不會出現分支預測失敗致使的性能損失。

和我以前想象的不太同樣,__跳轉指令不是大小比較產生的,而是像if for這種邏輯分叉產生的,條件跳轉只是依賴大小比較的結果而已。__

完整基準測試代碼:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
@Fork(3)
@Warmup(iterations = 1)
@Measurement(iterations = 3)
public class BranchPredictionTest {
    private static Random random = new Random();
    private static int MAX_LENGTH = 10_000_000;
    private static int[] arr;
    private static int[] arrSotred;
    private static int THRESHLOD = MAX_LENGTH >> 1;

    @Setup
    public static void init() {
        arr = new int[MAX_LENGTH];
        for (int i = 0; i < MAX_LENGTH; i++) {
            arr[i] = random.nextInt(MAX_LENGTH);
        }
        arrSotred = Arrays.copyOf(arr, arr.length);
        Arrays.sort(arrSotred);
    }

    @Benchmark
    public static void countUnsortedArr() {
        int cnt = 0;
        for (int i = 0; i < MAX_LENGTH; i++) {
            if (arr[i] < THRESHLOD) {
                cnt++;
            }
        }
    }

    @Benchmark
    public static void countSortedArr() {
        int cnt = 0;
        for (int i = 0; i < MAX_LENGTH; i++) {
            if (arrSotred[i] < THRESHLOD) {
                cnt++;
            }
        }
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(BranchPredictionTest.class.getSimpleName())
                .forks(1)
                .build();
        new Runner(opt).run();
    }
}

結語

CPU分支預測自己是爲了提高流水線下避免流水線等待的手段,其實本質上是利用了局部性原理,由於局部性的存在,大多數狀況下這個技術自己給性能帶來的是正向的(要否則它今天也不會存在了),因此咱們大多數狀況下都不須要關注它的存在,仍是放心大膽的寫代碼吧,不要由於咱們這篇博客就把全部的if改爲?:三目運算,可能對代碼可讀性的影響遠大於性能提高的收益。再次強調下,我今天只是構造了一個極端的數據來驗證其性能差別,由於局部性的存在大多數狀況下分支預測都是對的。

最後,我還發現若是把上面基準測試中全部的小於號改爲大於號,全部的性能差別就都消失了,實際測試結果以下。想不通,看起來是沒有分支預測了,有知道的大佬解釋下嗎?

Benchmark                              Mode  Cnt     Score      Error  Units
BranchPredictionTest.count1            avgt    3  3354.059 ± 3995.160  us/op
BranchPredictionTest.count2            avgt    3  4047.069 ± 2285.700  us/op
BranchPredictionTest.countSortedArr    avgt    3  5732.614 ± 6491.716  us/op
BranchPredictionTest.countUnsortedArr  avgt    3  5251.890 ±   64.813  us/op

image
  

參考資料

  1. 維基百科指令流水線
  2. 維基百科分支預測
  3. CPU分支預測
  4. 局部性原理
本文來自 https://blog.csdn.net/xindoo
相關文章
相關標籤/搜索