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

TLB 與虛實位址轉換,再訪

你已經知道,你的程式碰到的每一個位址都是虛擬的,而作業系統握有一張分頁表把它轉成實體位址。這篇導引打開那個在每一次記憶體存取上都要做這項轉換的硬體——並說明若沒有一個叫 TLB 的小小快取,你那座飛快的資料快取其實毫無用處。

藏在每一次載入裡的第二道轉換

從虛擬記憶體那一級,你已經帶著這個頭條事實:你指標裡的位址,是核心對你的行程說的一個謊。你程式碼算出的每一個位址都是虛擬位址,而某處有一張分頁表,把它所在的那一頁映射到 DRAM 裡一個真實的實體框(frame)。那一級讓你略過的——而且略過得合理,因為你當時在學的是那個觀念——是時機。這個映射究竟在什麼時候被套用?答案很殘酷:在你程式做的每一次記憶體存取上。每一次載入、每一次儲存、每一次指令提取。在 CPU 能向快取或 DRAM 索取你位址處的那些位元組之前,它必須先把那個虛擬位址轉成一個實體位址。

現在把這件事擺在前兩篇導引教你的東西旁邊。你學到一條 L1 快取列在屈指可數的幾個週期內就會送達,也學到整座記憶體階層之所以存在,就是為了把那個命中率維持得高聳入雲。但快取是用一個位址來定址的——至少有一部分是用實體位址。所以,如果把虛擬轉成實體這件事本身就需要去走一趟 DRAM 裡的分頁表,那麼每一次 L1 命中都會偷偷拖著一趟一百多週期的記憶體之旅。這荒謬至極:飛快的快取會被那道在它之前發生的轉換,鏈在慢吞吞的記憶體上。必然得有一個專給轉換用的快取。那個快取就是 TLB,轉換後備緩衝區(translation lookaside buffer),它是這篇導引裡那位安靜的英雄。

實際上被查找的是什麼

回想一個虛擬位址是怎麼切開的。低位的位元是頁內的偏移量(offset);高位的位元是虛擬頁碼(virtual page number)。在典型的 x86-64 系統、頁大小 4 KiB 的情況下,最低 12 個位元是偏移量(因為 2^12 = 4096 = 4 KiB),而它們之上的全部都用來命名那一頁。轉換從不碰偏移量——你那個位元組在它的框裡的偏移量,和它在頁裡的偏移量是一樣的。唯一被轉換的東西是頁碼:記憶體管理單元(MMU)拿你的虛擬頁碼,產出一個實體框碼,然後把那個原封不動的偏移量重新黏回最底下。

virtual address  (x86-64, 4 KiB pages):

  63        12 11         0
  +-----------+-----------+
  | page num  |  offset   |
  +-----------+-----------+
        |           |
        |           +------------------> copied straight through
        v
  TLB / page walk: page num  -->  frame num
        |
        v
  +-----------+-----------+
  | frame num |  offset   |   = physical address
  +-----------+-----------+
只有頁碼被轉換;12 位元的偏移量原封不動地搭便車過去。TLB 是一個以虛擬頁碼為鍵的小型關聯式快取,直接回傳框碼。

所以 TLB 在形狀上,恰恰就像你上一篇導引遇到的那種組關聯式快取——只是它快取的不是以位址為鍵的資料列,而是以頁碼為鍵的框碼。一個典型的 L1 TLB 只裝大約 64 個項目;一個 L2 TLB 也許裝 1024 到 2048 個。小得很。每一個項目說的是「虛擬頁 0x7fff_a 映射到框 0x1c3,這是它的權限位元」。當你那次載入的頁碼在 TLB 裡時,實體位址在單一週期內就備妥,資料快取的查找便毫無額外延遲地進行下去。這就是順利的路徑,而在真實的工作負載上它是壓倒性地常見的那條——這正是為何這麼一張小桌子值得打造。

未命中:慢動作下的分頁走訪

現在來看不順利的路徑。你的頁碼不在 TLB 裡——一次 TLB 未命中。硬體現在必須用慢的方式去找出那個轉換,也就是把真正的分頁表從記憶體裡讀出來。在 x86-64 上,這不是單一次查找:分頁表是一張多層分頁表,一棵四層深的樹。MMU 拿你的虛擬頁碼,把它切成四個索引欄位,然後一次一層地走訪這棵樹,每一層都住在實體記憶體裡。這就是分頁走訪,而在現代晶片上它是由專用硬體完成的,不是核心。

  1. 從根表開始,它的實體位址放在一個控制暫存器裡(x86-64 上是 CR3)。用虛擬頁碼最上面的索引欄位,在這張第四層的表裡挑出一個項目。
  2. 那個項目裝著下一層表的實體位址。從記憶體把它讀出來,再用第二個索引欄位在那裡挑一個項目。一路往下,穿過第三層和第二層,重複這個動作。
  3. 在最深的一層,那個項目就是葉子:那筆分頁表項目,最終裝著框碼以及權限與狀態位元。把原本的偏移量黏到那個框上,你就有了實體位址。
  4. 把剛找到的頁到框的映射裝進 TLB,好讓下一次對這同一頁的存取成為一次單週期的命中,然後才終於放行原本那次載入。

