
做這個實驗的時候我第一反應(yīng)是有點懷疑的CPU 的數(shù)據(jù)手冊上都寫著 L1 多少、L2 多少、cache line 是 64 字節(jié)為什么還要讓我寫一個 C 程序去測但真正把代碼跑起來、把曲線畫出來的那一刻我才意識到手冊給的只是標稱值程序見到的才是真實世界里的緩存行為。這個實驗本質(zhì)上是一個黑盒測量它不依賴任何 CPU 型號信息只靠訪問時間和緩存命中率的物理差異就能把一臺機器的緩存層次、容量、塊大小全部反推出來。這篇文章就把我完成計組實驗5cache 大小測量與 cache line 大小測量的完整過程整理出來包括原理、代碼、讀圖方法、踩坑記錄。如果你是計算機組成原理、體系結(jié)構(gòu)相關(guān)課程的學(xué)生或者自己買了個新 CPU 想驗證緩存參數(shù)這篇文章都可以直接照著做。1. 實驗想回答的核心問題1.1 為什么測比查手冊更有意義緩存cache是 CPU 和主存之間的一層高速緩沖。現(xiàn)代 CPU 至少有三層緩存L1 最快但最小L3 最慢但最大。我們平時常說的緩存也可能指 Redis 注解、HTTP 緩存、本地磁盤緩存各種術(shù)語容易被繞暈。但在計組實驗里我們關(guān)心的只有 CPU 硬件緩存參數(shù)就兩個緩存總?cè)萘恳约熬彺胬锩娴淖钚》峙鋯挝弧簿褪?cache line 的大小。查手冊當(dāng)然能查到這些參數(shù)但實際行為會受很多因素影響預(yù)取器開沒開、虛擬內(nèi)存的頁著色、同一物理核心的超線程、多核共享 L3 時的競爭都會讓性能發(fā)生變化。手冊的標稱值是一個理想邊界而實驗測量出來的是一個程序可見的拐點。對于操作系統(tǒng)、編譯器、性能優(yōu)化的人來說這個實測拐點才是真正有意義的。1.2 緩存容量和 cache line 分別是什么先簡單對齊概念。緩存容量是指 L1、L2、L3 分別能裝多少數(shù)據(jù)。cache line 是緩存和內(nèi)存之間傳輸數(shù)據(jù)的最小單位常見值是 64 字節(jié)也有平臺是 128 字節(jié)部分老處理器是 32 字節(jié)。舉個例子程序想讀一個 1 字節(jié)的 charCPU 不會只把 1 字節(jié)拿回來而是把它所在的整條 cache line比如 64 字節(jié)一起載入緩存。所以程序中兩個相距 0 到 63 字節(jié)的訪問在實際硬件上可能只觸發(fā)一次內(nèi)存讀取如果兩個訪問相距 64 字節(jié)就一定是兩條不同的 cache line。這個特性就是本次實驗的突破口。緩存大小影響訪問延遲的容量拐點cache line 大小影響跨步訪問的步長拐點。只要把時間和數(shù)據(jù)規(guī)模的關(guān)系測出來兩個參數(shù)就都浮出水面了。2. 兩個指標的測量原理拆解2.1 用容量階梯測量緩存大小假設(shè)我們有一個很大很大的數(shù)組比如 64MB。我們以 64 字節(jié)為步長遍歷它也就是一次只碰一條 cache line把數(shù)組從頭掃到尾。然后不斷縮小數(shù)組規(guī)模重復(fù)同樣的遍歷記錄每次訪問的平均耗時。原理特別直觀當(dāng)數(shù)組規(guī)模遠超某一級緩存時大部分訪問會落到下一級乃至內(nèi)存里速度明顯變慢當(dāng)數(shù)組規(guī)模剛好能被某級緩存裝下時訪問速度就會處于一個相對平緩的臺階。如果我們從 1KB 一直測到 64MB理論上會看到三個明顯的臺階對應(yīng) L1、L2、L3 的容量。臺階交界處就是對應(yīng)緩存的容量。但順序遍歷會遇到預(yù)取器干擾?,F(xiàn)在 CPU 的硬件預(yù)取器很聰明它發(fā)現(xiàn)你在順序讀就會提前把后面的數(shù)據(jù)拉進緩存導(dǎo)致訪問速度看起來沒那么慢。所以更嚴謹?shù)淖龇ㄊ请S機訪問。用指針追逐模式讓下一次要訪問的地址由上一次訪問的結(jié)果決定預(yù)取器就基本猜不中。我在后面代碼里先給出最直觀的順序遍歷版本同時補充指針追逐的改進策略。2.2 用跨步拐點測量 cache line 大小這次我們把數(shù)組固定在一個很大的規(guī)模上比如 32MB保證它無論如何都裝不進最大緩存。然后改變訪問步長從 1 字節(jié)、2 字節(jié)、4 字節(jié)一直增加到 1024 字節(jié)統(tǒng)計每次訪問的平均耗時。關(guān)鍵邏輯在于訪問同一個緩存行內(nèi)的不同字節(jié)只有第一次是真的從內(nèi)存加載后續(xù)都是緩存命中。當(dāng)步長小于 cache line 大小時每加載一條 cache line會有多個被訪問到的字節(jié)落在同一條 line 內(nèi)相當(dāng)于一次內(nèi)存訪問攤到多次程序訪問上平均時間就低。當(dāng)步長等于 cache line 大小時每條 cache line 只被訪問一次每次訪問都對應(yīng)一次實際的內(nèi)存加載平均時間達到峰值。步長繼續(xù)增大超過 cache line 大小后每條訪問都從新的一行拿數(shù)據(jù)但實際訪問次數(shù)變少了所以均攤下來每條的時間保持在一個高平臺上。用數(shù)字算一下假設(shè) cache line 是 64 字節(jié)數(shù)組 32MB。步長為 1 字節(jié)時一次完整的遍歷會發(fā)生 32MB 次程序訪問但內(nèi)存只真正加載了 32MB / 64 512K 次每個程序訪問平均攤到的硬成本大概是內(nèi)存延遲的六十四分之一。步長為 64 字節(jié)時程序訪問次數(shù)是 512K每次訪問都要跨一條新 cache line每個程序訪問都承擔(dān)一次完整的內(nèi)存加載延遲。所以曲線上必然在步長 64 附近出現(xiàn)一個突然爬升的拐點這個拐點對應(yīng)的橫坐標就是 cache line 大小。2.3 預(yù)取器、頻率和多核對測量的干擾原理聽起來很清爽實際上手才會發(fā)現(xiàn)臟活全在后頭。最典型的三個干擾源第一是預(yù)取器。順序訪問時預(yù)取器會把后續(xù)幾行提前拉進來讓內(nèi)存延遲看起來變短。對策是改用隨機化訪問或者在同一個測試內(nèi)重復(fù)多次把預(yù)取效果壓低。第二是 CPU 頻率?,F(xiàn)代處理器有睿頻溫度一變頻率就變時間讀數(shù)就會亂飄。實驗之前最好把測試線程綁定到固定核心并且盡量讓機器空閑不要開一堆后臺任務(wù)。第三是多核共享。L3 是整個芯片共享的別的核也在跑程序的話L3 同時被占用測量結(jié)果會被拉偏。寫代碼時用sched_setaffinity把進程綁到一個核上雖然不能完全隔離 L3 競爭但至少能減少一部分。3. 實驗環(huán)境與 C 語言實現(xiàn)3.1 工具選擇和準備我的環(huán)境是 Linux編譯器用 gcc。計時用clock_gettime(CLOCK_MONOTONIC)它返回單調(diào)時鐘不會因為手動改系統(tǒng)時間而跳變精度也足夠到納秒量級。需要特別注意的是編譯器問題。如果數(shù)組內(nèi)容簡單累加編譯器可能把整個循環(huán)優(yōu)化成無意義的常量計算所以測試數(shù)組必須聲明為volatile并且用一個 volatile 變量承接讀取結(jié)果確保每次內(nèi)存訪問都真實發(fā)生。還要做兩件事關(guān)掉會拉偏測試的 CPU 遷移用sched_setaffinity綁定到 0 號核心如果機器支持也可以考慮關(guān)掉超線程后再測減少同核爭搶。3.2 實驗一代碼測量緩存大小核心思路是讓數(shù)組容量從 1KB 增長到 64MB固定步長 64 字節(jié)記錄每次訪問的平均納秒數(shù)。#include stdio.h #include stdlib.h #include string.h #include time.h #include sched.h #define STRIDE 64 #define MAX_SIZE (64 * 1024 * 1024) static volatile unsigned char pool[MAX_SIZE]; static double now_ns(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, ts); return (double)ts.tv_sec * 1e9 (double)ts.tv_nsec; } static double run(int bytes, int loops) { volatile unsigned char sink 0; double t0, t1; int i, j; // 預(yù)熱把訪問過的頁面提前碰一遍避免把缺頁時間算進去 for (i 0; i bytes; i STRIDE) sink pool[i]; t0 now_ns(); for (j 0; j loops; j) { for (i 0; i bytes; i STRIDE) sink pool[i]; } t1 now_ns(); return (t1 - t0) / (double)((long)loops * (bytes / STRIDE)); } int main(void) { int kb[] {1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 6144, 8192, 12288, 16384, 32768, 65536}; int i; memset((void *)pool, 0, sizeof(pool)); // 綁定到 0 號核心 cpu_set_t set; CPU_ZERO(set); CPU_SET(0, set); sched_setaffinity(0, sizeof(set), set); for (i 0; i sizeof(kb) / sizeof(kb[0]); i) { int bytes kb[i] * 1024; int lines bytes / STRIDE; int loops 16 * 1024 * 1024 / lines; if (loops 1) loops 1; double ns run(bytes, loops); printf(%8d KB %10.3f ns/access\n, kb[i], ns); } return 0; }這段代碼的關(guān)鍵在loops的調(diào)整。數(shù)組變小的時候每輪訪問到的 cache line 數(shù)量少所以要多跑幾輪保證每個規(guī)模點的總訪問量差不多避免小數(shù)組因為跑得太快、計時器精度不夠?qū)е抡`差。volatile unsigned char sink是為了告訴編譯器每次讀取的結(jié)果都可能改變從而保住每一次內(nèi)存訪問。編譯時用gcc -O2 -o cache_size cache_size.c -lrt-lrt在老版本 glibc 上需要新系統(tǒng)一般可以省略。如果你想更嚴格可以在這段順序遍歷的基礎(chǔ)上改成指針追逐版先生成一個大小為bytes/4的隨機索引鏈然后從鏈頭開始一步步pos next[pos]。這樣每條指令的地址依賴上一次結(jié)果預(yù)取器基本沒法發(fā)揮作用L1、L2、L3 之間的臺階會更清晰。3.3 實驗二代碼測量 cache line 大小這次數(shù)組固定為 32MB保證大于大多數(shù) CPU 的 L3 容量。步長從 1 字節(jié)逐步增大到 1024 字節(jié)計算每次訪問的平均耗時。#include stdio.h #include stdlib.h #include string.h #include time.h #include sched.h #define MAX_SIZE (32 * 1024 * 1024) static volatile unsigned char pool[MAX_SIZE]; static double now_ns(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, ts); return (double)ts.tv_sec * 1e9 (double)ts.tv_nsec; } int main(void) { int stride[] {1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024}; int i; memset((void *)pool, 0, sizeof(pool)); cpu_set_t set; CPU_ZERO(set); CPU_SET(0, set); sched_setaffinity(0, sizeof(set), set); for (i 0; i sizeof(stride) / sizeof(stride[0]); i) { int s stride[i]; long access_count MAX_SIZE / s; int loops 64 * 1024 * 1024 / access_count; if (loops 4) loops 4; volatile unsigned char sink 0; double t0, t1; int j, k; // 預(yù)熱 for (k 0; k MAX_SIZE; k s) sink pool[k]; t0 now_ns(); for (j 0; j loops; j) { for (k 0; k MAX_SIZE; k s) sink pool[k]; } t1 now_ns(); double ns (t1 - t0) / (double)((long)loops * access_count); printf(stride %4d B, %10.3f ns/access\n, s, ns); } return 0; }這段代碼的運行邏輯是步長為 1 時access_count很大loops就會被壓小步長為 1024 時每輪訪問次數(shù)很少loops會變大。這是為了讓總訪問次數(shù)在同一個量級從而讓每毫秒的計時誤差不至于在數(shù)據(jù)點上造成完全不可信的波動。步長范圍建議根據(jù)實際 cache line 大小調(diào)整。常見的 x86 平臺 cache line 是 64 字節(jié)所以我在 32 和 64 之間多插了一個點。有些 ARM 平臺是 128 字節(jié)那你在 64、128、256 之間可以再加幾個點比如 96、160把拐點找得更準。注意步長最好保持 2 的冪這樣與緩存索引和組映射的關(guān)系更清晰。3.4 編譯、運行和結(jié)果采集兩個程序編譯后直接運行即可普通用戶權(quán)限就夠不需要 root。輸出是文本方便你重定向到文件后用 Python、Excel 或 gnuplot 畫圖。gcc -O2 -o cache_size cache_size.c gcc -O2 -o cache_line cache_line.c ./cache_size size_result.txt ./cache_line line_result.txt畫圖時橫軸建議用對數(shù)坐標。緩存大小測試橫軸是數(shù)組容量從 KB 到 MB差異可能上千倍線性坐標根本看不出早段的細節(jié)cache line 測試步長從 1 到 1024也要用對數(shù)坐標才直觀。4. 實測數(shù)據(jù)與讀圖方法4.1 預(yù)期曲線形態(tài)現(xiàn)代 x86-64 桌面處理器的典型緩存參數(shù)大致是層級典型容量典型訪問延遲L132KB ~ 64KB1 ~ 1.3nsL2256KB ~ 2MB4 ~ 10nsL38MB ~ 32MB20 ~ 50ns主存無上限60 ~ 120ns因此緩存大小測試的輸出曲線應(yīng)該是在 32KB 附近出現(xiàn)第一次跳升L2 邊界出現(xiàn)第二次跳升L3 邊界出現(xiàn)第三次跳升。三次跳升把曲線分成四段平緩區(qū)這個階梯非常明顯。cache line 測試的輸出曲線應(yīng)該是步長在 1 到 32 字節(jié)之間時每次訪問的平均時間相對平穩(wěn)在步長 64 附近突然變高之后保持在偏高平臺。拐點橫坐標直接等于 cache line 大小。4.2 縱軸和橫軸讀法緩存大小測試的縱軸是每次訪問 cache line 的平均耗時單位 ns/access。這里說的一次訪問是指一次pool[i]讀取由于步長固定為 64 字節(jié)它近似等于訪問一條 cache line的成本。緩存行測試的縱軸含義不同。縱軸是每訪問一個元素的時間元素大小是 1 字節(jié)。當(dāng)步長小的時候一次內(nèi)存加載能覆蓋多個元素均攤成本低當(dāng)步長等于 cache line 大小時一次訪問就要承擔(dān)一次完整的內(nèi)存加載成本高。所以這個實驗的縱軸不是純訪問延遲而是均攤到每個邏輯元素上的平均延遲。這個區(qū)別如果不說明白畫圖的時候很容易誤解成 cache line 越大越慢。4.3 我的一臺實際測試機結(jié)果在一臺 L1 32KB、L2 256KB、L3 8MB 的機器上緩存大小測試得到的數(shù)據(jù)大概是測試規(guī)模每訪問平均耗時8KB約 0.4 ns32KB約 0.9 ns64KB約 1.8 ns256KB約 3.5 ns1MB約 7.5 ns8MB約 14 ns16MB約 22 ns64MB約 24 ns可以看到在 32KB 附近曲線第一次抬頭對應(yīng) L1 容量在 256KB 附近第二次抬頭對應(yīng) L28MB 之后增幅變緩對應(yīng) L3 容量。為什么 L3 平臺不如前兩級那么陡因為現(xiàn)代 CPU 預(yù)取器和跨步訪問策略對 L3 訪問的掩蓋作用比較強但拐點依然可辨。cache line 測試部分的數(shù)據(jù)也符合預(yù)期步長 1 到 32 字節(jié)的平均訪問時間大約在 1.2 到 2.0ns 之間步長 64 突然跳到 8ns 以上。這說明 cache line 邊界就是 64 字節(jié)和手冊上的參數(shù)完全吻合。5. 常見問題與排查實錄5.1 編譯器把測試代碼優(yōu)化成什么都沒做這是最容易踩的坑。如果你沒把測試數(shù)組聲明成volatile編譯器會認為整個循環(huán)的累加結(jié)果從來沒被使用直接把它刪除或合并。你測到的不是內(nèi)存訪問時間而是循環(huán)空轉(zhuǎn)的耗時甚至可能快到一個不合常理的數(shù)值比如每訪問 0.001ns。解決方法是三件套測試數(shù)組全局聲明為volatile承接結(jié)果的變量也聲明為volatile編譯時用-O2而不是-O0。只要三點做到編譯器就沒法偷懶。5.2 曲線臺階不明顯全是鋸齒原因主要是預(yù)取器和系統(tǒng)噪聲。我遇到過的情況是機器后臺有索引服務(wù)在跑L3 時快時慢曲線在 16MB 到 64MB 之間上下抖動 30%。處理辦法先把后臺程序盡量停掉然后把測試進程綁定到一個固定核心。如果還不行可以嘗試關(guān)閉 CPU 硬件預(yù)取但普通環(huán)境下沒有 root 權(quán)限通常改不了 MSR所以我一般是用隨機訪問模式做替代不再依賴順序遍歷。隨機訪問會讓每個數(shù)據(jù)點都被真實未命中主導(dǎo)鋸齒會小很多。5.3 計時器分辨率不夠有些虛擬機或老內(nèi)核里clock_gettime的分辨率可能只有幾微秒而我們測量一次訪問只有零點幾納秒到幾十納秒直接測單次訪問肯定不行。代碼里已經(jīng)做了多次循環(huán)取平均但如果你的機器特別老可以把loops那一行的基準值從16 * 1024 * 1024提高到64 * 1024 * 1024讓總耗時放大到毫秒級。如果是在虛擬化環(huán)境里測我的建議是放棄這個實驗去實體 Linux 機器上跑。虛擬機的時間切片和中斷注入會讓曲線變成瘋子測出來的數(shù)據(jù)只能作為相對趨勢參考不能當(dāng)真實硬件參數(shù)。5.4 打開perf驗證硬件計數(shù)器時間測量本質(zhì)上是間接推斷如果想確認緩存大小臺階確實對應(yīng)緩存失效可以在 Linux 下用perf stat看硬件計數(shù)器。比如緩存大小測試跑到 32KB 和 64KB 兩個規(guī)模時分別看 L1 緩存失效次數(shù)perf stat -e cache-references,cache-misses,l1d-loads,l1d-load-misses ./cache_sizeperf stat輸出是整個程序的總計不方便按規(guī)模拆分。更精細的做法是在 C 代碼里調(diào)用perf_event_open在每個測量點前后讀一次硬件計數(shù)器。不過對大多數(shù)實驗課來說用時間曲線已經(jīng)足夠了。硬件計數(shù)器主要用于驗證為什么這里會拐彎屬于錦上添花。5.5 多核緩存共享帶來的誤判如果你在一顆 8 核開滿任務(wù)的情況下測 L3測出來的 L3 容量可能不足因為有一部分被其他核搶占了。更隱蔽的是同一顆物理核心的超線程也會共享 L1 和 L2如果另一個邏輯核在跑任務(wù)你的 L1、L2 就會被擠壓。我的經(jīng)驗是先用taskset -c 0或者代碼里的sched_setaffinity綁定核心再用top或者htop確認 0 號核心基本空閑然后才開始測。如果機器是大小核架構(gòu)比如 Intel 12 代以后的 P 核和 E 核最好綁定到一個固定的 P 核上大小核混跑會讓數(shù)據(jù)出現(xiàn)兩套完全不同的曲線。6. 最后說點個人體會這個實驗做完之后我對緩存是什么的理解完全不一樣了。以前背的L1 32KB、L2 256KB、cache line 64 字節(jié)只是紙面上的數(shù)字現(xiàn)在我看一條曲線就能直接說出這臺機器緩存分幾層、每層大概多大。這種能力在調(diào)優(yōu)內(nèi)存訪問密集型程序的時候特別有用比如矩陣分塊、池化分配、鏈表轉(zhuǎn)數(shù)組這些優(yōu)化本質(zhì)上都是在順應(yīng)緩存的行為。實際跑實驗時我個人建議不要只跑一遍。多跑兩三遍取每次讀數(shù)的中位數(shù)而不是平均數(shù)因為平均數(shù)容易被偶發(fā)的中斷拉高。如果你用 Python 處理結(jié)果可以直接畫一張雙對數(shù)圖把緩存大小測試和 cache line 測試兩條曲線放在一起很多規(guī)律一眼就看出來了。這個實驗后續(xù)還能繼續(xù)擴展比如測量緩存相聯(lián)度、測量不同寫分配策略的效果甚至改成用rdtsc指令做高精度計時。每次換一種測法都能從 CPU 這個黑盒里多撬出一層真相。