JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

記憶體階層與快取列

你一直把記憶體想成一整片扁平的位元組陣列,中央處理器一步就讀得到。那幅圖像是個圖個方便的謊言:真實的記憶體慢得令人絕望,而你的程式之所以不至於冰河般遲緩,唯一的理由是一座藏在中央處理器背後、由微小而飛快的快取疊成的塔。這篇導引從地基把這座塔砌起來,最後落在那個暗中統御這一切的單位上——64 位元組的快取列。

關於記憶體,你一直被告知的那個謊

到目前為止,每一級都遞給你同一幅令人安心的圖像:記憶體是一條長長的位元組陣列,而 `p` 一步、均勻地讀出某個位址處的那個位元組。從位元組陣列那一級,一路經過指標、堆疊、堆積,那個扁平模型一直是恰恰好的抽象——它讓你能推理你的程式碰了什麼*,而不必淹沒在「每一次碰要花多久」裡。這一篇,就是我們掀開蓋子、承認後半段真相的地方。那片扁平的陣列是個圖個方便的謊,而它藏起了「你這輩子會去編程的每一台機器」最要緊的那個效能事實。

事實在此,而且用的是唯一要緊的單位——時間。一個現代中央處理器核心大約以每秒 30 億個週期運轉,所以一個週期約莫是三分之一奈秒。在那一個週期裡,它能把兩個已經坐在暫存器裡的數字相加。但一次必須一路跑到主記憶體(DRAM)的讀取,要花大約 100 奈秒——姑且算它 300 個週期。好好咀嚼這個比例。如果把中央處理器想成一個每秒能做一道算術的人,那麼伸手去 DRAM 拿一個值,就像那個人為了去拿它而停下手邊工作整整五分鐘。一個除了等記憶體以外什麼都不做的程式,跑起來會遠低於這顆晶片真正算術速度的百分之一。

這不是一個細節;它是現代硬體的核心張力。處理器變快的速度,遠遠快過記憶體變快的速度,而這道鴻溝——常被稱作記憶體牆(memory wall)——數十年來只是越拉越開。所以整台機器都圍繞著一個問題打造:你要怎麼用一個慢了 300 週期的記憶體,去餵一個快了 300 週期的算術引擎,還讓它忙個不停?答案是記憶體階層,而你一旦看見它,就再也無法在你的效能數字裡對它視而不見了。

砌起這座塔:從暫存器到磁碟

你沒辦法做出一個既巨大又飛快的記憶體——物理和金錢都不准。飛快的記憶體(用和中央處理器相同的電晶體做的)又小又貴;便宜的記憶體(DRAM)又大又慢。於是工程師不再嘗試二選一,而是把它們疊起來:在逐層變大、變慢的那些層之前,擺幾個又小又快的層。每一層都裝著它下一層最有用的那一小片的副本。那個堆疊就是記憶體階層,而讓它行得通的把戲是:又小又快的那些層回答了壓倒性多數的請求,所以你大多時候付的是快的價碼,只偶爾才付慢的。

level        typical size     typical latency      ~cycles
--------------------------------------------------------------
registers    ~ 256 bytes       0 cycles (in CPU)        0
L1 cache     ~ 32-64 KiB       ~ 1 ns                   ~4
L2 cache     ~ 256 KiB-1 MiB   ~ 4 ns                   ~12
L3 cache     ~ 8-32 MiB        ~ 15 ns                   ~40
main memory  ~ 8-64 GiB        ~ 100 ns                 ~300
SSD / disk   ~ TiB             ~ 100 us - 10 ms       100k+

  smaller & faster  <----------------------->  larger & slower

(approximate; exact numbers vary by chip, but the SHAPE is universal)
每往下一階大約慢上 3 到 4 倍、也大得多。確切數字在不同機器間會變動,但這道階梯的形狀——以及從 L3 跌落到 DRAM 的那道懸崖——存在於每一顆現代中央處理器上。

從上往下讀這道階梯。暫存器你已經在組合語言那一級認得了——rax、rsp 之流——它們實際上免費;中央處理器直接拿它們來運算。在它們之下坐著三層晶片上的快取,L1、L2、L3,一層比一層大、一層比一層慢,全都用快速的 SRAM 做成,全都和那些核心住在同一塊矽晶粒上。在那些之下是主記憶體,就是你以 GiB 為單位買的那個 DRAM,再之下是 SSD 或磁碟,它慢到屬於另一個世界(就是虛擬記憶體那一級的分頁錯誤伸手進去的那個世界)。注意那道懸崖:每一階快取只讓你付出一個小倍數,但從 L3 跌進 DRAM 是一道 *7 倍*的落差,而從 DRAM 跌到磁碟是一千倍的落差。整場遊戲就是待在這道階梯的高處。

快取究竟為何行得通:區域性

一個公道的反對:一個 32 KiB 的 L1 快取,怎麼可能在程式漫遊於好幾個 GiB 之上時,還服務了大多數的請求?如果記憶體存取真的是隨機的,它就辦不到——快取會毫無指望,任何階層都救不了。快取之所以行得通,純粹是因為真實的程式並非隨機。它們服從兩條經驗上的規律,合稱區域性,而這正是支撐起整座塔的承重假設。

