CPU Cache簡介

備註:需對CPU有一定理解,建議閱讀《CPU簡介》

爲什麼需要CPUCache

真空中光速爲299,792,458米/秒,目前,Intel的i7頻率可以達到4GHz,簡單換算一下,可以得出結論:光(電流)在一個Cycle內移動的距離約爲0.075米。顯然,目前的內存條的芯片(反正兩面。約爲3.75cm)大大超過了這個長度,換句話說,理論上,在一個Cycle內內存條上總有一個位置是我們無法觸摸。

實際上,早期的計算機結構相對簡單,就是不同組件構成的一個體系,包括CPU,內存條,硬盤和網卡等,各組件之間的性能相對平衡。後來,隨着各個組件的不斷髮展,各自形成了一個相對獨立的子體系,衆所周知的摩爾定律,就是描述CPU性能的迅猛發展。不同組件之間的性能差異日益顯著。

巧婦難爲無米之炊,內存不給力,再快的CPU也無用武之地。難道真的沒有辦法提高內存的性能嗎?答案是有。

內存可以分爲兩大類:SRAM(靜態)和DRAM(動態),因爲如下是兩者的電路對比:

前者性能高,狀態穩定,直接訪問M1~M4晶體管,獲取當前的內存狀態(0或1),而後者將內存狀態保存在C(電容器),這個設計有三個問題,第一是訪問容器C會導致放電,因此,需要不停的刷新充電,這個過程中無法訪問該內存存放的數據,同時需要使用放大器才能區分狀態(0或1),每次讀操作後需要重新充電,總之,這導致了動態內存的設計下,無法直接獲取內存狀態。

原則上,我也是看不懂上圖的,但直覺告訴我,前者比後者複雜的多。俗話說“人至賤則無敵”,最終,出於成本的考慮,更便宜的動態內存成爲主流。

假設你是一個圖書館的管理員,配有一張迷你辦公桌。每次有人借書,你會檢查辦公桌上是否有這本書,如果有就直接給借書人;如果沒有,你就需要去書庫中找到這本書,再給借書人。很明顯,前者幾乎不會花費時間,相當於讀取寄存器數據,後者就要花不少時間,相對於讀取內存數據。

如果是三十年前,那這個場景不會有什麼變化,但後來,我們可以做一個小書架,放在書桌旁,能裝幾本書。缺點是這個書架得用黃花梨做。隨着工藝的提高,我們可以做一個三層小書架。這樣,我們可以“預測”那些最受歡迎的書,放到書架上,減少(耗時的)書庫取書的次數。這就是用靜態內存來做CPU Cache的思路。

如何設計Cache

緩存機制,理論上緩解了CPU和內存之間性能差距日益增大這個難題。但實際中,緩存數據是否是當前CPU所需要的數據,這就是一個很嚴肅的問題了。

首先,一級緩存只有32K,而通常內存條則是8G,這就是一個抽屜問題:8*1024個球放到32個抽屜裏,如何提高抽屜的利用率,避免過勞或過閒;如何設置球的優先級,是上一次訪問的數據優先級高,還是訪問次數最多的數據優先級高,或者隨機給一個優先級,這是一個Replace policy的設計。

N-way associative

N-way associative具體算法在之前的《CPU簡介》中已經談到,這裏默認大家都瞭解,不討論具體該細節。

如上圖,吃飯時間到了,狗狗會遍歷所有飯盒,看是否有空位,如果有,則佔有該飯盒;如果沒有,則挑一個好欺負的狗狗,霸佔它的飯盒。這和數據存儲是一個思路:假設有8個抽屜,現在需要放一個球,會依次打開抽屜看是否有空抽屜,這稱爲full associative。好處是能找到一個抽屜來存放球(數據),缺點是需要遍歷所有抽屜,當抽屜數目過長時,複雜度是O(n),線性增長。

爲了優化遍歷時間,自然想到用Hash Table。基於上面抽屜的場景,我們現在對8個抽屜編號,所有的球也都有一個唯一的編號(內存地址),然後用編號除以8,根據餘數找到對應的抽屜,如果抽屜裏面有球,則替換。這樣,找到抽屜的時間爲O(1),但缺點是容易出現Hash衝突,導致效率下降。這稱爲direct mapping。

full associative和direct mapping各有利弊,於是兩者結合,也就是目前採用的N-way associative的設計方案。如上圖,6195 的二進制是 00...0110000 011 0011。這樣,在8(2^3)sets中,取綠色的三位,存放在索引3;在4(2^2)sets中,取綠色的二位,存放在索引3中,裏面有兩個“抽屜”(2-way)可供選擇;在2(2^1)sets中,取綠色的後一位,存放在索引1中,裏面有四個“抽屜”(4-way)可供選擇。

不難發現,full associative和direct mapping是一維的行或列的設計方式,1-way就相當於direct mapping,8-way就是full associative。N-way則是一種行和列的二維設計,提高了靈活性,在遍歷和減少Hash衝突之間的互相平衡。

如上的公式,我們可以通過C++ template設計一個N-way associative,實現一個緩存策略的模擬。

template <intnCacheSize, intnWayNum, intnBlockSize> class associativity {}

Replace policy

