プログラミング
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のデータを処理します。
テストセットアップ
ハードウェア: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 Pod vs. 4 Pods
影響を分離するために、制御されたテストを実行しました。同じホスト上で、同じ同時クエリ負荷で、1つのポッドと4つのポッドを比較しました。4つのポッドが優位になると予想していました。より多くの並列処理、より良い分離のためです。私たちは間違っていました。14日間のクエリ(ページキャッシュを埋めるのに十分な長さ)では、1つのポッドが4つのポッドを大幅に上回りました。最大で41%高速、p95で20%以上高速でした。mmapのページキャッシュはホスト上に存在し、ポッド間で共有されるため、4つのポッドはCPUを奪い合っていたのではなく、ページキャッシュを奪い合っていました。同じ実行でのperf recordは、カーネルレベルで100%のロック競合を示しました。根本的な問題:mmapのページキャッシュは暗黙的に共有される状態です。ホスト上のすべてのプロセスは、1つのキャッシュ、1つのロック階層、1つのエビクションポリシーを共有します。単一のポッドが、読み取りレイテンシにとって最も重要なリソースを制御できず、同時実行性が高まるにつれて、 everyone のそのリソースの分け前は縮小します。
ページフォールトの嵐
推測するだけでは済ませたくなかったので、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は数千エントリしかTLBを保持できません。(ページフォールトを排除することはできませんが、より良く管理することはできます。パート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万回以上のコンテキストスイッチ。ウォームキャッシュ実行時の14,000回/秒と比較して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%(コールド時)に低下します。これは、作業量が少ないからではなく、カーネルがはるかに多くの作業を行っているためです。
オフCPU時間(bpftraceによる、アイドルRayonスレッドを除く実効時間のみ):
Futex: 30.9% (1,172s) — 他のスレッドのページフォールトハンドラーの後ろにキューイングされ、同期でブロックされているスレッド
Preempted: 29.3% (1,109s) — 192コア上の12スレッドとしては驚くほど高い。カーネルのページフォールト作業(リードヘッダーkスレッド)がワースレッドをプリエンプトしていました。
ディスク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%
ストレスフルな実行中のvmstatから得られた、mmapが実際に提供したピーク値:
3.44 GB/s — ハードウェアが可能なことの約16%。
そのギャップが、賞金のサイズでした。
io_uringの登場
io_uringはその評判に値します。非同期カーネルI/Oを超えて、その約束の一部は、ページキャッシュを完全にバイパスする直接ユーザーI/Oです。これが私たちの問題の大部分を引き起こしていました。読む価値があるもの:「io_uring for high performance DBMS」— 最適化の良い概要ですが、4KBページバッファを持つ従来のDBMSに焦点を当てているため、多くは翻訳されません。