CPU緩存
緩存原理
首先,我們都知道現在的CPU多核技術,都會有幾級緩存,老的CPU會有兩級內存(L1和L2),新的CPU會有三級內存(L1,L2,L3 ),如下圖所示:

其中:
- L1緩分成兩種,一種是指令緩存,一種是數據緩存;L2緩存和L3緩存不分指令和數據。
- L1和L2緩存在每一個CPU核中,L3則是所有CPU核心共享的內存。
- 訪問速度 L1 > L2 > L3;存儲大小 L3 > L2 > L1;
例如:Intel Core i7-8700K ,是一個6核的CPU,每核上的L1是64KB(數據和指令各32KB),L2 是 256K,L3有2MB。
CPU Cache再往后面就是內存(RAM),內存的后面就是硬盤(HD)。我們來看一些他們的速度:
- L1 的存取速度:4 個CPU時鍾周期
- L2 的存取速度: 11 個CPU時鍾周期
- L3 的存取速度:39 個CPU時鍾周期
- RAM內存的存取速度:107 個CPU時鍾周期
我們可以看到,L1的速度是RAM的27倍,但是L1/L2的大小基本上也就是KB級別的,L3會是MB級別的。
CPU訪問數據就從內存向上,先到L3,再到L2,再到L1,最后到寄存器進行CPU計算。
對於CPU來說,它是不會一個字節一個字節的加載的,因為這非常沒有效率,一般來說都是要一塊一塊的加載的,對於這樣的一塊一塊的數據單位,術語叫“Cache Line”。
一般來說,一個主流的CPU的Cache Line 是 64 Bytes,64Bytes也就是16個int值,這就是CPU從內存中存取數據的最小單位。
N-Way關聯
因為Cache的大小遠遠小於內存,所以,需要有一種地址關聯的算法,能夠讓內存中的數據可以被映射到Cache中來。而且這種關聯算法還需要能夠高效的查找cache是否存在某個對象。
N-Way 關聯,就是把連續的N個Cache Line綁成一組,例如L1 Cache有32KB,那就包含32KB/64B = 512條Cache Line,如果N=8,表示有8路,那么每路包含512/8=64 條Line。
如下圖,列表示Way(共8列),行表示Cache Line(共64行),為了方便索引內存地址,
- Tag:每條 Cache Line 前都會有一個獨立分配的 24 bits來存的 tag,其就是內存地址的前24bits(定位列)
- Index:內存地址后續的6個bits則是在這一Way的是Cache Line 索引,2^6 = 64 剛好可以索引64條Cache Line(定位行)
- Offset:再往后的6bits用於表示在Cache Line 里的偏移量,2^6=64 剛好索引Line的64字節(定位Cache line內的起始位置)

當拿到一個內存地址的時候,先拿出中間的 6bits 來,找到對應的index,然后,在這一個8組的cache line中,再進行O(n) n=8 的遍歷,主是要匹配前24bits的tag。如果匹配中了,就算命中,如果沒有匹配到,那就是cache miss,如果是讀操作,就需要進向后面的緩存進行訪問了。L2/L3同樣是這樣的算法。而淘汰算法有兩種,一種是隨機一種是LRU。

也就是說,當CPU要訪問一個內存的時候,先通過這個內存地址中間的6bits 定位是哪個index(行),通過前 24bits 定位相應的Way(列),這樣就匹配一條Cache Line。
與HashTable類似,可以把index看做hash地址,Way看做hash沖突的掛鏈表方式,也就是這是一個長度為64的Hash表,同一個hash地址的單鏈表最長為8。
此外,當有數據沒有命中緩存的時候,CPU就會以最小為Cache Line的單元向內存更新數據。當然,CPU並不一定只是更新64Bytes,因為訪問主存實在是太慢了,所以,一般都會多更新一些。好的CPU會有一些預測的技術,如果找到一種pattern的話,就會預先加載更多的內存,包括指令也可以預加載,這叫 Prefetching 技術,參考例子。
緩存更新
read/write through
- Read Through 套路就是在查詢操作中更新緩存,也就是說,當緩存失效的時候(過期或LRU換出),則由緩存服務自己來加載,對應用方是透明的;
- Write Through 套路和Read Through相仿,不過是在更新數據時發生。當有數據更新的時候,如果沒有命中緩存,直接更新backend(如數據庫),然后返回。如果命中了緩存,則更新緩存,然后再由Cache自己更新數據庫(這是一個同步操作)。