有了數據存儲方案,如果沒有空的區塊時,應該替換哪一個區塊呢?這裏面也涉及到一個算法:每次有新的數據增加到Cache區塊時,需要更新每一個區塊的優先級,確定一個當前最不重要的數據,以便下次需要時替換。

  • Belady 最常見的替換算法,FIFO先進先出的策略。每次數據加入隊列時,如果該數據已經存在,仍然認爲是舊數據
  • Random 顧名思義,抽死籤,隨機找一個block替換
  • LRU:Least recently used 類似FIFO,每次數據加入隊列時,如果該數據已經存在,認爲是新數據
  • LFU:Least-frequently used 引用計數,引用次數最少的將被替換掉

當然還有很多替換策略,如上是我自己寫的Cache中加入的替換策略,發現並沒有明顯的好壞之分。

三級緩存

我們的設計中,有三級緩存C1~C3的層級關係,對應到代碼中,三者的實現原理都一樣,都可以通過templateclass實現,無非是N-way和CacheSize的不同而已。但我們還需要一個CacheManager來管理三個緩存以及內存之間的關係。

首先,寫數據時,是否需要寫入到內存,還是暫時保存在緩存中,離開緩存時再寫入內存;三級緩存中,是否允許存在重複數據;當數據從高一級的緩存中替換出來時,如何加入到下一級緩存中。

最後,需要增加一個計數能力,統計一下緩存命中率。下圖是我設計的一個三級緩存模擬器,綠色是一級緩存命中,藍色是二級,橙色是三級,紅色是未命中。

Locality

通過上面兩部分,我們能瞭解爲何設計CPU Cache以及實現思路,在程序設計時,這些知識有什麼用處呢?如何能讓我們的編碼具備較高的緩存命中率呢?畢竟,有了先進的武器,如果不能熟練掌握,也是無濟於事。

Access data sequentially

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

如上,隨着K增加,K=16是一個臨界點,說明該CPUCache中,一個Block(Cache Line)存儲了16 * sizeof(int) = 64字節,基於緩存的考慮,不難理解,因爲CacheLine中的數據連續,因此緩存命中率較高,讀寫速度很快,因此,在這個臨界點內,K的變化,儘管會減少操作指令,但不會帶來性能的提升。

記得當年剛畢業時做的面試題,其中一個是遍歷二維數組A[M][N],for(M) { for(N)}和for(N){ for(M)}之間有什麼區別。前者行優先,訪問的內存依次連續,而後者是列優先,內存不連續。

通常一個class會封裝多個組件,比如一個Entity,裏面包含AI模塊,Physics物理模擬模塊以及Render模塊,通常,我們習慣這樣寫代碼來遍歷多個Entity Array:

while (!gameOver) { // Process AI. for (int i = 0;i < numEntities; i++) { entities[i]->ai()->update(); } // Updatephysics. for (int i = 0;i < numEntities; i++) { entities[i]->physics()->update(); } // Draw toscreen. for (int i = 0;i < numEntities; i++) { entities[i]->render()->render(); } // Other gameloop machinery for timing... }

看上去邏輯優雅,這也是OOP的設計核心:狀態的管理。但從Cache的角度而言則非常糟糕,數據角度上,內存是跳躍的。

假如我們改造一下代碼,將Entity類拆分成三個組件對應的類,然後依次遍歷具體的方法:

AIComponent* aiComponents = new AIComponent[MAX_ENTITIES]; PhysicsComponent* physicsComponents = new PhysicsComponent[MAX_ENTITIES]; RenderComponent* renderComponents = new RenderComponent[MAX_ENTITIES]; while (!gameOver) { // Process AI. for (int i = 0;i < numEntities; i++) { aiComponents[i].update(); } // Updatephysics. for (int i = 0;i < numEntities; i++) { physicsComponents[i].update(); } // Draw toscreen. for (int i = 0;i < numEntities; i++) { renderComponents[i].render(); } // Other gameloop machinery for timing... }

此時,數據訪問上,內存是連續的,性能就會有明顯的提升,測試中性能提升了50倍。

large data structures

我們在看這段有意思的代碼:

public longUpdate(byte[] arr, int K) { // Number of iterations – arbitrary const int rep =1024*1024; int p = 0; for (int i = 0;i < rep; i++) { arr[p]++; p += K; if (p >=arr.Length) p = 0; } }

如下圖,依次增大array length和K-Step,白色表示時間較短,藍色表示時間久,會發現,當K爲2的N次方時,會有一根突出的藍線。原因是K間隔下的內存地址,正好導致了Hash衝突,引起了內存和緩存之間的頻繁交替,因此性能會下降。

在Agner Fog的optimizing_cpp的9.10中,針對矩陣轉置的例子,有更爲詳細的介紹,這種情況下,我們不妨大塊拆成小塊的思路,來得到效率的提升。

總結

CPU Cache的介紹就到此結束,希望大家在編碼時,能留意讓自己的代碼更好的發揮緩存的優勢。能夠認識到OOP編程下,看似整潔的代碼下,也夾雜着看不見的性能的犧牲。

下一篇分享一下CPU系列的最後一部分內容:SIMD以及面向數據DOD

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章