インフラ・DevOps
Preempt_none は廃止されました。あなたのPostgresはたぶん気にしないでしょう
Preempt_none Is Dead; Your Postgres Probably Doesn't Care (thebuild.com)
要約
Linuxカーネルのバージョン7.0でpreempt_noneモードが廃止され、一部のPostgreSQLベンチマークでスループットが大幅に低下するという報告がありました。しかし、この問題は特定のベンチマーク設定と巨大ページを使用しない場合に顕著になるものであり、ほとんどのPostgreSQLユーザーには影響しないと筆者は解説しています。根本原因は、カーネルのスケジューリングモード変更と、4KBページでの共有メモリ使用時に発生するスピンロック内のマイナーページフォールトの組み合わせにあります。
全文翻訳
今月、AWSからLinux 7.0上でのPostgreSQLのスループットがLinux 6.x上での同じワークロードの0.51倍に低下したというベンチマーク結果が出ました。Phoronixの見出しはそれ自体で書かれました。Hacker NewsもHacker Newsらしく反応しました。その週の終わりまでに、3人の異なるクライアントからカーネルアップグレードを保留すべきかどうか尋ねられました。彼らは保留する必要はありません。ほとんど誰も必要としません。このリグレッションは実在しますが、96vCPUのボックスと100GB以上の共有メモリに対して既に誤設定されていたベンチマーク構成の、狭く、騒がしいアーティファクトです。見出しはこの話が「それをやってはいけない」という話である度合いを過小評価し、LinuxがPostgresを壊したという話である度合いを過大評価しています。実際に何が起こったのかを説明しましょう。その説明はそれ自体で興味深いからです。スケジューラ、TLB、ページフォールト、そしてバッファマネージャ以外誰も考えないPostgresの唯一のスピンロックに触れています。そして、多くのPostgresパフォーマンスの話が終わるところで終わります。それは巨大ページです。Lætitia Avrotは4月15日にMy DBA Notebookで同じリグレッションについて自身の明確な解説を書いており、もしこの記事を読むなら、彼女の記事だけを読むべきです。以下は私のバージョンの物語で、メカニズムといくつかの図により多くの時間を費やします。
Linux 7.0で実際に何が変わったのか
Linux 7.0より前は、3つのプリエンプションモードのいずれかでカーネルをビルドまたはブートできました。
PREEMPT_NONE — カーネルは実行中のユーザースレッドをほとんど中断しません。スレッドは自身のタイムスライスを使用し、システムコールやスリープ時に譲ります。これは歴史的にサーバーで望ましかったものです。バッチスループットに優れ、コンテキストスイッチのオーバーヘッドが最小限です。
PREEMPT_FULL — カーネルはほぼすべての安全なポイントでユーザースペースを中断できます。低レイテンシ、多数のコンテキストスイッチ、歴史的にはデスクトップのデフォルトでした。
PREEMPT_LAZY — より新しい中間的な選択肢です。スケジューラは中断できますが、可能な場合は「自然な」境界まで待機します。
Linux 7.0は、Peter Zijlstraのプリエンプションクリーンアップシリーズを通じて、arm64、x86、powerpc、riscv、s390、loongarchでPREEMPT_NONEを完全に削除しました。現在得られるのはPREEMPT_FULLまたはPREEMPT_LAZYです。ほとんどのディストリビューションでは、デフォルトがPREEMPT_LAZYに移行しました。ほぼすべてのワークロードでこれは問題ありません。PREEMPT_LAZYは、スループット指向の負荷下でPREEMPT_NONEの動作を近似するように設計されています。ほとんどの場合、それはうまくいきます。例外は、ユーザースレッドがクリティカルセクションに入り、そこでプリエンプションされることが壊滅的であり、かつスケジューラが予期するよりも少し長くそのクリティカルセクションに留まる場合です。つまり、スピンロックです。
ベンチマーク
Salvatore DipietroがAWSで4月3日にLKMLにリグレッションを投稿しました。件名は「sched: Restore PREEMPT_NONE as default.」でした。
セットアップ:
m8g.24xlarge — 96 vCPU Graviton4 — Amazon Linux 2023を実行。
カーネル: next-20260331、linux-nextのスナップショット。PREEMPT_NONEをarm64で削除したcommit 7dadeaa6e851 (“sched: Further restrict the preemption modes”) をリバートしたもの、およびリバートしていないもの。
ストレージ: 12× 1 TB io2ボリューム、それぞれ32,000 IOPS、RAID0、XFS。
PostgreSQL 17、pgbench simple-update、1,024クライアント、96スレッド、prepared protocol、scale factor 8,470、fillfactor=90、1,200秒実行。
結果(それぞれ3回の実行の平均):
構成 | 平均tps | 比率
---|---|---
ベースライン (linux-next, PREEMPT_LAZY) | 50,751.96 | 1.00x
7dadeaa6e851 をリバートした場合 | 98,565.86 | 1.94x
ベースラインがヘッドラインを飾ったものです。プリエンプションモードの変更をリバートすると、スループットがほぼ倍増します。逆に言えば、このワークロードではLinux 7.0は0.51倍のスループットしか提供しません。
perfは、リグレッションがほとんど希釈されずに、単一のコールチェーンに座っていることを示しました。
Copy1|- 56.03% - StartReadBuffer
2 |- 55.93% - GetVictimBuffer
3 |- 55.93% - StrategyGetBuffer
4 |- 55.60% - s_lock <<<< CPUの55%
5 |- 0.08% - LockBufHdr
6 |- 0.07% - hash_search_with_hash_value
マシンのCPU時間の半分以上が1つのユーザースペーススピンロックで消費されており、ホットスポットが着地する場所としては非常に特定され、非常に示唆に富んでいます。
なぜそのスピンロックなのか、そしてなぜ今なのか
StrategyGetBufferは、バックエンドがバッファを必要とし、まだ持っていない場合にPostgresが呼び出す関数です。それは2つのケースで1つのスピンロック — StrategyControl->buffer_strategy_lock — でシリアライズします。コールドバッファプールでは、フリーリストからポップします。フリーリストが枯渇すると、クロックスイープを実行し、nextVictimBufferを進め、候補を返します。どちらのパスも同じスピンロックを取得します。96vCPUマシンで1,024クライアントの場合、どのようなシリアライゼーションポイントでも目立つようになりますが、このロックには特定の特性があります。ロックによって保護されているセクションは、間違った構成下ではマイナーページフォールトを引き起こす可能性があります。それが、悪いが許容できるシリアライゼーションポイントをCPUの55%を消費する大惨事にする部分であり、カーネルの変更が重要になる場所です。この高い並列度では、競合は実在します。問題は、なぜPREEMPT_LAZYの下でそれが2倍悪化したのかということです。答えは、Andres Freundが-hackersとHacker Newsで解明したように、スケジューラではなく、直接的にはそうではありません。
実際の原因:スピンロック内のマイナーページフォールト
これは、ゆっくり時間をかける価値のある部分です。huge_pages=offの場合、Postgresの共有メモリは通常の4KBページでマッピングされます。120GBのshared_buffersは、PTEの用語では約3100万ページです。それらの各ページは、最初に触れたときにマイナーページフォールトを引き起こします — VMサブシステムは物理ページを配線し、PTEをインストールする必要があります。そのマイナーフォールトはマイクロ秒かかり、スピンロックの観点からは永遠です。そして、scale factor 8,470での1,200秒のベンチマークは、最初の数秒だけでなく、実行中に以前にマップされていないページに触れ続けることになります。pgbenchの127GBのpgbench_accountsテーブルに対する均一ランダムアクセスパターンは、新しいページが長時間ワーキングセットに入り続けることを意味します。
ここでホットパス上のシーケンスを考えてみましょう。バックエンドはbuffer_strategy_lockを保持しています。フリーリストからバッファをポップするため、またはバックエンドがまだ触れていないページ上にあるバッファヘッダに対するクロックスイープを進めるために、フォールトが発生していない共有メモリを読み書きする必要があります。そのアクセスはマイナーページフォールトを引き起こします。スピンロックの保持者は、カーネルのフォールトハンドラで停止します。他のすべてのバックエンド — 1,024クライアント下で96vCPUボックス上の数十または数百 — はユーザースペースでスピンし、CPUを消費し、待機しています。
sequenceDiagram
participant A as Backend A<br/>(lock holder)
participant K as Linux kernel<br/>(VM + scheduler)
participant B as Backends B..N<br/>(spinning, ×1000)
A->>A: acquire buffer_strategy_lock
A->>A: touch unmapped 4 KB page
A->>K: minor page fault
Note over K: allocate physical frame<br/>install PTE (~µs)
B->>B: s_lock() spin
B->>B: s_lock() spin
B->>B: s_lock() spin
Note over B: 1,000+ CPUs<br/>burning cycles<br/>waiting for A
K-->>A: PTE installed, resume
A->>A: finish critical section
A->>A: release lock
B->>B: one backend acquires, rest keep spinning
ここでPREEMPT_LAZYを重ねてみましょう。PREEMPT_NONEは、ユーザースペースで要求しない限り、バックエンドAがスピンロックを保持している間は決してプリエンプトしませんでした。PREEMPT_LAZYはそうするかもしれません。そうすると、スピンロックの保持時間は「ページフォールトサービスのマイクロ秒」から「ページフォールトサービスのマイクロ秒に、スケジューラが制御を保持者に返すのにかかる時間」に膨れ上がります。スピナーのキューが増加します。無駄なCPU時間は累積します。プリエンプションモードの変更はバグを作成しているわけではありません。それは、4KBページでマッピングされ、100GB以上の共有メモリに対して、不条理な並列度下でページフォールトを取るという、既存のバグを2倍にする形で可視化しています。
なぜ巨大ページが問題を引き起こさなくなるのか
ここにTLB密度物語を1枚の写真で示します — 同じ120GBのshared_buffersを3つの異なる方法でマッピングした場合です。
flowchart LR
S["shared_buffers = 120 GB"]
S --> A["4 KB pages<br/>~31,457,280 PTEs<br/>~31M possible first-touch faults<br/>TLB thrashes under load"]
S --> B["2 MB huge pages<br/>~61,440 PTEs<br/>~61K first-touch events<br/>TLB fits the working set"]
S --> C["1 GB huge pages<br/>120 PTEs<br/>~120 first-touch events<br/>TLB is effectively"]