引言
上一篇學習了虛擬內存,虛擬內存解決了多道程序併發運行的內存分配和解決小內存運行大程序的問題。這一篇將講解高速緩存,來解決內存的低速和cpu高速不匹配的問題,計算機的很多的問題,其實都可以中間加一層解決,因此計算機才變得複雜,想象一下如果主存的速度可以匹配cpu的速度那麼高速緩存就不需要了,再進一步,假設硬盤的速度可以匹配cpu,我想主存也可以不需要了。但是現有的技術還達不到這個目標,所以這一部分還是得學習一下高速緩存,看看高速緩存是如何解決cpu快速訪問指令和數據的問題。
基本概念
CPU、緩存、內存架構
現代cpu一般都是多核的,高速緩存通常分了三級,L1分爲指令緩存和數據緩存並且是cpu核心獨有;L2也是cpu核心獨有;L3則是多核心共享,其示意圖如下
通用緩存基礎參數
- S:通常代表幾路,也代表分組
- E:代表每一路中有多少個緩存行,代表緩存的路數
- B:代表一個緩存行有多少個byte
- 所以緩存的容量爲
全相聯映射
-
全相聯映射地址結構
-
全相聯和內存映射關係,全相聯緩存的一個緩存行可能緩存內存中任何一行,檢查緩存行時遍歷緩存比較tag,然後通過Block offset取值
-
全相聯的缺點:全相聯判斷緩存是否命中時需要比較緩存行中的每一行,電路延遲較長
直接相聯映射
-
直接相聯映射結構,和全相聯不同,直接相聯的地址分爲三部分,tag、set index和Block offset,set index表示將地址映射到緩存的Set上索引,因此內存不在是任意的映射到緩存,而是通過set index位嚴格映射到對應的緩存Set上,直接相聯的映射流程爲先求出set index找到緩存中對應的Set,然後判斷tag是否一致
-
直接相聯缺點:緩存衝突時,必須被換出,頻繁更換內容會造成大量延遲
分組相聯映射
-
分組相聯映射結構,結合了全相聯和直接相聯映射的特點,爲了解決直接相聯緩存衝突導致頻繁更換造成緩存的延遲,增加了一個Set中緩存行的行數,可以有效降低緩存的衝突。分組相聯映射查找緩存的流程是先求出set index找到對應的緩存分組,然後遍歷分組中緩存行的tag,對比是否命中。分組相聯的結構如圖
-
一個實際cpu的緩存行
緩存寫策略
- Cache命中時寫策略
- 寫穿透,數據直接寫回緩存和主存
- 回寫,數據寫回緩存,僅當數據塊被替換時,將數據寫回主存
- Cache未命中時寫策略
- 寫不分配,直接將數據寫回主存
- 寫分配,將數據塊讀入緩存,然後在寫回緩存
緩存替換策略
- 隨機替換
- 輪轉替換
- 最近最少使用LRU
MESI緩存一致性協議(四個狀態的首字母)
-
定義:MESI協議是一種窺探協議,cache和cache之間的數據傳輸發生在同一條總線上,cache不但與主存通信和總線打交道,還會窺探總線上發生的數據交換,跟蹤其他cache在做什麼
-
四種狀態
- Modified:緩存行是髒的(dirty),與主存的值不同
- Exclusive:緩存行只在當前緩存中,但是乾淨的(clean)–緩存數據同於主存數據。當別的緩存讀取它時,狀態變爲共享;當前寫數據時,變爲Modified狀態
- Shared:緩存行也存在於其它緩存中且是乾淨的。緩存行可以在任意時刻拋棄
- Invalid:緩存行是無效的
-
操作
- cpu對緩存的請求
- PrRd: 處理器請求讀一個緩存塊
- PrWr: 處理器請求寫一個緩存塊
- 總線對緩存的請求
- BusRd: 窺探器請求指出其他處理器請求讀一個緩存塊
- BusRdX: 窺探器請求指出其他處理器請求寫一個該處理器不擁有的緩存塊
- BusUpgr: 窺探器請求指出其他處理器請求寫一個該處理器擁有的緩存塊
- Flush: 窺探器請求指出請求回寫整個緩存到主存
- FlushOpt: 窺探器請求指出整個緩存塊被髮到總線以發送給另外一個處理器(緩存到緩存的複製)
- cpu對緩存的請求
-
狀態轉換圖
-
cpu請求緩存的響應
-
總線請求緩存的響應
緩存實際運用
查看cpu緩存信息
lscpu
如下圖,L1d和L1i分別爲32kb,L2爲256kb,L3爲10240kb。
查看L1緩存的路數和行數,64組8路塊大小64bytes的緩存
緩存測試
public class CacheTest {
public static void main(String[] args) {
int[][] nums = new int[10000000][16];
for (int i=0; i<10000000; i++) {
for (int j=0; j<16; j++) {
nums[i][j] = i+j;
}
}
System.out.println(System.currentTimeMillis());
sum1(nums, 10000000, 16);
// sum2(nums, 10000000, 16);
System.out.println(System.currentTimeMillis());
}
/**
* 緩存命中低
* @param nums
* @param row
* @param col
* @return
*/
private static int sum1(int[][] nums, int row, int col) {
int sum = 0;
for (int j=0; j<col; j++) {
for (int i=0; i<row; i++) {
sum += nums[i][j];
}
}
return sum;
}
/**
* 緩存命中高
* @param nums
* @param row
* @param col
* @return
*/
private static int sum2(int[][] nums, int row, int col) {
int sum = 0;
for (int i=0; i<row; i++) {
for (int j=0; j<col; j++) {
sum += nums[i][j];
}
}
return sum;
}
}
爲了測試一下有效利用緩存的區別,寫了上邊的測試代碼,因爲緩存行的塊大小爲64bytes,所以我們定義一個二維數組,每行16個int值,剛好是64bytes,也就是一個緩存行。
測試結果,sum1是按列的順序求和,執行時間在1000ms左右;sum2是按行求和,執行時間爲140ms左右,由此可見緩存命中與否對性能有很大的差別