處理器快取影響大觀園

oschina發表於2013-08-28

  大多數讀者都明白快取是一個用於儲存最近訪問的記憶體位置的高速小容量儲存器。這個描述是相當準確的,但是處理器快取如何工作的“無聊”細節可以更好的幫助我們瞭解程式的效能。

  在這篇部落格中,我會用程式碼示例來說明快取工作的各個方面及其在實際執行中對程式效能的影響。

  這些示例是用C#寫的,但不同語言的實現將會對效能的得分以及得出的結論有一些小的影響。

  Example 1: 記憶體訪問和效能

  你認為迴圈2的執行時間比迴圈1要快多少?

int[] arr = new int[64 * 1024 * 1024];

// Loop 1
for (int i = 0; i < arr.Length; i++) arr[i] *= 3;

// Loop 2
for (int i = 0; i < arr.Length; i += 16) arr[i] *= 3;

  迴圈1將陣列中的每個值都乘以3,迴圈2將陣列中每隔16個元素的值乘以3。第二個迴圈僅僅只做了第一個迴圈所做的6%,但是在現在的機器上,這兩個迴圈花費的時間幾乎相同:分別為80ms78ms

  這個現象出現的原因和記憶體有關。上述迴圈執行的時間主要花費在陣列的記憶體訪問上,而不是在整數乘法上。我會在例2中解釋為什麼硬體對兩個迴圈執行了相同的記憶體訪問。

  Example 2: cache lines的影響

  讓我們深入探索這個例子。接下來我們將會使用其它步長值,不僅僅是1和16:

for (int i = 0; i < arr.Length; i += K) arr[i] *= 3;

  下面就是在迴圈中不同的步長值(K)的執行時間:

image

  注意,步長在1到16之間變化,但是執行時間卻幾乎沒有改變。但是從16以後,步長每增加一倍,執行時間就為原來的一半。

  這種現象的原因就是現代的CPU並不是以1byte1byte的訪問記憶體,而是,它們會一塊為單位為取得,典型的就是64bytes,這就叫做cache lines.當你要讀取特定的記憶體時,這cache line就會獲取整塊記憶體到快取中,而且,從這cache line中訪問其它值更快。

  因為16 ints 就是64bytes(一個cache line),for迴圈步長從1到16,都在會使用相同的cache lines: 所有的cache lines都在陣列中。但是一旦步長是32,我們就會使用其它一個的cache line,步長為64時,就會使用4個cache line.

  理解了cache line,對於某些型別的程式優化十分重要。例如,資料佇列可能會影響到一個操作使用一個或者兩個cache line。正如我們上面看到那樣,在非線性的情況,這個操作可能會慢兩倍。

  Example 3: L1 和 L2快取大小

  現在的計算機都有二級或三級快取,通常稱為L1, L2可能還有L3。如果你想知道不同快取的大小,可以使用CoreInfo 這樣的SysInternals工具,或使用GetLogicalProcessorInfo 這樣的Windows API 呼叫。兩種方法都會在快取大小之外,告訴你快取記憶體行的大小

  在我的機器上,CoreInfo報告稱我有一個32kB L1資料快取,一個32kB L1指令快取,以及一個4MB L2資料快取。L1快取是每個核一個,L2快取則在雙核之間共享:

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個整數增加一——這是編輯每個快取行的一種簡單方法。當我們訪問到最後一個數值,再從頭迴圈。我們拿不同的陣列大小進行實驗,我們可以看到在陣列大小溢位一個快取級別的時候,效能有一個下降。

  這裡是程式:

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)
}

  這裡是所用時間:

image

  你可以看到在32kB和4MB後面有明顯的下降——這是我的機器L1 和 L2快取的大小。

  Example 4: 指令級並行性

  現在,我們看一些不一樣的東西。在這兩個迴圈中,你認為哪一個會更快一些?

int steps = 256 * 1024 * 1024;
int[] a = new int[2];

// Loop 1
for (int i=0; i<steps; i++) { a[0]++; a[0]++; }

