程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> 關於JAVA >> java數組遍歷的性能分析

java數組遍歷的性能分析

編輯:關於JAVA
 

問題

完全遍歷有序和無序的數組,時間復雜度都是O(n),為什麼遍歷有序數組比無序數組速度更快?

下面是一個C++代碼,由於一些奇怪的原因,已排序的數據數組比未排序地數組運算差不多快6倍。

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

int main()
{
    // 生成數據
    const unsigned arraySize = 32768;
    int data[arraySize];

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

    // !!! 排過序,接下來的循環運行會更快
    std::sort(data, data + arraySize);

    // 開始計時
    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i)
    {
        //主循環
        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.54s.
  • 對於已排序的數據,代碼運行時間為1.93s.

起初,可能僅僅是語言或者編譯器的反常的原因,因此嘗試用JAVA實現。

import java.util.Arrays;
import java.util.Random;

public class Main
{
    public static void main(String[] args)
    {
        // 生成數據
        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;

        // !!! 排過序,接下來的循環運行會更快
        Arrays.sort(data);

        // 開始計時
        long start = System.nanoTime();
        long sum = 0;

        for (int i = 0; i < 100000; ++i)
        {
            //主循環
            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);
    }
}

結果跟前面的C++的情況,基本一致,也是排序過的數組比未排序的快很多。


解答

根本原因是由於分支預測器引起的,下面詳細展開解釋

考慮一種鐵路連接器的場景:

railroad junction

為了解釋這樣現象,假設這是早在1800年,一個在長途或無線電通信出現之前的時代。

你是火車軌道連接器的操作員,你聽到火車來了。你根本不知道火車應該往哪個方向走。這時你需要讓火車停下車來,詢問他火車要往哪個方向走。然後你設置適當的開關。

列車是很重並且有很大的慣性,因此需要不停地啟動和減速。

有更好的方法嗎?假設提前猜測火車將要去往哪個方向!

  • 如果猜測對了,火車不需要停下來,可以直接繼續前行。
  • 如果猜測錯了,火車需要停下來,回退,然後切換線路。火車再重新進入另一個線路。

如果你每一次都猜測對了,火車每次都不需要停下來。 如果你猜測錯的次數太頻繁,火車需要花很多時間停下來,回退,重新啟動。


考慮一個if語句,對於處理器的來說,它就是一個分支指令地址:

railroad junction

當處理器遇到分支,並不知道哪一條路該走,應該怎麼辦? 處理器中止執行,等待前一條判斷語句執行完成。然後你繼續正確的線路。

現代的處理器是復雜的,有很長的管道。因此總是采取“預准備”和“減速”過程。

是否有更好的方法呢?提取猜測進入哪一條分支 - 如果猜測對了,繼續執行 - 如果猜測錯了,需要清空管道,分支回滾,然後重新進入另一個條分支。

如果每一次都猜測對了,處理器每次都不需要停下來。 如果猜測錯的次數太頻繁,處理器需要很多時間停止運轉,回滾,重新運行。


這就是分支預測。這可能不是最好的比喻,因為車可能在路上有信號和標志的方向。但在計算機中,處理器在最後一刻之前並不知道執行哪個分支。

所以,你如何策略性地猜測,使火車回退再進入另一個軌道的發生次數盡可能少?你看看過去的歷史!如果火車99%的時間往左走,那麼你猜測往左。如果左右交替發現,那麼你猜測火車也將交替選擇軌道。如果每3次進入一次某條軌道,你按這個邏輯猜想…

換言之,你試著發現規律並以相同的規律預測分支的選擇策略。這就是分支預測器的主要工作。

大多數的引用程序都有一個較好的分支行為。因此現代的分支預測器基本都能實現大於90%的命中率。但是面對無規律的不可預測的分支是,分支預測器就束手無策了。

關於分支預測,進一步閱讀“Branch predictor” article on Wikipedia.。


前面說到的奇怪現象, 就出在這個if語句:

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

數據隨機分布在0~255區間,當數據被排序,數據的前半部分應該不會進入if語句。之後的講都會進入if語句。 同一個分支連續進入很多次,這對於分支預測器來說是一個非常友好的。即使是簡單的計數器都能正確預測的這樣的分支,除非幾個迭代它切換方向之後。

快速可視化:

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];

等效替換分析:

在分析之前,先將一下基本知識,

<< :左移運算符,num << 1,等價於num*2;
>> :右移運算符,num >> 1,等價於num/2; 空位以符號位來補齊
>>> :無符號右移,忽略符號位(最高位),空位都以0補齊;
~: 非運算符,當位為0,則結果為1;,但位為1,則結果為0;

正式分析:

  • 當data[c]大於128時,int t = (data[c] - 128) >> 31,根據帶符號的右移位運算法則,得到t=0,則~t=-1(即所有的位都為1); 從而~t & data[c] = data[c],故等效sum += data[c];
  • 當data[c]小於或者等於128時,int t = (data[c] - 128) >> 31,根據帶符號的右移位運算法則,得到t=-1,則~t=0; 從而~t & data[c] = 0,故等效此未進入分支;

這消除了分支預測,並與一些位運算來替換它的操作。 (這種方案並不能等價於原始的if語句。但是在當前的if語句情況下,對所有的data[]數值是有效的)

Benchmarks: Core i7 920 @ 3.5 GHz

C++ - Visual Studio 2010 - x64 Release

//  原始-隨機的
seconds = 11.777

//  原始-已排序的
seconds = 2.352

//  改進後-隨機的
seconds = 2.564

//  改進後-排序的
seconds = 2.587

Java - Netbeans 7.1.1 JDK 7 - x64

//  原始-隨機的
seconds = 10.93293813

//  原始-已排序的
seconds = 5.643797077

//  改進後-隨機的
seconds = 3.113581453

//  改進後-排序的
seconds = 3.186068823

觀察可知:

  • 原始方法: 有非常大的差異在排序與未排序的數據之間
  • 改進後方法: 排序與未排序的數據基本無差異
  • C++語言下,改進後的方法,對於排序過的數據計算稍微比原始的方法慢一些。

一般的經驗法則是避免在臨界循環數據依賴分支。(比如在這個示例中)


另外:

  • 在x64上 GCC 4.6.1(-O3 或者 -ftree-vectorize)的環境下,是可以生成條件移動(conditional move,沒有想到合適的譯文,暫時稱為條件移動),因為在排序與未排序的數據沒有區別,一樣快。
  • VC++ 2010是無法為分支生成條件移動。
  • Intel Compiler 11做了一些特殊處理。它交換了兩個循環,從而把不可預測的分支提到外循環。因此,它不僅是避免了預測失誤,也是快兩倍於VC++和GCC。換句話說,ICC通過測試循環來獲取更好的性能…
  • 如果你給Intel編譯器的改進代碼,它只是向右向量化…跟含有循環交換的分支一樣快。

這一切都表明現代成熟的編譯器變在優化代碼上有能力做很多變化。

 
  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved