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

讓位址轉換變快:TLB

分頁讓每個程式都擁有自己的私有位址空間,卻藏著一個討厭的代價:現在每一次記憶體存取都得先做一趟分頁表查找。這篇導覽要來認識 TLB——一個又小又快、專門快取最近轉換結果的硬體,它讓那趟查找在大多數時候縮成不到一個週期就有答案——並誠實地說明它如何與快取合作、以及偶爾未命中時會發生什麼事。

分頁剛剛偷偷課的稅

前幾篇導覽替我們買到了一件美好的東西:有了分頁,每個程式都相信自己擁有一塊乾淨、連續的位址空間,而一張分頁表在背後悄悄地把每個虛擬頁面對映到它在實體記憶體裡真正的位置。但仔細看看這張帳單。每當 CPU 碰到記憶體——每次載入、每次儲存、每次取指令——它都得先把一個虛擬位址變成一個實體位址,而那趟轉換意味著要去讀分頁表,分頁表本身又住在記憶體裡。於是一個單純的「載入這個字」就偷偷變成了「先去記憶體查出這個字在記憶體的哪裡,再去把那個字拿回來」。我們把記憶體流量加倍了(甚至更糟)。

碰上多層分頁表時情況更糟——上一篇導入它,是為了不讓表本身大得荒謬。一張兩層的表,光是轉換就要兩次記憶體存取;一張四層的表——64 位元機器上的常態——在你能開始去取真正想要的資料之前,得先做次記憶體存取。如果每一趟轉換真的都要四趟慢吞吞的主記憶體往返,虛擬記憶體會是一場效能災難。一定得有個東西讓常見情況變快才行。

TLB:一本記著最近答案的小筆記

解法跟快取早已教過我們的把戲一樣,只是這次套在轉換結果上、而不是資料上。TLB——轉譯後備緩衝區——是一個非常小、非常快的硬體快取,記著最近用過的分頁表答案:「虛擬頁面 0x1F 對映到實體框格 0x83」。它通常只放幾十到幾百筆,因為它必須在一個時脈週期的零頭裡、與管線其餘部分平行地給出答案。當 CPU 需要一筆轉換,它先問 TLB;若答案就在裡面(一次 TLB 命中),實體位址幾乎不費力就跳出來,分頁表根本碰都不用碰。

為什麼這麼小一本筆記就這麼管用?靠的是讓資料快取划算的那同一個區域性。一個跑迴圈的程式會一遍又一遍碰到那少少幾個頁面——它的程式碼頁、它的堆疊頁、它正在輾過的那個陣列。那些轉換就坐在 TLB 裡,被重複使用上千次。實務上,一個守規矩的程式有超過 99% 的時間命中 TLB,所以那趟四次記憶體存取的分頁表行走幾乎從來不會真的發生。昂貴的查找是真實存在的;它只是很少跑而已。

走一遍命中,再走一遍未命中

我們來追蹤一次載入。CPU 算出一個虛擬位址——比方說高位是虛擬頁面 0x1F,低位是該頁面內位移 0x0C4。注意那個位移從來不用轉換:頁面是一整塊磚,所以一個位元組在它頁面裡的位置,在虛擬與實體兩個世界裡是一模一樣的。只有頁碼需要被查。下面先走幸運的路、TLB 命中,再走倒楣的路、TLB 未命中

  1. 把虛擬位址切開:虛擬頁碼(0x1F)與頁內位移(0x0C4)。位移原封不動地一路帶下去。
  2. 向 TLB 詢問頁面 0x1F。命中時它立刻回傳實體框格號(0x83)以及該頁面的保護位元。
  3. 把框格 0x83 接上位移 0x0C4 拼成實體位址,拿它去讀資料快取。大約一個週期就搞定。
  4. 若 TLB 未命中,就改去記憶體裡行走分頁表(每層一次存取),找到對映、把它填進 TLB——然後重試這次存取,現在就命中了。

未命中時是誰去走那趟分頁表?在大多數現代晶片上,一個叫做分頁表行走器(page-table walker,屬於 MMU 的一部分)的專用硬體會自動完成,代價是幾次記憶體存取,但不需軟體插手。在某些設計裡,硬體則改為發出一個錯誤,由作業系統用軟體去走表——每次未命中較慢,但更有彈性。不論哪種,一次未命中是數十到數百個週期,而一次命中基本上是零——這正是為什麼維持高 TLB 命中率如此要緊。

TLB 與快取如何共舞

這裡有個微妙的時序謎題。TLB 把虛擬位址變成實體位址,但資料快取通常是用實體位址來索引的(這樣兩個程式相同的虛擬位址才不會撞在一起)。天真地看這意味著:先轉換、再查快取——兩個慢步驟接連發生。一個聰明又很常見的安排,叫做虛擬索引、實體標籤(VIPT)快取,靠著注意到頁內位移那幾個位元不會被轉換而閃開了這個問題。快取可以在 TLB 正在轉換頁碼的同一瞬間,就用那些未轉換的位移位元開始查找,於是兩者平行進行,實體標籤的比對只是稍後一刻確認一下結果。

virtual address (page number | page offset)
   [ 0x1F ......... | 0x0C4 ]
        |                 |
        |                 +--> drives cache index NOW (not translated)
        v                 |
  +-----------+           v
  |   TLB     |--hit--> frame 0x83 --> physical tag, compared to cache tag
  +-----------+
        |  (miss)
        v
  page-table walk in memory --> fill TLB --> retry
未被轉換的位移讓快取查找與 TLB 轉換重疊進行(VIPT);TLB 未命中則退而行走分頁表。

再來一個誠實的小皺摺:TLB 快取的對映屬於某個特定的行程。當作業系統從一個程式切換到另一個程式,那些條目可能就錯了。老機器在每次情境切換時乾脆把整個 TLB 沖掉(這是真實的代價——新程式從冷的狀態開始,吃上一陣猛烈的未命中)。現代設計則為每筆條目標上一個位址空間識別碼,讓不同行程的條目能共存,避開大部分這類沖刷。無論哪種,這都說明了為什麼虛擬記憶體本質上是硬體/作業系統的協同設計:作業系統掌管分頁表,硬體掌管 TLB,而兩者必須對「什麼是真的」達成一致。

誠實的極限:當轉換主宰一切

TLB 很小,所以如果你程式的頁面工作集很大或很分散,就很容易把它撐爆。一個以很大的步幅、在巨大陣列上每次存取都隔很遠的迴圈,幾乎每一輪都會碰到一個新頁面,撐破 TLB 那幾百筆條目,把幾乎每次存取都變成未命中。算術完全一樣——程式算出同樣的答案——但它可能純粹因為 TLB 未命中而慢上好幾倍。這是快取不友善程式碼在轉換層的表親,而且除非你去量測,否則它是隱形的。

硬體用更大的 TLB、第一層後面再加一層第二級 TLB(就像快取有 L1 與 L2)、以及巨頁來反擊——讓一筆條目涵蓋例如 2 MB 或 1 GB、而不是 4 KB,這樣單一個 TLB 格子就對映到大得多的區域。這些都不能讓一次 TLB 未命中變得不花錢;它們只是讓未命中變得更罕見。誠實的結論是:虛擬記憶體那美麗的幻覺並非免費——多數時候,多虧 TLB,它幾乎不花什麼;但在最壞情況下,一次得跑去磁碟的分頁錯誤、或一場 TLB 未命中的風暴,可能會主宰你的執行時間,正如這個段落開頭所警示的那樣。