プログラミング
ページテーブルのメモリ消費
Page Table Memory Consumption (frn.sh)
要約
ページテーブルは、仮想メモリのアドレス変換に使用されるデータ構造ですが、特に多数のプロセスが同じメモリ領域を共有する場合や、メモリを頻繁に解放・再割り当てする場合に、予想外に多くのメモリを消費することがあります。この問題は、特に大規模なシステムや特定のワークロードで顕著になり、パフォーマンスの低下やメモリ不足を引き起こす可能性があります。この課題に対処するため、共有ページテーブルや巨大ページ(huge pages)などの技術が提案・実装されています。
全文翻訳
先日、私はLinus Torvaldsがハッシュ化されたページテーブルについて人々を批判しているのを読んでいました。
彼の主張はこうです:ツリー構造では、隣接するページの項目は隣接していますが、ハッシュテーブルは逆に、隣接するものをバケットに散らばらせます。
事実は、あなたは良いものが何であるかを知らないということです。
私が教えましょう:良いTLBフィルとは、1つのキャッシュラインに収まるすべてのエントリでTLBを事前に入力することです。
標準的なIntel CPUが一度に8つのTLBエントリを取得することを認識していますか?
自己マッピング(またはAndyが指摘するように、他のレベルを専用キャッシュにキャッシュすることもできます)と組み合わせると、単一のメモリ参照で、あの愚かなPowerハッシュテーブルの8倍のカバレッジが得られることを意味します。
この議論は2003年に行われました。1997年には、彼の修士論文で、カーネルにおける3段階のマルチレベルページテーブルの採用について説明しています。
彼はレイテンシに非常に興味を持っていたようですが、メモリサイズにはあまり興味がなかったかもしれません。
二次的な懸念もあります:仮想マシンメモリマッピングはメモリ効率が良い必要があり、マッピング情報がファイルシステムキャッシュやユーザープログラムの実行に有効活用できる物理メモリを大量に消費しないようにする必要があります。
2020年、Shakeel Buttは、メモリ効率が決して二次的な懸念ではない、まさにそのケースを見つけました。
Shakeelは、ページテーブルメトリックをmemory.statに追加するパッチを送信しました。
Roman Gushchinは、その統計情報の用途を尋ね、ページテーブルはマッピングされたメモリの約1/512であり、ほとんどのcgroupの1%未満(「非常に大きなcgroup以外では、値はper-cpuカウンターのノイズの範囲内でしょう」)だと主張しました。
しかし、Shakeelは、ユーザー空間ネットワークドライバーが、大きなpgtables使用量でゼロコピーのためにアプリケーションメモリをマッピングしているという興味深いケースを挙げています。
各ページが一度だけマッピングされる場合、 those 1/512 は意味があります。
しかし、N個のプロセス11 正直に言うと、Nは「プロセス」ではなく「アドレス空間」であるべきです。すべてのタスク task_struct は mm_struct へのポインタを持っています。
同じページをマッピングする場合、各プロセスはそのための独自のPTEを必要とします。
したがって、ページテーブルのコストは、マッピングされたメモリの約 N/512 になります。
一般的に言えば、これは、512個のプロセスが同一のPTEで実行されている場合、ページテーブルのコストはデータページのコストと同じくらいになることを意味します。
2データページは4 KiBです。各PTEは8バイトです。
512個のプロセスが同じページをマッピングする場合、512個のPTEが必要です:512 * 8 = 4 KiB。
Shakeelのケースと同様の他の問題が、検索中に見つかりました。
Andrea Arcangeli、2002年。
Andreaは、システムに大量の空きメモリがあるにもかかわらず、64 GiBのx86マシンを持つユーザーがメモリ不足になる理由を発見しました。
彼らのワークロードには、それぞれ同じ1 GiBの共有メモリをマッピングする数百のプロセスがありました。
データページは共有されていましたが、各プロセスはそのマッピングのために独自のページテーブルを必要としました - PAE33 x86-32機能(物理アドレスを36ビットに拡張し、64 GiBのRAMを可能にする)で約2 MiB。
ページテーブルは「lowmem」領域に存在し、32ビットx86では約896 MiBの物理メモリに対応します。
パッチはページテーブルをlowmemからhighmemに移動しました。
これは、ユーザーがlowmemという制約(32ビットマシン)を持ちながら、物理アドレスを拡張できる機能を使用していたために発生したエッジケースシナリオでした。
かなり良いです。
Khalid Aziz、2022年。
20年後、Khalidは同様の問題を報告しました:1500人以上のクライアントが300 GBのSGA(共有メモリ)に接続したOracleデータベースサーバーがOOMでクラッシュしました。
Andreaのケースと同様に、データページは共有されていましたが、各プロセスはそのマッピングのために独自のページテーブルを必要としました。
8バイトPTEで、同じ4 KiBページをマッピングする2千プロセスは、そのページのために16 KBのPTEを必要とします。
最悪の場合、各プロセスがSGA全体をマッピングすると、PTEだけで878 GBが必要になります。
彼は、プロセスがページテーブルを共有できるようにするmshareを提案しました。
私が調べた限りでは、mshareのどのバージョンもまだマージされていません。
Qi Zheng、2021年。
Qiは、590 GiBのRSSと110 GiBのページテーブルを持つプロセスを報告しました(!)
それほど多くのメモリをマッピングする場合、約1.2 GiBのPTEしか必要としないはずです。
そのワークロードはjemallocとtcmallocを使用しており、これらはmunmap()44 munmap() の代わりに madvise(MADV_DONTNEED) を使用してカーネルにメモリを返します。これは、ある時点で free_pgtables を呼び出し、最終的に free_pgd_range を呼び出します。
MADV_DONTNEED はデータページを解放しPTEをクリアしますが、ページテーブルは割り当てられたままなので、空のページテーブルが積み重なりました。
AndreaやKhalidのケースとは異なり、共有マッピングはありませんでした:解放されたメモリのためにページテーブルを保持し続けた単一のプロセスでした。
このパッチは何度か書き直され、2025年にマージされました。
これらはカーネルメーリングリストからのエッジケースのように読めるかもしれませんが、パターンは一般的です:多数のプロセスが大きな共有メモリセグメントをマッピングし、それぞれがそれに対する独自のページテーブルを持っています。
データベースに関連する2つの記事を見つけました。
Percona、2021年。
Jobin Augustineは、本番インシデントで頻繁に発生するPostgresのOOMキルに遭遇した失敗を再現しました。
192 GBのマシン、共有バッファは138 GiB、接続数はわずか80。
バックエンドがキャッシュのさらに多くの部分に触れるにつれて、ページテーブルは45 MiBから25 GiB以上に増加しました。
メモリが圧迫され、マシンはスワップを開始しました。
彼は、巨大ページを使用して問題を解決しました:同じワークロードでページテーブルは61 MiBにとどまりました。
ClickHouse、2026年。
Kaushik Iskaは成長を直接測定しました。
128 GiBのマシンと32 GiBの共有バッファ。
各バックエンドが15.6 GiBのキャッシュされたテーブルをスキャンすると、31 MiBのページテーブルが追加されました。
200接続で、ページテーブルは6.1 GiBを占めました。
巨大ページを使用すると、200接続でわずか111 MiBでした。
ただし、巨大ページは無料ではありません。
それらを予約するには、断片化されていないメモリが必要です:ClickHouseは、負荷のかかったマシンでは、「同じリクエストが定期的に失敗する」と指摘しており、THPは割り当てでスタックする可能性があります。
2016年、Mel Gormanは、カーネルがTHP(Transparent Huge Pages)のデフォルトでのデフラグを停止することを提案しました。「何年も経ったので、タオルを投げ捨てる時が来た」
Nelson Elhageは、THPが引き起こす可能性のあるメモリリークやCPU使用率の問題について書いています。
NUMAマシンでのページテーブルの問題
レイテンシとメモリサイズに関して、NUMAマシンで新たな問題が発生しました。
複数のノードがある場合、別のノードで実行されているスレッドは、TLBミス時にリモートメモリ上のページテーブルをウォークする必要があります。
Mitosis(ASPLOS 2020)は、リモートページテーブルがアプリケーションをリモートデータと同じくらい遅くする可能性があることを示しました。
そしてHydra論文(USENIX ATC 2024)は、8 TBのRAMを搭載した8ソケットマシンでこれを再現しました。
Mitosisの選択は、ページテーブルツリー全体を各ノードに複製することでした(これはメモリを消費します(ページテーブルサイズ * ノード数)、そして各変更はすべてのコピーを更新する必要があります)。
Hydraでは、PTEはそのノードでフォールトが発生したスレッドがそのノードにいる場合にのみコピーされ、各ページテーブルページはコピーを保持しているノードのリストを保持します。
複数のノードがあるマシンでは、ページテーブルのコピーを1つだけ保持してTLBミス時にリモートテーブルを読み取るコストを支払うか、各ノードにコピーを保持して同期を維持するためのコストを支払うかのどちらかです。
正直に言うと、Nは「プロセス」ではなく「アドレス空間」であるべきです。すべてのタスク task_struct は mm_struct へのポインタを持っています。↩︎
データページは4 KiBです。各PTEは8バイトです。512個のプロセスが同じページをマッピングする場合、512個のPTEが必要です:512 * 8 = 4 KiB。↩︎
x86-32機能(物理アドレスを36ビットに拡張し、64 GiBのRAMを可能にする)。↩︎
munmap() は、ある時点で free_pgtables を呼び出し、最終的に free_pgd_range を呼び出します。↩︎