512 個 process 映射同一個 4 KiB 的資料頁,資料在實體記憶體裡只有一份,替它們記路的頁表卻要整整一頁。
頁表比資料還大——共享記憶體的隱藏帳單
讀完這篇,你能心算出一組共享記憶體要付多少頁表:每個 process、每映射一個 4 KiB 的頁,多付 8 位元組。素材是 Fernando Simões 的〈Page Table Memory Consumption〉,他把 2002 年到 2026 年的幾次事故排在一起,每一次的帳都落在同一條算式上。算式本身很簡單,難的是乘數:它可以是連線數,也可以是一堆沒有被拆掉的空頁表。
一頁資料,五百一十二份地圖
process 看到的是虛擬位址,資料實際在哪一個實體頁,由核心替每個 process 維護的頁表(page table)記錄。每個 process 的位址空間各自獨立,同一個虛擬位址在兩個 process 裡可以指向不同的資料,所以頁表是每個 process 各一份,共享的只有實體的資料頁。頁表裡的每一筆叫 PTE,指向一個資料頁。尺寸是:一個資料頁 4 KiB,一個 PTE 8 位元組。頁表本身其實是多層的樹(Simões 提到,Torvalds 1997 年的碩士論文談的是核心採用三層的多層頁表),但他的算式只數最底層那一排 PTE,下面也照這個簡化,不計上層的目錄頁。
8 除以 4096,是 1/512,約 0.2%。一個 process 單獨用記憶體,頁表只是零頭。
共享記憶體改變了乘數。資料頁在實體記憶體裡只有一份,但每個 process 都要在自己的頁表裡留一筆 PTE 指向它。Simões 把它寫成最短的一條算式:
「A data page has 4 KiB. Each PTE has 8 bytes. If 512 processes map the same page, they need 512 PTEs: 512 * 8 = 4 KiB.」
下面的畫面把這個過程放慢。左邊是那一個資料頁,右邊是頁表頁。每個小格是一個 process 的 PTE,一格 8 位元組,一頁裝得下 512 格。按播放,讓 process 一個個接上來。
按播放,看 process 一個個映射同一頁 · 最多 2048 個
一格是一個 process 為同一個資料頁留下的 PTE(8 位元組)
每個 process 映射同一個資料頁都要自己的 8 位元組 PTE,512 個 process 時頁表等於一頁資料,2048 個時是四頁。
填到 512 格,第一個頁表頁剛好滿,和左邊的資料頁一樣大,Simões 的說法是 512 個 process 的 PTE 相同時,頁表的成本和資料頁一樣。繼續填下去,頁表會比資料大。動畫跑到 2048 個 process,是四個頁表頁、16 KiB,資料仍然只有一頁:2048 除以 512 是 4,每多 512 個 process,就多出一個和資料同樣大的頁表頁。談 Aziz 的案例時,他用的是整數:2000 個 process 映射同一個 4 KiB 的頁,那一頁需要 16 KB 的 PTE。2000 個 process 乘 8 位元組是 16000 位元組,動畫的 2048 個是 16 KiB,兩者是同一件事,只是 process 數取了整數。如果每個 process 映射的是不同的資料頁,每個資料頁只有一個 PTE 指向它,頁表就回到資料的 1/512。放大帳單的是「同一頁被多少個 process 映射」。
乘數:從 1/512 到 N/512
Simões 拿 Torvalds 的判斷當對照。Torvalds 1997 年的碩士論文把頁表這類映射資訊的記憶體用量列為次要考量,理由是映射資訊不該吃掉太多本來可以拿去做檔案系統快取或跑使用者程式的實體記憶體。以單一 process 的帳來看,這個判斷沒有錯,成本只有資料的 1/512。
後來出事的是乘數。把剛才的例子一般化,頁表總量約等於「process 數 × 每個 process 映射的大小 ÷ 512」;Simões 給的特例是 N 個 process 映射同一頁,成本約為資料的 N/512。N 是 512 時,成本和資料一樣大;N 是 2000 時,是資料的近四倍。
這條式子可以換成更好心算的單位。1 GiB 是 262,144 個 4 KiB 的頁,每頁一個 8 位元組的 PTE,合計 2 MiB。所以每個 process 每映射 1 GiB 的共享記憶體,就多付 2 MiB 的頁表;一百個 process 映射同一個 1 GiB,是 200 MiB,資料還是 1 GiB。共享的量大一點也一樣好算:100 GiB 的共享記憶體,每個 process 的 PTE 是 200 MiB,一百個 process 合計約 20 GiB。
PTE 是在頁面被碰到時才建立的。Percona 的案例把這件事寫得很具體:頁表隨著 backend 碰過更多快取,才一路長大。映射了 138 GiB,頁表不會一開始就長到 138 GiB 除以 512 那麼大,它跟 process 實際碰過多少頁成正比。下面的計算器因此只給上界,假設每個 process 都把共享的記憶體摸過一遍;我猜跑久了的資料庫 backend 接近這種情形。
後面的五個案例有兩種成因。Arcangeli、Aziz、Percona、ClickHouse 是很多 process 映射同一批共享頁,頁表大小隨連線數線性增長;Zheng 只有一個 process,頁表卻遠大於映射所需,乘數不在 process 數上。
先把算式變成可以動的東西。下面的計算器只有兩個旋鈕:連線數 N 與共享記憶體大小 M。預設值是 ClickHouse 的設定,另外兩個按鈕是 Percona 與 Aziz 的數字。
拖動連線數與共享記憶體 · 或點三個案例按鈕
200 × 15.6 GiB ÷ 512 ≈ 6.09 GiB,是共享資料的 39.1%。上界假設每個 process 都碰過全部頁面。
三個預設各自對應一個案例,數字的核對留到案例那一節。
五張帳單,同一條算式
下面的時間軸依序列出 Simões 的案例。間距是等分的,不代表年數。拖動圓點,或聚焦後用左右方向鍵,每一格會顯示該案例的數字。
拖動圓點逐案例檢視 · 7 個節點
數字都取自原文各案例的描述;卡片裡的單位沿用原文(GB 與 GiB 並存)
從 2002 到 2026 的案例都落在同一條算式上,頁表大小取決於映射數乘 8 位元組,差別只在乘數是 process 數,還是被留下來的空頁表。
2002 年的帳最古老。Andrea Arcangeli 查出為什麼 64 GiB 的 x86 機器會把記憶體用完,頁表在 PAE 下約 2 MiB,他的修法是把頁表從 lowmem 搬到 highmem。從修法看,頁表原本擠在 lowmem 這個有限的區域裡,機器有 64 GiB,卡住的卻是放頁表的那一塊。lowmem 的大小固定,頁表隨映射量與 process 數增長,機器再多裝記憶體也不會讓這一塊變大。Simões 交代了當時的設定:數百個 process 各自映射同一份 1 GiB 的共享記憶體,資料頁共享,每個 process 卻要有自己的頁表,PAE 下約 2 MiB。這和前面的心算一致,映射 1 GiB 正好是 2 MiB 的 PTE。
2021 年 Qi Zheng 的案例只有一個 process:RSS 590 GiB,頁表 110 GiB,映射這麼多資料本來只需要約 1.2 GiB。590 除以 512 約 1.15,與 1.2 相符;110 GiB 是這個量的九十幾倍,扣掉該有的部分,多出來的一百多 GiB 依原文的成因,應該主要是沒被拆掉的空頁表。換成頁數來看:590 GiB 切成 4 KiB 的頁約 1.5 億個,每頁一個 8 位元組的 PTE,剛好是 1.2 GiB;110 GiB 的空間可以放下約 150 億個 PTE,遠超過這個 process 任何時刻真正需要的數量。成因在 allocator:它用 madvise 的 MADV_DONTNEED 把記憶體還給系統,而不是用 munmap。MADV_DONTNEED 會釋放資料頁、清掉 PTE,頁表本身卻繼續配置著,所以頁表的大小跟著曾經映射過的量走,空頁表一路堆積。要分辨自己碰到的是哪一種成因,可以問乘數從哪來:頁表隨連線數線性增長,連線數減半帳就減半,那是共享映射;只有一個 process 而頁表遠大於它映射所需,多出來的多半是沒被拆掉的結構。這個 patch 經過數次改寫,2025 年合併。
同在 2021 年的 Percona 案例,機器是 192 GB,shared buffers 設 138 GiB,只開 80 條連線,頁表從 45 MiB 長到超過 25 GiB。照前面講的,45 MiB 是連線剛建立、還沒碰多少資料的狀態,25 GiB 是 backend 把緩衝逐漸摸過的結果。改用 huge pages 之後,同一個負載的頁表停在 61 MiB,與 45 MiB 的起點同一個量級;從 45 MiB 到 25 GiB,是五百多倍的成長。25 GiB 約占那台 192 GB 機器的 13%(GiB 與 GB 混用,只作量級)。
用上面的算式核對 Percona:一個 backend 摸遍 138 GiB 的共享緩衝,需要 276 MiB 的 PTE,80 個 backend 約 21 GiB。這是只算共享緩衝最底層 PTE 的上界,Simões 的數字卻更大:25 GiB 減去 21 GiB 約 4 GiB,是上界的近兩成。原文沒有解釋這段落差,本文的簡化算式又略去了上層的目錄頁,所以不能直接把它歸給任何一個原因。ClickHouse 的 backend 也守同一條比例:它掃的 15.6 GiB 對應 31 MiB,差別只在每個 backend 碰過多少資料。
2022 年是 Khalid Aziz 談的案例:一台 Oracle 資料庫伺服器,512 GB RAM,1500 個以上的 client 連到 300 GB 的 SGA,結果 OOM。原文估了一個最壞情況:每個 process 都映射整個 SGA,光 PTE 就要 878 GB,這是情境推演,不是實測用量。每個 process 映射整個 SGA 要約 0.6 GB 的 PTE,1500 乘 300 再除以 512 是 878.9,與 878 吻合。878 比機器的 512 GB RAM 多出約七成。1500 個 client 平均每個背著 0.6 GB 的 PTE,這筆帳和資料庫有多忙無關,只看有多少個 process 映射了 SGA。最壞情況下,記憶體裡除了 SGA 本身的 300 GB,還要再放 878 GB 的頁表,合計 1178 GB,是 512 GB RAM 的 2.3 倍。
300 GB 的 SGA 是資料庫刻意配置的共享記憶體,1500 以上的連線數也只是正常的客戶端數量,兩邊都沒有人做錯事。每多一個 client,資料本身多佔的空間是零,因為資料只有一份,新增的成本全部落在頁表上。
最近的一筆是 2026 年 ClickHouse 的測試,機器 128 GiB,shared buffers 32 GiB。每個 backend 掃一張 15.6 GiB 的快取表,就多出 31 MiB 的頁表;連線開到 200 條,頁表是 6.1 GiB。改用 huge pages 後,200 條連線只花 111 MiB。6.1 GiB 約占整台機器的 5%,卻是 32 GiB shared buffers 的近兩成;shared buffers 與頁表加起來約 38 GiB,頁表占其中 16%。31 MiB 怎麼來,下一節逐步算。
五個案例的設定放在一起比,共享記憶體占機器 RAM 的比例差很多:Percona 是 138 GiB 對 192 GB,約 72%;Aziz 是 300 GB 對 512 GB,約 59%;ClickHouse 是 32 GiB 對 128 GiB,25%(GB 與 GiB 混用,只作量級)。連線數也從 80、200 到 1500 以上不等。頁表占共享資料的比例只看連線數:80 條是 N/512 的 16%,200 條是 39%,1500 條是近三倍。共享資料占機器的比例,則決定這筆帳壓在整台機器上有多重。把三組設定代入前面的計算器:80 乘 138 GiB 是約 21 GiB,200 乘 15.6 GiB 是約 6.1 GiB,1500 乘 300 GB 是約 878 GB。最大與最小差了兩個量級,連線數與共享量各貢獻一部分,缺了哪一邊,帳都不會這麼大。
把這幾組放在同一張對數圖上,可以看到每一列都有一個「觀察到的頁表」和一個對照值。對照值的意義每列不同:Percona 與 ClickHouse 是開了 huge pages 的結果,Zheng 是只映射所需的理論量,Aziz 是機器本身的 RAM。
四列的差距來源不一樣。Percona 與 ClickHouse 那兩列,對照值來自把映射的個數變少;Zheng 那一列,差距來自沒被回收的空頁表。
圖上的差距都以量級計。Aziz 那一列的最壞情況超過機器的 RAM,Zheng 那一列的頁表接近 RSS 的五分之一(110 除以 590)。
從 31 MiB 算到 111 MiB
用 ClickHouse 那組數字,可以自己把 31 MiB 與 6.1 GiB 算出來,再看 huge pages 把它壓到多少。整個過程只用到前面那兩個尺寸,不需要額外的假設。
// 每個 backend 掃一張 15.6 GiB 的快取表
15.6 GiB / 4 KiB = 約 4.09 百萬個 PTE
4.09 百萬 * 8 B = 約 31.2 MiB // 原文寫 31 MiB
31.2 MiB * 200 條連線 = 6,240 MiB = 約 6.1 GiB // 原文寫 6.1 GiB
// 對照資料本身
8 B / 4 KiB = 1 / 512 = 約 0.2%
200 / 512 = 約 39% // 頁表是 15.6 GiB 資料的 39%
同一張表的資料在實體記憶體裡只放一份,頁表複製了 200 次,合計相當於那張 15.6 GiB 表的 39%。
huge pages 讓一個 PTE 管更大的一塊記憶體,PTE 的個數因此變少。換算成倍數,ClickHouse 從 6.1 GiB 降到 111 MiB 約 56 倍,Percona 從超過 25 GiB 降到 61 MiB 至少約 420 倍。
這兩個倍數也留下一個沒有答案的問題。Simões 沒有指明頁大小,這裡以 x86 上常見的 2 MiB 推算,一個 huge page 等於 512 個 4 KiB 頁。一個 PTE 管 2 MiB,算式的分母就從 512 變成 512 乘 512,也就是 262,144;200 條連線的帳只剩資料的 0.08% 上下,ClickHouse 的 6.1 GiB 應該縮到 12 MiB 左右,實測卻是 111 MiB,差在哪裡沒有交代。照這個分母,連線數要到 262,144 條,頁表才會追上資料,512 條連線的頁表只有資料的 0.2%。
huge pages 處理的是乘數裡「每個 process 要記幾筆」那一項,不動「有幾個 process」那一項。連線數照樣是 200,每個 backend 照樣各有一份頁表,只是每一份變小了,111 MiB 仍是 200 條連線加起來的結果。要把「有幾個 process」這一項本身拿掉,得靠共享頁表。
算式 N × M ÷ 512 裡,各個修法動的項目不同。huge pages 動的是分母 512,也就是每個 PTE 涵蓋多大;mshare 動的是 N;從成因看,huge pages 不是 Zheng 案例的對症解法,它的問題是空頁表沒被回收,處理它的是 2025 年合併的 patch;Arcangeli 的修法不動算式,只是把頁表搬到放得下的地方。
共用地圖與複製地圖:mshare 與 NUMA
縮小每個 process 的頁表之外,還有一條更根本的路:乾脆讓 process 共用頁表。Simões 在 Aziz 的案例之後提到 mshare,用意是讓 process 共用頁表,提案者應該就是 Aziz;他附的 lore 連結,關鍵字是 shared PTEs across processes。他查過進度,到寫作時還沒有任何版本合併。頁表若真能共享,共享區域就不再有「乘上 N」這一項:以 Aziz 的 300 GB 為例,共用一份只需要約 0.6 GB,不必再乘上 1500。共用時,多個 process 的頁表指向同一份 PTE,不再各存一份,共享區域的 PTE 只剩 M ÷ 512 這一份,和 N 無關。這個做法和 huge pages 動的是不同的項,兩者可以疊加。
另一個麻煩出現在多節點的 NUMA 機器上。頁表不只是佔記憶體,TLB miss 時還要去讀它。TLB 是暫存位址轉換結果的快取,命中時不碰頁表,miss 才需要走訪,所以頁表放得離 CPU 遠,慢的是 miss 那一刻。如果所有節點共用同一份頁表,每次 miss 都可能得跨節點讀;如果每個節點各放一份,讀是近了,頁表一有更動就得同步。Simões 的結論段把選擇寫得很乾脆:
「In machines with multiple nodes, you either keep one copy of the page tables and pay the cost of reading remote tables on TLB misses, or you keep a copy on each node and pay to keep them in sync.」
他列了兩篇論文,各走一邊。Mitosis(ASPLOS 2020)的選擇是把整棵頁表樹複製到每個節點。Hydra(USENIX ATC 2024)則是只有在某個節點上的執行緒 fault 時,才把 PTE 複製過去,而且每個頁表頁會記下哪些節點持有副本。下面的動畫讓 node 1 上的執行緒觸發 TLB miss,看三種策略下讀取走多遠。
選策略,再觸發 node 1 的 TLB miss · 3 種策略
頁表只在 node 0。node 1 的 thread B 每次 TLB miss 都要跨 node 讀取。
動畫的快慢只表示遠近,不是量測值
多節點機器上,頁表只留一份就要為每次 TLB miss 付遠端讀取,每個節點各放一份就要付同步成本;Hydra 只在該節點 fault 時才複製 PTE。
複製頁表解決了延遲,卻和本文的主題疊在一起:整棵頁表樹複製到每個節點,頁表的記憶體用量就再乘上節點數:兩個節點是兩倍,四個節點是四倍。Hydra 讓副本只在真的有執行緒用到的節點出現,沒用到的節點不花這份記憶體,代價是每個頁表頁多一份持有者記錄要維護。
在自己的機器上對一次帳
如果你管的是 process-per-connection 的資料庫,這篇文章可以轉成一份很短的檢查清單。先量:Linux 的 /proc/meminfo 有一個 PageTables 欄位。再對照:把 PageTables 的數字,和「連線數 × 共享記憶體 ÷ 512」放在一起看;再把 PageTables 除以連線數,得到平均每條連線背的頁表,ClickHouse 的 6.1 GiB 除以 200 就是 31 MiB,剛好對應單一 backend 掃過的 15.6 GiB。連線數超過 512 時,最壞情況下頁表就比共享資料本身大,可以拿 512 當警戒線:ClickHouse 的 200 條連線在警戒線之下,頁表是資料的 39%,再往上開,到 512 條就和資料一樣大。對得上,就是共享映射的乘數在作用,Percona 與 ClickHouse 兩個案例的處方都是 huge pages;實測值比上界小也不必意外,實際取決於各 process 碰過多少頁:接近上界,表示 process 幾乎摸遍了共享記憶體;遠低於上界,表示只碰了一小部分。對不上,而且是一個長壽的 process 持續膨脹,就換查另一條線:你的核心是否帶了 2025 年合併的那個 patch,以及你的 allocator 是否大量使用 MADV_DONTNEED。
這份清單的前提是你看得到頁表的總量。各個案例也都是先有人發現記憶體用量與資料對不上,才回頭去查頁表:Arcangeli 面對的是用完記憶體的 64 GiB 機器,Aziz 面對的是 OOM 的資料庫伺服器,Zheng 面對的是 RSS 與頁表的比例失衡。
Mental model:頁表的帳不跟資料量算,跟映射數算,每個 process 每映射一個 4 KiB 的頁就多付 8 位元組,所以 512 個 process 共用一頁,頁表就和資料一樣大。