為什麼處理排序陣列比未排序陣列快

劉小緒同學發表於2018-11-30

    今天在群裡看到一個有意思的問題——為什麼處理排序陣列比處理沒有排序的陣列要快,這個問題來源於 StackoverFlow,雖然我看到程式碼略微知道原因,但是模模糊糊不夠清晰,搜了很多部落格也講的不夠明白,所以就自己來總結了。

    首先來看一下問題,下面是很簡單的一段程式碼,隨機生成一些數字,對其中大於 128 的元素求和,記錄並列印求和所用時間。

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);
    }
}

    我的執行結果:分別在對陣列排序和不排序的前提下測試,在不排序時所用的時間比先排好序所用時間平均要多 10 ms。這不是巧合,而是必然的結果。

    問題就出在那個if判斷上面,在舊文順序、條件、迴圈語句的底層解釋中其實已經提到了造成這種結果的原因,只是舊文中沒有拿出具體的例子來說明。

    為了把這個問題搞明白,需要先對流水線有一定的瞭解。計算機是指令流驅動的,執行的是一個一個的指令,而執行一條指令,又要經過取指、譯碼、執行、訪存、寫回、更新六個階段(不同的劃分方式所包含的階段不一樣)。

    六個階段使用的硬體基本是不一樣的,如果一條指令執行完再去執行另一條指令,那麼在這段時間裡會有很多硬體處於空閒狀態,要使計算機的速度變快,那麼就不能讓硬體停下來,所以有了流水線技術。

    流水線技術通過將指令重疊來實現幾條指令並行處理,下圖表示的是三階段指令時序,即把一個指令分為三個階段。在第一條指令的 B 階段,A 階段相關的硬體是空閒的,於是可以將第二條指令的 A 階段提前操作。

image

    很明顯,這種設計大幅提高了指令執行的效率,聰明的你可能發現問題了,要是不知道下一條指令是什麼怎麼辦,那提前的階段也就白乾了,那樣流水線不就失效了?沒錯,這就是導致開篇問題的原因。

    讓流水線出問題的情況有三種:1、資料相關,後一條指令需要用到前一條指令的運算結果;2、控制相關,比如無條件跳轉,跳轉的地址需要在譯碼階段才能知道,所以跳轉之後已經被取出的指令流水就需要清空;3、結構相關,由於一些指令需要的時鐘週期長(比如浮點運算等),長時間佔用硬體,導致之後的指令無法進入譯碼等階段,即它們在爭用同一套硬體。

    程式碼中的if (data[c] >= 128)翻譯成機器語言就是跳轉指令,處理器事先並不知道要跳轉到哪個分支,那難道就等知道了才開始下一條指令的取指工作嗎?處理器選擇了假裝知道會跳轉到哪個分支(不是謙虛,是真的假裝知道),如果猜中了是運氣好,而沒有猜中那就浪費一點時間重新來幹。

    沒有排序的陣列,元素是隨機排列的,每次data[c] >= 128的結果也是隨機的,前面的經驗就不可參考,所以下一次執行到這裡理論上還是會有 50% 的可能會猜錯,猜錯了肯定就需要花時間來修改犯下的錯誤,自然就會浪費更多的時間。

    對於排好序的陣列,開始幾次也需要靠猜,但是猜著猜著發現有規律啊,每次都是往同一個分支跳轉,所以以後基本上每次都能猜中,當遍歷到與 128 分界的地方,才會出現猜不中的情況,但是猜幾次之後,發現這又有規律啊,每次都是朝著另外一個相同分支走的。

    雖然都會猜錯,但是在排好序的情況下猜錯的機率遠遠小於未排序時的機率,最終呈現的結果就是處理排序陣列比未排序陣列快,其原因就是流水線發生了大量的控制相關現象,下面通俗一點,加深一下理解。

image

    遠在他方心儀多年的姑娘突然告訴你,其實她也喜歡你,激動的你三天三夜睡不著覺,決定開車前往她的城市,要和她待在一起,但是要去的路上有很多很多岔路,你只能使用的某某地圖導航,作為老司機並且懷著立馬要見到愛人心情的你,開車超快,什麼樣罰單都不在乎了。

    地圖定位已經跟不上你的速度了,為了儘快到達,遇到岔路你都是隨機選一條路前進,遺憾的是,自己的選擇不一定對(我們假設高速可以回退),走錯路了就要重新回到分岔點,這就對應著未排序的情況。

    現在岔路是有規律的,告訴你開始一直朝著一邊走,到某個地點後會一直朝著另一邊走,你只需要花點時間去探索一下開始朝左邊還是右邊,到了中間哪個地點會改變方向就可以了,相比之下就能節省不少時間了,儘快見到自己的愛人,這對應著排好序的情況。

    最後的故事改編自兩個人的現實生活,一位是自己最好的朋友之一,談戀愛開心的睡不著覺;另一位是微信上的一位好友,為了對方從北京裸辭飛到了深圳。

相關文章