為什麼處理排序的陣列要比非排序的快

雲揚四海發表於2019-05-22

這世上有三樣東西是別人搶不走的:一是吃進胃裡的食物,二是藏在心中的夢想,三是讀進大腦的書

為什麼處理排序的陣列要比非排序的快

問題

以下是**c++**的一段非常神奇的程式碼。由於一些奇怪原因,對資料排序後奇蹟般的讓這段程式碼快了近6倍!!

#include <algorithm>
#include <ctime>
#include <iostream>

int main()
{
    // Generate data
    const unsigned arraySize = 32768;
    int data[arraySize];

    for (unsigned c = 0; c < arraySize; ++c)
        data[c] = std::rand() % 256;

    // !!! With this, the next loop runs faster
    std::sort(data, data + arraySize);

    // Test
    clock_t start = clock();
    long long sum = 0;

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

    double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;

    std::cout << elapsedTime << std::endl;
    std::cout << "sum = " << sum << std::endl;
}
複製程式碼
  • 沒有std::sort(data, data + arraySize);,這段程式碼執行了11.54秒.
  • 有這個排序的程式碼,則執行了1.93秒. 我原以為這也許只是語言或者編譯器的不一樣的問題,所以我又用Java試了一下。

以下是Java程式碼段

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);
    }
}
複製程式碼

結果相似,沒有很大的差別。


我首先得想法是排序把資料放到了cache中,但是我下一個想法是我之前的想法是多麼傻啊,因為這個陣列剛剛被構造。

  • 到底這是為什麼呢?
  • 為什麼排序的陣列會快於沒有排序的陣列?
  • 這段程式碼是為了求一些無關聯的資料的和,排不排序應該沒有關係啊。

回答

什麼是分支預測?

看看這個鐵路分岔口

為什麼處理排序的陣列要比非排序的快
Image by Mecanismo, via Wikimedia Commons. Used under the CC-By-SA 3.0 license.

為了理解這個問題,想象一下,如果我們回到19世紀.

你是在分岔口的操作員。當你聽到列車來了,你沒辦法知道這兩條路哪一條是正確的。然後呢,你讓列車停下來,問列車員哪條路是對的,然後你才轉換鐵路方向。

火車很重有很大的慣性。所以他們得花費很長的時間開車和減速。

是不是有個更好的辦法呢?你猜測哪個是火車正確的行駛方向

  • 如果你猜對了,火車繼續前行
  • 如果你猜錯了,火車得停下來,返回去,然後你再換條路。

如果你每次都猜對了,那麼火車永遠不會停下來。 如果你猜錯太多次,那麼火車會花費很多時間來停車,返回,然後再啟動


考慮一個if條件語句:在處理器層面上,這是一個分支指令:

為什麼處理排序的陣列要比非排序的快
當處理器看到這個分支時,沒辦法知道哪個將是下一條指令。該怎麼辦呢?貌似只能暫停執行,直到前面的指令完成,然後再繼續執行正確的下一條指令? 現代處理器很複雜,因此它需要很長的時間"熱身"、"冷卻"

是不是有個更好的辦法呢?你猜測下一個指令在哪!

  • 如果你猜對了,你繼續執行。
  • 如果你猜錯了,你需要flush the pipeline,返回到那個出錯的分支,然後你才能繼續。

如果你每次都猜對了,那麼你永遠不會停 如果你猜錯了太多次,你就要花很多時間來滾回,重啟。


這就是分支預測。我承認這不是一個好的類比,因為火車可以用旗幟來作為方向的標識。但是在電腦中,處理器不能知道哪一個分支將走到最後。

所以怎樣能很好的預測,儘可能地使火車必須返回的次數變小?你看看火車之前的選擇過程,如果這個火車往左的概率是99%。那麼你猜左,反之亦然。如果每3次會有1次走這條路,那麼你也按這個三分之一的規律進行。

換句話說,你試著定下一個模式,然後按照這個模式去執行。這就差不多是分支預測是怎麼工作的。

大多數的應用都有很好的分支預測。所以現代的分支預測器通常能實現大於90%的命中率。但是當面對沒有模式識別、無法預測的分支,那分支預測基本就沒用了。

如果你想知道更多:Branch predictor" article on Wikipedia.


有了前面的說明,問題的來源就是這個if條件判斷語句

if (data[c] >= 128)
    sum += data[c];
複製程式碼

注意到資料是分佈在0到255之間的。當資料排好序後,基本上前一半大的的資料不會進入這個條件語句,而後一半的資料,會進入該條件語句.

