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

未命中、寫入策略與平均存取時間(AMAT)

你已經會把位址拆成標籤、索引與偏移——但一個快取到底有多好?這一篇我們用平均存取時間(AMAT)給它一個數字,把每一次未命中歸進三種有名字的原因之一,解決程式寫入時該怎麼辦這個尷尬問題,並看看為什麼兩層快取疊在一起會勝過一個大快取。

給快取一個數字:平均存取時間

到現在,你已經能拿一個 32 位元位址,把它切成標籤、索引與偏移,並走完那條以命中未命中作結的查找。但「大多會命中」是一種感覺,不是一個量測值。要比較兩個快取,或判斷某個改動值不值得它佔的矽面積,我們需要一個誠實的數字,來說明一次平均的記憶體存取究竟要花多久。這個數字就是平均記憶體存取時間,也就是 AMAT,它把前面幾篇講過的東西全綁進一條公式裡。

想法很簡單。每一次存取都要付命中時間——探查快取本身所花的時間——因為你總得先看一眼。接著,只有在未命中時,你才額外付未命中代價:從下一層把快取行取上來的那段額外時間。所以 AMAT = 命中時間 + 未命中率 x 未命中代價。未命中率就是會未命中的存取所佔的比例;它是唯一受區域性掌控的項,也正是整個階層之所以管用的原因。注意這個形狀:一個小小的命中時間你總是要付,加上一個很大的未命中代價你卻很少要付。

AMAT = hit_time + miss_rate x miss_penalty

Example: hit_time = 1 cycle
         miss_penalty = 100 cycles (go to DRAM)

  miss_rate = 10%  ->  AMAT = 1 + 0.10 x 100 = 11 cycles
  miss_rate =  3%  ->  AMAT = 1 + 0.03 x 100 =  4 cycles
  miss_rate =  1%  ->  AMAT = 1 + 0.01 x 100 =  2 cycles

Going from 10% to 1% misses: same hits, but ~5x faster average.
未命中代價遠大於命中時間,所以削減未命中率才是真正撼動 AMAT 的關鍵。

盯著那張表看一會兒,因為它承載了快取的全部寓意。命中時間幾乎沒變;未命中代價從頭到尾沒變;可是平均值卻擺盪了 5 倍。當一次未命中的代價遠遠壓過一次命中的成本——而跑去 DRAM 真的差不多是一百個週期對上一個——平均值就被那些罕見的未命中、而不是常見的命中所主宰。這就是為什麼架構師會執著於未命中率的最後一個百分點,也是為什麼這篇接下來都在講如何理解並降低它。

三種 C:每一次未命中都有原因

要降低未命中率,得先知道你為什麼未命中。一個極為釐清思路的分類法,三種未命中的 C,說每一次未命中都恰好屬於三種之一。強制性(或冷啟動)未命中是某一行的第一次被碰到——資料根本從沒進過快取,所以再大的快取也幫不上忙。容量未命中發生在快取太小、裝不下程式整個工作集時,於是你需要的某一行為了騰位子而被趕了出去。衝突未命中則是最令人痛的那種:那一行被丟掉不是因為快取滿了,而是因為太多行對應到同一個集合,把彼此擠了出去。

這個劃分的價值在於,每一種 C 都有自己的解法,而這些解法朝不同方向拉扯。把每次未命中多抓一點資料——更大的區塊,或在資料被要求之前就預取下一行——能減少強制性未命中。把快取做大,能減少容量未命中。提高關聯度,能減少衝突未命中,因為一個關聯度更高的集合有更多空位來吸收彼此相撞的行;集合關聯快取的衝突遠少於直接對應快取,而依定義,一個全關聯快取根本沒有衝突未命中。

當程式寫入時:兩種策略

目前為止每一次存取都是讀取——快取交回一份複本,而記憶體裡的原本毫髮無傷。寫入則引出一個真正尷尬的問題:快取裝的是記憶體的一份複本,那麼當程式改動了這份複本,DRAM 裡真正的記憶體什麼時候才會知道?把快取想成你桌上的影印本,把記憶體想成檔案櫃裡的母本。如果你在影印本上塗改,遲早得把它和母本對帳——唯一的問題是什麼時候,而這有兩個誠實的答案。

寫穿同時更新兩邊:每一次寫入都進快取並且直接送到記憶體。它很單純,母本永遠是最新的,但它讓通往記憶體的路徑塞滿流量,因為就算是一個被你塗改一千次的變數,也會往下游送一千次寫入。寫回比較懶,也常見得多:一次寫入只更新快取裡的那一行,並設一個髒位元標記「這份複本變了」。記憶體一直保持過時,直到那一行被逐出,這時整條髒行才一次寫回去。一個在迴圈裡狂奔的計數器,是在結尾被碰一次,而不是在中途被碰一千次。

