プログラミング
タスクは成功したが、失敗した:NICとディスク帯域幅の飽和
Task Failed Successfully: Saturating NIC and Disk Bandwidth (blog.mrcroxx.com)
要約
この記事は、AIエージェントによるシステムパフォーマンス最適化の過程で「タスクは成功したが、失敗した」事例を分析しています。AIはシステムのスループットを半分の状態から完全に飽和させましたが、その理由の説明は間違っていました。筆者は、この最適化の背後にある分析とデバッグプロセスを、io_uringのバッファ登録機能を使用してDirect I/OのCPUオーバーヘッドを解消し、NIC帯域幅を飽和させる方法を中心に詳細に解説しています。
全文翻訳
#System #TLB #RDMA #io_uring
0. “タスクは成功したが、失敗した”
AI時代は、ほとんどの人が予想していたよりも速く到来しました。エージェントコーディングは、私が日々働く方法を完全に変えました。正直に言うと、しばらくの間、仕事でコードを一行も書いていません。はい、本当です。一行も書いていません!!それでも、何百ものHPCサーバーからなるクラスターで、コードが最高のパフォーマンスで実行されるのを止めることはできませんでした。
もちろん、コードを書かない(あるいは完全にレビューしない)からといって、サルがタイプするようないい加減なやり方をしているわけではありません。要件を分析し、エージェントとデザインを洗練させ、デモを構築し、模擬実験を実行し、小規模テストの結果を研究し、見つかった問題を繰り返し修正し、完全で堅牢なテストプロセスを維持する必要がある、などなど。
サルがタイプ
しかし、AIとエージェントコーディングにより、すべてが高速化されました。時には、コードが私たちが完全に理解するよりも速く生成されます。そして時には、AIが理解するよりもさらに速く生成されます。はい、あなたは正しく読みました。そして、この記事はまさにそのような例から来ています。
私のエージェントにシステムパフォーマンスを最適化するよう指示した後、AIはすぐにスループットをほぼ半分から完全に飽和させました。しかし、その機能に関する説明は完全に間違っていました。それは典型的な「タスクは成功したが、失敗した」のケースでした。
タスクは成功したが、失敗した
この記事は、AIがなぜ「成功したが失敗した」のかについては語りません。このシステムパフォーマンス最適化の背後にある分析とデバッグプロセスの詳細な説明です。
1. 1つのNICと8つのディスクによるデモの最適化
複雑なビジネスではなく、パフォーマンス最適化に焦点を当てるために、システムを単純な抽象化にしてみましょう。
単一のスレッドが8台のNVMeドライブにわたって1 MiBのランダムなダイレクトI/Oリードを発行し、その後RDMA WRITEを介してデータをリモートホストに送信します。次に、NIC帯域幅を飽和させます。
より具体的には、各ドライブは最大7 GiB/sのリードスループットを提供でき、NICは400 Gb/sのネットワーク帯域幅を提供します。すべてのデバイスは同じNUMAノードに接続されています。ワーカー スレッドはCPU0以外のコアに固定されています。ホストはIOMMUをパススルーモードで実行し、関与するI/OデバイスはIOMMUを介して翻訳されません。
実装については、私(実際には私のAIエージェント)が非常に単純なイベントループを構築しました。クライアントはサーバーにリード要求を送信し、サーバーはRDMA CQをポーリングして受信要求を処理し、io_uringを介してリードをサブミットし、結果のCQEをポーリングし、その後RDMA WRITEを介してデータを返送します。
シンプルなデモトポロジー
このデモ設定は非常にシンプルで、ほとんどすべての干渉源を排除しています。飽和させようとしているNIC以外は、すべてのコンポーネントに十分なヘッドルームがあります。NICの理論上の最大スループットは46.6 GiB/sで、各ドライブは平均6 GiB/s未満のリードスループットで、総IOPSは50,000未満に抑えられ、CPUも十分な容量を持っています。
すべてが整ったところで、結果を見てみましょう。
inflight GiB/s avg µs p50 µs p90 µs p99 µs p99.9 µs
4 8.87 440 430 519 632 759
8 15.56 501 486 616 784 955
16 22.69 688 670 850 1118 1436
32 22.53 1386 1384 1696 1934 2272
64 22.15 2821 2819 3133 3366 3691
驚くべきことに、システムはI/O深度がわずか16の時点でボトルネックに達し、集約スループットはNIC帯域幅の約半分にしかなりませんでした。そして、CPU使用率は100%に達しました。
何かが間違っていることは明らかだったので、I/O深度16でperfを使ってシステムをプロファイリングしました。これがフレームグラフです。
シンプルなデモフレームグラフ (iodepth=16)
フレームグラフが示すように、CPU時間のほとんどはio_submit_sqesに費やされており、CPUコスト全体の81.62%を占めています。デモではDirect I/Oを使用しているため、すべてのI/Oサブミッションで、カーネルがユーザー空間バッファからDMAメタデータを構築し、ブロックデバイスがそれを消費する必要があります。このパスで最もコストがかかる部分は次のとおりです。
__bio_iov_iter_get_pages: iovをbioページに変換します。
pin_user_pages_fast: ユーザー空間仮想アドレス範囲をstruct pageポインタの配列に変換し、デバイスがDMAを実行している間、これらのページが回収、移行、またはスワップアウトされないようにピン留めします。
bio_set_pages_dirty: バッファページをダーティとマークします。Direct I/Oでは、NVMeデバイスはデータをユーザー空間バッファをバックアップするページに直接DMA書き込みします。これらのページは、VMがクリーンページとして扱わないようにダーティとマークする必要があります。
folio_*: 参照カウント、ダーティ状態、マッピング、ロック、および回収関連状態を含む、フォリオに関連付けられたVM状態を更新します。Linux VMでは、フォリオは物理的に連続した一連のページのための統一された抽象化です。
一言で言えば、io_submit_sqesの広いフレームは、Direct I/O DMAのためにユーザーメモリを準備する累積コストを表しています。各SQEにはユーザー空間ポインタと長さしか含まれていません。カーネルはページテーブルを辿り、バックアップするstruct pageを見つけてピン留めし、bio_vecエントリを構築し、フォリオの状態を更新し、結果のbioをサブミットする必要があります。
この作業のほとんどは、ページごとに支払われます。4 KiBページでバックアップされた1 MiBの読み取りは、約256ページにアクセスし、1つの論理読み取りを数百のページテーブルルックアップ、ページピン、フォリオ更新、およびbioベクトル操作に変えます。毎秒20,000から50,000回の読み取りでは、システムは毎秒約500万から1,300万ページをGUP(Get User Pages)を通じて処理します。仮想アドレス範囲が著しく断片化された物理メモリによってバックアップされている場合、同程度の数のフォリオメタデータ更新とアトミックな参照カウント/ピンカウント更新、および潜在的なクロス コアキャッシュライン所有権転送が発生する可能性があります。
したがって、I/Oごとにユーザー空間バッファを処理するコストを回避できれば、パフォーマンスを向上させることができるはずです。幸いなことに、liburingはまさにそれを行う方法を提供します。io_uring_register_buffers(3)を使用すると、I/Oバッファを事前に登録でき、このメタデータ準備作業をI/Oごとのパスから移動できます。より具体的には、io_uring_register_buffers(3)は、起動時に次の作業を実行します。
iovceを事前に検証し、アドレス範囲、長さ、アライメント、カウント制限をチェックします。
バッファに対してGUPを実行し、ユーザー空間仮想アドレスを対応するstruct page/foliosに変換し、登録期間中これらのページをピン留めします。
カーネル側のバッファメタデータを構築して保持し、登録された各バッファに対してio_mapped_ubufを構築します。
これらはまさに、フレームグラフで観察した主要なコストです!試してみましょう。デモでは、64 MiBのリードアリーナを導入し、I/Oサイズに合わせて1 MiBのスロットに分割します。起動時に、64スロットのリードアリーナを、スロットごとに1つのiovceを持つ64個のio_uring固定バッファとしてio_uring_register_buffers(3)を通じて登録します。各読み取りでは、opcodeをopcode::Readからopcode::ReadFixedに切り替え、buf_indexを対応するスロットに設定します。これにより、I/Oパスが登録済みバッファを使用できるようになります。結果は次のとおりです。
inflight GiB/s avg µs p50 µs p90 µs p99 µs p99.9 µs
4 9.09 429 421 519 641 767
8 16.78 465 447 590 775 966
16 28.08 556 514 774 1119 1425
32 39.87 783 705 1229 1754 2184
64 46.00 1358 1248 2089 3195 4214
I/O深度が増加するにつれて、スループットは上昇し続けます。I/O深度が64の場合、NIC帯域幅をほぼ飽和させます。
inflight ベースライン GiB/s READ_FIXED GiB/s Δ ベースライン p99 µs READ_FIXED p99 µs
4 8.87 9.09 +2% 632 641
8 15.56 16.78 +8% 784 775
16 22.69 28.08 +24% 1118 1119
32 22.53 39.87 +77% 1934 1754
64 22.15 46.00 +108% 3366 3195
ベースラインと比較すると、CPUがボトルネックになっていない低いI/O深度ではスループットは似ています。I/O深度が16の時点ですでにベースラインはCPUの負荷を示しており、それ以降はI/Oごとのバッファ処理が完全にCPUバウンドになります。READ_FIXEDはこのボトルネックを解消し、スループットがNICを飽和させるまでスケールし続けることを可能にします。
フレームグラフもこれを裏付けています。
シンプルなデモフレームグラフ (iodepth=16, READFIXED使用)
2. 大規模な展開へのスケールアップ
簡単なデモが解決されたので、実世界の展開をより密接に反映した大規模なデモに進むことができます。
大規模デモでは、クライアントは8 * 400 Gb/s NICを搭載した単一ノードで構成されます。サーバー側は4ノードで構成されます。各サーバーには2 * NUMAノードがあり、各NUMAノードには1 * 400 Gb/s NICと…