Textbook:
《計算機組成與設計——硬件/軟件接口》 HI
《計算機體系結構——量化研究方法》 QR
Ch4. Cache Optimization
本章要討論的問題就是 How to Improve Cache Performance?
前面講過 Average memory access time = HitTime + (MissRate * MissPenalty)
那么我們的方向就是Reduce MissRate / HitTime / MissPenalty
1. 6 Basic Cache Optimization(PPT P3)
• Reducing hit time
1. Giving Reads Priority over Writes
• E.g., Read complete before earlier writes in write buffer ??
2. Avoiding Address Translation during Cache Indexing
Cache中使用虛擬地址,這樣就可以同時Access TLB和Cache / Access Cache firstly
• Reducing Miss Penalty
3. Multilevel Caches
AMAT = Hit TimeL1 + Miss RateL1 x Miss PenaltyL1
Miss PenaltyL1 = Hit TimeL2 + Miss RateL2 x Miss PenaltyL2
原來Miss PenaltyL1要訪問內存,很慢。現在多了L2
• Reducing Miss Rate
4. Larger Block size (Compulsory misses)
...
5. Larger Cache size (Capacity misses)
...
6. Higher Associativity (Conflict misses)
...
2. 11 Advanced Cache Optimizations (PPT P12)
• Reducing hit time
1. Small and simple caches(QR P59)
如果僅考慮Cache Hit Time,那么結構越簡單、容量越小、組相連路數越少的緩存肯定是越快的。
所以出於速度考慮,CPU的L1緩存都是很小的。比如從Pentium MMX到Pentium 4,L1緩存的容量都沒有增長。
不過太少了肯定也是不行的。。。所以這也是一個Trade off
2. Way prediction
直接相連的Hit Time是很快的,但conflict miss多。組相連可以減少conflict miss,但結構復雜功耗也高一些,hit time也多一點。那么有什么方法能兩者兼得呢?
因為組相連緩存中,每一個組里面的N個路(block)是全相連的。也就是相當於讀的時候,每次映射好一個set之后,要遍歷一遍N個block,當N越大的時候費的時間就越多。有一種黑科技方法叫做路預測(way prediction),它的思想就是在緩存的每個塊中添加預測位,來預測在下一次緩存訪問時,要訪問該組里的哪個塊。當下一次訪問時,如果預測准了就節省了遍歷的時間(相當於直接相連的速度了);如果不准就再遍歷唄。。。
好在目前這個accuracy還是很高的,大概80+%了。
不過有個缺點就是Hit Time不再是確定的幾個cycle了(因為沒命中的時候要花的cycle多嘛),不便於后面進行優化(參考CPU pipeline)。
3. Trace caches
這個只針對instruction cache。在讀取指令緩存時,要不斷jump來讀取不同的指令(也就是比較random的Access pattern),這樣就不如sequential的access快了。
在牙膏廠的Pentium 4中,使用了trace caches的黑科技。它會嘗試找出相鄰被訪問的指令(比如A jump to B),然后把這些block放到鄰近的位置,這樣就可以access instruction cache sequentially。
但因為現在code reuse rate不高了(程序太多了,很多程序可能一段時間內只執行一次),再加上這個黑科技implement比較復雜,后來就放棄了。
• Increasing cache bandwidth
4. Pipelined caches
在本科的計組課上我們學過pipeline的思想。在cache訪問中也可以使用pipeline技術。
但pipeline是有可能提高overall access latency的(比如中間有流水線氣泡),而latency有時候比bandwidth更重要。所以很多high-level cache是不用pipeline的
5. cache with Multiple Banks
對於Lower Level Cache(比如L2),它的read latency還是有點大的。假設我們有很多的cache access需要訪問不同的數據,能不能讓它們並行的access呢?
可以把L2 Cache分成多個Bank(也就是多個小分區),把數據放在不同Bank上。這樣就可以並行訪問這幾個Bank了。
那么如何為數據選一個合適的Bank來存呢?一個簡單的思路就是sequential interleaving:Spread block addresses sequentially across banks. E,g, if there 4 banks, Bank 0 has all blocks whose address modulo 4 is 0; bank 1 has all blocks whose address modulo 4 is 1; ...... 因為數據有locality嘛,把相鄰的塊存到不同bank,就可以盡量並行的訪問locality的塊了
6. Nonblocking caches
假設要執行下面一段程序:
1 Reg1:=LoadMem(A); 2 Reg2:=LoadMem(B); 3 Reg3:=Reg1 + Reg2;
當執行第一行時,cpu發現地址A不在cache中,就需要去內存讀。但讀內存的時間是很長的,此時CPU也不會閑着,就去執行了第二行。然后發現B也不在cache中。那么此時cache會怎么做呢?
- (a). cache阻塞,等着先把A讀進來,然后再去讀B。這種叫做Blocking Cache
- (b). cache同時去內存讀B,最終B和A一起進入Cache。這種叫做Non-Blocking Cache
可以看出Non-Blocking Cache應該是比較高效的一種方法。在這種情況下,兩條語句的總執行時間就只有一個miss penalty了:
(圖中只是大概的描述,不是精確的時間計算。。。如果用了上面介紹的multiple bank cache,那么hit時間可能也只需要一次了,很棒棒吧!)
• Reducing Miss Penalty
7. Early Restart and Critical word first
相對一個Word來說,cache block size一般是比較大的。有時候cpu可能只需要一個block中的某一個word,那么如果cpu還要等整個block傳輸完才能讀這個word就有點慢了。因此我們就有了兩種加速的策略:
- Critical Word First:首先從存儲器中讀想要的word,在它到達cache后就立即發給CPU。然后在載入其他目前不急需的word的同時,CPU就可以繼續運行了
- Early Restart:或者就按正常順序載入一整個block。當所需的word到達cache后就立即發給CPU。然后在載入其他目前不急需的word的同時,CPU就可以繼續運行了
大概就是這個意思:
根據locality的原理,一般來說CPU接下來要訪問的也就是這個block中的剩余內容。所以沒毛病!
8. Merging write buffers
?????(QR P65)
• Reducing Miss Rate
9. Compiler optimizations
這是最喜聞樂見的一種方法了hhhh
這里的reducing miss rate又可以分為Instruction miss和data miss兩類:
Instruction Miss:
• Reorder procedures in memory so as to reduce conflict misses
• Profiling to look at conflicts(using tools they developed) (之前面試還被問到過Linux profiling了......)
Data Miss:這個是比較重要的一種方式了。網上很多大神所說的黑科技優化C代碼的原理就是這個。
- 1. Merging Arrays: improve spatial locality by single array of compound elements vs. 2 arrays
假設有下面兩個定義(他們的功能都是一樣的,只是寫法不同):
/* Before: 2 sequential arrays */ int val[SIZE]; int key[SIZE]; /* After: 1 array of stuctures */ struct merge { int key; int val; }; struct merge merged_array[SIZE];
我們可以比較一下對於這兩種定義方式,它們在內存中的組織方式:
好的現在我們要對index k,分別訪問key[k]和val[k]。
/* Before: Miss Rate = 100% */ int k=rand(k); int _key=key[k]; int _val=val[k]; /* After: Miss Rate = 50% */ int k=rand(k); int _key=dat[k].key; int _val=dat[k].val;
可以看出第二種方式充分利用了spatial locality。對於同一個index k,讀取key_k的同時,val_k也被讀進cache啦,這樣就節省了一次訪問內存的時間。
上面這個還可以引申出另一個話題,叫做結構體對齊。
- 2. Loop Interchange: change nesting of loops to access data in order stored in memory
還是下面兩種程序,它們只是循環次序改變了:
int x[][]; //very large
//Assume a cacheline could contain 2 integers.
/* Before */ for (j = 0; j < 100; j = j+1) for (i = 0; i < 5000; i = i+1) x[i][j] = 2 * x[i][j];
/* After */ for (i = 0; i < 5000; i = i+1) for (j = 0; j < 100; j = j+1) x[i][j] = 2 * x[i][j];
我們知道在C語言中,二維數組在內存中的存儲方式是Row Major Order的,也就是這樣:
那么對於第一種寫法,訪問順序是x[0][0], x[1][0], x[2][0], ......。Miss Rate達到了100%
第二種寫法,訪問順序是x[0][0], x[0][1], x[0][2], x[0][3], ......。讀x[0][0]的時候可以把x[0][1]也讀進來,讀x[0][2]的時候可以把x[0][3]也讀進來,以此類推。這樣Miss Rate就只有50%啦
- 3. Loop Fusion: Combine 2 independent loops that have same looping and some variables overlap
來看個例子:
1 /* Before */ 2 for (i = 0; i < N; i = i+1) 3 for (j = 0; j < N; j = j+1) 4 a[i][j] = 1/b[i][j] * c[i][j]; 5 for (i = 0; i < N; i = i+1) 6 for (j = 0; j < N; j = j+1) 7 d[i][j] = a[i][j] + c[i][j]; 8 9 10 /* After */ 11 for (i = 0; i < N; i = i+1) 12 for (j = 0; j < N; j = j+1){ 13 a[i][j] = 1/b[i][j] * c[i][j]; 14 d[i][j] = a[i][j] + c[i][j]; 15 }
在第二種寫法中,line 13已經把a[i][j]和c[i][j]讀進cache了,line14就可以接着用了。加起來比第一種要省很多cache miss。
不過第一種寫法本身時間復雜度也高啊。。。這樣寫代碼會被人打的。。。
emmm上面這個例子比較弱智。。。下面再來看一個經典的Matrix Multiplication的例子:
假設我們要計算一個大矩陣的乘法,然后cache block是4個integer的大小。
矩陣乘法是三重循環,O(N^3)的。我們來分析不同的循環順序下,最內層循環的cache miss情況(因為cache很小,只會在最內層循環起作用,外面的肯定都要有miss的):
- 4. Blocking: Improve temporal locality by accessing “blocks” of data repeatedly vs. going down whole columns or rows
從上面的例子中可以看到,當每次access的是同一column中的不同row(a[1][3], a[2][3], a[3][3], a[4][3], ......),而不是同一row的不同colum時,miss rate是很可怕的。那么怎么避免這一現象呢?
一種思路是我們把整個大矩陣分解成若干個小矩陣(以所需的數據能被cache全部裝下為標准),然后每次都把這個小塊內要計算的任務全部完成,這樣就不用access whole column了。
/* Before */ for (i = 0; i < N; i = i+1) for (j = 0; j < N; j = j+1){ r = 0; for (k = 0; k < N; k = k+1) r = r + y[i][k]*z[k][j]; x[i][j] = r; } /* After */ for (jj = 0; jj < N; jj = jj+B) for (kk = 0; kk < N; kk = kk+B) for (i = 0; i < N; i = i+1) for (j = jj; j < min(jj+B-1,N); j = j+1){ r = 0; for (k = kk; k < min(kk+B-1,N); k = k+1){ r = r + y[i][k]*z[k][j]; } x[i][j] = x[i][j] + r; }
其中B叫做Blocking Factor。(QR P67)
• Capacity Misses from 2N3 + N2 to 2N3/B +N2
• Conflict Misses Too?(沒講)
Blocking Transformation
其實前面提到的這些access pattern現在已經可以被compiler自動優化了,所以也算是上古時代的黑科技了......
• Reducing miss penalty or miss rate via parallelism
10. Hardware prefetching
假設cache block只能裝下一個int,然后我們有如下指令:
int a[]; load a[0]; load a[1]; load a[2]; load a[3]; load a[4]; load a[5];
那么與其每次都cache miss重新載入,不如在第一次cache miss(load a[0])時,讓cache預測到接下來會用到a[1], a[2], a[3], ......,然后提前載入到next level cache里備用。這就是硬件的prefetching。
對於Instruction Prefetching,CPU fetches 2 blocks on a miss: the requested block and the next consecutive block.(Requested block is placed in instruction cache when it returns, and prefetched block is placed into instruction stream buffer)
對於Data Prefetching,Pentium 4 can prefetch data into L2 cache from up to 8 streams from 8 different 4 KB pages. Prefetching invoked if 2 successive L2 cache misses to a page, or if distance between those cache blocks is < 256 bytes.
但hardware prefetching只對比較predictable的access pattern(特別是instruction prefetching)起作用。如果是訪問一個動態鏈表那就不管用了......
11. Compiler prefetching
????(QR P69)
最后是對這些cache optimization的一個總結(QR P72):
...