1. 程式人生 > >linux cache 機制

linux cache 機制

在閱讀文章前,您應該具備基本的儲存器層次結構知識,至少要了解區域性性原理。要詳細瞭解cache基本原理,可以參考本書《深入理解計算機系統》中儲存器體系結構一章:

zcover 

帶著疑問來看文章,cache對於程式設計師是不可見的,它完全是由硬體控制的,為什麼在linux核心中還有cache.h這個標頭檔案,定義了一些關於cache的結構?

1. cache概述

cache,中譯名高速緩衝儲存器,其作用是為了更好的利用區域性性原理,減少CPU訪問主存的次數。簡單地說,CPU正在訪問的指令和資料,其可能會被以後多次訪問到,或者是該指令和資料附近的記憶體區域,也可能會被多次訪問。因此,第一次訪問這一塊區域時,將其複製到cache中,以後訪問該區域的指令或者資料時,就不用再從主存中取出。

2. cache結構

假設記憶體容量為M,記憶體地址為m位:那麼定址範圍為000…00~FFF…F(m位)

倘若把記憶體地址分為以下三個區間:

截圖01《深入理解計算機系統》p305 英文版 beta draft 

tag, set index, block offset三個區間有什麼用呢?再來看看Cache的邏輯結構吧:

截圖02

將此圖與上圖做對比,可以得出各引數如下:

B = 2^b

S = 2^s

現在來解釋一下各個引數的意義:

一個cache被分為S個組,每個組有E個cacheline,而一個cacheline中,有B個儲存單元,現代處理器中,這個儲存單元一般是以位元組(通常8個位)為單位的,也是最小的定址單元。因此,在一個記憶體地址中,中間的s位決定了該單元被對映到哪一組,而最低的b位決定了該單元在cacheline中的偏移量。valid通常是一位,代表該cacheline是否是有效的(當該cacheline不存在記憶體對映時,當然是無效的)。tag就是記憶體地址的高t位,因為可能會有多個記憶體地址對映到同一個cacheline中,所以該位是用來校驗該cacheline是否是CPU要訪問的記憶體單元。

當tag和valid校驗成功是,我們稱為cache命中,這時只要將cache中的單元取出,放入CPU暫存器中即可。

當tag或valid校驗失敗的時候,就說明要訪問的記憶體單元(也可能是連續的一些單元,如int佔4個位元組,double佔8個位元組)並不在cache中,這時就需要去記憶體中取了,這就是cache不命中的情況(cache miss)。當不命中的情況發生時,系統就會從記憶體中取得該單元,將其裝入cache中,與此同時也放入CPU暫存器中,等待下一步處理。注意,以下這一點對理解linux cache機制非常重要:

當從記憶體中取單元到cache中時,會一次取一個cacheline大小的記憶體區域到cache中,然後存進相應的cacheline中。

例如:我們要取地址 (t, s, b) 記憶體單元,發生了cache miss,那麼系統會取 (t, s, 00…000) 到 (t, s, FF…FFF)的記憶體單元,將其放入相應的cacheline中。

下面看看cache的對映機制:

當E=1時, 每組只有一個cacheline。那麼相隔2^(s+b)個單元的2個記憶體單元,會被對映到同一個cacheline中。(好好想想為什麼?)

當1<E<C/B時,每組有E個cacheline,不同的地址,只要中間s位相同,那麼就會被對映到同一組中,同一組中被對映到哪個cacheline中是依賴於替換演算法的。

當E=C/B,此時S=1,每個記憶體單元都能對映到任意的cacheline。帶有這樣cache的處理器幾乎沒有,因為這種對映機制需要昂貴複雜的硬體來支援。

不管哪種對映,只要發生了cache miss,那麼必定會有一個cacheline大小的記憶體區域,被取到cache中相應的cacheline。

現代處理器,一般將cache分為2~3級,L1, L2, L3。L1一般為CPU專有,不在多個CPU中共享。L2 cache一般是多個CPU共享的,也可能裝在主機板上。L1 cache還可能分為instruction cache, data cache. 這樣CPU能同時取指令和資料。

下面來看看現實中cache的引數,以Intel Pentium處理器為例:

