原子操作與內存屏障之一——CPU緩存


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 的存取速度:
  • L2 的存取速度: 
  • L3 的存取速度:
  • RAM內存的存取速度

我們可以看到,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)有四個狀態:

  1. Modified ,被所屬的處理器修改了,cache line變為dirty。如果一個緩存行處於已修改狀態,那么它在其他處理器緩存中的拷貝馬上會變成invaliid狀態,此外,已修改緩存行如果被丟棄或標記為失效,那么先要把它的內容回寫到內存中。
  2. Exclusive ,
  3. Shared,
  4. 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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM