HN 日本語サマリー

← 一覧へ戻る
プログラミング

io_uring をリードヘッドなしで使用する

Io_uring Without Readahead (frn.sh)

98 pointsby porridgeraisin31 コメント

要約

この記事は、Linuxのio_uringサブシステムにおけるリードヘッド(readahead)機能の有無がパフォーマンスに与える影響を調査しています。リードヘッドなしのio_uringは、アプリケーション側での並行処理の欠如により遅延が発生する可能性がある一方、リードヘッドを有効にするとブロックレイヤーでのリクエストマージが進み、パフォーマンスが向上するケースがあることを示しています。

全文翻訳

io_uring: Readaheadなしでのパフォーマンス

誰かがTursoにreadaheadを実装するPRをオープンしました。これは一時的な実装でしたが、io_uringを測定し、それについてさらに理解するための良い機会となりました。

Tursoには2つのバックエンドがあります。syscall は pread(2) を使用します。io_uring は io_uring を使用し、データベースファイルを O_DIRECT で開きますが、バッファリングされたI/Oを使用するオプションはありません。O_DIRECT はカーネルのreadaheadを無効にするため、それを復活させるにはアプリケーション側での実装が必要です。11 ScyllaDBのブログには、I/Oの内部構造に関する素晴らしい解説があります: https://www.scylladb.com/2024/11/25/database-internals-working-with-io/

readaheadがない場合、io_uringは一度に1つのエントリのみを発行します。問題は並行性の欠如です。各読み込みは前の読み込みを待ちます。アプリケーションは「この時点でページ100が必要だ」と知っています。これは、Tursoがページ100の読み込みSQEをサブミットし、それが完了するのを待つことを意味します。スキャンは続行されます。次にTursoはページ101を必要とし、そのページに対する新しい読み込みSQEをサブミットします。22 上記の例は、シーケンシャルな物理readaheadを想定しています。Alex Miller は、それがデータベースにとってなぜナイーブなのかを指摘しました。許可を得て引用します: 「キャッシュされたI/Oによって実装されるreadaheadは純粋に物理的なreadaheadであり、テーブル/インデックスがクラスタ化されている、つまりページが論理的に物理的にソートされるのと同じ順序でソートされているという賭けです。Bツリーのページはどの順序でも配置される可能性があり、それは単なる賭けです。スキャンオペレータがシーケンシャル読み込み(テーブルスキャン)を行うかランダム読み込み(インデックス結合)を行うかのヒントを提供できることは珍しくなく、ストレージがキャッシュの理由(大きなスキャンがワーキングセットをメモリから空にしないようにするため)とI/O読み込み(ランダム読み込みでreadaheadを実行しない)の両方のためにヒントを適用できるようにします。ファイル全体をスイープして、ソートされていない順序でリーフページを拾い上げることでフルテーブルスキャンを実行できるシステムがいくつかあると思いますが、SQLiteはそうではありません。SQLiteはBツリーウォークに従うため、readaheadを配置するのに適切な場所は、そのBツリーウォーク内にあり、ランダムな推測ではなく、実際に読み込むページを取得できます。」

readaheadがオンの場合、状況は変わります。Tursoはページ100を必要とし、シーケンシャルアクセスを検出し、1ページを要求する代わりに、ページ100から131までの読み込みをサブミットします。これにより、同時に32件の読み込みが進行中になります。

以下の測定では、TPC-H33 を使用しました。TursoのCONTRIBUTION.mdの推奨アプローチであるTPC-Hを使用したためです: https://github.com/tursodatabase/turso/blob/main/CONTRIBUTING.md#tpc-h これは、分析データベースの標準ベンチマークであり、1.2 GiBのデータベースで実行しました。

  • Q6、私が測定したクエリは、ベンチマークで最大のテーブルである lineitem のフルスキャンを実行します。
  • off は PRAGMA prefetch_pages=0 を意味します: readaheadなしのPRのコードです。
  • on は32ページのウィンドウを意味します。

リクエストのマージ

