改幾行代碼,C語言函數執行效率變成原來2倍,真正看懂的人都不錯

2024年2月6日 16点热度 0人点赞

在C語言中,分支預測是一種關鍵的性能優化策略,旨在減少由於分支語句(如if、switch等)導致的預測錯誤,從而提高程序的執行效率。本文將通過一個經典案例來演示如何使用分支預測來優化程序性能,並對優化前後的性能進行對比。

原始代碼

如果讓你用C語言寫一個函數,用來合並兩個已經有序的數組為一個有序數組,升序。

可能很多朋友都會寫成大概下面這個樣子代碼:

#include <stdio.h>
#include <sys/time.h>
void mergeSortedArrays(int *arr1, int size1, int *arr2, int size2, int *result) {
    // 比較兩個數組的元素並將它們合並到結果數組中
    while (size1 > 0 && size2 > 0) {
        if (*arr1 <= *arr2) {
            *result   = *arr1  ;
            size1--;
        } else {
            *result   = *arr2  ;
            size2--;
        }
    }
    // 如果arr1中還有剩餘元素,將它們復制到結果數組中
    while (size1 > 0) {
        *result   = *arr1  ;
        size1--;
    }
    // 如果arr2中還有剩餘元素,將它們復制到結果數組中
    while (size2 > 0) {
        *result   = *arr2  ;
        size2--;
    }
}
int main() {
    struct timeval start, end;
    int i=0;
    int arr1[] = {1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29};
    int size1 = sizeof(arr1) / sizeof(arr1[0]);
    int arr2[] = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30};
    int size2 = sizeof(arr2) / sizeof(arr2[0]);
    int mergedSize = size1   size2;
    int result[mergedSize];
    // 記錄函數執行前的時間
    gettimeofday(&start, NULL);
    // 合並兩個已排序數組
    mergeSortedArrays(arr1, size1, arr2, size2, result);
    // 記錄函數執行後的時間
    gettimeofday(&end, NULL);
    // 計算函數執行時間(以微秒為單位)
    long seconds = end.tv_sec - start.tv_sec;
    long micro_seconds = end.tv_usec - start.tv_usec;
    double execution_time = seconds   micro_seconds / 1e6;
    // 打印執行時間
    printf("函數執行時間: %f 秒\n", execution_time);
    // 輸出合並且有序的數組
    printf("合並且有序的數組: ");
    for (i = 0; i < mergedSize; i  )
        printf("%d ", result[i]);
    printf("\n");
    return 0;
}

主函數裡面構建兩個人有序數組arr1和arr2,然後重點是使用mergeSortedArrays函數來合並兩個數組,然後在合並函數的前後分別調用gettimeofday來計算合並函數的耗時,最後打印出合並函數耗時和合並後的數組結果。非常簡單,重點是如下的合並函數mergeSortedArrays的代碼實現。

合並函數代碼

下面我們來看看具體合並實現。函數將2個被合並有序數組首地址和長度傳進來,另外傳進來一個保存最終合並後數組數據的數組首地址result。

1.函數中第一個while循環中,用size1和size2來記錄兩個被合並數組的剩餘長度。隻要兩個數組中有一個遍歷完了就退出循環。循環裡面用分支語句if來逐個去比較兩個數組成員,哪個成員的值較小就把他復制到result結果數組裡去。

2.第二個while循環主要是看arr1數組中是否還有剩餘元素,如果有就逐個復制到結果數組中。

3.第三個while循環主要是看arr2數組中是否還有剩餘元素,如果有就逐個復制到結果數組中。

講完了,很多朋友是不是覺得代碼非常精簡漂亮,效率很高啊,沒問題,非常完美!

好那就運行一下吧,在ubuntu下面使用gcc並且加了-O2優化選型,優化代碼,編譯運行,結果如下:

原始代碼運行結果

可以看到合並函數耗時是0.000002s,也就是2微秒,非常短,不錯。

但是看看代碼真的沒辦法優化這個代碼了嗎?如果我們來結合分支預測來考慮一下呢?

分支預測

我們來看一下整個函數中總共有4處條件判斷。我們分別來看看四個分支條件的可預測性分別怎麼樣?

4個判斷分支預測

