1. 程式人生 > >充分利用CPU快取記憶體,提高程式效率(原理篇)

充分利用CPU快取記憶體,提高程式效率(原理篇)

提高程式效率應該充分利用CPU的快取記憶體。要想編寫出對CPU快取友好的程式就得先明白CPU快取記憶體的執行機制。

 i5-2400S:

      1、有三級快取分別為 32k(資料、指令快取分開,分為32k),256K,6144K(四個CPU之間共享);

      2、主頻為2.5G,則一個時鐘週期為1/2.5x10^9=0.4ns(主頻=1/時鐘週期)。

 CPI:

  CPU中每條指令執行所需的機器週期不同CPI:平均每條指令的平均時鐘週期個數,注:一個機器週期等於若干個時鐘週期,如一個機器週期等於5個時鐘週期

 MIPS:

  MIPS = 每秒執行百萬條指令數 = 1/(CPI×時鐘週期)= 主頻/CPI,通過 cat /proc/cpuinfo | grep bogomips 命令可以檢視linux系統的mips,對於i5-2400s CPU 其mips=4988.85

從而我們可以算出平均執行一個指令所需時間 T = 1/(4988.85x10^6)=0.2ns,注意:每個CPU時鐘內可並行處理多條指令。

 記憶體體系:

  核心到主存的延遲變化範圍很大,大約在10-100納秒。在100ns內,一個2.5GH的CPU可以處理多達100/T=500條指令,所以CPU使用快取子系統避免了處理核心訪問主存的時延,從而使CPU更加高效的處理指令。所以在程式設計中提供程式快取記憶體的命中率對於程式效能的提高幫助很大。尤其是要著重考慮主要資料結構的設計。

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的效能遭到了極大的損傷!此程式在多核處理器下的效能還不如在單核處理器下的效能高。

多CPU同時訪問同一塊記憶體區域就是“共享”,就會產生衝突,需要控制協議來協調訪問。會引起“共享”的最小記憶體區域大小就是一個cache line。因此,當兩個以上CPU都要訪問同一個cache line大小的記憶體區域時,就會引起衝突,這種情況就叫“共享”。但是,這種情況裡面又包含了“其實不是共享”的“偽共享”情況。比如,兩個處理器各要訪問一個word,這兩個word卻存在於同一個cache line大小的區域裡,這時,從應用邏輯層面說,這兩個處理器並沒有共享記憶體,因為他們訪問的是不同的內容(不同的word)。但是因為cache line的存在和限制,這兩個CPU要訪問這兩個不同的word時,卻一定要訪問同一個cache line塊,產生了事實上的“共享”。顯然,由於cache line大小限制帶來的這種“偽共享”是我們不想要的,會浪費系統資源(此段摘自如下網址:http://software.intel.com/zh-cn/blogs/2010/02/26/false-sharing/)

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

 

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

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