I/Oパスでreadaheadが何を変えるか、つまりTursoがいくつのリクエストをサブミットし、何がデバイスに到達するかを見たかったのです。そのため、Q6を実行中に iostat -dxm 1 sda を実行し、io_uring:io_uring_submit_req トレースポイントをカウントするために perf stat を使用しました。

off (n=1)on (n=1)
SQEs submitted195,207218,212
device requests~196,000~16,300
rareq-sz4.37 KiB56.53 KiB
%rrqm~091-93%

rareq-sz は、デバイスに到達する読み込み要求の平均サイズです。%rrqm は、発行される前に別の要求とマージされた読み込み要求の割合です。ここでの「デバイス」は、ゲストの仮想ディスクです。これらの数値は、VM下の物理ストレージを指すものではありません。

readaheadがオンの場合、Tursoは23,005件のSQEを多く発行し、より多くのページをフェッチするため、デバイスはより多くのバイトを読み込みます。しかし、デバイスが受け取る要求数は少なくなります。

キュー内の2つの要求が隣接するセクターをカバーしている場合、ブロックレイヤーはそれらを1つの大きな要求に結合します。これは、両方の要求が同時にキューにある場合にのみ機能します。perf でマージトレースポイントをカウントしました。

readaheadがオフの場合、195,516件のBIOSのうち140件しかマージされませんでした。これは、キューにSQEが1つしかなく、マージするものが何もないため理にかなっています。readaheadがオンの場合、218,493件のBIOSのうち202,539件がマージされ、デバイスはわずか15,951件の要求しか受け取りませんでした。キューに複数のSQEがあったため、カーネルがそれらをマージしました。

ポーリングスレッド

Tursoはsqpoll付きのio_uringを使用します。sqpollは、作業が配信されたかどうか、およびカーネルが何かを行うべきかどうかをチェックするためにスピンするスレッドを使用します。これは、スピンのコストをかけて作業を追跡するスレッドです。

prefetch_pages=32 でsqpollの実行時間を計測し、どこに時間が費やされているかを確認しました。壁時計時間で8.22秒、ユーザー時間で3.70秒、システム時間で8.46秒(7回の実行の中央値)でした。システム時間は壁時計時間よりも大きく、これは2つのスレッドが同時にCPUを消費する場合にのみ可能です。

サイクルがクエリコードにあると予想していましたが、そうではありませんでした。

SQ pollingを使用したio_uringバックエンドでのQ6。io_sq_thread、カーネルポーリングスレッドが65%のサイクルを占めています。このプロファイルは再構築されたホストからのものです。

そのため、SQ pollingなしでTursoを再構築し、setup_sqpoll ビルダーをプレーンな IoUring::new に置き換えました。これは、sqpollのセットアップに失敗した場合のTursoのフォールバックです。

プレーン/デフォルトリング: 壁時計時間で8.62秒、ユーザー時間で3.62秒、システム時間で1.27秒でした。ポーリングスレッドを削除すると、壁時計時間はわずかに悪化し、システム時間は大幅に減少しました。システム時間がゼロでないのは、サブミットごとにまだ io_uring_enter(2) を呼び出しており、Tursoは一度に1つのSQEをサブミットするためです。

ポーリングが価値があるかどうかは、マシンによって異なります。Didonaらは、2022年のSYSTOR論文でこれを測定しました。1つのNVMeドライブと1つのCPUコアでのサブミッションポーリングは、わずか13 KIOPS(1秒あたり13,000 I/O操作)しか達成できませんでした。2つのスレッドは1つのコアを共有する必要があったため、順番に処理していました。コアが2つになると、パフォーマンスは完全に回復しました。私が使用しているこのボックスには4つのvCPUがあります。クエリはシングルスレッドであり、1つのポーリングスレッドを持つ1つのリングを使用しているため、ポーリングスレッドがクエリと競合することなく実行するには十分なCPUがありました。

キャッシュミス

SQ pollingをオフにして、両方のバックエンドでQ6を再度計測しました。io_uringで8.55秒、syscallで3.02秒でした。この差は顕著です。その追加時間がどこに行くのかを理解したかったので、perf で命令数とキャッシュミス数をカウントしました。このテーブルのカウンターに関する注意点: これらは再構築されたホストからのものです(誰がアイドル時間のためにHetznerに支払いたいでしょうか?)。そのため、絶対値は上記のタイミングと一致しません。