PS:這里的Cache如果指 L1,那么lower memory就是指L2,如果Cache是L2,那么lower memory就是L3或者RAM。
write back
Write Back在更新數據的時候,只更新緩存,不更新backend,而我們的緩存會異步地批量更新backend。因為異步,write back還可以合並對同一個數據的多次操作,所以性能的提高是相當可觀的。但是,其帶來的問題是,數據不是強一致性的,而且可能會丟失。
另外,Write Back實現邏輯比較復雜,因為他需要track有哪數據是被更新了的,需要刷到持久層上(lazy write)。

一般來說,主流的CPU(如:Intel Core i7/i9)采用的是Write Back的策略,因為直接寫內存實在是太慢了。
好了,現在問題來了,如果有一個數據 x 在 CPU 第0核的緩存上被更新了,那么其它CPU核上對於這個數據 x 的值也要被更新,這就是緩存一致性的問題。(當然,對於我們上層的程序我們不用關心CPU多個核的緩存是怎么同步的,這對上層的代碼來說都是透明的)。
緩存一致性
一般來說,在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
先從最簡單的Cache一致性協議MESI開始,其主要表示緩存數據(Cache Line)有四個狀態:
- Modified ,被所屬的處理器修改了,cache line變為dirty。如果一個緩存行處於已修改狀態,那么它在其他處理器緩存中的拷貝馬上會變成invaliid狀態,此外,已修改緩存行如果被丟棄或標記為失效,那么先要把它的內容回寫到內存中。
- Exclusive ,
- Shared,
- Invalid,
下圖展示了不同狀態的轉化機制,看起來也比較復雜

舉個例子,CPU0從RAM讀一個變量x到其cache中,此時該變量對其他cpu不可見,如果其他cpu也需要讀該變量呢?大概流程如下:
| 當前操作 | CPU0 | CPU1 | Memory | 說明 |
|---|---|---|---|---|
| 1) CPU0 read(x) | x=1 (E) | x=1 | 只有一個CPU有 x 變量, 所以,狀態是 Exclusive |
|
| 2) CPU1 read(x) | x=1 (S) | x=1(S) | x=1 | 有兩個CPU都讀取 x 變量, 所以狀態變成 Shared |
| 3) CPU0 write(x,9) | x=9 (M) | x=1(I) | x=1 | 變量改變,在CPU0中狀態 變成 Modified,在CPU1中 狀態變成 Invalid |
| 4) 變量 x 寫回內存 | x=9 (M) | X=1(I) | x=9 | 目前的狀態不變 |
| 5) CPU1 read(x) | x=9 (S) | x=9(S) | x=9 | 變量同步到所有的Cache中, 狀態回到Shared |
在第3步,CPU0修改了變量x,由於采用write back方式,此時RAM里面的x可能還是舊值,但會標記x是dirty,而通過MESI協議需要將其他CPU對該變量的狀態置為invalid,當其他CPU需要讀變量x時,發現該變量為invalid,那就要重新從RAM里加載到Cache。
MOESI
如上例,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核心共享的)。
最后看個例子,對於一個二維數組,分別按行優先、列優先順序去對數組賦值,
#include <stdlib.h> #include <stdio.h> #include <sys/time.h> #define row 128 #define column 4096 typedef int(*parr)[column]; void test1() { parr arr = (parr)malloc(row * column * sizeof(int)); for (int i = 0; i < row; i++) // row priority for (int j = 0; j < column; j++) arr[i][j] = 1; free(arr); } void test2() { parr arr = (parr)malloc(row * column * sizeof(int)); for (int i = 0; i < column; i++) // column priority for (int j = 0; j < row; j++) arr[j][i] = 1; free(arr); } typedef void (*fn)(); void tooktime(fn f, char* desc) { struct timeval t = {0, 0}; gettimeofday(&t, 0); long begin = t.tv_sec * 1000 + t.tv_usec / 1000; f(); gettimeofday(&t, 0); long end = t.tv_sec * 1000 + t.tv_usec / 1000; printf("[%s]=%d\n", desc, (int)(end - begin)); } int main() { tooktime(test1, "row priority"); tooktime(test2, "column priority"); }
結果就是行優先的效率更高,這就是因為數組本身是以行優先順序存儲的,首次存取arr[0][0],下次存取arr[0][1],它們在同一個cache line里面,加載一次即可;
而按列優先的方式,這次存取arr[0][0],下次存取arr[1][0],中間隔了4096*4 Bytes, 可能會導致cache line的重新加載。
參考:
https://coolshell.cn/articles/20793.html
https://zhuanlan.zhihu.com/p/102293437
