七個例子幫你更好地理解 CPU 快取

Sheng Gordon發表於2015-08-21

我的大多數讀者都知道快取是一種快速、小型、儲存最近已訪問的記憶體的地方。這個描述相當準確,但是深入處理器快取如何工作的“枯燥”細節,會對嘗試理解程式效能有很大幫助。

在這篇博文中,我將通過示例程式碼來說明快取是如何工作的,以及它對現實世界中程式效能的影響。

雖然例子用的是 C#,但是不論哪種程式語言,對效能資料和最終結論的影響很小。

例1:記憶體訪問和效能

你預計執行 迴圈2 比 迴圈1 快多少?

第一個迴圈對陣列中的每個元素都乘以 3,而第二個迴圈對每隔 16 個元素的資料乘以 3。第二個迴圈只做了第一個迴圈的大約6%的計算量,但是在現代計算機上,這兩個 for 迴圈執行的時間差不多相等:我電腦上分別是 80 和 78 毫秒。

這兩個迴圈耗費相同時間的原因與記憶體有關。這些迴圈的執行時間主要由訪問陣列記憶體來決定,而不是整數乘法。並且我在例2中將解釋,硬體對這兩個迴圈執行相同的主儲存器訪問。

例2:快取行(cache lines)的影響

(校對注:什麼是 cache lines ?在記憶體和快取直接傳輸的資料是大小固定的成塊資料,稱為 cache lines 。)

我們來深入地研究一下這個例子。我們嘗試1和16之外的其他步長:

下面是這個迴圈執行不同步長(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快取在兩個核心間共享:

讓我們通過實驗來核實一下。要做到這一點,我們將以16個整數為步長遍歷一個陣列——這是修改每一個快取行的一個簡單方法。當我們遍歷到最後一個值時,再回到開始向後遍歷。我們將實驗不同的陣列長度,並且我們應該看到,每當陣列長度超過一個快取級別時,效能會隨著降低。

下面是程式:

下面是時間計時:

你可以看到在 32KB 和 4MB 後有明顯的下降——這正是我電腦上L1和L2快取的大小。

例4:指令級並行

現在,讓我們看一些不一樣的東西。

下面這兩個迴圈中,你認為哪個會更快一些?

結果是,至少在我測試過的所有電腦上,第二個迴圈都比第一個迴圈快一倍。為什麼呢?這與兩個迴圈主體中指令間的依賴關係有關。

在第一個迴圈主體中,指令間的依賴關係如下:

但是第二個迴圈中,依賴關係是這樣的:

現代處理器包含多個有並行機制的部件:它能同時讀取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個快取槽容納不下就會出現競爭)

這個方法對陣列中每隔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快取。具體來說,現代多核機器擁有多層次的快取機制,其中更快和更小的快取屬於獨立的處理器。

當一個處理器在它的快取中修改一個值時,其他的處理器不能再使用舊的值了。在所有的快取中,這個記憶體地址將變成無效地址。另外,由於快取的粒度是快取行,而不是單獨的位元組,因此在所有快取中的整個快取行都變成無效!

為了演示這個問題,考慮下面的例子:

在我的4核機器上,如果我在4個執行緒中呼叫UpdateCounter,引數分別是0、1、2、3,所有執行緒執行結束後花費的時間是4.3秒。

另一方面,如果我分別使用16、32、48、64的引數呼叫UpdateCounter,只花費了0.28秒!

為什麼呢?在第一種情形下,所有的4個資料很可能位於同一個快取行。核心每遞增一個數值,它就使包含這4個值的那個快取行無效。其他所有核心訪問這個數值時,就會出現快取未命中的情況。執行緒的這種行為使快取失去了效果,消弱了程式的效能。

例7:硬體複雜性

即使你瞭解快取工作的基本知識,但有時候硬體仍然會讓你驚訝。在優化措施、啟發式排程以及工作的細節上,不同的處理器存在差異。

在一些處理器上,當兩次訪問操作分別訪問不同的記憶體體(Memory Bank)時,L1快取能夠並行執行這兩次訪問;而如果訪問相同的記憶體體,則會序列執行。同樣的,處理器的高階優化也會使你吃驚。例如,我過去在多臺電腦上執行過的“快取行共享假象”例子,在我家裡的電腦上需要微調程式碼才能得到期望的結果——對於一些簡單的情況電腦能夠優化執行,以減少快取失效。

下面是一個表明“硬體離奇性”的例子:

當我分別使用下面的三段不同程式碼替換“”時,我得到下面的執行時間:

對A、B、C、D的遞增操作時間要比遞增A、C、E、G的時間長。更離奇的是,只遞增A和C使用了比遞增A、C、E、G更長的時間!

我並不清楚這些時間數字背後的原因,但是我猜測它與記憶體體(Memory Bank)有關。如果有人能夠解釋它的原因,我將非常願意傾聽。

這個例子告訴我們,很難完全地預測硬體效能。你確實可以預測很多方面,但是最後,你需要測試並驗證你的預測結果,這非常重要。

結論

真心希望本文能夠幫助你理解快取工作的細節,並在你的程式中應用這些知識。

校對注:維基百科上的“ CPU 快取”詞條,非常詳細,可溫習。

相關文章