我的大多數讀者都知道快取是一種快速、小型、儲存最近已訪問的記憶體的地方。這個描述相當準確,但是深入處理器快取如何工作的“枯燥”細節,會對嘗試理解程式效能有很大幫助。
在這篇博文中,我將通過示例程式碼來說明快取是如何工作的,以及它對現實世界中程式效能的影響。
雖然例子用的是 C#,但是不論哪種程式語言,對效能資料和最終結論的影響很小。
例1:記憶體訪問和效能
你預計執行 迴圈2 比 迴圈1 快多少?
1 2 3 4 5 6 7 8 9 |
int[] arr = new int[64 * 1024 * 1024]; // 迴圈1 for (int i = 0; i < arr.Length; i++) arr[i] *= 3; // 迴圈2 for (int i = 0; i < arr.Length; i += 16) arr[i] *= 3; |
第一個迴圈對陣列中的每個元素都乘以 3,而第二個迴圈對每隔 16 個元素的資料乘以 3。第二個迴圈只做了第一個迴圈的大約6%的計算量,但是在現代計算機上,這兩個 for 迴圈執行的時間差不多相等:我電腦上分別是 80 和 78 毫秒。
這兩個迴圈耗費相同時間的原因與記憶體有關。這些迴圈的執行時間主要由訪問陣列記憶體來決定,而不是整數乘法。並且我在例2中將解釋,硬體對這兩個迴圈執行相同的主儲存器訪問。
例2:快取行(cache lines)的影響
(校對注:什麼是 cache lines ?在記憶體和快取直接傳輸的資料是大小固定的成塊資料,稱為 cache lines 。)
我們來深入地研究一下這個例子。我們嘗試1和16之外的其他步長:
1 2 |
for (int i = 0; i < arr.Length; i += K) arr[i] *= 3; |
下面是這個迴圈執行不同步長(K)所花費的時間:
注意步長在1到16的範圍內時,迴圈的執行時間幾乎不變。但是從16開始,步長每增加一倍,其執行時間也減少一半。
其背後的原因是,如今的CPU並不是逐個位元組地訪問記憶體。相反,它以(典型的)64位元組的塊為單位取記憶體,稱作快取行(cache lines)。當你讀取一個特定的記憶體地址時,整個快取行都被從主記憶體取到快取中。並且,此時讀取同一個快取行中的其他數值非常快!
因為16個整數佔用了64位元組(一個快取行),因此步長從1到16的for迴圈都必須訪問相同數量的快取行:即陣列中的所有快取行。但是如果步長是32,我們只需要訪問約一半的快取行;步長是64時,只有四分之一。
理解快取行對特定型別的程式優化非常重要。例如,資料對齊可能會決定一個操作訪問一個還是兩個快取行。如我們上面例子中看到的,它意味著在不對齊的情形下,操作將慢一倍。
例3:一級快取(L1)和二級快取(L2)的大小
如今的計算機都有兩級或者三級快取,通常叫做L1,L2以及L3。如果你想知道不同快取的大小,你可以使用SysInternals的CoreInfo工具,或者呼叫GetLogicalProcessorInfo Windows API。兩個方法都會告訴你各級快取的大小,以及快取行的大小。
在我電腦上,CoreInfo報告我有一個32KB的L1資料快取,一個32KB的L1指令快取,和一個4MB的L2資料快取。L1快取是每個核心獨享的,而每個L2快取在兩個核心間共享:
1 2 3 4 5 6 7 8 9 10 11 |
Logical Processor to Cache Map: 邏輯處理器與快取對應圖 *--- Data Cache 0, Level 1, 32 KB, Assoc 8, LineSize 64 *--- Instruction Cache 0, Level 1, 32 KB, Assoc 8, LineSize 64 -*-- Data Cache 1, Level 1, 32 KB, Assoc 8, LineSize 64 -*-- Instruction Cache 1, Level 1, 32 KB, Assoc 8, LineSize 64 **-- Unified Cache 0, Level 2, 4 MB, Assoc 16, LineSize 64 --*- Data Cache 2, Level 1, 32 KB, Assoc 8, LineSize 64 --*- Instruction Cache 2, Level 1, 32 KB, Assoc 8, LineSize 64 ---* Data Cache 3, Level 1, 32 KB, Assoc 8, LineSize 64 ---* Instruction Cache 3, Level 1, 32 KB, Assoc 8, LineSize 64 --** Unified Cache 1, Level 2, 4 MB, Assoc 16, LineSize 64 |
讓我們通過實驗來核實一下。要做到這一點,我們將以16個整數為步長遍歷一個陣列——這是修改每一個快取行的一個簡單方法。當我們遍歷到最後一個值時,再回到開始向後遍歷。我們將實驗不同的陣列長度,並且我們應該看到,每當陣列長度超過一個快取級別時,效能會隨著降低。
下面是程式:
1 2 3 4 5 6 |
int steps = 64 * 1024 * 1024; // Arbitrary number of steps int lengthMod = arr.Length - 1; for (int i = 0; i < steps; i++) { arr[(i * 16) & lengthMod]++; // (x & lengthMod) is equal to (x % arr.Length) } |
下面是時間計時:
你可以看到在 32KB 和 4MB 後有明顯的下降——這正是我電腦上L1和L2快取的大小。
例4:指令級並行
現在,讓我們看一些不一樣的東西。
下面這兩個迴圈中,你認為哪個會更快一些?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
int steps = 256 * 1024 * 1024; int[] a = new int[2]; // 迴圈1 for (int i=0; i < steps; i++) { a[0]++; a[0]++; } // 迴圈2 for (int i=0; i < steps; i++) { a[0]++; a[1]++; } |
結果是,至少在我測試過的所有電腦上,第二個迴圈都比第一個迴圈快一倍。為什麼呢?這與兩個迴圈主體中指令間的依賴關係有關。
在第一個迴圈主體中,指令間的依賴關係如下:
但是第二個迴圈中,依賴關係是這樣的:
現代處理器包含多個有並行機制的部件:它能同時讀取L1的兩個記憶體地址,或者同時執行兩條簡單的算數指令。在第一個迴圈內,處理器不能施展這種指令級並行;但是第二個迴圈中可以。
[更新]:reddit上很多人問編譯器優化的事情,以及是否能夠把 { a[0]++; a[0]++; } 優化成 { a[0]+=2; }。事實上,在涉及陣列訪問時,C# 編譯器和 CLR JIT 不會做這個優化。我在 release 模式下(即包含優化選項)編譯了所有的例子,並在JIT之後的程式碼中檢查是否有這個優化,但是沒有發現。
例5:快取相關性
快取設計的一個重要決策是,主存的每個塊是否能夠放入任何一個快取槽,或某幾個快取槽中的一個。
(譯者注:這裡一個快取槽和前面的快取行相同;按照槽的大小,把主存分成若干塊,以塊為單位與快取槽對映。下文提到的塊索引chunk index等於主存大小除以槽大小)。
把快取槽對映到記憶體塊,有 3 種可選方案:
1. 直接對映快取(Direct mapped cache)
每個記憶體塊只能儲存到一個快取槽。一個簡單方案是通過塊索引把記憶體塊對映到快取槽(塊索引 % 快取槽數量(即取餘數操作))。對映到同一個槽的記憶體塊不能同時儲存在快取中。
2. N路關聯快取(N-way set associative cache)
每個記憶體塊對映到N個特定快取槽的任意一個槽。例如一個16路快取,任何一個記憶體塊能夠被對映到16個不同的快取槽。通常,具有相同低bit位地址的記憶體塊共享相同的16個槽。
3. 完全關聯快取(Fully associative cache)
每個記憶體塊可以被對映到任意一個快取槽(cache slot)。事實上,快取操作和雜湊表很像。
直接對映會遭遇衝突的問題——當多個塊同時競爭快取的同一個槽時,它們不停地將對方踢出快取,這將降低命中率。另一方面,完全關聯過於複雜,很難在硬體層面實現。N路關聯是典型的處理器快取設計方案,因為它在實現難度和提高命中率之間做了良好的折衷。
例如,我電腦上的4M L2 快取採用 16 路關聯的方案。所有的64位元組大小的記憶體塊被分配到集合中(基於塊索引的低位元組),同一個集合中的塊競爭使用 L2 快取的16個槽。
由於 L2 快取有65536 個槽,而每個集合需要16個槽,因此我們有4096個集合。由此,塊索引的低12位元能夠確定這個塊所在的集合(2^12 = 4096)。進而可以計算出,相差262144位元組倍數的地址(4096*64)會競爭同一個槽。
為了使快取相關性的影響表現出來,我需要重複地訪問同一個集合中的超過16個塊(譯者注:這樣16個快取槽容納不下就會出現競爭)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public static long UpdateEveryKthByte(byte[] arr, int K) { Stopwatch sw = Stopwatch.StartNew(); const int rep = 1024*1024; // Number of iterations – arbitrary int p = 0; for (int i = 0; i < rep; i++) { arr[p]++; p += K; if (p >= arr.Length) p = 0; } sw.Stop(); return sw.ElapsedMilliseconds; } |
這個方法對陣列中每隔K個元素做遞增操作。當達到陣列尾部時,再從頭開始。執行足夠多次後(2^20次),迴圈結束。
我使用不同尺寸的陣列(每次遞增1MB大小),和不同的步長K,來執行UpdateEveryKthByte()。下面的圖呈現了結果,顏色越綠表示執行時間越長,顏色越白表示執行時間越短。
藍色區域(執行時間較長)部分表示當我們重複更新陣列值時,這些值不能同時儲存在緩衝中。比較亮的區域對應的執行時間約是80毫秒,接近白色的區域對飲執行時間約是10毫秒。
讓我來解釋一下圖中的藍色部分:
1. 為什麼會出現豎直線?
豎直線對應的這些步長,在一次迴圈中訪問到的值跨越了同一個集合中的多個記憶體塊(大於16個)。對於這些步長訪問到的值,我電腦上的16路關聯快取不能同時儲存這些值。
一些糟糕的步長是2的冪次方:256和512。例如當陣列是8MB,步長是512時。8MB的快取行包含地址互相間隔262144位元組倍數的32個值。由於512能夠整除262144,因此在一次迴圈內,這32個值都會被訪問到。
由於32大於16,因此這32個值將一直競爭快取中相同的16個槽。
而一些不是2冪次方的值則是因為不夠幸運,它們剛好訪問到了同一個集合內的很多值。這些步長同樣會顯示成藍色線。
2. 為什麼藍色線在4MB位置結束了呢?
當陣列長度為4MB或者更小時,16路關聯快取的表現和完全關聯快取相同。
16路關聯快取最多可以儲存以262144位元組長度分割的16個快取行。在4MB中,由於16 * 262144 = 4194304 = 4MB,因此不會出現第17個或者更多個集合。
3. 為什麼藍色的三角形位於左上角?
在三角形的區域,我們不能把所需的資料同時放入快取——與快取相關性無關,而與L2快取大小有關係。
舉個陣列長度為16MB、步長為128時的例子。我們重複地每隔128個位元組更新陣列中的值,即每次跨越了一個64位元組的記憶體塊。對於16MB的陣列,每隔一個塊儲存到快取,這樣我們需要8MB大小的快取。但是,我機器的快取只有4MB。
即使我電腦上的4MB快取使用完全關聯的方式,它仍然無法容納8MB的資料。
4. 為什麼三角形最左側顏色變淡了呢?
注意變淡部分是從0開始,到64結束——正好是一個快取行!正如例1和例2中解釋的,訪問同一個快取行內的其他資料非常快。例如,當步長為16時,需要4步到達下一個快取行。因此,這4次記憶體訪問的代價和1次訪問差不多。
由於對於所有用例,步數是相同,因此步數越少,執行時間越短。
當擴充套件這個圖時,規律是一樣的:
快取相關性非常有趣,並且容易被證實,但是與本文中討論的其他問題相比,它並不是一個很大的問題。當你編寫程式時,它不應該是你首先要考慮的問題。
例6:快取行共享假象
在多核機器上,快取遇到了另一個問題——一致性。不同的核有完全獨立或者部分獨立的快取。在我的電腦上,L1快取是獨立的(這很常見);有兩組處理器,每組處理器共享一個L2快取。具體來說,現代多核機器擁有多層次的快取機制,其中更快和更小的快取屬於獨立的處理器。
當一個處理器在它的快取中修改一個值時,其他的處理器不能再使用舊的值了。在所有的快取中,這個記憶體地址將變成無效地址。另外,由於快取的粒度是快取行,而不是單獨的位元組,因此在所有快取中的整個快取行都變成無效!
為了演示這個問題,考慮下面的例子:
1 2 3 4 5 6 7 8 |
private static int[] s_counter = new int[1024]; private void UpdateCounter(int position) { for (int j = 0; j < 100000000; j++) { s_counter[position] = s_counter[position] + 3; } } |
在我的4核機器上,如果我在4個執行緒中呼叫UpdateCounter,引數分別是0、1、2、3,所有執行緒執行結束後花費的時間是4.3秒。
另一方面,如果我分別使用16、32、48、64的引數呼叫UpdateCounter,只花費了0.28秒!
為什麼呢?在第一種情形下,所有的4個資料很可能位於同一個快取行。核心每遞增一個數值,它就使包含這4個值的那個快取行無效。其他所有核心訪問這個數值時,就會出現快取未命中的情況。執行緒的這種行為使快取失去了效果,消弱了程式的效能。
例7:硬體複雜性
即使你瞭解快取工作的基本知識,但有時候硬體仍然會讓你驚訝。在優化措施、啟發式排程以及工作的細節上,不同的處理器存在差異。
在一些處理器上,當兩次訪問操作分別訪問不同的記憶體體(Memory Bank)時,L1快取能夠並行執行這兩次訪問;而如果訪問相同的記憶體體,則會序列執行。同樣的,處理器的高階優化也會使你吃驚。例如,我過去在多臺電腦上執行過的“快取行共享假象”例子,在我家裡的電腦上需要微調程式碼才能得到期望的結果——對於一些簡單的情況電腦能夠優化執行,以減少快取失效。
下面是一個表明“硬體離奇性”的例子:
1 2 3 4 5 6 7 8 |
private static int A, B, C, D, E, F, G; private static void Weirdness() { for (int i = 0; i < 200000000; i++) { <something> } } |
當我分別使用下面的三段不同程式碼替換“”時,我得到下面的執行時間:
1 2 3 4 |
<something> Time A++; B++; C++; D++; 719 ms A++; C++; E++; G++; 448 ms A++; C++; 518 ms |
對A、B、C、D的遞增操作時間要比遞增A、C、E、G的時間長。更離奇的是,只遞增A和C使用了比遞增A、C、E、G更長的時間!
我並不清楚這些時間數字背後的原因,但是我猜測它與記憶體體(Memory Bank)有關。如果有人能夠解釋它的原因,我將非常願意傾聽。
這個例子告訴我們,很難完全地預測硬體效能。你確實可以預測很多方面,但是最後,你需要測試並驗證你的預測結果,這非常重要。
結論
真心希望本文能夠幫助你理解快取工作的細節,並在你的程式中應用這些知識。
校對注:維基百科上的“ CPU 快取”詞條,非常詳細,可溫習。