E B S C
L1 i-cache 4 32B 128 16KB
L1 d-cache 4 32B 128 16KB
L2 4 32B 1024~16384 128KB~2MB

3. cache miss的代價

cache可能被分為L1, L2, L3, 越往外,訪問時間也就越長,但同時也就越便宜。

L1 cache命中時,訪問時間為1~2個CPU週期。

L1 cache不命中,L2 cache命中,訪問時間為5~10個CPU週期

當要去記憶體中取單元時,訪問時間可能就到25~100個CPU週期了。

所以,我們總是希望cache的命中率儘可能的高。

4. False Sharing(偽共享)問題

到現在為止,我們似乎還沒有提到cache如何和記憶體保持一致的問題。

其實在cacheline中,還有其他的標誌位,其中一個用於標記cacheline是否被寫過。我們稱為modified位。當modified=1時,表明cacheline被CPU寫過。這說明,該cacheline中的內容可能已經被CPU修改過了,這樣就與記憶體中相應的那些儲存單元不一致了。因此,如果cacheline被寫過,那麼我們就應該將該cacheline中的內容寫回到記憶體中,以保持資料的一致性。

現在問題來了,我們什麼時候寫回到記憶體中呢?當然不會是每當modified位被置1就寫,這樣會極大降低cache的效能,因為每次都要進行記憶體讀寫操作。事實上,大多數系統都會在這樣的情況下將cacheline中的內容寫回到記憶體:

當該cacheline被置換出去時,且modified位為1。

現在大多數系統正從單處理器環境慢慢過渡到多處理器環境。一個機器中整合2個,4個,甚至是16個CPU。那麼新的問題來了。

以Intel處理器為典型代表,L1級cache是CPU專有的。

先看以下例子:

系統是雙核的,即為有2個CPU,CPU(例如Intel Pentium處理器)L1 cache是專有的,對於其他CPU不可見,每個cacheline有8個儲存單元。

我們的程式中,有一個 char arr[8] 的陣列,這個陣列當然會被對映到CPU L1 cache中相同的cacheline,因為對映機制是硬體實現的,相同的記憶體都會被對映到同一個cacheline。

2個執行緒分別對這個陣列進行寫操作。當0號執行緒和1號執行緒分別運行於0號CPU和1號CPU時,假設執行序列如下:

開始CPU 0對arr[0]寫;

隨後CPU 1對arr[1]寫;

隨後CPU 0對arr[2]寫;

……

CPU 1對arr[7]寫;

根據多處理器中cache一致性的協議:

當CPU 0對arr[0]寫時,8個char單元的陣列被載入到CPU 0的某一個cacheline中,該cacheline的modified位已經被置1了;

當CPU 1對arr[1]寫時,該陣列應該也被載入到CPU 1的某個cacheline中,但是該陣列在cpu0的cache中已經被改變,所以cpu0首先將cacheline中的內容寫回到記憶體,然後再從記憶體中載入該陣列到CPU 1中的cacheline中。CPU 1的寫操作會讓CPU 0對應的cacheline變為invalid狀態注意,由於相同的對映機制,cpu1 中的 cacheline 和cpu0 中的cacheline在邏輯上是同一行(直接對映機制中是同一行,組相聯對映中則是同一組)

當CPU 0對arr[2]寫時,該cacheline是invalid狀態,故CPU 1需要將cacheline中的陣列資料傳送給CPU 0,CPU 0在對其cacheline寫時,又會將CPU 1中相應的cacheline置為invalid狀態

……

如此往復,cache的效能遭到了極大的損傷!此程式在多核處理器下的效能還不如在單核處理器下的效能高。

對於偽共享問題,有2種比較好的方法:

1. 增大陣列元素的間隔使得由不同執行緒存取的元素位於不同的cache line上。典型的空間換時間 
2. 在每個執行緒中建立全域性陣列各個元素的本地拷貝,然後結束後再寫回全域性陣列

而我們要說的linux cache機制,就與第1種方法有關。

5. Cache友好的程式碼

Cache友好的程式碼,簡單地說,

    1. 減小cache miss率
    2. 在多核環境下,減小乃至消除“偽共享”問題發生的概率。