連續的進入同一個執行分支很多次,這對分支預測是非常友好的。可以更準確地預測,從而帶來更高的執行效率。

快速理解一下
T = branch taken
N = branch not taken

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  (easy to predict)
複製程式碼

但是當資料是完全隨機的,分支預測就沒什麼用了。因為他無法預測隨機的資料。因此就會有大概50%的概率預測出錯。

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 ...   (completely random - hard to predict)
複製程式碼

我們能做些什麼呢

如果編譯器無法優化帶條件的分支,如果你願意犧牲程式碼的可讀性換來更好的效能的話,你可以用下面的一些技巧。

if (data[c] >= 128)
    sum += data[c];
複製程式碼

替換成

int t = (data[c] - 128) >> 31;
sum += ~t & data[c];
複製程式碼

這消滅了分支,把它替換成按位操作.

(說明:這個技巧不是非常嚴格的等同於原來的if條件語句。但是在data[]當前這些值下是OK的)

使用的裝置引數是:Core i7 920 @ 3.5 GHz C++ - Visual Studio 2010 - x64 Release

//  Branch - Random
seconds = 11.777

//  Branch - Sorted
seconds = 2.352

//  Branchless - Random
seconds = 2.564

//  Branchless - Sorted
seconds = 2.587
複製程式碼

Java - Netbeans 7.1.1 JDK 7 - x64

//  Branch - Random
seconds = 10.93293813

//  Branch - Sorted
seconds = 5.643797077

//  Branchless - Random
seconds = 3.113581453

//  Branchless - Sorted
seconds = 3.186068823
複製程式碼

結論:

  • 用了分支(if):沒有排序和排序的資料,效率有很大的區別
  • 用了上面提到的按位操作替換:排序與否,效率沒有很大的區別
  • 在使用C++的情況下,按位操作還是要比排好序的分支操作要慢。

一般的建議是儘量避免在關鍵迴圈上出現對資料很依賴的分支。(就像這個例子)


更新:

  • GCC 4.6.1 用了 -O3 or -ftree-vectorize,在64位機器上,資料有沒有排序,都是一樣快。 ... ... ...等各種例子

說明了現代編譯器越發成熟強大,可以在這方面充分優化程式碼的執行效率

相關內容

CPU的流水線指令執行

想象現在有一堆指令等待CPU去執行,那麼CPU是如何執行的呢?具體的細節可以找一本計算機組成原理來看。CPU執行一堆指令時,並不是單純地一條一條取出來執行,而是按照一種流水線的方式,在CPU真正指令前,這條指令就像工廠裡流水線生產的產品一樣,已經被經過一些處理。簡單來說,一條指令可能經過過程:取指(Fetch)、解碼(Decode)、執行(Execute)、放回(Write-back)。

假設現在有指令序列ABCDEFG。當CPU正在執行(execute)指令A時,CPU的其他處理單元(CPU是由若干部件構成的)其實已經預先處理到了指令A後面的指令,例如B可能已經被解碼,C已經被取指。這就是流水線執行,這可以保證CPU高效地執行指令。

分支預測

如上所說,CPU在執行一堆順序執行的指令時,因為對於執行指令的部件來說,其基本不需要等待,因為諸如取指、解碼這些過程早就被做了。但是,當CPU面臨非順序執行的指令序列時,例如之前提到的跳轉指令,情況會怎樣呢?

取指、解碼這些CPU單元並不知道程式流程會跳轉,只有當CPU執行到跳轉指令本身時,才知道該不該跳轉。所以,取指解碼這些單元就會繼續取跳轉指令之後的指令。當CPU執行到跳轉指令時,如果真的發生了跳轉,那麼之前的預處理(取指、解碼)就白做了。這個時候,CPU得從跳轉目標處臨時取指、解碼,然後才開始執行,這意味著:CPU停了若干個時鐘週期!

這其實是個問題,如果CPU的設計放任這個問題,那麼其速度就很難提升起來。為此,人們發明了一種技術,稱為branch prediction,也就是分支預測。分支預測的作用,就是預測某個跳轉指令是否會跳轉。而CPU就根據自己的預測到目標地址取指令。這樣,即可從一定程度提高執行速度。當然,分支預測在實現上有很多方法。

stackoverflow連結

這個問題的所有回答中,最高的回答,獲取了上萬個vote,還有很多個回答,非常瘋狂,大家覺得不過癮可以移步到這裡檢視

stackoverflow.com/questions/1…

歡迎關注 yunlongn.github.io

相關文章