// Loop 2
for (int i=0; i<steps; i++) { a[0]++; a[1]++; }

  結論是第二個迴圈大約是第一個迴圈的兩倍快,至少在我測試的所有機器上是這樣的。為什麼呢?它與兩個迴圈體之間的操作有關。

  在第一個迴圈體內,操作之間相互依賴關係如下:

image

  但在第二個例子中,我們只有這些依賴:

image

  現代處理器有多種部件具有一定的並行性:它可以同時訪問L1中的兩個記憶體地址,或者執行兩個簡單的算術運算。在第一個迴圈中,處理器不能發揮這種指令級別的並行性,但在第二個迴圈中,它可以。

  【更新】:reddit網站上許多人詢問編譯器優化,是否{ a[0]++; a[0]++; }會優化為{ a[0]+=2; }。實際上,C#編譯器和CLR JIT(Common Language Runtime通用語言執行時 Just In-Time compile即時編譯 )不會做這種優化——當牽涉到訪問陣列時不會。我在釋出模式建立了所有的測試(即具有優化模式),但是我觀察了JIT-ted編譯,驗證了優化不會影響到這個結果。

  Example 5: 快取對映方式

  快取設計的一個最關鍵的因素就是主存塊能否被儲存在快取槽當中或者就是快取槽的一部分。

  記憶體對映到快取槽有三條可能的途徑:

  1. 直接對映方式:

    每個記憶體塊只能被快取中的一個特別的快取槽所儲存。一個簡單的解決辦法就是用記憶體塊的索引chunk_index和快取槽來進行對映(通過chunk_index和cache_slots求餘的方式)。兩個對映到相同快取槽的記憶體塊不能同時被儲存在快取當中。

  2. N路組關聯對映方式:

    任何一個記憶體塊只能被快取裡的N個組中的一個快取槽所儲存。舉個例子,在16路快取中,每一個記憶體塊可以被16個不同的快取槽所儲存,一般情況下,索引最低位位元組相同的記憶體塊將會共享16個快取槽。

  3. 全關聯對映方式:

    每個記憶體塊可以被任何快取槽所儲存。實際上,這種快取的方式和雜湊表是一樣的。

  直接快取對映方式的效率將會受到衝突的影響——當多個值共同競爭同一快取槽時,它們將不斷相互驅逐,命中率直線下降。另一方面,全關聯對映方式的硬體實現複雜而又昂貴。所以目前處理器快取的典型解決方案是 N路組關聯對映方式,因為它在實現的簡單性和較好的命中率之間做了很好的權衡。

  例如,我電腦上的4MB二級快取採用的是16路組關聯對映方式。所有64位元組的記憶體塊將被分成組(按照記憶體塊索引的最低位位元組來劃分),相同組的記憶體塊共同競爭二級快取中的16個快取槽。

  由於二級快取中有65536個快取槽,每組需要16個快取槽,二級快取共可劃分為4096個組。所以記憶體塊將會對映到的組將由記憶體塊索引的最低12位決定(212 = 4,096)。其結果就是cache line中262144個位元組整數倍(4096*64)的記憶體地址將會共同競爭同一快取槽。我電腦上的快取可以容納16路這樣的cache line。

  為了使快取對映的效果更明顯,我需要從同一組中重複取出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個元素的值將會增加1,一旦達到了陣列的末尾,將從頭開始再次迴圈。執行足夠長時間後(2^20步),迴圈結束。

  我用不同的陣列長度(每次增加1MB)和不同的步長來執行UpdateEveryKthByte(),得到了如下結果,藍色表示執行時間比較長,白色表示執行時間比較短:

 image

  藍色區域(執行時間比較長)中更新的值由於不斷被重複遍歷,所以不能夠被同時儲存在快取中。亮藍色區域的執行時間花費在80ms左右,白色區域的執行時間花費在10ms左右。

  解釋一下圖示的藍色部分:

  1. 為什麼會出現藍色的豎線?藍色豎線表示步長會訪問很多(>16)相同集合中的記憶體。對於這些步長我們不能同時把所有的訪問記憶體保持在一個16路相關的快取中。一些很差的步長是2的整數次冪:256 和 512。 例如,考慮步長512和陣列大小為8M. 一個8M的快取行包含32個被262,144分割的值。所有這些值都會被我們的每一次迴圈更新,因為262,144 可以被512整除。所以這32個值會互相競爭同一個16slot的集合。一些不是2的整數次冪的也不幸地不成比例的過多訪問同一個集合中的值.(什麼樣的值會不幸?)

  2. 為什麼豎線會在資料大小為4M時結束? 當陣列大小為4MB或者更小時,16路相關的cache正好跟全相關快取一樣。262,144*14 = 4M,一個16路的相關cache能快取所有以262,144對齊的資料。

  3. 為什麼藍色的三角形在左上方? 在三角形區域,我們不能同時儲存必須的資料...不是因為相關方式,而是簡單的因為L2快取大小限制。例如,假設陣列大小為16MB步長為128,。 我們不斷更新間隔為128的位元組,這意味著我們會訪問另外的64個位元組的記憶體塊。所以為了儲存每一個 快取行,我們需要8M的cache。但我的機器只有4MB的快取。即使採用全相關,仍然不能快取8MB的資料。

  4. 為什麼在左邊三角形淡化?步長從0到64, 一個快取行!就像在example1和2裡所闡釋的,額外的對同一個快取行的訪問幾乎是免費的。例如,如果 步長為16, 每四步會訪問另一個快取行,所以我們用一行的代價得到了四次記憶體訪問。

  當你擴充套件這幅圖的時候,這些模式一直都是如此:

assoc_big

  快取結合性理解了就很有趣,而且它當然可以被證明,不過和本文討論的其他問題比起來,它應該是一個較小的問題。當然,它並不是在你寫程式的時候,腦海裡應該浮現出來的東西。

  Example 6: 錯誤的快取線共享

  在多核的機器上,快取又遇到另外一個問題——一致性。不同的核心有完全或者部分分離的快取。在我的機器上,L1快取是分離的(像通常一樣),有兩對處理器,每對分享L2快取。雖然細節有所不同,但是現代的多核機器都具有多層次的快取架構,更快更小的快取屬於獨立的處理器。

  當一個處理器修改了其快取中的一個值時,其它的處理器再也不能使用舊的值。那個記憶體位置將會在所有的快取中失效。而且,因為快取操作的粒度是快取線,而不是單獨的位元組,所以所有快取中的整行快取線將會失效!

  為了展示這個問題,考慮這個例子:

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;
    }
}

  在我的四核的機器上,如果我從四個不同的執行緒以引數0,1,2,3呼叫UpdateCounter,直到所有的執行緒完畢需要 4.3秒

  另外一種情況,如果我以引數16,32,48,64呼叫UpdateCounter,需要0.28秒

  為什麼?在第一種情況中,所有四個值可能出現在同一個快取線。每次一個核心增加counter,它會使含有所有四個counter的快取線失效。所有其它核心下次訪問自己的counter的時候都會出現快取不命中。這種執行緒行為使快取失效,嚴重影響程式的效能。

  Example 7: 硬體複雜性

  即使你知道了快取工作的基本方式,硬體有時也會讓你驚訝。不同的處理器在優化、啟發和微妙的細節上有所不同。

  對於某些處理器,如果從不同的bank訪問快取線,L1快取可以並行處理兩個訪問,如果屬於同一個bank那麼就序列執行。另外處理器的優化也會讓你驚訝。例如錯誤的共享的例子,我在許多機器上都試過,但是在我的機器上就不正常——我的機器可以優化執行減少快取失效。

  這是一個“硬體不可思議”的古怪的例子:

private static int A, B, C, D, E, F, G;
private static void Weirdness()
{
    for (int i = 0; i < 200000000; i++)
    {
        <something>
    }
}

  當我用三個不同的程式碼塊替換"<something>",獲得下述結果:

<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花費時間長

  我不太確定其中的原因,但是我懷疑與記憶體有關。如果有人可以解釋其中的原因,我會很謙虛的聆聽。

  這個例子教給我們完全預測硬體效能是很難的。有很多你覺得可以預測,但是最後,測量和驗證你的程式是非常重要的。

  英文來源:http://igoro.com/archive/gallery-of-processor-cache-effects/

相關文章