backendcycles (median, n=7)instructions (median, n=7)IPCcache missesmiss rate
io_uring plain5.374 B21.497 B3.99910.666 M13.255%
syscall4.734 B21.297 B4.4995.889 M7.691%

io_uringは0.2 B多い命令を実行しますが、これはほとんど無視できる量ですが、4.8 M多いキャッシュミスが発生します。

両方のバックエンドはDMAを使用します。ディスクハードウェアはCPUが作業を行うことなく、データをRAMに直接書き込みます。しかし、2つのバックエンド間にも違いがあります。バッファリングされた読み込みでは、ディスクはデータをページキャッシュに書き込みます。44 ユーザーランドでのディスクI/Oに関する興味深いブログ投稿があります: https://transactional.blog/how-to-learn/disk-io もし興味があれば。その後、カーネルはページキャッシュからプロセスバッファにデータをコピーします。コピーの副作用として、データはCPUキャッシュ(L1/L2/L3)に配置されます。

しかし、O_DIRECT では、ディスクはコピー手順なしで直接プロセスバッファにデータを書き込みます。これは、CPUがプロセスに関与しないことを意味するため、何もCPUキャッシュに到達しません。

この説明は仮説です。カウンターはio_uringがsyscallよりも多くのキャッシュミスがあることを示していますが、ミスがコピー手順の欠如に起因することを追跡したことはありません。

コスト

これらすべてを踏まえて、各モデルのコストについていくつかの意見があります:

  • sqpollは、マシンに複数のvCPUがある場合に合理的なモデルです。しかし、アプリケーションがリソースをどのように使用するかを理解する必要があります。アプリケーションがすでにCPU負荷が高い場合、ポーリングスレッドはCPUをめぐって競合します。その場合、sqpollをオンにする前に、カーネルスレッドに1つのvCPUを割り当てることの影響を測定するのが良いでしょう。
  • プレーン/デフォルトのio_uringも合理的です。なぜなら、バッチ処理ができるからです。1つの io_uring_enter(2) で複数のSQEをバッチ処理できます。SYSTOR論文ではこれを測定しました。キュー深度64でI/Oあたり1.01回のシステムコールでした。

  1. ScyllaDBのブログには、I/Oの内部構造に関する素晴らしい解説があります: https://www.scylladb.com/2024/11/25/database-internals-working-with-io/ ↩︎

  2. 上記の例は、シーケンシャルな物理readaheadを想定しています。Alex Miller は、それがデータベースにとってなぜナイーブなのかを指摘しました。許可を得て引用します: 「キャッシュされたI/Oによって実装されるreadaheadは純粋に物理的なreadaheadであり、テーブル/インデックスがクラスタ化されている、つまりページが論理的に物理的にソートされるのと同じ順序でソートされているという賭けです。Bツリーのページはどの順序でも配置される可能性があり、それは単なる賭けです。スキャンオペレータがシーケンシャル読み込み(テーブルスキャン)を行うかランダム読み込み(インデックス結合)を行うかのヒントを提供できることは珍しくなく、ストレージがキャッシュの理由(大きなスキャンがワーキングセットをメモリから空にしないようにするため)とI/O読み込み(ランダム読み込みでreadaheadを実行しない)の両方のためにヒントを適用できるようにします。ファイル全体をスイープして、ソートされていない順序でリーフページを拾い上げることでフルテーブルスキャンを実行できるシステムがいくつかあると思いますが、SQLiteはそうではありません。SQLiteはBツリーウォークに従うため、readaheadを配置するのに適切な場所は、そのBツリーウォーク内にあり、ランダムな推測ではなく、実際に読み込むページを取得できます。」 ↩︎

  3. TursoのCONTRIBUTION.mdの推奨アプローチであるTPC-Hを使用したため、これを測定しました: https://github.com/tursodatabase/turso/blob/main/CONTRIBUTING.md#tpc-h ↩︎

  4. ユーザーランドでのディスクI/Oに関する興味深いブログ投稿があります: https://transactional.blog/how-to-learn/disk-io もし興味があれば。 ↩︎