先來看一張本文所有概念的一個思維導圖(在新視窗檢視原圖)
為什麼要有CPU Cache
隨著工藝的提升最近幾十年CPU的頻率不斷提升,而受制於製造工藝和成本限制,目前計算機的記憶體主要是DRAM並且在訪問速度上沒有質的突破。因此,CPU的處理速度和記憶體的訪問速度差距越來越大,甚至可以達到上萬倍。這種情況下傳統的CPU通過FSB直連記憶體的方式顯然就會因為記憶體訪問的等待,導致計算資源大量閒置,降低CPU整體吞吐量。同時又由於記憶體資料訪問的熱點集中性,在CPU和記憶體之間用較為快速而成本較高的SDRAM做一層快取,就顯得價效比極高了。
為什麼要有多級CPU Cache
隨著科技發展,熱點資料的體積越來越大,單純的增加一級快取大小的價效比已經很低了。因此,就慢慢出現了在一級快取(L1 Cache)和記憶體之間又增加一層訪問速度和成本都介於兩者之間的二級快取(L2 Cache)。下面是一段從What Every Programmer Should Know About Memory中摘錄的解釋:
Soon after the introduction of the cache the system got more complicated. The speed difference between the cache and the main memory increased again, to a point that another level of cache was added, bigger and slower than the first-level cache. Only increasing the size of the first-level cache was not an option for economical rea- sons.
此外,又由於程式指令和程式資料的行為和熱點分佈差異很大,因此L1 Cache也被劃分成L1i (i for instruction)和L1d (d for data)兩種專門用途的快取。
下面一張圖可以看出各級快取之間的響應時間差距,以及記憶體到底有多慢!
什麼是Cache Line
Cache Line可以簡單的理解為CPU Cache中的最小快取單位。目前主流的CPU Cache的Cache Line大小都是64Bytes。假設我們有一個512位元組的一級快取,那麼按照64B的快取單位大小來算,這個一級快取所能存放的快取個數就是512/64 = 8個。具體參見下圖:
為了更好的瞭解Cache Line,我們還可以在自己的電腦上做下面這個有趣的實驗。
下面這段C程式碼,會從命令列接收一個引數作為陣列的大小建立一個數量為N的int陣列。並依次迴圈的從這個陣列中進行陣列內容訪問,迴圈10億次。最終輸出陣列總大小和對應總執行時間。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#include "stdio.h" #include <stdlib.h> #include <sys/time.h> long timediff(clock_t t1, clock_t t2) { long elapsed; elapsed = ((double)t2 - t1) / CLOCKS_PER_SEC * 1000; return elapsed; } int main(int argc, char *argv[]) #******* { int array_size=atoi(argv[1]); int repeat_times = 1000000000; long array[array_size]; for(int i=0; i<array_size; i++){ array[i] = 0; } int j=0; int k=0; int c=0; clock_t start=clock(); while(j++<repeat_times){ if(k==array_size){ k=0; } c = array[k++]; } clock_t end =clock(); printf("%lu\n", timediff(start,end)); return 0; } |
如果我們把這些資料做成折線圖後就會發現:總執行時間在陣列大小超過64Bytes時有較為明顯的拐點(當然,由於博主是在自己的Mac筆記本上測試的,會受到很多其他程式的干擾,因此會有波動)。原因是當陣列小於64Bytes時陣列極有可能落在一條Cache Line內,而一個元素的訪問就會使得整條Cache Line被填充,因而值得後面的若干個元素受益於快取帶來的加速。而當陣列大於64Bytes時,必然至少需要兩條Cache Line,繼而在迴圈訪問時會出現兩次Cache Line的填充,由於快取填充的時間遠高於資料訪問的響應時間,因此多一次快取填充對於總執行的影響會被放大,最終得到下圖的結果:
如果讀者有興趣的話也可以在自己的linux或者MAC上通過gcc cache_line_size.c -o cache_line_size編譯,並通過./cache_line_size執行。
瞭解Cache Line的概念對我們程式猿有什麼幫助?
我們來看下面這個C語言中常用的迴圈優化例子
下面兩段程式碼中,第一段程式碼在C語言中總是比第二段程式碼的執行速度要快。具體的原因相信你仔細閱讀了Cache Line的介紹後就很容易理解了。
1 2 3 4 5 6 7 |
for(int i = 0; i < n; i++) { for(int j = 0; j < n; j++) { int num; //code arr[i][j] = num; } } |
1 2 3 4 5 6 7 |
for(int i = 0; i < n; i++) { for(int j = 0; j < n; j++) { int num; //code arr[j][i] = num; } } |
CPU Cache 是如何存放資料的
你會怎麼設計Cache的存放規則
我們先來嘗試回答一下那麼這個問題:
假設我們有一塊4MB的區域用於快取,每個快取物件的唯一標識是它所在的實體記憶體地址。每個快取物件大小是64Bytes,所有可以被快取物件的大小總和(即實體記憶體總大小)為4GB。那麼我們該如何設計這個快取?
如果你和博主一樣是一個大學沒有好好學習基礎/數位電路的人的話,會覺得最靠譜的的一種方式就是:Hash表。把Cache設計成一個Hash陣列。記憶體地址的Hash值作為陣列的Index,快取物件的值作為陣列的Value。每次存取時,都把地址做一次Hash然後找到Cache中對應的位置操作即可。
這樣的設計方式在高等語言中很常見,也顯然很高效。因為Hash值得計算雖然耗時(10000個CPU Cycle左右),但是相比程式中其他操作(上百萬的CPU Cycle)來說可以忽略不計。而對於CPU Cache來說,本來其設計目標就是在幾十CPU Cycle內獲取到資料。如果訪問效率是百萬Cycle這個等級的話,還不如到Memory直接獲取資料。當然,更重要的原因是在硬體上要實現Memory Address Hash的功能在成本上是非常高的。
為什麼Cache不能做成Fully Associative
Fully Associative 字面意思是全關聯。在CPU Cache中的含義是:如果在一個Cache集內,任何一個記憶體地址的資料可以被快取在任何一個Cache Line裡,那麼我們成這個cache是Fully Associative。從定義中我們可以得出這樣的結論:給到一個記憶體地址,要知道他是否存在於Cache中,需要遍歷所有Cache Line並比較快取內容的記憶體地址。而Cache的本意就是為了在儘可能少得CPU Cycle內取到資料。那麼想要設計一個快速的Fully Associative的Cache幾乎是不可能的。
為什麼Cache不能做成Direct Mapped
和Fully Associative完全相反,使用Direct Mapped模式的Cache給定一個記憶體地址,就唯一確定了一條Cache Line。設計複雜度低且速度快。那麼為什麼Cache不使用這種模式呢?讓我們來想象這麼一種情況:一個擁有1M L2 Cache的32位CPU,每條Cache Line的大小為64Bytes。那麼整個L2Cache被劃為了1M/64=16384條Cache Line。我們為每條Cache Line從0開始編上號。同時32位CPU所能管理的記憶體地址範圍是2^32=4G,那麼Direct Mapped模式下,記憶體也被劃為4G/16384=256K的小份。也就是說每256K的記憶體地址共享一條Cache Line。
但是,這種模式下每條Cache Line的使用率如果要做到接近100%,就需要作業系統對於記憶體的分配和訪問在地址上也是近乎平均的。而與我們的意願相反,為了減少記憶體碎片和實現便捷,作業系統更多的是連續集中的使用記憶體。這樣會出現的情況就是0-1000號這樣的低編號Cache Line由於記憶體經常被分配並使用,而16000號以上的Cache Line由於記憶體鮮有程式訪問,幾乎一直處於空閒狀態。這種情況下,本來就寶貴的1M二級CPU快取,使用率也許50%都無法達到。
什麼是N-Way Set Associative
為了避免以上兩種設計模式的缺陷,N-Way Set Associative快取就出現了。他的原理是把一個快取按照N個Cache Line作為一組(set),快取按組劃為等分。這樣一個64位系統的記憶體地址在4MB二級快取中就劃成了三個部分(見下圖),低位6個bit表示在Cache Line中的偏移量,中間12bit表示Cache組號(set index),剩餘的高位46bit就是記憶體地址的唯一id。這樣的設計相較前兩種設計有以下兩點好處:
- 給定一個記憶體地址可以唯一對應一個set,對於set中只需遍歷16個元素就可以確定物件是否在快取中(Full Associative中比較次數隨記憶體大小線性增加)
- 每2^18(256K)*64=16M的連續熱點資料才會導致一個set內的conflict(Direct Mapped中512K的連續熱點資料就會出現conflict)
為什麼N-Way Set Associative的Set段是從低位而不是高位開始的
下面是一段從How Misaligning Data Can Increase Performance 12x by Reducing Cache Misses摘錄的解釋:
The vast majority of accesses are close together, so moving the set index bits upwards would cause more conflict misses. You might be able to get away with a hash function that isn’t simply the least significant bits, but most proposed schemes hurt about as much as they help while adding extra complexity.
由於記憶體的訪問通常是大片連續的,或者是因為在同一程式中而導致地址接近的(即這些記憶體地址的高位都是一樣的)。所以如果把記憶體地址的高位作為set index的話,那麼短時間的大量記憶體訪問都會因為set index相同而落在同一個set index中,從而導致cache conflicts使得L2, L3 Cache的命中率低下,影響程式的整體執行效率。
瞭解N-Way Set Associative的儲存模式對我們有什麼幫助
瞭解N-Way Set的概念後,我們不難得出以下結論:2^(6Bits <Cache Line Offset> + 12Bits <Set Index>) = 2^18 = 512K。即在連續的記憶體地址中每512K都會出現一個處於同一個Cache Set中的快取物件。也就是說這些物件都會爭搶一個僅有16個空位的快取池(16-Way Set)。而如果我們在程式中又使用了所謂優化神器的“記憶體對齊”的時候,這種爭搶就會越發增多。效率上的損失也會變得非常明顯。具體的實際測試我們可以參考: How Misaligning Data Can Increase Performance 12x by Reducing Cache Misses 一文。
這裡我們引用一張Gallery of Processor Cache Effects 中的測試結果圖,來解釋下記憶體對齊在極端情況下帶來的效能損失。
該圖實際上是我們上文中第一個測試的一個變種。縱軸表示了測試物件陣列的大小。橫軸表示了每次陣列元素訪問之間的index間隔。而圖中的顏色表示了響應時間的長短,藍色越明顯的部分表示響應時間越長。從這個圖我們可以得到很多結論。當然這裡我們只對記憶體帶來的效能損失感興趣。有興趣的讀者也可以閱讀原文分析理解其他從圖中可以得到的結論。
從圖中我們不難看出圖中每1024個步進,即每1024*4即4096Bytes,都有一條特別明顯的藍色豎線。也就是說,只要我們按照4K的步進去訪問記憶體(記憶體根據4K對齊),無論熱點資料多大它的實際效率都是非常低的!按照我們上文的分析,如果4KB的記憶體對齊,那麼一個80MB的陣列就含有20480個可以被訪問到的陣列元素;而對於一個每512K就會有set衝突的16Way二級快取,總共有512K/20480=25個元素要去爭搶16個空位。那麼快取命中率只有64%,自然效率也就低了。
想要知道更多關於記憶體地址對齊在目前的這種CPU-Cache的架構下會出現的問題可以詳細閱讀以下兩篇文章:
- How Misaligning Data Can Increase Performance 12x by Reducing Cache Misses
- Gallery of Processor Cache Effects
Cache淘汰策略
在文章的最後我們順帶提一下CPU Cache的淘汰策略。常見的淘汰策略主要有LRU和Random兩種。通常意義下LRU對於Cache的命中率會比Random更好,所以CPU Cache的淘汰策略選擇的是LRU。當然也有些實驗顯示在Cache Size較大的時候Random策略會有更高的命中率
總結
CPU Cache對於程式猿是透明的,所有的操作和策略都在CPU內部完成。但是,瞭解和理解CPU Cache的設計、工作原理有利於我們更好的利用CPU Cache,寫出更多對CPU Cache友好的程式