重JAVA視角理解CPU緩存

http://coderplay.iteye.com/blog/1485760


從Java視角理解系統結構連載, 關注我的微博(鏈接)瞭解最新動態


衆所周知, CPU是計算機的大腦, 它負責執行程序的指令; 內存負責存數據, 包括程序自身數據. 同樣大家都知道, 內存比CPU慢很多. 其實在30年前, CPU的頻率和內存總線的頻率在同一個級別, 訪問內存只比訪問CPU寄存器慢一點兒. 由於內存的發展都到技術及成本的限制, 現在獲取內存中的一條數據大概需要200多個CPU週期(CPU cycles), 而CPU寄存器一般情況下1個CPU週期就夠了.

CPU緩存
網頁瀏覽器爲了加快速度,會在本機存緩存以前瀏覽過的數據; 傳統數據庫或NoSQL數據庫爲了加速查詢, 常在內存設置一個緩存, 減少對磁盤(慢)的IO. 同樣內存與CPU的速度相差太遠, 於是CPU設計者們就給CPU加上了緩存(CPU Cache). 如果你需要對同一批數據操作很多次, 那麼把數據放至離CPU更近的緩存, 會給程序帶來很大的速度提升. 例如, 做一個循環計數, 把計數變量放到緩存裏,就不用每次循環都往內存存取數據了. 下面是CPU Cache的簡單示意圖. 

隨着多核的發展, CPU Cache分成了三個級別: L1, L2, L3. 級別越小越接近CPU, 所以速度也更快, 同時也代表着容量越小. L1是最接近CPU的, 它容量最小, 例如32K, 速度最快,每個核上都有一個L1 Cache(準確地說每個核上有兩個L1 Cache, 一個存數據 L1d Cache, 一個存指令 L1i Cache). L2 Cache 更大一些,例如256K, 速度要慢一些, 一般情況下每個核上都有一個獨立的L2 Cache; L3 Cache是三級緩存中最大的一級,例如12MB,同時也是最慢的一級, 在同一個CPU插槽之間的核共享一個L3 Cache.

從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

感興趣的同學可以在Linux下面用cat /proc/cpuinfo, 或Ubuntu下lscpu看看自己機器的緩存情況, 更細的可以通過以下命令看看:
Shell代碼  收藏代碼
  1. $ cat /sys/devices/system/cpu/cpu0/cache/index0/size  
  2. 32K  
  3. $ cat /sys/devices/system/cpu/cpu0/cache/index0/type  
  4. Data  
  5. $ cat /sys/devices/system/cpu/cpu0/cache/index0/level   
  6. 1  
  7. $ cat /sys/devices/system/cpu/cpu3/cache/index3/level     
  8. 3  

就像數據庫cache一樣, 獲取數據時首先會在最快的cache中找數據, 如果沒有命中(Cache miss) 則往下一級找, 直到三層Cache都找不到,那隻要向內存要數據了. 一次次地未命中,代表取數據消耗的時間越長.

緩存行(Cache line)
爲了高效地存取緩存, 不是簡單隨意地將單條數據寫入緩存的.  緩存是由緩存行組成的, 典型的一行是64字節. 讀者可以通過下面的shell命令,查看cherency_line_size就知道知道機器的緩存行是多大.
Shell代碼  收藏代碼
  1. $ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size   
  2. 64  

CPU存取緩存都是按行爲最小單位操作的. 在這兒我將不提及緩存的associativity問題, 將問題簡化一些. 一個Java long型佔8字節, 所以從一條緩存行上你可以獲取到8個long型變量. 所以如果你訪問一個long型數組, 當有一個long被加載到cache中, 你將無消耗地加載了另外7個. 所以你可以非常快地遍歷數組.

實驗及分析
我們在Java編程時, 如果不注意CPU Cache, 那麼將導致程序效率低下. 例如以下程序, 有一個二維long型數組, 在我的32位筆記本上運行時的內存分佈如圖:

32位機器中的java的數組對象頭共佔16字節(詳情見 鏈接), 加上62個long型一行long數據一共佔512字節. 所以這個二維數據是順序排列的.
Java代碼  收藏代碼
  1. public class L1CacheMiss {  
  2.     private static final int RUNS = 10;  
  3.     private static final int DIMENSION_1 = 1024 * 1024;  
  4.     private static final int DIMENSION_2 = 62;  
  5.   
  6.     private static long[][] longs;  
  7.   
  8.     public static void main(String[] args) throws Exception {  
  9.         Thread.sleep(10000);  
  10.         longs = new long[DIMENSION_1][];  
  11.         for (int i = 0; i < DIMENSION_1; i++) {  
  12.             longs[i] = new long[DIMENSION_2];  
  13.             for (int j = 0; j < DIMENSION_2; j++) {  
  14.                 longs[i][j] = 0L;  
  15.             }  
  16.         }  
  17.         System.out.println("starting....");  
  18.   
  19.         final long start = System.nanoTime();  
  20.         long sum = 0L;  
  21.         for (int r = 0; r < RUNS; r++) {  
  22. //          for (int j = 0; j < DIMENSION_2; j++) {  
  23. //              for (int i = 0; i < DIMENSION_1; i++) {  
  24. //                  sum += longs[i][j];  
  25. //              }  
  26. //          }  
  27.   
  28.             for (int i = 0; i < DIMENSION_1; i++) {  
  29.                 for (int j = 0; j < DIMENSION_2; j++) {  
  30.                     sum += longs[i][j];  
  31.                 }  
  32.             }  
  33.         }  
  34.         System.out.println("duration = " + (System.nanoTime() - start));  
  35.     }  
  36. }  

編譯後運行,結果如下
Shell代碼  收藏代碼
  1. $ java L1CacheMiss   
  2. starting....  
  3. duration = 1460583903  

然後我們將22-26行的註釋取消, 將28-32行註釋, 編譯後再次運行,結果是不是比我們預想得還糟?
Shell代碼  收藏代碼
  1. $ java L1CacheMiss   
  2. starting....  
  3. duration = 22332686898  

前面只花了1.4秒的程序, 只做一行的對調要運行22秒. 從上節我們可以知道在加載longs[i][j]時, longs[i][j+1]很可能也會被加載至cache中, 所以立即訪問longs[i][j+1]將會命中L1 Cache, 而如果你訪問longs[i+1][j]情況就不一樣了, 這時候很可能會產生 cache miss導致效率低下.
下面我們用perf來驗證一下,先將快的程序跑一下.
Shell代碼  收藏代碼
  1. $ perf stat -e L1-dcache-load-misses java L1CacheMiss   
  2. starting....  
  3. duration = 1463011588  
  4.   
  5.  Performance counter stats for 'java L1CacheMiss':  
  6.   
  7.        164,625,965 L1-dcache-load-misses                                         
  8.   
  9.       13.273572184 seconds time elapsed  

一共164,625,965次L1 cache miss, 再看看慢的程序
Shell代碼  收藏代碼
  1. $ perf stat -e L1-dcache-load-misses java L1CacheMiss   
  2. starting....  
  3. duration = 21095062165  
  4.   
  5.  Performance counter stats for 'java L1CacheMiss':  
  6.   
  7.      1,421,402,322 L1-dcache-load-misses                                         
  8.   
  9.       32.894789436 seconds time elapsed  

這回產生了1,421,402,322次 L1-dcache-load-misses, 所以慢多了.

以上我只是示例了在L1 Cache滿了之後纔會發生的cache miss. 其實cache miss的原因有下面三種:
1. 第一次訪問數據, 在cache中根本不存在這條數據, 所以cache miss, 可以通過prefetch解決.
2. cache衝突, 需要通過補齊來解決.
3. 就是我示例的這種, cache滿, 一般情況下我們需要減少操作的數據大小, 儘量按數據的物理順序訪問數據.
具體的信息可以參考這篇論文.

下一篇將介紹CPU cache的另一種誤區: 僞共享(False Sharing).
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章