第一處,也就是最後一個while條件判斷,因為大多數情況下條件都會返回true,最後一次返回false,所以這個是可以預測的。

第二處,和第一處是一樣的,也是可預測的。

第三處,是if判斷,條件式判斷兩個數組的兩個元素大小,這個大概一半是返回true,一半概率返回false,所以是不可預測的。

第四處,有事while條件判斷,判斷是否有至少有一個數組成員被復制完,這個大多數情況下是返回true的,所以也是可預測的。

總的結果如下表:

可預測否?

優化思路

那麼既然第三處的if分支預測是不可預測的我們來否做點什麼來優化一下?

我們知道,分支預測的目的是在程序執行時預測分支語句(如if語句、循環中的條件判斷等)的走向,以便更好地執行預測分支的代碼路徑。

分支預測失敗率表示在分支語句的預測中,實際執行的路徑與預測的路徑不一致的比例。高分支預測失敗率表示預測不準確,導致程序執行了不必要的分支,從而浪費了處理器的時間。

一個優秀的分支預測器應該能夠高效地識別程序中的分支模式,並根據歷史執行情況進行準確的預測。如果分支預測準確性良好,那麼程序能夠更好地利用指令流水線和其他硬件特性,提高執行效率。相反,低分支預測準確性可能導致流水線中的指令冒險(pipeline stalls)和性能下降。

那麼既然我們第三處這個if判斷既然大半概率是會失敗,那麼我們是不是就不要用if分支語句來寫這個代碼,我們可以用位操作來實現這個if else分支代碼,避免分支預測失敗,從而優化代碼效率。

優化代碼

下面來看看如何用位操作代碼代替ifelse分支功能吧。其它代碼都不改,隻是改一下合並函數第一個while循環裡面代碼:

void mergeSortedArrays(int *arr1, int size1, int *arr2, int size2, int *result) {
    // 比較兩個數組的元素並將它們合並到結果數組中
    while (size1 > 0 && size2 > 0) {
        int cmp = (*arr1 <= *arr2);
        int min = *arr2 ^ ((*arr2 ^ *arr1) & (-cmp));
        *result   = min;
        arr1  = cmp;
        size1 -= cmp;
        arr2  = !cmp;
        size2 -= !cmp;
    }
    // 如果arr1中還有剩餘元素,將它們復制到結果數組中
    while (size1 > 0) {
        *result   = *arr1  ;
        size1--;
    }
    // 如果arr2中還有剩餘元素,將它們復制到結果數組中
    while (size2 > 0) {
        *result   = *arr2  ;
        size2--;
    }
}

新代碼

重點就是上圖紅框中的代碼,首先用cmp來保存兩個數組中元素的大小結果,要門是0,要門是1。接著借用異或(^)這個位操作巧妙拿到兩個數中較小得的賦給min變量保存。我們來分析一下如何拿到兩個數中較小的數的。

如果arr1小於arr2,那麼cmp就是1,-cmp就是-1,-1其實就是補碼形式存儲,所有位全是1,-cmp與上arr1^arr2,那就是還是arr1^arr2,arr1^arr2其實就是兩個數隻要哪一位不同都會被置1,相當於不同位掩碼。然後這個掩碼再異或arr2其實就是將arr2中和arr1不同的位全部逆反。這樣arr2是不是就變成arr1一樣,所以min其實拿到就是arr1值。

如果arr1大於arr2,那麼cmp是0,-cmp也是0,arr2異或0還是arr2。

這段代碼最復雜部分就講完了。

性能對比

下面我們來運行一下優化後代碼,同上,還是在ubuntu下面使用gcc並且加了-O2優化選型,優化代碼,編譯運行,結果如下:

優化代碼運行結果

可以看到合並函數耗時是0.000001秒,也就是1微秒,效率提升原來2倍。

需要指出的是,有些編譯器自動優化後,效果不一定有提升。

提升這麼點時間,有意義嗎?真沒有意義嗎?如果說這個被合並的數組將來數據量非常大,比如說數組長度是1000000,那就是合並時間就可以縮短1秒了,那就非常厲害了。現在機器學習、大數據時代,數據大是很正常的,能縮短數據處理時間是很有價值的。

後續持續更新系列高質量文章,碼字不易,覺得寫的不錯歡迎關註、點贊、收藏以及提問。