Linux 7.0がPostgreSQLを壊した方法
(read.thecoder.cafe)- Linux 7.0で、従来サーバーのデフォルトだった PREEMPT_NONE プリエンプションモードが削除され、同一ハードウェア上でPostgreSQLのスループットが半減する深刻な性能退行が発生
- AWSのエンジニアが96-vCPUのGraviton4マシンでpgbenchを実行した結果、Linux 6.xと比べてLinux 7.0では 1秒あたりのトランザクション数が98,565件から50,751件 に低下し、CPUの55%が単一のスピンロック関数で消費された
- PostgreSQLの 共有バッファプール (shared buffer pool) へのアクセスを保護するスピンロックが、4KBメモリページのマイナーページフォールトと組み合わさることで、ロック保持中にスケジューラによるプリエンプトが起きると、待機中のすべてのバックエンドがCPUを浪費しながらスピンする
- Huge Pages (2MBまたは1GB) を有効にすると、潜在的なページフォールト数が3,100万件から数万〜数百件へ減少し、退行現象が解消
- カーネル側では Restartable Sequences (rseq) の採用が提案されたが、PostgreSQLコミュニティは、カーネルアップグレードによる性能低下そのものが「ユーザースペースを壊さない」という原則に反するとの立場
問題の現象
- AWSのエンジニア Salvatore Dipietro が 96-vCPU Graviton4 プロセッサでpgbenchを実行し、scale factor 8,470(約8億4,700万行テーブル)、1,024クライアント、96スレッド構成の高並列負荷テストを実施
- Linux 6.xでは 98,565 TPS、Linux 7.0では 50,751 TPS と、スループットがほぼ半減
perfプロファイリングの結果、CPU時間の 55.60%がs_lock関数 の内部で消費されていた- 呼び出し経路:
StartReadBuffer→GetVictimBuffer→StrategyGetBuffer→s_lock
- 呼び出し経路:
プリエンプション (Preemption) とは
- OSスケジューラが実行中のスレッドを中断し、別のスレッドへCPUを渡す決定が プリエンプション
- Linux 7.0以前には3つの選択肢が存在
- PREEMPT_NONE: スレッドが自発的にCPUを譲るまで(syscall、I/Oブロック、sleep)ほとんど中断しない。従来のサーバー向けデフォルトで、コンテキストスイッチが少なくスループットが高い
- PREEMPT_FULL: 安全なほぼすべての地点で実行中のスレッドを中断可能。応答時間は短くなるが、コンテキストスイッチのオーバーヘッドが増える。従来のデスクトップ向けデフォルト
- PREEMPT_LAZY: Linux 6.12で導入された折衷案で、自然な境界を待ちながら必要に応じてプリエンプトを許可する。PREEMPT_NONEのスループット特性に近づけるよう設計
- Linux 7.0では PREEMPT_NONEが最新CPUアーキテクチャで削除 され、PREEMPT_FULLとPREEMPT_LAZYだけが残った
- PREEMPT_LAZYはほとんどのサーバーソフトウェアでは代替として機能するが、PostgreSQLでは 致命的な差 が生じた
PostgreSQLのメモリ管理
- PostgreSQLは固定サイズの データページ (デフォルト8KB)を基本的な保存単位として使用し、テーブル行、B-treeインデックスノード、メタデータなどをすべてこのページに保存する
- ディスク読み込みを減らすため、共有バッファプール (shared buffer pool) という大規模な共有メモリ領域に最近読み込んだデータページをキャッシュする
- クライアントが接続すると専用の バックエンドプロセス が生成され、バッファプールにないページはディスクから読み込んだうえで、空きバッファまたは追い出し可能なバッファを探す必要がある
- このバッファ選択処理を担当する関数が
StrategyGetBuffer
- このバッファ選択処理を担当する関数が
PostgreSQLのスピンロック
- スピンロック は、ロック待ちの間にスリープせず、ループしながら継続的に確認するロック機構
- 非常に短いクリティカルセクションでは、スレッドを眠らせて起こすコストよりスピンのほうが効率的
- 中核となる前提: ロックを保持したスレッドがごく短時間で解放すること
StrategyGetBufferはバッファ選択を保護するために 単一のグローバルスピンロック を使用- 96-vCPU、1,024クライアント環境では、すべてのバックエンドが同じロックを奪い合う
仮想メモリとTLB
- すべてのプロセスは 仮想メモリアドレス を使い、ハードウェアが ページテーブル (多段ツリー構造)を通じて物理アドレスへ変換する
- 毎回ページテーブルをたどると遅いため、CPUは最近の変換結果をキャッシュする TLB (Translation Lookaside Buffer) を備える
- TLBヒット時は高速にアクセスでき、TLBミス 時にはページテーブルウォークが必要になって時間がかかる
- Linuxは 遅延割り当て (lazy allocation) の原則を使い、仮想メモリ割り当て時には実際の物理ページを最初のアクセス時点でマッピングする
- 初回アクセス時には マイナーページフォールト が発生し、カーネルが物理ページを割り当ててマッピングを保存するため、通常の読み書きより数マイクロ秒単位で遅い
4KBページの問題
- ベンチマークでは
shared_buffersを120GBに設定しており、4KBメモリページ基準では約 3,100万個のメモリページ、すなわち3,100万件の潜在的な初回アクセスページフォールトがある - 120GBの共有バッファプールを使う長時間ベンチマークでは、新しいメモリ領域が継続的にワーキングセットへ入ってくるため、ページフォールトは開始時だけでなく継続的に発生 する
StrategyGetBuffer内でスピンロックを保持したまま共有メモリへアクセスした際、その領域がまだマッピングされていなければ マイナーページフォールトが発生 する- PREEMPT_NONE(Linux 7.0以前): バックエンドAがページフォールトハンドラに入っても、自発的な再スケジューリングポイントを避けるため、フォールト解決前にスケジュールアウトされる可能性は低い。待ち時間は想定より長くなるが、被害は限定的
- PREEMPT_LAZY(Linux 7.0以降): スケジューラはページフォールトハンドラの内部でバックエンドAを プリエンプトして別プロセスをスケジュール できる。フォールト処理が完了しても、スケジューラが制御を戻すまで追加待ち時間
tが発生- この追加待ち時間は単なる
tではなく、現在スピン中のすべてのバックエンド数 × t のCPU浪費として増幅される - 96-vCPUで数百バックエンドという環境では、この乗数効果が致命的になり、結果としてCPUの56%が
s_lockで消費された
- この追加待ち時間は単なる
Huge Pagesによる解決
shared_buffers120GBを前提に、メモリページサイズ を変更すると潜在的なページフォールト数は劇的に減少する- 4KBページ: 約31,000,000件の潜在的ページフォールト
- 2MB Huge Pages: 約61,440件
- 1GB Huge Pages: 約120件
- ページサイズ拡大はページフォールト数を減らすだけでなく、TLBプレッシャーも緩和 する。同じメモリをはるかに少ないTLBエントリでカバーできるため、TLBミスとページテーブルウォークが減る
StrategyGetBufferがロック保持中にフォールトを起こさなくなり、ロック保持者はすぐに完了し、他のバックエンドはミリ秒ではなくマイクロ秒だけ待てばよくなる。退行現象は解消 する- PostgreSQLでの huge pages 設定は
huge_pagesパラメータで制御するoff,on,try(デフォルト値)の3つをサポートtryは huge pages が使えれば使い、使えなければ4KBへ静かにフォールバックするため、誤設定に気づけない危険があるonに設定すると huge pages を使えない場合にPostgreSQLの起動が失敗し、問題を即座に認識できる
- トレードオフ: huge pages は事前割り当て・予約方式のため、PostgreSQLがすべて使わなくても、そのメモリはシステムの他の部分で利用できない。ページの一部しか使わない場合は残りが無駄になる。大規模な
shared_buffersを使う本番環境では、一般的に受け入れる価値のあるトレードオフといえる
今後の展開
- このプリエンプション変更を設計したIntelのカーネルエンジニア Peter Zijlstra は、PostgreSQLが Restartable Sequences (rseq) を採用することを提案
rseqは、ユーザースペースコードが クリティカルセクション中のプリエンプトやマイグレーションの有無を検知 し、その区間を再実行できるようにするLinuxカーネル機能- PostgreSQLのスピンロック経路に
rseqを適用すれば、プリエンプトされたロック保持者が待機中の全バックエンドを遅延させるシナリオを回避できる
- PostgreSQLコミュニティの反応は否定的
- Linux 7.0以前には無料で得られていた性能を取り戻すために、別のカーネル機能を採用しなければならない点は受け入れがたい
- カーネルの長年の原則である 「ユーザースペースを壊さない」 (カーネルアップグレード前に正常動作していたソフトウェアは、アップグレード後も正常動作すべき)に反するという立場
まだコメントはありません。