寫Java也得了解CPU快取

2014-08-24    分類:JAVA開發、程式設計開發、首頁精華3人評論發表於2014-08-24

本文由碼農網 – 小峰原創翻譯,轉載請看清文末的轉載要求,歡迎參與我們的付費投稿計劃

CPU,一般認為寫C/C++的才需要了解,寫高階語言的(Java/C#/pathon…)並不需要了解那麼底層的東西。我一開始也是這麼想的,但直到碰到LMAX的Disruptor,以及馬丁的博文,才發現寫Java的,更加不能忽視CPU。經過一段時間的閱讀,希望總結一下自己的閱讀後的感悟。本文主要談談CPU快取對Java程式設計的影響,不涉及具體CPU快取的機制和實現。

現代CPU的快取結構一般分三層,L1,L2和L3。如下圖所示:

CPU三級快取示意圖

 

級別越小的快取,越接近CPU, 意味著速度越快且容量越少。

L1是最接近CPU的,它容量最小,速度最快,每個核上都有一個L1 Cache(準確地說每個核上有兩個L1 Cache, 一個存資料 L1d Cache, 一個存指令 L1i Cache);

L2 Cache 更大一些,例如256K,速度要慢一些,一般情況下每個核上都有一個獨立的L2 Cache;

L3 Cache是三級快取中最大的一級,例如12MB,同時也是最慢的一級,在同一個CPU插槽之間的核共享一個L3 Cache。

當CPU運作時,它首先去L1尋找它所需要的資料,然後去L2,然後去L3。如果三級快取都沒找到它需要的資料,則從記憶體裡獲取資料。尋找的路徑越長,耗時越長。所以如果要非常頻繁的獲取某些資料,保證這些資料在L1快取裡。這樣速度將非常快。下表表示了CPU到各快取和記憶體之間的大概速度:

從CPU到     大約需要的CPU週期  大約需要的時間(單位ns)
暫存器         1 cycle
L1 Cache    ~3-4 cycles ~0.5-1 ns
L2 Cache ~10-20 cycles ~3-7 ns
L3 Cache ~40-45 cycles ~15 ns
跨槽傳輸  ~20 ns
記憶體  ~120-240 cycles  ~60-120ns

利用CPU-Z可以檢視CPU快取的資訊:

CPU-Z

在linux下可以使用下列命令檢視proc檔案系統或者sys下的裝置描述。

有了上面對CPU的大概瞭解,我們來看看快取行(Cache line)。快取,是由快取行組成的。一般一行快取行有64位元組(由上圖”64-byte line size”可知)。所以使用快取時,並不是一個一個位元組使用,而是一行快取行、一行快取行這樣使用;換句話說,CPU存取快取都是按照一行,為最小單位操作的。

這意味著,如果沒有好好利用快取行的話,程式可能會遇到效能的問題。可看下面的程式:

public class L1CacheMiss {
    private static final int RUNS = 10;
    private static final int DIMENSION_1 = 1024 * 1024;
    private static final int DIMENSION_2 = 6;

    private static long[][] longs;

    public static void main(String[] args) throws Exception {
        Thread.sleep(10000);
        longs = new long[DIMENSION_1][];
        for (int i = 0; i < DIMENSION_1; i++) {
            longs[i] = new long[DIMENSION_2];
            for (int j = 0; j < DIMENSION_2; j++) {
                longs[i][j] = 0L;
            }
        }
        System.out.println("starting....");

        long sum = 0L;
        for (int r = 0; r < RUNS; r++) {

            final long start = System.nanoTime();

            //slow
//            for (int j = 0; j < DIMENSION_2; j++) {
//                for (int i = 0; i < DIMENSION_1; i++) {
//                    sum += longs[i][j];
//                }
//            }

            //fast
            for (int i = 0; i < DIMENSION_1; i++) {
                for (int j = 0; j < DIMENSION_2; j++) {
                    sum += longs[i][j];
                }
            }

            System.out.println((System.nanoTime() - start));
        }

    }
}

 

以我所使用的Xeon E3 CPU和64位作業系統和64位JVM為例,如這裡所說,假設編譯器採用行主序儲存陣列。

64位系統,Java陣列物件頭固定佔16位元組(未證實),而long型別佔8個位元組。所以16+8*6=64位元組,剛好等於一條快取行的長度:

快取行效果

如32-36行程式碼所示,每次開始內迴圈時,從記憶體抓取的資料塊實際上覆蓋了longs[i][0]到longs[i][5]的全部資料(剛好64位元組)。因此,內迴圈時所有的資料都在L1快取可以命中,遍歷將非常快。

假如,將32-36行程式碼註釋而用25-29行程式碼代替,那麼將會造成大量的快取失效。因為每次從記憶體抓取的都是同行不同列的資料塊(如longs[i][0]到longs[i][5]的全部資料),但迴圈下一個的目標,卻是同列不同行(如longs[0][0]下一個是longs[1][0],造成了longs[0][1]-longs[0][5]無法重複利用)。執行時間的差距如下圖,單位是微秒(us):

程式執行效果對比

最後,我們都希望需要的資料都在L1快取裡,但事實上經常事與願違,所以快取失效 (Cache Miss)是常有的事,也是我們需要避免的事。

一般來說,快取失效有三種情況:
1. 第一次訪問資料, 在cache中根本不存在這條資料, 所以cache miss, 可以通過prefetch解決。
2. cache衝突, 需要通過補齊來解決(偽共享的產生)。
3. cache滿, 一般情況下我們需要減少操作的資料大小, 儘量按資料的物理順序訪問資料。

譯文連結:http://www.codeceo.com/article/java-cpu-cache.html
翻譯作者:碼農網 – 小峰
轉載必須在正文中標註並保留原文連結、譯文連結和譯者等資訊。]

相關文章