藏在每次存取裡的稅
第 2 篇導覽把整套位址轉譯的機器都建好了:CPU 產生一個邏輯位址,硬體把它拆成一對(分頁編號、位移),拿分頁編號去分頁表查出一個框,再把框和位移黏起來,得到真正的實體位址。它就像一本書的索引:查那一筆,讀出東西真正住在哪裡。乾淨又正確——但它藏著一個我們現在必須正面面對的代價。
刺人的地方就在這裡。分頁表住在 RAM 裡,硬體透過分頁表基底暫存器找到它。所以為了轉譯一個位址,CPU 必須先讀分頁表——光是為了知道資料在哪,就跑了一趟記憶體。只有在這之後,它才能跑第二趟去取程式真正想要的資料。每一次記憶體存取都安靜地變成了兩次。程式讀的記憶體是它所要求的兩倍,其中一半是純粹的記帳。如果一張平面表讓你付雙倍,一張多層表(第 4 篇導覽的主題)讓你付三倍或更糟——每多一層就多一趟。
為你剛用過的分頁貼的便利貼
拯救之道是一個小而極快的快取,它坐在 CPU 的記憶體管理單元裡,存著你最近最常用到的那幾筆轉譯。它就是轉譯後備緩衝區,也就是 TLB。把它想成貼在你書桌邊緣的便利貼。在書的索引(分頁表)裡查一個名字,要完整跑一趟到書架;但那幾頁你一直翻回去的,你會在便利貼上隨手寫下——「第 5 頁住在框 87」——從此以後你只要瞄一眼那張便利貼。TLB 正是如此:一張極小的近期(分頁編號到框)映射表,緊挨著 CPU 放著,於是一次查找基本上是免費的。
有兩個設計事實讓 TLB 與眾不同。第一,它很小——通常只有數十到數百筆——因為它是用昂貴、快速的硬體做的。你沒辦法把整張分頁表塞進這裡,只能放下正在使用中的那批分頁(工作集)。第二,它的搜尋方式是「同時、平行地」比對它所有的項,而不是一筆一筆地檢查。正是那套平行搜尋硬體讓瞄一眼便利貼幾乎是瞬間完成的,而這也正是 TLB 必須維持小巧的原因:一次比對好幾千筆,做起來太貴了。
為什麼只留那一把便利貼就有用?因為參考局部性——真實程式那個深層、可靠的習慣。程式幾乎從不把它的存取均勻撒滿整個記憶體。它會反覆捶打同一個迴圈、走過同一個陣列、一連幾千次碰同一個堆疊框。所以在任何一個短暫的時間窗裡,總是那少數幾頁被一用再用,而一個只記住那少數幾頁的快取,絕大多數時候都會贏。局部性是支撐整顆 TLB 的那個安靜假設;少了它,一個極小的快取幾乎永遠不會剛好握著你想要的那一頁。
一次命中、一次失誤,逐步來看
現在來看硬體在路徑上有 TLB 的情況下,如何轉譯單一一個位址。CPU 剛產生了一個邏輯位址;它切出分頁編號,把它呈給 TLB。一切都取決於那個分頁編號是否在某張便利貼上——一次TLB 命中——還是不在——一次 TLB 失誤。跟著兩條分支走:
- 拆分並呈遞。硬體把邏輯位址切成(分頁編號、位移),把分頁編號交給 TLB。此時還沒碰到記憶體。
- TLB 命中(常見情況)。TLB 同時比對它所有的項,找到了那個分頁編號,立刻交回那個框。把框黏上位移,做那唯一一次真正的記憶體存取去取資料。總計:零次分頁表往返——那張表根本沒被碰到。
- TLB 失誤(罕見情況)。那個分頁編號不在任何一張便利貼上。現在硬體退回到慢路徑:前往分頁表基底暫存器,讀 RAM 裡的分頁表去找那個框。這就是我們原本想避免的那趟額外記憶體往返。
- 寫下新的便利貼。既然已經付了慢查找的代價,硬體就把這筆新鮮的(分頁編號到框)映射存進 TLB,好讓下一次存取這一頁就是命中。如果 TLB 滿了,它會逐出一筆舊的項來騰出空間——通常是最久沒用到的那張便利貼。
- 完成這次存取。框現在已知,把它黏上位移,做那次真正的記憶體存取去取資料——和命中時最後一步相同,只是走了長路才抵達。
把那道岔路的形狀記在腦子裡,因為那就是整件事的重點。在命中分支上,分頁表從未被打開——一次記憶體存取,直奔資料。在失誤分支上,你多付一次存取去讀表,然後留下一張便利貼,好讓對那一頁的「下一次」存取就變成命中。兩條分支在同一個最後步驟重新會合:框加上位移得到實體位址,一次真正的存取取回資料。TLB 並不取代分頁表;它只是讓你在絕大多數的存取上,省下那趟前往分頁表的往返。
它真的划算嗎?算一算
類比能說服人,但數字能證明。誠實的量尺是有效存取時間——把快速的命中和緩慢的失誤,按各自發生的頻率混合之後,一次記憶體存取平均花的時間。算法就是一個加權平均:把每條路徑的代價乘上它的機率再相加。設一次記憶體存取花 100 奈秒,並假設 TLB 查找本身快到我們把它約為零。一次命中花一次記憶體存取(100 奈秒)。一次失誤花兩次(一次讀分頁表、一次取資料),所以是 200 奈秒。
現在代入一個實際的命中率。多虧局部性,真實的 TLB 命中率高得驚人——常常是 99% 或更好。所以在 99% 命中率下:有效存取時間 = 0.99 乘以 100 加上 0.01 乘以 200 = 99 加 2 = 101 奈秒。再讀一次:平均一次存取花 101 奈秒,相對於理想的 100 奈秒。第一節那場雙倍存取的災難,已經縮到 1% 的額外開銷。TLB 沒有讓記憶體變快;它讓分頁表幾乎永遠不出現在關鍵路徑上。
Let one memory access = 100 ns; TLB lookup ~ 0 ns.
hit cost = 1 access = 100 ns (page table NOT read)
miss cost = 2 accesses = 200 ns (read table, then data)
EAT = (hit ratio)x(hit cost) + (miss ratio)x(miss cost)
at 99% hits: EAT = 0.99(100) + 0.01(200)
= 99 + 2
= 101 ns --> only +1% over ideal
at 80% hits: EAT = 0.80(100) + 0.20(200) = 120 ns (+20%)誠實的附帶細則
TLB 有一道鋒利的邊緣,而它會在情境切換時咬人。每個行程都有自己的分頁表,所以「第 5 頁」對一個行程意味著框 87,對另一個行程卻是完全不同的框。當作業系統從行程 A 切換到行程 B 時,A 的那些便利貼如今全是謊言——分頁編號相同、框卻是錯的。最粗暴的修法是在「每一次切換」時清空(flush)TLB:把所有便利貼都扔掉。正確,但昂貴——新行程帶著一個空的 TLB 起步,在它重新暖機的過程中要承受一陣失誤。這又是情境切換並非免費的一個理由。
現代硬體用位址空間識別碼(ASID)來緩和這件事:每一筆 TLB 項都被標上它屬於哪個行程,於是 A 和 B 的便利貼能共存而不混淆,一次切換也就不必再把全部扔光。這就好比「把你所有的便利貼都報廢」與「依專案幫它們標上不同顏色」之間的差別。TLB 還必須在其他方面保持誠實,而這些由作業系統小心地管理:如果它曾改動一張分頁表——譬如把某頁換出去——它就必須讓相符的那筆 TLB 項失效,否則那張過時的便利貼會指向一個已不再裝著該頁的框。