RustクエリエンジンでMMAPをio_uringに置き換えたら、遅くなった
We Replaced MMAP with Io_uring in Our Rust Query Engine. It Got Slower (conviva.ai)
要約
Conviva社は、大規模データ分析のためにRustで構築されたクエリエンジンにおいて、ディスクからのデータ読み込みにmmap(メモリマップトファイル)を使用していました。しかし、高負荷時の同時クエリ処理において、mmapがOSのページキャッシュの競合を引き起こし、パフォーマンスが著しく低下する問題に直面しました。この問題を解決するため、ページキャッシュをバイパスするio_uringを導入しましたが、結果としてパフォーマンスはさらに低下しました。
全文翻訳
Rust製クエリエンジンのmmapをio_uringに置き換えたら、遅くなった。
当初、mmapは便利だった。メモリ管理を自分で行うことなく、ディスクから大量のArrow IPCファイルを遅延読み込みできた。ファイルフォーマットとも相性が良かった――Arrow IPCのレイアウトはゼロコピーランダムアクセス用に設計されており、mmapはまさにそれを実現してくれた。
しかし、本番環境にデプロイし、実際の同時クエリ負荷を実行したところ、mmapは深刻な問題となった。
私たちのワークロード
Convivaでは、エンドユーザーエクスペリエンスの特定と診断のために、1日に数兆件のイベントを分析している。私たちのアーキテクチャの中核は、DataFusion、Arrow、Rust、Rayon、Tokioを基盤としたイベントおよびパターン分析エンジンである。生イベントは変換され、主に数値からなる独自のフォーマットにエンコードされてクラウドに保存される。それらをローカルNVMeにコピーし、大きな(約3〜5 GB)Arrow IPCファイルを読み込む。Arrow IPCを選択したのは、シンプルさと速度のためだ――メモリとディスクのレイアウトが同一であるため、デコードコストは最小限で済み、mmapはarrow-rustによってネイティブにサポートされるゼロコピー読み込みを提供してくれる。典型的なクエリは、8つのバッチファイル(ファイルごとに1バッチ)にわたる6つのカラムを処理し、バッチあたり約1.6 GB、1日あたり合計約13 GBのデータを扱う。
[画像: クエリワークロードのダイアグラム:1日あたりのデータでバッチファイル全体にわたって読み取られるカラム]
テストセットアップ
ハードウェア:192コアボックス、約750 GB RAM。調査中のディスク構成は2種類:2× NVMe LVMストライピング(約5.5 GB/sのfio天井)と32× NVMe RAID-0(約21 GB/sのfio天井)。調査中はカーネル5.15、本番環境では6.xを使用。
本番環境での症状
軽い負荷では、mmapはうまく機能していた――高速で、数秒で生イベントからクエリを提供できた。問題は、より高い同時実行性のもとで始まった。負荷下でのレイテンシの増加は予想される――より多くのクエリが同じCPUを競合する。しかし、p95およびp99のレイテンシが、線形スケーリングが予測する値をはるかに超えて急増し、同時実行性を考慮しても、コアあたりのスキャン行数が急激に減少するのを目にした。
- OSのページキャッシュが縮小した――各ポッドは、共有キャッシュとしてよりも、プライベート割り当てとしてより多くのメモリを消費した。
- 大量のページフォルトが発生した。
- p95が実際の同時負荷下で約30秒から150秒以上に急増した。
- ポッドを追加すると、状況は悪化した。
これは、メモリプレッシャー下でのmmapページキャッシュのスラッシングを示唆していた。
制御されたベンチマーク:1ポッド vs. 4ポッド
効果を分離するために、制御されたテストを実行した:同じホスト上で1ポッドと4ポッド、同じ同時クエリ負荷。4ポッドが勝つと予想していた――より高い並列性、より良い分離。しかし、私たちは間違っていた。
14日間のクエリ(ページキャッシュを埋めるのに十分な期間)では、1ポッドが4ポッドを大幅に上回った:最大で41%高速、p95で20%以上高速だった。mmapのページキャッシュはホスト上に存在し、ポッド間で共有されるため、4つのポッドはCPUを奪い合っていたのではなく、ページキャッシュを奪い合っていたのだ。同じ実行でperfレコードを取ると、カーネルレベルで100%のロック競合が示された。
根本的な問題:mmapのページキャッシュは暗黙的な共有状態である。ホスト上のすべてのプロセスが、1つのキャッシュ、1つのロック階層、1つのエビクションポリシーを共有する。読み込みレイテンシにとって最も重要なリソースを制御できるポッドは1つもなく、同時実行性が上昇するにつれて、そのリソースにおける各ポッドのシェアは縮小していく。
ページフォルトの嵐
推測するだけでなく、mmapのメカニズムとページフォルトの統計を詳しく調べた。ファイルをmmapすると、アプリケーションはディスク上のファイルによってバックアップされたメモリ領域を取得する。アクセス時に何が起こるか:
- CPUが、物理ページがアタッチされていない仮想メモリアドレス(VMA)に触れ、ページフォルトを発生させる。
- カーネルが例外を処理する:VMAを検索し、所有権を確認し、別のスレッドがその仮想空間を同時に変更またはアンマップしている可能性があるため、ロックを取得する。
- Linux 6.4以降では、高速なVMAごとのロックが使用される。それ以前のカーネルは、より遅い
mmap_lockにフォールバックする。 - カーネルがVMAがファイルバックされていることを確認すると、ファイルフォルトをトリガーする――バイトがすでにリード・アヘッドによってウォームなページキャッシュにある場合はマイナーフォルト、物理I/Oをトリガーする必要がある場合はメジャーフォルトとなる。
推奨される読み物:mmap_lockのスケーラビリティ(LWN)およびVMAごとのロック(LWN、Suren Baghdasaryanによる設計)。
重度のページキャッシュ競合下では、リード・アヘッドが容量不足になり、メジャーフォルトが急増する。ストレスのかかる実行中の1つのプロセスに対するpidstatの結果は以下のようになった:
23:26:46 RSS = 652 GB (87.88%) 23:27:47 RSS = 734 GB (98.91%) ← ピーク、ほぼ全RAM 23:27:48 RSSが低下し始める ← カーネルがエビクションを開始 23:28:05 メジャーフォルトが出現:571/s, 1352/s, 975/s
RSSはRAMの98.91%まで増加 → カーネルはまだ必要なページをエビクトせざるを得なくなる → エビクトされたページに再びアクセスされる → ディスクから再読み込みされる際のメジャーフォルトの嵐。初期段階では、リード・アヘッドはフォルトをほとんどマイナーに保ち高速だが、同時クエリが積み重なるにつれて、リード・アヘッドが追いつかなくなり、メジャーフォルトが急増する。
一方、マイナーフォルトは毎秒数百万件で持続的に発生していた:
23:27:09 1,255,709 マイナーフォルト/秒 23:27:41 2,124,327 マイナーフォルト/秒 23:27:46 2,354,383 マイナーフォルト/秒
各マイナーフォルトはアトミック操作を通じてキャッシュラインに触れる――毎秒200万フォルトでは、L1/L2全体をスラッシングさせるのに十分であり、これは大きなキャッシュ常駐ルックアップテーブルに依存するアプリケーションにとって致命的である。フォルトはTLBシュートダウンを引き起こす可能性もあり、CPUは数千エントリしか保持できない。(ページフォルトを完全に排除することはできないが、より良く管理することはできる――それについてはパート2で詳述する。)
競合のないマイナーフォルトのコストは約0.5〜1マイクロ秒なので、毎秒200万フォルトはmmapが維持できる上限に近い――そして実際の競合下では、スレッドから見える遅延はそれをはるかに超える。
一方、多数のArrowファイルをmmapしたため、仮想アドレス空間は約3 TBにまで拡大していた:
開始時:3,125,750,740 KB(約2.9 TB仮想)
ピーク時:3,209,184,828 KB(約2.98 TB仮想)
最新のカーネルは大規模なVMAツリーを扱えるが、それは無料ではない――すべてのフォルトはVMAルックアップを行い、すべてのルックアップはmmapロック(高速パスは例外)を取得する。
コンテキストスイッチも同様の状況を示していた:
cs = 2,106,576/sec cs = 2,025,726/sec cs = 1,944,397/sec cs = 1,524,279/sec
毎秒200万以上のコンテキストスイッチ、ウォームキャッシュ実行時の14K/秒と比較して150倍多い。すべてのスレッドがページフォルトでブロックされ、スケジュール解除され、ページが到着すると再スケジュールされていた。
Perf TopとオフCPU分析
ページフォルトとロック競合の関連性を確認するため、同じクエリのコールド実行とウォーム実行でperf topを比較した:
| 関数 | コールド実行 | ウォーム実行 |
|---|---|---|
__filemap_add_folio (カーネル) | 78.0% | トップに表示されず |
| カーネルスピンロック | 0.96% | 0.76% |
| CPU/データ処理 | 4.96% | 45.08% |
__filemap_add_folioはページをページキャッシュに追加する。データがすでに存在するため、ウォーム時にはほとんど表示されない。コールド時、メモリプレッシャー下では、ページが絶えずエビクトされ再挿入されるため、これが支配的になる。実際のクエリコードは、CPU使用率の約45%(ウォーム時)から約5%(コールド時)に低下する――それは仕事量が減ったからではなく、カーネルがはるかに多くの仕事をしているからだ。
bpftraceによるオフCPU時間(アクティブな時間のみ、アイドルなRayonスレッドを除く):
- Futex: 30.9% (1,172s) — スレッドが同期処理でブロックされ、別スレッドのページフォルトハンドラのキューに並んでいる状態。
- Preempted: 29.3% (1,109s) — 192コアで12スレッドという環境では驚くほど高い。カーネルのページフォルト処理(リード・アヘッドkスレッド)がワーカー・スレッドをプリエンプトしていた。
- Disk I/O: 6.9% (262s) — 実際のNVMeレイテンシは、その上のメカニズムに比べて小さかった。
- mmap_sem: 0.9% (33.5s) — 明示的なVMAロック。キューに並んだスレッドからのカスケードするfutexウェイクアップを待つ時間は含まないため、小さい。
状況:負荷下では、ページキャッシュのスラッシングとカーネルレベルのロック競合が、ディスクI/Oではなくボトルネックとなっていた。これはmmapに固有のものではなく、バッファリングされたI/Oパスであれば同様のページキャッシュとロック競合に遭遇する可能性がある。
fioの天井
io_uringエンジンを使用したfio — 4プロセス、iodepth 32、4 MiBブロック、O_DIRECT:
READ: bw=20.2 GiB/s (21.7 GB/s) 全32台のNVMeドライブが約99.75%利用率 md0 util = 99.95%
mmapが実際に提供できたピーク値(ストレスのかかる実行中のvmstatから):3.44 GB/s — ハードウェアが可能な性能の約16%。その差が、獲得できるはずだった報酬の大きさだった。
io_uringの登場
io_uringはその評判に値する。非同期カーネルI/Oを超えて、その約束の一部は、ページキャッシュを完全にバイパスする直接ユーザーI/Oであり、これが上記のほとんどの問題を引き起こしていた。読むべきもの:
- [