時間區域性(temporal locality):如果你碰了一個位址,你極可能很快會再碰它一次。想想一個迴圈計數器、堆疊頂端,或一個熱門的全域變數——在數微秒之內被一用再用。空間區域性(spatial locality):如果你碰了一個位址,你極可能很快會碰它的鄰居。想想一個元素一個元素地走訪陣列,或一個接一個地讀一個結構的各個欄位。快取藉由留住最近用過的資料來利用時間區域性,又藉由每次提取任何東西時把鄰居也一把抓來來利用空間區域性——這正是下一節要談的快取列的由來。

快取列:記憶體真正的單位

現在來談這整級裡最重要的那一個觀念,也是接下來四篇導引全都倚靠的那一個。快取並不——也無法——儲存個別的位元組。它只以固定大小的區塊打交道,這些區塊叫做快取列,而在本質上每一顆現代中央處理器上,一條列都是 64 位元組。當你連一個 `char` 都讀的時候,硬體並不會去提取那一個位元組。它提取的是包含它的、整條對齊的 64 位元組列,並把全部 64 個位元組當成一個不可分割的單位裝進快取。你索取的那個位元組和它的 63 個鄰居一起抵達、一起搭便車、一起被擠出。

這單單一個設計選擇,就是空間區域性,烙進了矽裡。把記憶體想成被鋪成一格格 64 位元組的網格:位元組位址 0x00 到 0x3F 是第 0 列、0x40 到 0x7F 是第 1 列,依此類推——一條列總是從 64 的倍數的位址開始(它的低 6 個位元為零,因為 2^6 = 64)。當你走過一個 `int` 陣列(每個 4 位元組)時,對一條新列的第一次存取會未命中、並付足那趟 DRAM 之旅,但接下來的 15 個 int 就住在那條剛提取的同一列裡、幾乎免費就命中。這就是為什麼緊湊地循序走過一個陣列,是中央處理器做得最快的事情之一:你每 16 個元素才為記憶體付一次費,而不是每個元素一次。

快取列重新框定了一條你很久以前就遇過的規則。對齊與填充那一級曾叫你把結構的欄位整整齊齊地擺好;現在你能看見更深的理由了。你一起使用的欄位應該坐在同一條快取列上,好讓一次未命中把它們全帶進來,而一個無謂地橫跨 64 位元組邊界的結構,會為了本該一次完成的事而逼出兩次列提取。對齊從來就不只是一道 ABI 的形式手續——它在底層,談的是你的資料落在哪一條快取列上。列,而不是位元組,才是記憶體真正的紋理,而接下來那些導引裡幾乎每一個微架構效應,骨子裡都是一個關於列的故事。

未命中、預取,與受限於記憶體

當中央處理器需要一個位元組時,它先查 L1;在那裡找到那條列就是一次命中(hit)。如果 L1 沒有它,那就是一次 L1 未命中(miss),請求便往下走到 L2、再到 L3、再到 DRAM,在第一個持有那條列的層停下,並在回來的路上把它往上複製進更近的那些層。統御你程式記憶體效能的那單單一個數字,是命中率(hit rate):在 99% 的 L1 命中下,一個工作負載感覺起來和中央處理器一樣快,但掉到 90%,那落穿到 DRAM 的 10%——每一次都付 300 個週期——就可能主宰整個執行時間。命中率差幾個百分點,就是「快」與「不能用」之間的差別。

硬體並不被動地等你未命中。因為循序存取太常見了,中央處理器內含一個硬體預取器:一個小單元,盯著你的存取模式,當它認出一個步幅(你讀了第 5 列、然後第 6 列、然後第 7 列)時,便在你開口之前投機地提取第 8 列,好讓你抵達時資料已在快取裡。這就是為什麼可預測地向前行進走過一個陣列,能以近乎 DRAM 頻寬的速度跑、幾乎看不見未命中延遲,而一場隨機的指標追逐——它給不了預取器任何可鎖定的模式——卻幾乎在每一次存取上都停滯。預取器是你沉默的盟友,但前提是你的存取模式規律到足以讓它預測;用循序、可預測的步幅去回報它。

這一切就是為什麼有一種心智習慣——有時叫做機械同理心(mechanical sympathy)——把好的系統程式設計師和其餘人區分開來:寫出順著硬體紋理、而非與之相逆的程式碼。當一個程式的速度是由「記憶體能多快餵它」、而非由「中央處理器能多快運算」所決定時,我們稱它受限於記憶體(memory-bound)——而真實軟體裡有極大一部分正是如此,即使作者以為瓶頸在算術。這個誠實的結論很令人謙卑:在現代硬體上,你的資料在哪裡、你怎麼走過它,往往比你的演算法多聰明還要要緊。接下來四篇導引會把這底下的機制一一攤開——結合度、一致性、TLB、管線——但它們每一篇,骨子裡都是你剛才認識的那條快取列的故事。