摘要:本文重點介紹幾種透過最佳化Cache使用提高程式效能的方法。
本文分享自華為雲社群《編譯器最佳化那些事兒(7):Cache最佳化》,作者:畢昇小助手。
引言
軟體開發人員往往期望計算機硬體擁有無限容量、零訪問延遲、無限頻寬以及便宜的記憶體,但是現實卻是記憶體容量越大,相應的訪問時間越長;記憶體訪問速度越快,價格也更貴;頻寬越大,價格越貴。為了解決大容量、高速度、低成本之間的矛盾,基於程式訪問的區域性性原理,將更常用資料放在小容量的高速儲存器中,多種速度不同的儲存器分層級聯,協調工作。
圖1 memory hierarchy for sever [1]
現代計算機的儲存層次可以分幾層。如圖1所示,位於處理器內部的是暫存器;稍遠一點的是一級Cache,一級Cache一般能夠儲存64k位元組,訪問它大約需要1ns,同時一級Cache通常劃分為指令Cache(處理器從指令Cache中取要執行的指令)和資料Cache(處理器從資料Cache中存/取指令的運算元);然後是二級Cache,通常既儲存指令又儲存資料,容量大約256k,訪問它大約需要3-10ns;然後是三級Cache,容量大約16-64MB,訪問它大約需要10-20ns;再接著是主存、硬碟等。注意,CPU和Cache是以word傳輸的,Cache到主存以塊(一般64byte)傳輸的。
前文提到了程式的區域性性原理,一般指的是時間區域性性(在一定時間內,程式可能會多次訪問同一記憶體空間)和空間區域性性(在一定時間內,程式可能會訪問附近的記憶體空間),快取記憶體(Cache)的效率取決於程式的空間和時間的區域性性性質。比如一個程式重複地執行一個迴圈,在理想情況下,迴圈的第一個迭代將程式碼取至快取記憶體中,後續的迭代直接從快取記憶體中取資料,而不需要重新從主存裝載。因此,為了使程式獲得更好的效能,應儘可能讓資料訪問發生在快取記憶體中。但是如果資料訪問在快取記憶體時發生了衝突,也可能會導致效能下降。
篇幅原因,本文重點討論編譯器在Cache最佳化中可以做哪些工作,如果讀者對其他記憶體層次最佳化感興趣,歡迎留言。下面將介紹幾種透過最佳化Cache使用提高程式效能的方法。
對齊和佈局
現代編譯器可以透過調整程式碼和資料的佈局方式,提高Cache命中率,進而提升程式效能。本節主要討論資料和指令的對齊、程式碼佈局對程式效能的影響,大部分處理器中Cache到主存是以Cache line(一般為64Byte,也有地方稱Cache塊,本文統一使用Cache line)傳輸的,CPU從記憶體載入資料是一次一個Cache line,CPU往記憶體寫資料也是一次一個Cache line。假設處理器首次訪問資料物件A,其大小剛好為64Byte,如果資料物件A首地址並沒有進行對齊,即資料物件A佔用兩個不同Cache line的一部分,此時處理器訪問該資料物件時需要兩次記憶體訪問,效率低。但是如果資料物件A進行了記憶體對齊,即剛好在一個Cache line中,那麼處理器訪問該資料時只需要一次記憶體訪問,效率會高很多。編譯器可以透過合理安排資料物件,避免不必要地將它們跨越在多個Cache line中,儘量使得同一物件集中在一個Cache中,進而有效地使用Cache來提高程式的效能。透過順序分配物件,即如果下一個物件不能放入當前Cache line的剩餘部分,則跳過這些剩餘的部分,從下一個Cache line的開始處分配物件,或者將大小(size)相同的物件分配在同一個儲存區,所有物件都對齊在size的倍數邊界上等方式達到上述目的。
Cache line對齊可能會導致儲存資源的浪費,如圖2所示,但是執行速度可能會因此得到改善。對齊不僅僅可以作用於全域性靜態資料,也可以作用於堆上分配的資料。對於全域性資料,編譯器可以透過組合語言的對齊指令命令來通知連結器。對於堆上分配的資料,將物件放置在Cache line的邊界或者最小化物件跨Cache line的次數的工作不是由編譯器來完成的,而是由runtime中的儲存分配器來完成的[2]。
圖2 因塊對齊可能會浪費儲存空間
前文提到了資料物件對齊,可以提高程式效能。指令Cache的對齊,也可以提高程式效能。同時,程式碼佈局也會影響程式的效能,將頻繁執行的基本塊的首地址對齊在Cache line的大小倍數邊界上能增加在指令Cache中同時容納的基本塊數目,將不頻繁執行的指令和頻繁指令的指令放到不同的Cache line中,透過最佳化程式碼佈局來提升程式效能。
利用硬體輔助
Cache預取是將記憶體中的指令和資料提前存放至Cache中,達到加快處理器執行速度的目的。Cache預取可以透過硬體或者軟體實現,硬體預取是透過處理器中專門的硬體單元實現的,該單元透過跟蹤記憶體訪問指令資料地址的變化規律來預測將會被訪問到的記憶體地址,並提前從主存中讀取這些資料到Cache;軟體預取是在程式中顯示地插入預取指令,以非阻塞的方式讓處理器從記憶體中讀取指定地址資料至Cache。由於硬體預取器通常無法正常動態關閉,因此大部分情況下軟體預取和硬體預取是並存的,軟體預取必須盡力配合硬體預取以取得更優的效果。本文假設硬體預取器被關閉後,討論如何利用軟體預取達到效能提升的效果。
預取指令prefech(x)只是一種提示,告知硬體開始將地址x中的資料從主存中讀取到Cache中。它並不會引起處理停頓,但若硬體發現會產生異常,則會忽略這個預取操作。如果prefech(x)成功,則意味著下一次取x將命中Cache;不成功的預取操作可能會導致下次讀取時發生Cache miss,但不會影響程式的正確性[2]。
資料預取是如何改成程式效能的呢?如下一段程式:
double a[n]; for (int i = 0; i < 100; i++) a[i] = 0;
假設一個Cache line可以存放兩個double元素,當第一次訪問a[0]時,由於a[0]不在Cache中,會發生一次Cache miss,需要從主存中將其載入至Cache中,由於一個Cache line可以存放兩個double元素,當訪問a[1]時則不會發生Cache miss。依次類推,訪問a[2]時會發生Cache miss,訪問a[3]時不會發生Cache miss,我們很容易得到程式總共發生了50次Cache miss。
我們可以透過軟體預取等相關最佳化,降低Cache miss次數,提高程式效能。首先介紹一個公式[3]:
上述公式中L是memory latency,S是執行一次迴圈迭代最短的時間。iterationAhead表示的是迴圈需要經過執行幾次迭代,預取的資料才會到達Cache。假設我們的硬體架構計算出來的iterationAhead=6,那麼原程式可以最佳化成如下程式:
double a[n]; for (int i = 0; i < 12; i+=2) //prologue prefetch(&a[i]); for (int i = 0; i < 88; i+=2) { // steady state prefetch(&a[i+12]); a[i] = 0; a[i+1] = 0; } for (int i = 88; i < 100; i++) //epilogue a[i] = 0;
由於我們的硬體架構需要迴圈執行6次後,預取的資料才會到達Cache。一個Cache line可以存放兩個double元素,為了避免浪費prefetch指令,所以prologue和steady state迴圈都展開了,即執行prefetch(&a[0])後會將a[0]、a[1]從主存載入至Cache中,下次執行預取時就無需再次將a[1]從主存載入至Cache了。prologue迴圈先執行陣列a的前12個元素的預取指令,等到執行steady state迴圈時,當i = 0時,a[0]和a[1]已經被載入至Cache中,就不會發生Cache miss了。依次類推,經過上述最佳化後,在不改變語義的基礎上,透過使用預取指令,程式的Cache miss次數從50下降至0,程式的效能將會得到很大提升。
注意,預取並不能減少從主儲存器取資料到快取記憶體的延遲,只是透過預取與計算重疊而隱藏這種延遲。總之,當處理器有預取指令或者有能夠用作預取的非阻塞的讀取指令時,對於處理器不能動態重排指令或者動態重排緩衝區小於我們希望隱藏的具體Cache延遲,並且所考慮的資料大於Cache或者是不能夠判斷資料是否已在Cache中,預取是適用的。預取也不是萬能,不當的預取可能會導致快取記憶體衝突,程式效能降低。我們應該首先利用資料重用來減少延遲,然後才考慮預取。
除了軟體預取外,ARMv8還提供了Non-temporal的Load/Store指令,可以提高Cache的利用率。對於一些資料,如果只是訪問一次,無需佔用Cache,可以使用這個指令進行訪問,從而保護Cache中關鍵資料不被替換,比如memcpy大資料的場景下,使用該指令對於其關鍵業務而言,是有一定的收益的。
迴圈變換
重用Cache中的資料是最基本的高效使用Cache方法。對於多層巢狀迴圈,可以透過交換兩個巢狀的迴圈(loop interchange)、逆轉迴圈迭代執行的順序(loop reversal)、將兩個迴圈體合併成一個迴圈體(loop fusion)、迴圈拆分(loop distribution)、迴圈分塊(loop tiling)、loop unroll and jam等迴圈變換操作。選擇適當的迴圈變換方式,既能保持程式的語義,又能改善程式效能。我們做這些迴圈變換的主要目的是為了實現暫存器、資料快取記憶體以及其他儲存層次使用方面的最佳化。
篇幅受限,本節僅討論迴圈分塊(loop tiling)如何改善程式效能,若對loop interchange感興趣,請點選查閱。下面這個簡單的迴圈:
for(int i = 0; i < m; i++) { for(int j = 0; j < n; j++) { x = x+a[i]+c*b[j]; } }
我們假設陣列a、b都是超大陣列,m、n相等且都很大,程式不會出現陣列越界訪問情況發生。那麼如果b[j]在j層迴圈中跨度太大時,那麼被下次i層迴圈重用時資料已經被清出快取記憶體。即程式訪問b[n-1]時,b[0]、b[1]已經被清出快取,此時需要重新從主存中將資料載入至快取中,程式效能會大幅下降。
我們如何透過降低Cache miss次數提升程式的效能呢?透過對迴圈做loop tiling可以符合我們的期望,即透過迴圈重排,使得資料分成一個一個tile,讓每一個tile的資料都可以在Cache中被hint[4]。從內層迴圈開始tiling,假設tile的大小為t,t遠小於m、n,t的取值使得b[t-1]被訪問時b[0]依然在Cache中,將會大幅地減少Cache miss次數。假設n-1恰好被t整除,此時b陣列的訪問順序如下所示:
i=1; b[0]、b[1]、b[2]...b[t-1] i=2; b[0]、b[1]、b[2]...b[t-1] ... i=n; b[0]、b[1]、b[2]...b[t-1] ... ... ... i=1; b[n-t]、b[n-t-1]、b[n-t-2]...b[n-1] i=2; b[n-t]、b[n-t-1]、b[n-t-2]...b[n-1] ... i=n; b[n-t]、b[n-t-1]、b[n-t-2]...b[n-1]
經過loop tiling後迴圈變換成:
for(int j = 0; j < n; j+=t) { for(int i = 0; i < m; i++) { for(int jj = j; jj < min(j+t, n); jj++) { x = x+a[i]+c*b[jj]; } } }
假設每個Cache line能夠容納X個陣列元素,loop tiling前a的Cache miss次數為m/X,b的Cache miss次數是m*n/X,總的Cache miss次數為m*(n+1)/x。loop tiling後a的Cache miss次數為(n/t)*(m/X),b的Cache miss次數為(t/X)*(n/t)=n/X,總的Cache miss次數為n*(m+t)/xt。此時,由於n與m相等,那麼loop tiling後Cache miss大約可以降低t倍[4]。
前文討論了loop tiling在小用例上如何提升程式效能,總之針對不同的迴圈場景,選擇合適的迴圈交換方法,既能保證程式語義正確, 又能獲得改善程式效能的機會。
小結
汝之蜜糖,彼之砒霜。針對不同的硬體,我們需要結合具體的硬體架構,利用效能分析工具,透過分析報告和程式,從系統層次和演算法層次思考問題,往往會有意想不到的收穫。本文簡單地介紹了記憶體層次最佳化相關的幾種方法,結合一些小例子深入淺出地講解了一些記憶體層次最佳化相關的知識。紙上得來終覺淺,絕知此事要躬行,更多效能最佳化相關的知識需要我們從實踐中慢慢摸索。
參考
-
John L. Hennessy, David A. Patterson. 計算機體系結構:量化研究方法(第6版). 賈洪峰,譯
-
Andrew W.Apple, with Jens Palsberg. Modern Compiler Implenentation in C
-
http://www.cs.cmu.edu/afs/cs/academic/class/15745-s19/www/lectures/L20-Global-Scheduling.pdf
-
https://zhuanlan.zhihu.com/p/292539074