在單核環境下,有一個典型的例子:

Cache友好的程式碼:

int sumarrayrows(char a[M][N])
{
    int i, j, sum = 0;

    for (i = 0; i < M; i++)
        for (j = 0; j < N; j++)
            sum += a[i][j];
    return sum;
}

由於一般的機器中,C語言陣列都是按行優先儲存的。假設Cacheline的大小為B個位元組,Cache總容量為C位元組,直接對映儲存方式,那麼一共有C/B行Cacheline。對於a[M][N]這個M*N個位元組。每每讀到第 n*B 個數組元素時( 0<n<M*N/B ),才會發生cache miss,因此至多發生 M*N/B 次cache miss,不命中率至多為 (M*N/B)/(M*N) = 1/B。

Cache不友好的程式碼:

int sumarraycols(char a[M][N])
{
    int i, j, sum = 0;

    for (j = 0; j < N; j++)
        for (i = 0; i < M; i++)
            sum += a[i][j];
    return sum;
}

這個程式碼是按列優先訪問的,情況要複雜一些。我們只看一種比較簡單的情況:

  1. 當 N=B , M*N > C, E是cacheline的行數,即為C/B。 看看會發生什麼:在訪問a[0][0]~a[E-1][0]時,每次都會造成Cache miss,然後訪問a[E][0]~a[M-1][0]時,又會把第0~M-E-1行cacheline給覆蓋掉,因此當訪問a[0][0]~a[M-1][0]時總是會造成Cache miss。在訪問a[0][1]~a[M-1][1]時,分為2個過程,前0~M-E-1行由於被覆蓋了,故而Cache又會不命中,而在第M-E~E-1行中, 也就是訪問a[M-E][1]~a[E-1][1]時,由於沒有被覆蓋,這些行將會命中。因此總共有 M+2*(M-E)*(N-1)次cache miss。不命中率可算得:2-2E/M-1/N+2E/(M*N)。可見,當M>=2E時,不命中率>=1。

當賦予M,N較大值時,測試結果將會是列優先訪問程式的執行時間遠遠大於行優先訪問程式執行時間。

多核環境下,只要不同的執行緒或者程序訪問同一cacheline的不同內容,就會發生“偽共享問題”。這樣的問題較為隱蔽,難以發現。

6. GCC Attribute

7. 標頭檔案<linux/cache.h>解讀 

程式碼就不貼了

a. L1_CACHE_ALIGN(x)這個巨集

#define L1_CACHE_ALIGN(x) ALIGN(x, L1_CACHE_BYTES)

// linux/kernel.h
#define __ALIGN_KERNEL(x, a) __ALIGN_KERNEL_MASK(x, (typeof(x))(a) - 1)
#define __ALIGN_KERNEL_MASK(x, mask) (((x) + (mask)) & ~(mask))

該巨集返回x所指向的記憶體區域的起始的cacheline的邊界地址。

b. ____cacheline_aligned巨集

#define SMP_CACHE_BYTES L1_CACHE_BYTES
#define ____cacheline_aligned __attribute__((__aligned__(SMP_CACHE_BYTES)))

該巨集是gcc屬性,對定義的資料結構做空間對齊,使之起始位置對齊cacheline

c. __cacheline_aligned巨集

#define __cacheline_aligned \
__attribute__((__aligned__(SMP_CACHE_BYTES), \
__section__(".data..cacheline_aligned")))

把資料分配到data段的cacheline_aligned子段裡面,並且資料的起始位置對齊cacheline。.data..cacheline_aligned的定義在arch/XXX/kernel/vmlinuz.lds.S下面,有興趣的讀者可以自行查閱程式碼

b和c巨集看起來很類似,只差了2個下劃線而已,區別在於前者用於區域性資料的宣告,後者聲明於全域性資料,可以放在.data段

有一些在多處理器體系結構下的關鍵資料結構,就是用cacheline_aligned來宣告的,譬如:

// linux+v3.1.1/arch/ia64/kernel/numa.c#L27
u16 cpu_to_node_map[NR_CPUS] __cacheline_aligned;
這樣能夠避免每個CPU在對屬於自己的那個map讀寫時造成false sharing問題