還有一個小選擇隨之而來:在一次未命中的寫入上,你是先把那一行拉進快取(寫入配置),還是只把寫入推到記憶體、跳過快取(不配置)?寫回快取幾乎總是搭配寫入配置,因為一旦那一行進了快取,接下來對它的所有寫入就都變成便宜的命中。所以你在幾乎每一顆現代 CPU 裡會碰到的日常組合,就是寫回加寫入配置、每行一個髒位元——懶散、流量輕,並且完美契合你已經知道寫入往往具備的時間區域性。

多層快取:不要選小或大——兩個都要

回頭看 AMAT 公式,你能感覺到一股張力。要讓命中時間極小,你想要一個貼著核心的小快取。要讓未命中率很低,你想要一個大快取。你沒辦法有一個既小大的快取——所以現代晶片乾脆做不只一個。多層快取把一個又快又小的 L1 緊貼在管線旁,後面接一個較大、較慢的 L2,再後面常常還有一個大型的共享 L3,最底下是 DRAM。每一層都是一張桌子;L1 是手肘邊的幾本書,L2 是房間另一頭的書架,L3 是走廊盡頭的儲藏室,而記憶體是城另一邊的圖書館。

讓這套划算的訣竅,在於某一層的未命中代價會變成下一層的 AMAT。當 L1 未命中時,它不會直接跳到上百週期的 DRAM;它去問 L2,而 L2 也許是十個週期、通常會命中。於是公式層層嵌套:AMAT = L1 命中時間 + L1 未命中率 x(L2 命中時間 + L2 未命中率 x L2 未命中代價)。如今那罕見的 L1 未命中大多被一個快速的 L2 吸收掉,只有更加罕見、連兩層都逃掉的未命中才付完整的 DRAM 代價。每一層都削掉它上一層的有效代價。

套上數字才感受得到這場勝利。在單一層下,5% 的未命中率對上 100 週期的 DRAM 代價,得到 AMAT = 1 + 0.05 x 100 = 6.0 個週期。現在在 L1 與記憶體之間插進一個 L2:L1 仍然 5% 的時候未命中,但它的代價不再是 100——而是 L2 的 AMAT。如果 L2 用 10 個週期命中、L1 的未命中只有 20% 逃到 100 週期的 DRAM,那麼 AMAT = 1 + 0.05 x(10 + 0.20 x 100)= 1 + 0.05 x 30 = 2.5 個週期。同樣是 5% 的 L1 未命中率,平均值卻砍掉一半有餘,因為 L2 接住了大多數 L1 漏掉的東西。

有兩個數字能幫你誠實地讀懂多層快取。某一層的區域未命中率是未命中數除以抵達該層的存取數——L2 的區域率看起來高,是因為 L1 已經把所有容易命中的都撈走了。全域未命中率則是某一層的未命中數除以 CPU 的全部存取數,而只有最後一層的全域率,才告訴你你真正一路掉到記憶體的頻率有多高。把這兩者搞混,會讓一個其實很好的 L2 看起來很糟,所以架構師會兩個都報出來。

這換來了什麼,以及我們接著去哪

退一步看,快取就不再是個神祕的盒子,而變成一組你能推理的旋鈕。AMAT 把「這個快取好不好?」變成算術。三種 C 告訴你該轉哪一個旋鈕——區塊大小對應強制性、容量對應容量、關聯度對應衝突。寫回加上一個髒位元,能在逐出之前把寫入流量擋在匯流排外。而多層快取藉由把層級疊起來、讓每一層接住上一層漏掉的,化解了小與大之間的兩難。

  1. 用 AMAT = 命中時間 + 未命中率 x 未命中代價 來量測快取;記得主宰結果的是罕見的未命中,不是常見的命中。
  2. 把每次未命中診斷為強制性、容量或衝突——這個標籤會直接指向解法。
  3. 日常 CPU 選寫回加寫入配置;保留一個髒位元,只在逐出時才和記憶體對帳。
  4. 把 L1/L2/L3 疊起來,讓每一層的未命中代價變成下一層小得多的 AMAT。

這裡有件最重要、要帶著走的事:我們轉的每一個旋鈕都是硬體,但餵養它的未命中率,是由你的程式碼決定的。快取只獎賞那些富含時間與空間區域性的存取樣式,而一個對快取不友善的迴圈,會算出一模一樣的答案、卻慢上許多倍。這個階段的下一篇、也是最後一篇,將跨過那條線——從架構師那一側來到你這一側——並展示迴圈順序,以及像分塊(tiling)這樣對快取友善的技巧,如何能在同一顆晶片上悄悄讓一個程式快上好幾倍。