这篇 CPU Cache,估计要消化一下
點擊上方“朱小廝的博客”,選擇“設為星標”
后臺回復"書",獲取
后臺回復“k8s”,可領取k8s資料
無論你寫什么樣的代碼都會交給 CPU 來執行,所以,如果你想寫出性能比較高的代碼,這篇文章中提到的技術還是值得認真學習的。另外,千萬別覺得這些東西沒用,這些東西非常有用,十多年前就是這些知識在性能調優上幫了我的很多大忙,從而跟很多人拉開了差距……
基礎知識
首先,我們都知道現在的 CPU 多核技術,都會有幾級緩存,老的 CPU 會有兩級內存(L1 和 L2),新的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(我的蘋果電腦是 Intel Core i9-8950HK,和Core i7-8700K 的Cache大小一樣)。
我們的數據就從內存向上,先到 L3,再到 L2,再到 L1,最后到寄存器進行 CPU 計算。為什么會設計成三層?這里有下面幾個方面的考慮:
一個方面是物理速度,如果要更大的容量就需要更多的晶體管,除了芯片的體積會變大,更重要的是大量的晶體管會導致速度下降,因為訪問速度和要訪問的晶體管所在的位置成反比,也就是當信號路徑變長時,通信速度會變慢。這部分是物理問題。
另外一個問題是,多核技術中,數據的狀態需要在多個CPU中進行同步,并且,我們可以看到,cache 和RAM 的速度差距太大,所以,多級不同尺寸的緩存有利于提高整體的性能。
這個世界永遠是平衡的,一面變得有多光鮮,另一面也會變得有多黑暗。建立這么多級的緩存,一定就會引入其它的問題,這里有兩個比較重要的問題,
一個是比較簡單的緩存的命中率的問題。
另一個是比較復雜的緩存更新的一致性問題。
尤其是第二個問題,在多核技術下,這就很像分布式的系統了,要對多個地方進行更新。
緩存的命中
在說明這兩個問題之前。我們需要要解一個術語 Cache Line。緩存基本上來說就是把后面的數據加載到離自己近的地方,對于 CPU 來說,它是不會一個字節一個字節的加載的,因為這非常沒有效率,一般來說都是要一塊一塊的加載的,對于這樣的一塊一塊的數據單位,術語叫 Cache Line,
一般來說,一個主流的 CPU 的 Cache Line 是 64 Bytes(也有的CPU用32Bytes和128Bytes),64 Bytes也就是 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 里的偏移量
如下圖所示:(圖片來自《Cache: a place for concealment and safekeeping》)
當拿到一個內存地址的時候,先拿出中間的 6bits 來,找到是哪組。
然后,在這一個 8 組的 cache line 中,再進行 O(n) n=8 的遍歷,主是要匹配前 24bits 的 tag。如果匹配中了,就算命中,如果沒有匹配到,那就是 cache miss,如果是讀操作,就需要進向后面的緩存進行訪問了。
L2/L3 同樣是這樣的算法。而淘汰算法有兩種,一種是隨機一種是 LRU。現在一般都是以 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 技術 (參看,Wikipedia 的 Cache Prefetching 和 紐約州立大學的 Memory 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 多個核的緩存是怎么同步的,這對上層的代碼來說都是透明的)
一般來說,在 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 Interactive Animations),你可以進行交互操作,這個動畫演示中使用的 Write Through 算法):
| 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 |
MESI 這種協議在數據更新后,會標記其它共享的 CPU 緩存的數據拷貝為 Invalid 狀態,然后當其它 CPU 再次read 的時候,就會出現 cache miss 的問題,此時再從內存中更新數據。從內存中更新數據意味著 20 倍速度的降低。
我們能不能直接從我隔壁的 CPU 緩存中更新?是的,這就可以增加很多速度了,但是狀態控制也就變麻煩了。還需要多來一個狀態:Owner(宿主),用于標記,我是更新數據的源。于是,出現了 MOESI 協議
MOESI 協議的狀態機和演示示例我就不貼了(有興趣可以上Berkeley上看看相關的課件),我們只需要理解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 核心共享的)。(相關的比較可以參看StackOverlow上這個問題的答案)
程序性能
了解了我們上面的這些東西后,我們來看一下對于程序的影響。
示例一
首先,假設我們有一個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倍的。但實際跑下來并不是,在我的機器上,第一個循環需要 127 毫秒,第二個循環則需要 121 毫秒,相差無幾。
這里最主要的原因就是 Cache Line,因為 CPU 會以一個 Cache Line 64Bytes 最小時單位加載,也就是 16 個 32bits 的整型,所以,無論你步長是 2 還是 8,都差不多。而后面的乘法其實是不耗 CPU 時間的。
示例二
我們再來看一個與緩存命中率有關的代碼,我們以一定的步長increment 來訪問一個連續的數組。
for?(int?i?=?0;?i?<?10000000;?i++)?{for?(int?j?=?0;?j?<?size;?j?+=?increment)?{memory[j]?+=?j;}}我們測試一下,在下表中, 表頭是步長,也就是每次跳多少個整數,而縱向是這個數組可以跳幾次(你可以理解為要幾條 Cache Line),于是表中的任何一項代表了這個數組有多少,而且步長是多少。
比如:橫軸是 512,縱軸是4,意思是,這個數組有 4*512 = 2048 個長度,訪問時按512步長訪問,也就是訪問其中的這幾項:[0, 512, 1024, 1536] 這四項。
表中同的項是,是循環 1000 萬次的時間,單位是“微秒”(除以1000后是毫秒)
|?count?|???1????|???16??|??512??|?1024??|------------------------------------------|?????1?|??17539?|?16726?|?15143?|?14477?||?????2?|??15420?|?14648?|?13552?|?13343?||?????3?|??14716?|?14463?|?15086?|?17509?||?????4?|??18976?|?18829?|?18961?|?21645?||?????5?|??23693?|?23436?|?74349?|?29796?||?????6?|??23264?|?23707?|?27005?|?44103?||?????7?|??28574?|?28979?|?33169?|?58759?||?????8?|??33155?|?34405?|?39339?|?65182?||?????9?|??37088?|?37788?|?49863?|156745?||????10?|??41543?|?42103?|?58533?|215278?||????11?|??47638?|?50329?|?66620?|335603?||????12?|??49759?|?51228?|?75087?|305075?||????13?|??53938?|?53924?|?77790?|366879?||????14?|??58422?|?59565?|?90501?|466368?||????15?|??62161?|?64129?|?90814?|525780?||????16?|??67061?|?66663?|?98734?|440558?||????17?|??71132?|?69753?|171203?|506631?||????18?|??74102?|?73130?|293947?|550920?|我們可以看到,從 [9,1024] 以后,時間顯著上升。包括 [17,512] 和 [18,512] 也顯著上升。這是因為,我機器的 L1 Cache 是 32KB, 8 Way 的,前面說過,8 Way 的有 64 組,每組 8 個 Cache Line,當 for-loop步長超過 1024 個整型,也就是正好 4096 Bytes 時,也就是導致內存地址的變化是變化在高位的 24bits 上,
而低位的1 2bits 變化不大,尤其是中間6bits沒有變化,導致全部命中同一組 set,導致大量的 cache 沖突,導致性能下降,時間上升。而 [16, 512]也是一樣的,其中的幾步開始導致L1 Cache開始沖突失效。
示例三
接下來,我們再來看個示例。下面是一個二維數組的兩種遍歷方式,一個逐行遍歷,一個是逐列遍歷,這兩種方式在理論上來說,尋址和計算量都是一樣的,執行時間應該也是一樣的。
const?int?row?=?1024;const?int?col?=?512int?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.081ms
逐列遍歷:1.069ms
執行時間有十幾倍的差距。其中的原因,就是逐列遍歷對于 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] :560ms
對于 p[0] 和 p[30]:104ms
這是因為 p[0] 和 p[1] 在同一條 Cache Line 上,而 p[0] 和 p[30] 則不可能在同一條Cache Line 上 ,CPU 的緩存最小的更新單位是 Cache Line,所以,這導致雖然兩個線程在寫不同的數據,但是因為這兩個數據在同一條 Cache Line 上,就會導致緩存需要不斷進在兩個 CPU 的 L1/L2 中進行同步,從而導致了 5 倍的時間差異。
示例五
接下來,我們再來看一下另外一段代碼:我們想統計一下一個數組中的奇數個數,但是這個數組太大了,我們希望可以用多線程來完成這個統計。下面的代碼中,我們為每一個線程傳入一個 id ,然后通過這個 id 來完成對應數組段的統計任務。這樣可以加快整個處理速度。
int?total_size?=?16?*?1024?*?1024;?//數組長度int*?test_data?=?new?test_data[total_size];?//數組int?nthread?=?6;?//線程數(因為我的機器是6核的)int?result[nthread];?//收集結果的數組void?thread_func?(int?id)?{result[id]?=?0;int?chunk_size?=?total_size?/?nthread?+?1;int?start?=?id?*?chunk_size;int?end?=?min(start?+?chunk_size,?total_size);for?(?int?i?=?start;?i?<?end;?++i?)?{if?(test_data[i]?%?2?!=?0?)?++result[id];}}然而,在執行過程中,你會發現,6 個線程居然跑不過 1 個線程。因為根據上面的例子你知道 result[] 這個數組中的數據在一個 Cache Line 中,所以,所有的線程都會對這個 Cache Line 進行寫操作,導致所有的線程都在不斷地重新同步 result[] 所在的 Cache Line,所以,導致 6 個線程還跑不過一個線程的結果。這叫 False Sharing。
優化也很簡單,使用一個線程內的變量。
void?thread_func?(int?id)?{result[id]?=?0;int?chunk_size?=?total_size?/?nthread?+?1;int?start?=?id?*?chunk_size;int?end?=?min(start?+?chunk_size,?total_size);int?c?=?0;?//使用臨時變量,沒有cache?line的同步了for?(?int?i?=?start;?i?<?end;?++i?)?{if?(test_data[i]?%?2?!=?0?)?++c;}result[id]?=?c;}我們把兩個程序分別在 1 到 32 個線程上跑一下,得出的結果畫一張圖如下所示(橫軸是線程數,縱軸是完成統的時間,單位是微秒):
上圖中,我們可以看到,灰色的曲線就是第一種方法,橙色的就是第二種(用局部變量的)方法。當只有一個線程的時候,兩個方法相當,基本沒有什么差別,但是在線程數增加的時候的時候,你會發現,第二種方法的性能提高的非常快。直到到達 6 個線程的時候,開始變得穩定(前面說過,我的 CPU 是6核的)。
而第一種方法無論加多少線程也沒有辦法超過第二種方法。因為第一種方法不是 CPU Cache 友好的。也就是說,第二種方法,只要我的 CPU 核數足夠多,就可以做到線性的性能擴展,讓每一個 CPU 核都跑起來,而第一種則不能。
轉自:程序員cxuan
https://mp.weixin.qq.com/s/s9w--YRkyAvQi4LQcenq4g
想知道更多?掃描下面的二維碼關注我后臺回復"技術",加入技術群 后臺回復“k8s”,可領取k8s資料【精彩推薦】ClickHouse到底是什么?為什么如此牛逼!
原來ElasticSearch還可以這么理解
面試官:InnoDB中一棵B+樹可以存放多少行數據?
架構之道:分離業務邏輯和技術細節
星巴克不使用兩階段提交
面試官:Redis新版本開始引入多線程,談談你的看法?
喜馬拉雅自研網關架構演進過程
收藏:存儲知識全面總結
微博千萬級規模高性能高并發的網絡架構設計
總結
以上是生活随笔為你收集整理的这篇 CPU Cache,估计要消化一下的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 蓝绿发布、滚动发布、灰度发布,有什么区别
- 下一篇: 为什么我建议你现在学Vue3?