誠實地把代價算一算。一次四層走訪最多是四次彼此相依的記憶體讀取,而其中每一次本身都可能在資料快取裡未命中、一路抵達 DRAM。在最壞的情況下,單單一次 TLB 未命中可能耗掉數百個週期——相當於好幾次普通快取未命中背靠背疊在一起的價碼,因為每一層的位址都相依於上一層的結果,所以它們無法重疊。實務上,分頁表上層自己往往就熱在快取裡,所以一次典型的未命中遠比最壞情況便宜。但這份危險的形狀是真實的:一個不斷錯失 TLB 的工作負載,會付出一筆任何資料快取調校都消除不了的稅,因為這筆稅是在資料快取連被問都還沒被問到之前已經課下去了。

TLB 觸及範圍,以及大型分頁為何存在

這裡有個要緊、而幾乎沒人會自己算的數字:TLB 的觸及範圍(reach)。每一個 TLB 項目恰好涵蓋一頁——4 KiB。所以一個 64 項的 L1 TLB,在任何一瞬間,只能映射 64 × 4 KiB = 256 KiB 的位址空間。再讀一遍。你的資料快取也許裝得下好幾百萬位元組,但如果你的熱資料散佈在超過 256 KiB 的不同分頁上,TLB 就無法同時為它們全部保有轉換,於是你開始為轉換而抖動(thrash),即使你的位元組正舒舒服服地待在 L2 或 L3 裡。這是前幾篇導引那種快取列調校碰不到的故障模式,也是為什麼區域性在分頁的粒度上、而不只是在快取列的粒度上,才這麼要緊。

硬體的答案是 大型分頁(huge pages):分頁表不用 4 KiB 的頁,而能用單一個項目描述一個 2 MiB 的頁(甚至 1 GiB)。轉換樹變短了——走訪提早一層停下,因為那一層就是葉子——而且更重要的是,現在一個 TLB 項目涵蓋 2 MiB 而非 4 KiB。有了大型分頁,同樣那個 64 項的 TLB 觸及到 64 × 2 MiB = 128 MiB,對一個在大型緩衝區裡串流的工作負載而言,觸及範圍增加了 512 倍。資料庫、語言執行時,以及那些動輒碰到好幾個 GiB 的科學程式,常常正是為了這個原因把大型分頁打開。

當核心必須伸手介入:行程切換與清空

最後一層把這一切繫回到行程。每一個行程都有它自己的虛擬位址空間,所以行程 A 裡的虛擬頁 0x1000,和行程 B 裡的虛擬頁 0x1000,幾乎肯定映射到不同的實體框。TLB 在最樸素的設計裡,快取映射時並不記下它們屬於哪個行程。所以在一次分頁表切換上——每一次排程器從 A 切換脈絡到 B 時——那些陳舊的 A 映射對 B 而言會災難性地錯誤。經典的修法很粗暴:在每一次脈絡切換上清空整個 TLB,把好不容易得來的全部 64 個(或 2048 個)轉換通通丟掉。這就是為什麼一次脈絡切換比它乍看之下昂貴得多:它不只是存暫存器,還悄悄地把這個快取倒空,逼新行程為它最初的那些存取重新走一遍分頁表。

現代 CPU 用一個位址空間識別碼來緩和這件事(Intel 叫它 PCID,ARM 叫它 ASID):一個存在每個 TLB 項目內部的小標籤,標明它所屬的位址空間。有了帶標籤的項目,CPU 就能把 A 和 B 的轉換並排放著,單純忽略那些標籤與當前行程不符的——於是切換回 A 時,會發現它的轉換還熱著,不需要清空。這就是為什麼啟用 PCID 能可量測地加快那些脈絡切換頻繁的工作負載,也是為什麼一項逼出額外清空的安全緩解措施(例如為了 Meltdown 而引入的核心/使用者分頁表分離),會以一個真實、可量測的變慢呈現出來:它正在付那筆這個硬體當初特地為了避開而打造的清空稅。