十分鐘教你掌握 CPU 緩存
【CSDN 編者按】由於 CPU 是核心硬件,相信我們在選擇 CPU 的時候都會去關心 CPU 參數方面,而在 CPU 核心參數中,我們經常會看到緩存這個參數,那麼 CPU 緩存有什麼用呢?隨着本文去了解一下吧。
作者 | 神技圈子 責編 | 歐陽姝黎
出品 | CSDN 博客
基礎知識
首先,大家都知道現在 CPU 的多核技術,都會有幾級緩存,現在的 CPU 會有三級內存(L1,L2, L3),如下圖所示。
其中:
L1 緩存分成兩種,一種是指令緩存,一種是數據緩存。L2 緩存和 L3 緩存不分指令和數據。
-
L1 和 L2 緩存在每一個 CPU 核中,L3 則是所有 CPU 核心共享的內存。
-
L1、L2、L3 的越離 CPU 近就越小,速度也就越快,越離 CPU 遠,速度也越慢。
再往後面就是內存,內存的後面就是硬盤。我們來看一些他們的速度。
-
L1 的存取速度:4 個 CPU 時鐘週期
-
L2 的存取速度:11 個 CPU 時鐘週期
-
L3 的存取速度:39 個 CPU 時鐘週期
-
RAM 內存的存取速度:107 個 CPU 時鐘週期
我們可以看到,L1 的速度是 RAM 的 27 倍,L1 和 L2 的存取大小基本上是 KB 級的,L3 則是 MB 級別的。例如,Intel Core i7-8700K, 是一個 6 核的 CPU,每核上的 L1 是 64KB(數據和指令各 32KB),L2 是 256K,L3 有 2MB。
我們的數據從內存向上,先到 L3,再到 L2,再到 L1,最後到寄存器進行計算。那麼,爲什麼會設計成三層?這裏有以下幾方面的考慮:
-
物理速度,如果要更大的容量就需要更多的晶體管,除了芯片的體積會變大,更重要的是大量的晶體管會導致速度下降,因爲訪問速度和要訪問的晶體管所在的位置成反比。也就是當信號路徑變長時,通信速度會變慢,這就是物理問題。
-
另外一個問題是,多核技術中,數據的狀態需要在多個 CPU 進行同步。我們可以看到,cache 和 RAM 的速度差距太大。所以,多級不同尺寸的緩存有利於提高整體的性能。
這個世界永遠是平衡的,一面變得有多光鮮,另一方面也會變得有多黑暗,建立多級的緩存,一定就會引入其它的問題。這裏有兩個比較重要的問題。
-
一個是比較簡單的緩存命中率的問題
-
另一個是比較複雜的緩存更新的一致性問題
尤其是第二個問題,在多核技術下,這就很像分佈式系統了,要面對多個地方進行更新。
緩存命中
首先,我們需要了解一個術語 Cache Line。緩存基本上來說就是把後面的數據加載到離自己最近的地方,對於 CPU 來說,它是不會一個字節一個字節的加載的。因爲這非常沒有效率,一般來說都是要一塊一塊的加載的,對於這樣一塊一塊的數據單位,術語叫 “Cache Line”。一般來說,一個主流的 CPU 的 Cache Line 是 64 Bytes(也有的 CPU 用 32Bytes 和 128Bytes),64Bytes 也就是 16 個 32 位的數字, 這就是 CPU 從內存中撈數據上來的最小數據單位。比如:Cache Line 是最小單位(64Bytes),所以先把 Cache 分佈多 個 Cache Line。比如:L1 有 32KB,那麼 32KB/64B = 512 個 Cache Line。
緩存需要把內存裏的數據放進來,英文叫 CPU Associativity,Cache 的數據放置策略決定了內存中的數據會拷貝到 CPU Cache 中的哪個位置上,因爲 Cache 的大小遠遠小於內存,所以,需要有一種地址關聯算法,能夠讓內存中的數據被映射到 Cache 中。這個就有點像內存地址從邏輯地址到物理地址的映射方法。但是不完全一樣。
基本上會有以下的一些方法
-
任何一個內存的數據可以被緩存在任何一個 Cache Line 裏,這種方法是最靈活的,但是,如果我們要知道一個內存是否存在於 Cache 中。我們就需要進行 O(n) 複雜度的 Cache 遍歷,這是沒有效率的。
-
另一種方法,爲了降低緩存搜索算法的時間複雜度,我們要使用像 hash table 這樣的數據結構,最簡單的 hash table 就是 “求模運算”。比如,我們的 L1 Cache 有 512 個 Cache Line, 那麼公式就是 (內存地址 mod 512) *64 就可以直接找到所在的 Cache 地址的偏移了。但是,這樣的方式需要程序對內存地址的訪問非常的平均,不然會造成嚴重的衝突。所以,這成了一個非常理想的情況了。
-
爲了避免上述的兩種方案的問題,於是就要容忍一定的 hash 衝突,也就出現了 N-Way 關聯。也就是把連續的 N 個 Cache Line 綁成一組,然後,先找到相關的組,然後再在組內找到相關的 Cache Line。這叫 Set Associativity。如下圖所示
對於 N-Way 組關聯,可能有點不好理解。這裏舉個例子,並多說一些細節(不然後面的代碼你會不能理解),Intel 大多數處理器的 L1 Cache 都是 32KB,8-Way 組相聯,Cache Line 是 64 Bytes。這意味着
-
32KB 的可以分成,32KB / 64 = 512 條 Cache Line;
-
因爲有 8 Way,於是會每一 Way 有 512 / 8 = 64 條 Cache Line;
-
於是每一路就有 64 x 64 = 4096 Byts 的內存。
爲了方便索引內存地址
-
**Tag:**每條 Cache Line 前都會有一個獨立分配的 24 bits 來存的 tag,其就是內存地址的前 24bits;
-
**Index:**內存地址後續的 6 個 bits 則是在這一 Way 的是 Cache Line 索引,2^6 = 64 剛好可以索引 64 條 Cache Line;
-
**Offset:**再往後的 6bits 用於表示在 Cache Line 裏的偏移量
索引過程如下圖所示:
- 當拿到一個內存地址的時候,先拿出中間的 6bits 來,找到是哪組;
- 然後在這一個 8 組的 cache line 中,再進行 O(n) ,n=8 的遍歷,主是要匹配前 24bits 的 tag。如果匹配中了,就算命中,如果沒有匹配到,那就是 cache miss,如果是讀操作,就需要進向後面的緩存進行訪問了。L2 和 L3 同樣是這樣的算法。而淘汰算法有兩種,一種是隨機,另一種是 LRU。
這也意味着:
-
L1 Cache 可映射 36bits 的內存地址,一共 2^36 = 64GB 的內存
-
當 CPU 要訪問一個內存的時候,通過這個內存中間的 6bits 定位是哪個 set,通過前 24bits 定位相應的 Cache Line。
-
就像一個 hash Table 的數據結構一樣,先是 O(1) 的索引,然後進入衝突搜索。因爲中間的 6bits 決定了一個同一個 set,所以,對於一段連續的內存來說,每隔 4096 的內存會被放在同一個組內,導致緩存衝突。
此外,當有數據沒有命中緩存的時候,CPU 就會以最小爲 Cache Line 的單元向內存更新數據。當然,CPU 並不一定只是更新 64Bytes,因爲訪問主存實在是太慢了,所以,一般都會多更新一些。好的 CPU 會有一些預測的技術,如果找到一種 pattern 的話,就會預先加載更多的內存,包括指令也可以預加載。這叫 Prefetching 技術。比如,你在 for-loop 訪問一個連續的數組,你的步長是一個固定的數,內存就可以做到 prefetching。
瞭解這些細節,會有利於我們知道在什麼情況下有可以導致緩存的失效。
緩存一致
對於主流的 CPU 來說,緩存的寫操作基本上是兩種策略
-
**Write Back:**寫操作只在 Cache 上,然後再 flush 到內存上
-
**Write Through:**寫操作同時寫到 cache 和內存上。
爲了提高寫的性能,一般來說,主流的 CPU(如:Intel Core i7/i9)採用的是 Write Back 的策略,因爲直接寫內存實在是太慢了。
好了,現在問題來了,如果有一個數據 x 在 CPU 第 0 核的緩存上被更新了,那麼其它 CPU 核上對於這個數據 x 的值也要被更新,這就是緩存一致性的問題。
一般來說,在 CPU 硬件上,會有兩種方法來解決這個問題。
-
**Directory 協議。**這種方法的典型實現是要設計一個集中式控制器,它是主存儲器控制器的一部分。其中有一個目錄存儲在主存儲器中,其中包含有關各種本地緩存內容的全局狀態信息。當單個 CPU Cache 發出讀寫請求時,這個集中式控制器會檢查併發出必要的命令,以在主存和 CPU Cache 之間或在 CPU Cache 自身之間進行數據同步和傳輸。
-
**Snoopy 協議。**這種協議更像是一種數據通知的總線型的技術。CPU Cache 通過這個協議可以識別其它 Cache 上的數據狀態。如果有數據共享的話,可以通過廣播機制將共享數據的狀態通知給其它 CPU Cache。這個協議要求每個 CPU Cache 都可以 “窺探” 數據事件的通知並做出相應的反應。如下圖所示,有一個 Snoopy Bus 的總線。
因爲 Directory 協議是一箇中心式的,會有性能瓶頸,而且會增加整體設計的複雜度。而 Snoopy 協議更像是微服務 + 消息通訊,所以,現在基本都是使用 Snoopy 的總線的設計。
在分佈式系統中我們一般用 Paxos/Raft 這樣的分佈式一致性的算法。而在 CPU 的微觀世界裏,則不必使用這樣的算法。因爲 CPU 的多個核的硬件不必考慮網絡會斷會延遲的問題。所以,CPU 的多核心緩存間的同步的核心就是要管理好數據的狀態就好了。
這裏介紹幾個狀態協議,先從最簡單的開始,MESI 協議,這個協議跟那個著名的足球運動員梅西沒什麼關係,其主要表示緩存數據有四個狀態:Modified(已修改), Exclusive(獨佔的),Shared(共享的),Invalid(無效的)。
MESI 這種協議在數據更新後,會標記其它共享的 CPU 緩存的數據拷貝爲 Invalid 狀態,然後當其它 CPU 再次 read 的時候,就會出現 cache miss 的問題,此時再從內存中更新數據。從內存中更新數據意味着 20 倍速度的降低。我們能不能直接從我隔壁的 CPU 緩存中更新?是的,這就可以增加很多速度了,但是狀態控制也就變麻煩了。還需要多來一個狀態:Owner(宿主),用於標記,我是更新數據的源。於是,出現了 MOESI 協議。
**MOESI 協議允許 CPU Cache 間同步數據,於是也降低了對內存的操作,**性能是非常大的提升,但是控制邏輯也非常複雜。
順便說一下,與 MOESI 協議類似的一個協議是 MESIF,其中的 F 是 Forward,同樣是把更新過的數據轉發給別的 CPU Cache 但是,MOESI 中的 Owner 狀態 和 MESIF 中的 Forward 狀態有一個非常大的不一樣—— Owner 狀態下的數據是 dirty 的,還沒有寫回內存,Forward 狀態下的數據是 clean 的,可以丟棄而不用另行通知。
需要說明的是,AMD 用 MOESI,Intel 用 MESIF。所以,F 狀態主要是針對 CPU L3 Cache 設計的(前面我們說過,L3 是所有 CPU 核心共享的)。
程序性能
瞭解了我們上面的這些東西后,我們來看一下對於程序的影響。
示例一
首先,假設我們有一個 64M 長的數組,設想一下下面的兩個循環:
const int LEN = 64*1024*1024;
int *arr = new int[LEN];
for (int i = 0; i < LEN; i += 2) arr[i] *= i;
for (int i = 0; i < LEN; i += 8) arr[i] *= i;
按我們的想法,第二個循環要比第一個循環少 4 倍的計算量。其應該要快 4 倍的。但實際跑下來並不是,在我的機器上,第一個循環需要 128 毫秒,第二個循環則需要 122 毫秒,相差無幾。這裏最主要的原因就是 Cache Line,因爲 CPU 會以一個 Cache Line 64Bytes 最小時單位加載,也就是 16 個 32bits 的整型,所以,無論你步長是 2 還是 8,都差不多。而後面的乘法其實是不耗 CPU 時間的。
示例二
接下來,我們再來看個示例。下面是一個二維數組的兩種遍歷方式,一個逐行遍歷,一個是逐列遍歷,這兩種方式在理論上來說,尋址和計算量都是一樣的,執行時間應該也是一樣的。
const int row = 1024;
const int col = 512
int matrix[row][col];
//逐行遍歷
int sum_row=0;
for(int _r=0; _r<row; _r++) {
for(int _c=0; _c<col; _c++){
sum_row += matrix[_r][_c];
}
}
//逐列遍歷
int sum_col=0;
for(int _c=0; _c<col; _c++) {
for(int _r=0; _r<row; _r++){
sum_col += matrix[_r][_c];
}
}
然而,並不是,在我的機器上,得到下面的結果。
逐行遍歷:0.083ms
逐列遍歷:1.072ms
執行時間有十幾倍的差距。其中的原因,就是逐列遍歷對於 CPU Cache 的運作方式並不友好,所以,付出巨大的代價。
示例三
接下來,我們來看一下多核下的性能問題,參看如下的代碼。兩個線程在操作一個數組的兩個不同的元素(無需加鎖),線程循環 1000 萬次,做加法操作。在下面的代碼中,我高亮了一行,就是 p2 指針,要麼是 p[1],或是 p[30],理論上來說,無論訪問哪兩個數組元素,都應該是一樣的執行時間。
void fn (int* data) {
for(int i = 0; i < 10*1024*1024; ++i)
*data += rand();
}
int p[32];
int *p1 = &p[0];
int *p2 = &p[1]; // int *p2 = &p[30];
thread t1(fn, p1);
thread t2(fn, p2);
然而,並不是,在我的機器上執行下來的結果是:
對於 p[0] 和 p[1] :570ms
對於 p[0] 和 p[30]:105ms
這是因爲 p[0] 和 p[1] 在同一條 Cache Line 上,而 p[0] 和 p[30] 則不可能在同一條 Cache Line 上 ,CPU 的緩存最小的更新單位是 Cache Line,所以,這導致雖然兩個線程在寫不同的數據,但是因爲這兩個數據在同一條 Cache Line 上,就會導致緩存需要不斷進在兩個 CPU 的 L1/L2 中進行同步,從而導致了 5 倍的時間差異。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/HvgaXjHgD4_nVz81ipmbhg