ハードウェア上級 #3 メモリ深掘り — ページキャッシュ・THP・帯域幅
中級 3 編 でメモリ運用の判断基準をつかみました。available で余裕を判定し、ダーティページの暴走を読み、cgroup の上限で OOMKilled を解釈するレベルです。今回の記事は、その判断の根拠になっているメカニズムの内側に入ります。ページキャッシュは読み取りと書き込みを正確にどう処理するのか、カーネルがこっそりページを大きくする THP がなぜ遅延スパイクの犯人になるのか、そしてコアが遊んでいるのにスループットが出ないメモリ帯域幅のボトルネックはどう確認するのか、という 3 点です。
ページキャッシュ — すべてのファイル I/O が通る道 #
アプリケーションが read() を呼ぶと、カーネルはディスクへ行く前にまずページキャッシュを探します。探しているページがあれば (キャッシュヒット) メモリコピー 1 回で終わり、ディスクには触れもしません。なければ (ミス) ブロック層に I/O を下ろし、読んできた内容をキャッシュに載せてから返します。このときカーネルは、アクセスがシーケンシャルだと判断すると、要求より先のブロックまであらかじめ読んでおきます (readahead)。シーケンシャルリードがランダムリードよりキャッシュヒット率まで良くなる理由です。
書き込みは方向が違います。write() はページキャッシュに書いてそのページをダーティと表示するところで終わり、ディスクへの反映は writeback スレッドがあとで行います。
読み取り read() ─▶ ページキャッシュ照会 ─▶ ヒット: メモリコピーで返却 (ディスクアクセスなし)
└▶ ミス: ブロック I/O + readahead ─▶ キャッシュに載せて返却
書き込み write() ─▶ ページキャッシュに記録 + ダーティ表示 ─▶ (即時返却)
└▶ あとで writeback スレッドがディスクへ反映そのため書き込み呼び出し自体はメモリ速度で返ってきて、代わりにダーティページの滞留と暴走という運用上の現象が生まれます。この動作と対処は 中級 3 編 で扱ったので、ここでは経路だけ確認しておきます。
この経路には例外が 1 つあります。O_DIRECT で開いたファイルはページキャッシュを迂回し、ディスクと直接やり取りします。データベースがよく使う方式ですが、自前のバッファプールを持っているため、カーネルキャッシュまで経由すると同じデータがメモリに 2 回載ってしまうからです。「DB サーバーでページキャッシュが妙に小さい」という観察は、障害ではなくこの設計の結果である可能性があります。
THP — タダではない大容量ページ #
Linux の基本ページは 4KB で、仮想アドレスを物理アドレスに変換した結果は TLB (Translation Lookaside Buffer、アドレス変換キャッシュ) に保存されます。問題は、TLB エントリがコアあたり数千個レベルに限られる点です。4KB ページでは数十 MB 程度しかカバーできず、ワーキングセットが数十 GB のプロセスはメモリアクセスのたびに TLB ミスとページテーブル探索を繰り返すことになります。
THP (Transparent Huge Pages、透過的大容量ページ) は、このコストを減らすためにカーネルが 4KB ページ 512 個を 2MB ページ 1 つへ自動昇格させる機能です。同じ TLB エントリ数でカバーできる範囲が 512 倍になるので、メモリを大きく使うワークロードでは数パーセントのスループット向上が実際に出ます。
ところが代償があります。2MB ページには物理的に連続した 2MB のメモリが必要です。長く稼働して断片化したサーバーにはそうした連続区間がまれで、カーネルは散らばったページを移して連続空間を作るコンパクション (compaction) を回します。このコンパクションがメモリ割り当ての経路で同期的に発生すると、その瞬間の割り当てが数十 ms ずつ止まり、バックグラウンドの khugepaged がページをマージしている間も CPU とロックのコストがかかります。平均スループットは良くなるのにテールレイテンシ (tail latency) が悪くなる、典型的なトレードオフです。
現在のモードと使用量は次で確認します。
$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never
$ grep AnonHugePages /proc/meminfo
AnonHugePages: 8388608 kB # 匿名メモリのうち 2MB ページへ昇格した量データベースベンダーが THP を無効にするよう勧告する理由がこれです。DB は平均よりテールレイテンシに敏感で、Redis のように fork でスナップショットを取るシステムは、2MB ページのせいで copy-on-write のコピー単位が大きくなり、メモリ使用量まで膨らみます。折衷案として madvise モードがあり、全体の自動昇格の代わりに madvise() で明示的に要求した領域だけ大容量ページを使わせる設定です。「THP を有効にするか無効にするか」という問いの答えは、ワークロードが平均を追うのかテールを追うのかにかかっています。
明示的 hugepages — 予約しておいて使う大きなページ #
THP の問題が「ランタイムにこっそり作ろうとして」生じるものなら、答えはあらかじめ作っておくことです。明示的 hugepages は、vm.nr_hugepages で 2MB (または 1GB) ページをブート直後のような断片化のないときに予約しておき、申請したアプリケーションだけに使わせる方式です。予約されたページはスワップ対象でもなく、コンパクションも必要ないので、THP の遅延スパイクなしに TLB の利得だけを持っていけます。
予約状態は /proc/meminfo で見ます。
$ grep -i hugepages /proc/meminfo
HugePages_Total: 8192 # 予約された 2MB ページ数 (= 16GB)
HugePages_Free: 2048 # まだ使われていない量
HugePages_Rsvd: 512 # 申請済みだがまだアクセス前の量
Hugepagesize: 2048 kB注意すべき点は、予約した分だけ一般メモリが減ることです。上のように 16GB を予約すると、hugepages を使えない一般プロセスから見れば、サーバーメモリが 16GB 小さいのと同じです。申請者が決まっているメモリだけ予約するのが原則です。
使う側も決まっています。PostgreSQL と Oracle は共有バッファを hugepages に載せる設定を提供し、KVM はゲストメモリを、DPDK のような高性能パケット処理フレームワークはバッファプールをここに置きます。副次効果として、ページテーブル自体が小さくなる利得もあります。数百個のプロセスが同じ数十 GB の共有メモリをマッピングする DB では、プロセスごとに作られるページテーブルだけで数 GB を食うことがありますが、ページが 512 倍大きくなればそのテーブルがその分だけ減ります。
スワップポリシー深掘り — swappiness の実際の実装と zswap #
中級 3 編 で swappiness を「何を先に追い出すかの性向」と整理しました。実装レベルまで降りると、カーネルは回収対象のページを匿名ページ (プロセスメモリ) のリストとファイルページ (ページキャッシュ) のリストに分けて管理しており、swappiness は 回収時に 2 つのリストをどの比率でスキャンするかの重み です。だから 0 にしてもスワップは無効になりません。ファイルページをすべて回収してもまだ足りなければ、カーネルは結局匿名ページを下ろします。
cgroup v2 ではこの値が 0〜200 まで拡張され、100 を超える値は「匿名ページの回収はファイルページの回収より安い」という宣言です。回転ディスクスワップの時代にはあり得ない値でしたが、スワップが NVMe や圧縮メモリへ向かう今日では合理的な選択肢になりました。
その圧縮メモリが zswap です。スワップへ出ていくページをディスクに書く前に RAM 内の圧縮プールへ先に保存し、プールが埋まると古いものからディスクへ下ろします。圧縮率が 2〜3 倍ほど出るワークロードなら、スワップ I/O のかなりの部分がディスク速度ではなく伸長速度で処理されます。メモリをオーバーコミットするデスクトップや一部のクラウド環境が、デフォルトで有効にして出荷する理由です。
メモリ帯域幅 — コアは遊んでいるのにバスは飽和 #
ここまではメモリの量と遅延を扱いましたが、3 つ目の軸があります。毎秒移動できるバイト数、つまり帯域幅です。ソケット 1 つのメモリ帯域幅は、おおよそチャネル数とメモリ速度の積で決まります。DDR5-4800 の 8 チャネルなら理論上 300GB/s 前後です。大きな数字に見えますが、数十個のコアが大きな配列をなめる分析クエリや科学計算を同時に回すと埋まります。
症状が特異です。コアを増やしても速くならず、CPU 使用率が 100% でも perf stat の IPC (サイクルあたり命令数) が際立って低くなります。コアが働いているのではなく、メモリからデータが来るのを待ちながらサイクルを燃やしているからです。
$ perf stat -a sleep 10
1,284,332,109,442 cycles
412,587,221,830 instructions # 0.32 insn per cycleキャッシュによく収まるワークロードの IPC が 2〜4 ほど出るのと比べると、0.3 台の IPC は、コアが大半のサイクルを待ちに使っているというシグナルです。第 1 編 で扱った perf の topdown 分析で backend bound、その中でも memory bound の比率が支配的に出ればこの絵が確定し、Intel の pcm-memory のようなツールを使えば、ソケットごとのメモリトラフィックを GB/s 単位で直接見られます。
このサーバーが実際に出せる上限は STREAM ベンチマークで測ります。キャッシュよりはるかに大きい配列をコピーして足すだけの単純なループを回し、持続可能な帯域幅を測定する古典的なツールです。
Function Best Rate MB/s
Copy: 241854.3
Scale: 238102.7
Add: 252331.9
Triad: 251887.4 # 理論値 300GB/s に対して約 84%理論値の 80% 前後が出れば正常範囲です。ワークロードの実測トラフィックがこの数値に張り付いているなら、そのボトルネックはコード最適化やコア増設では解けません。処方の方向も違います。DIMM をチャネルごとに均等に挿してチャネルを全部生かすこと、そしてメモリアクセスをキャッシュフレンドリーに変えてトラフィック自体を減らすことです。
NUMA と出会う場所 #
この記事のテーマはすべて、中級 4 編 の NUMA とノード単位で再会します。帯域幅はサーバー全体ではなくノードごとに計算されるので、あるノードのバスが飽和していても別のノードには余裕があり得ます。hugepages の予約や THP の断片化・コンパクションもノード単位で起きるため、ノード 0 には連続空間がないのにノード 1 には残っているという状況が生まれます。マルチソケットサーバーで今回のツールを使うときは、numastat とノードごとの指標を常に脇に置くのが安全です。
よく出会う落とし穴 #
- THP を無条件に切る — DB の勧告をすべてのサーバーに一般化した結果です。テールレイテンシが重要でないバッチ・分析ワークロードでは、THP の利得が実在します。ワークロードの敏感な軸を先に決めます。
- swappiness 0 をスワップ無効化だと思う — 0 はスキャンの重みであってスイッチではありません。極端な不足では依然としてスワップが動き、本当に切るならスワップ領域自体を削除する必要があります。ただし緩衝がなくなる代償は、中級 3 編で整理したとおりです。
- CPU 100% を見てコアを増やす — IPC が低く memory bound が支配的なら、コアではなく帯域幅のボトルネックです。増やしたコアは同じバスを分け合って一緒に待つだけです。
まとめ #
今回つかんだ絵です。
- ファイル I/O はページキャッシュを通り、読み取りはヒット・ミスと readahead で、書き込みはダーティ表示後の writeback で処理されます。O_DIRECT はこの経路を迂回します。
- THP は TLB ミスを減らす代わりに、コンパクションでテールレイテンシを大きくすることがあります。平均とテールのどちらが重要かで決めます。
- 明示的 hugepages は、あらかじめ予約して THP の副作用なしに TLB の利得を得る方式で、DB・仮想化・パケット処理の標準ツールです。
- swappiness は回収スキャンの重みで、zswap はスワップの手前の圧縮プールとしてスワップのコストを下げます。
- メモリ帯域幅のボトルネックは低い IPC と memory bound の比率で識別し、STREAM で上限を測り、処方はチャネル構成とアクセスパターンです。
次回 — ZFS 深掘り #
次の記事「ハードウェア上級 #4 ZFS 深掘り」ではストレージへ向かいます。チェックサムと copy-on-write でデータの整合性をファイルシステムが引き受ける ZFS の構造、ARC キャッシュとメモリの関係、そして RAID-Z とプール設計で運用者が下すべき決定を扱います。