1 ポイント 投稿者 GN⁺ 2024-08-27 | 1件のコメント | WhatsAppで共有
  • write で Linux パイプにデータを書き込むと、単純なメモリ書き込みよりはるかに遅くなり、実験環境では メモリバッファ書き込み 167GB/s に対して パイプ書き込み 17GB/s 程度にとどまった
  • ボトルネックは単一のデータコピーだけでは説明できず、pipe_write 内の ページ割り当て、ロック、カーネルのコピー処理ルーチンのコストが積み重なっている
  • vmsplice はユーザー空間バッファをカーネルへコピーせずにパイプへ接続し、__alloc_pages_raw_spin_lock_irqcopy_user_enhanced_fast_string のような高コスト経路を回避する
  • Fizz Buzz スループット事例では、vmsplice を使う解法は単一コアで 60.8GiB/s、複数コアで 208.3GiB/s に達し、別実験の vmsplice 例では 210GB/s を記録した
  • 訂正事項により SIMD未使用ペナルティ の解釈は信頼しにくく、プロセス間通信は L1 キャッシュだけでは完結しないため、167GB/s を実際のパイプ処理量として期待するのは難しい

出発点: vmsplice が生む大きな差

  • 一部のプログラムは、パイプ経由でデータをより高速に移動させるために vmsplice システムコールを使う
  • Code Golf StackExchange の Fizz Buzz スループット競争では、解法は大きく 2 グループに分かれる
    • vmsplice を使わない解法は毎秒数 GiB 程度で、neil の解法は 8.4GiB/s に達した
    • vmsplice を使う解法は tkluck の 15.5GiB/s、ais523 の 60.8GiB/s、david の複数コア 208.3GiB/s まで伸びる
  • 単にカーネル空間とユーザー空間の間のコピーを減らす効果だけでは、単一コアで約 7 倍の差を説明しにくい
  • 独自実験でも、ais523 の解法は 96.4GiB/s、david の解法は 7 コア使用時 277GB/s、1 コアあたり約 40GB/s を記録した

基準線: ユーザー空間メモリ書き込み

  • システムコールなしでユーザー空間メモリ内の 32KiB バッファを繰り返しコピーする Rust プログラムは、実験環境で 167GB/s を記録した
  • この数値は、使用した CPU の L1 キャッシュ書き込み速度と一致するレベルと見なされる
    • 実験システムは Ryzen 9 7950X3D、DDR5 6000T/s、Debian 12、Linux 6.1.0-18-amd64 環境
    • CPU 緩和オプションは mitigations=off で無効化されている
  • ftrace プロファイリングでは、ほとんどの時間が __memset_avx512_unaligned_erms に費やされていたことが示された
  • ただし、訂正事項によりこの解釈には制限がある
    • 中断時点の命令は rep stos であり、これは AVX-512 命令ではない
    • AVX2 と SSE2 のみを使うよう制限してもスループットが 167GB/s のままだったのも、どの場合も rep stos が使われていたためである

write でパイプに書くときの実際のコスト

  • 同じサイズのバッファを stdout.write() でパイプに書き込み、pv >/dev/null で消費すると、スループットは 17GB/s まで低下した
  • プロファイリング結果では、ほとんどの時間が write システムコール内で消費され、とくに 95%pipe_write 内にとどまっていた
  • pipe_write 内では、ページ準備、ロック、コピーがそろってコストになる
    • __alloc_pages: 全体時間の 36%、パイプ用の新しいメモリページを準備する
    • __mutex_lock.constprop.0: 全体時間の 25%、パイプ書き込みのためのロックコストを占める
    • _raw_spin_lock_irq: 全体時間の 5%、パイプ書き込み関連のロックコストとして現れる
    • copy_user_enhanced_fast_string: 全体時間の約 20%、ユーザー空間からカーネル側へデータをコピーする
  • pvsplice でページを /dev/null へ移動して消費するため、同じ少数のページをループで再利用し続けるのは難しい

カーネルのコピー処理ルーチンと訂正後の解釈

  • copy_user_enhanced_fast_string を逆アセンブルすると、大きなバッファコピーは REP MOV 命令で処理されている
  • この関数は C ではなく Linux カーネルのアセンブリコードで実装されており、コンパイラ最適化漏れではなく意図された経路である
  • 元の実験では、ユーザー空間で rep movsb を直接呼び出して 80GB/s を得ており、これをカーネルのコピー処理ルーチンが約 2 倍遅いという解釈につなげていた
  • その後の訂正で、実験条件が異なることが判明した
    • 32KiB バッファ 2 個で L1 データキャッシュが飽和する
    • 16KiB バッファを使うと性能は 153GB/s まで上がる
  • したがって、カーネルコピーでベクトル命令を使わないことが大きなペナルティだという元の解釈は信頼しにくい
  • それでも、パイプ書き込みで メモリ管理オーバーヘッド が大きい点は残る

vmsplice が避けるカーネル経路

  • vmsplice はユーザー空間のバッファ全体をカーネルへコピーせずにパイプへ渡し、パイプ利用コストを下げる
  • Francesco の pipes-speed-test に含まれる ./write 例は、無限に 'X' を書く最小例として使われる
  • この例は 210GB/s を記録したが、同じバッファを繰り返し vmsplice に渡しているため、一般的なデータ生成処理とは異なる
    • 定数バイト列でなければ、新しいデータをバッファへ埋める必要がある
    • このとき、単純なメモリ書き込み上限である 167GB/s が再び関係してくる
  • vmsplice 経路でも __mutex_lock.constprop.037% の時間が費やされる
  • しかし、write 経路で見られた __alloc_pages_raw_spin_lock_irqcopy_user_enhanced_fast_string は現れない
  • 代わりに add_to_pipeimport_ioveciov_iter_get_pages2 が主要経路として現れ、vmsplicewrite の高コスト部分を回避していることを示す

残る結論と注意点

  • 実験上、Linux パイプに write で書き込む経路は、単純なメモリ書き込みより約 10 倍遅い
  • 元の結論は、パイプ書き込みではロックコストと SIMD コンテキスト保存・復元コストが大きく、splicevmsplice がそれを回避するという内容だった
  • 訂正後は、結論をより限定的に見るべきである
    • カーネルの メモリ管理オーバーヘッド は依然としてパイプ性能低下の重要な要因である
    • ベクトル命令未使用が予想ほど大きなペナルティだという解釈は不正確である
    • プロセス間通信は L1 キャッシュだけで成り立つわけではないため、167GB/s は実際のパイプ読み取りまで含めたスループットとしては達成しにくい
  • いくつかの重大な誤りが訂正されており、結果の信頼性には限界があるため、数値は方向性を見るためのものとして解釈すべきである
  • パイプのスループットを高めるには、システムコール数を減らすだけでなく、write が通るカーネル内部経路やバッファ処理方式まで合わせて見る必要がある

1件のコメント

 
GN⁺ 2024-08-27
Hacker News の意見
  • この問題に取り組もうとしているサイドプロジェクトがある: https://lwn.net/Articles/976836/
    アイデアは、対応するすべてのファイルディスクリプタに対してリングバッファを取得するシステムコールを作るというもの。パイプも含まれ、両端がリングバッファの利用に対応していれば同じリングバッファをマップしてゼロコピー I/Oを行い、場合によってはカーネル呼び出しをまったく行わずに済むかもしれない。一緒にやる人を探している

    • 少なくともユーザー空間での用途なら、新しいカーネル機能が本当に必要かは分からない。以前、eventfd でパイプの動作をかなり近く再現する、ユーザー空間の単一プロデューサー/単一コンシューマーのリングバッファを実装したことがある
      リングバッファが満杯または空になったときに sleep/poll でき、それ以外ではロックなし、システムコールのオーバーヘッドもなしで動作する
    • パイプの両端がリングバッファに対応していることを相手側へ知らせる標準的なシグナル方式が計画されているのか気になる。そうすれば libc で透過的に処理できるはずだが、そうでないなら、パイプに限っては共有メモリと futex 同期に比べてどんな利点があるのかよく分からない
    • おそらく ringbuffer_wait()poll()読み取り可能状態にして通知できるのではないか
    • 既存のリングバッファインターフェースがこれを使うようになるのか、それともxkcd 927 的状況になるのか気になる。いずれにせよ興味深い試みに見える
    • バッファリングには理由があって存在しており、この方式はスクリプトで奇妙な失敗モードや脆弱性を生む可能性がある。肝心なのは、どんなストリーム生成側でも特定の消費側より遅くなり得るということだ
      瞬間的な hiccup だけでも、十分なバッファリングがなければパイプが完全におかしくなり、必要なバッファサイズはシステムごとに異なる
  • JMP がなぜ単なる RET ではないのかは、CONFIG_RETHUNK オプションのため。objdump の逆アセンブルで見えているのは、RETJMP __x86_return_thunk に置き換えられた結果だ
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/lib/ret...
    関数の先頭と末尾の NOP 命令は ftrace 用ではなく、ASM_CLAC/ASM_STAC マクロ由来のもの。このマクロは X86_FEATURE_SMAP が検出されると、実行時に CLAC/STAC 命令で埋めるための空間を残す。どちらの命令も 3 バイトなので NOP の数と同じになる
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/kernel/...

    • こういう内容を知っていながら、あんな意地悪な使い捨てニックネームを選ぶカーネル開発者は、おそらく片手で数えられるほどしかいないと思う
  • Linux パイプを「遅い」と呼ぶのは、Toyota Corolla を「遅い」と呼ぶようなもの。極端なユースケースでなければ十分速い
    自動車レースをしているのか? しかも速さが技術より重要な種目なのか? それならもっと速い車を買えばいい。そうでなければ Corolla に乗り続ければいい

    • これは寿命の間にせいぜい数十億回だけ実行されるような、あるプロジェクトのコードではなく、数百万から数十億台のコンピューターで頻繁に使われるコードだ
      だから、ごく小さな効率向上であっても多くの時間をかけて最適化することには経済的に意味がある
    • 実際、筆者のケースでは遅いというパイプが17GB/s、つまり 130Gbps を超える速度でデータを移している
      10年以上パイプをさまざまな用途に使ってきたが、パイプ速度に詰まったことはなく、たいてい tar、gzip、find、grep、nc のようなツールがボトルネックだった可能性が高い。もちろん、これらのツールもやっていることを考えればかなり速い部類だ
    • 生の動画をデコードするプロプライエタリ SDK を使うプロジェクトがある。デコード済みデータを純粋な RGBA として出力し、FFMpeg がパイプ経由で読んで標準コーデックに再エンコードする
      FFMpeg は Non-Free SDK をソースに含められず、純粋な RGBA をファイルに保存するのはまったく非現実的だ。だからパイプが唯一の方法であり、高スループットなパイプが必要になる正当な理由がある
    • どこでも使われているものを数パーセント速くすることは、十分に価値ある投資だ。個々の処理は大きく速くならなくても、世界全体で合計すれば莫大な電力と時間が節約される
    • ときには、より速い Corolla が本当に最善の答えでもある
      https://www.toyota.com/grcorolla/
      この車はエンジニアリングと性能がすばらしく、本来意図していた GR Yaris を米国市場に持ち込みにくくした規則を回避するためのハックのような存在でもある。HN の人たちが軽く受け止めるだけのエンジニアリング/性能/ハック/市場の文脈はあると思う。しかも社長自身も今なお運転して扱っている
  • 本文の核心とは別の話だが、現代の CPU では rep movsb が最速のベクトル化版と同じくらい速い。CPU がこれを高速化していることが分かっているためだ。
    カーネル関数名の copy_user_enhanced_fast_string もそれを示唆している。関連する CPU 機能は ERMS(Enhanced Repeat Move String、一定以上の長さで rep movsb を高速化する)と FSRM(Fast Short Repeat Move String、短いコピーも高速化する)である。

    • それがすべてではない。rep movsb はあるしきい値までは速いが、それを超えると通常のストア、または非一時ストア(non-temporal store)のほうが速い。
      すべてのしきい値は https://codebrowser.dev/glibc/glibc/sysdeps/x86_64/multiarch... に説明されている。
      そしてこれらの値は固定でもなく、Noah Goldstein が今も毎年更新している。
    • Linux は、記事で使われたカーネル 6.1 以降、x86 のコピーで ERMS と FSRM を使う方法を何度も変更している点も注目に値する。ちなみに FSRM と ERMS の両方を備えた私のマシンでは――意外なことに前者が後者を含意するわけではない――Linux 6.8 で普通のパイプと 32KiB バッファだけで 17GB/s が出る。
    • 短い memcpy については、単純な C ループ版を削除してもよいほど rep movsbrep stosb が速くなるのを、まだ待っている。
    • だとすると、C コンパイラが固定長の memcpy をインライン化するように、可変長の memcpy() もいつになったらインライン化してくれるのか気になる。
  • AVX512 について記事で見かけなかった点がある。xsave/xrstor のオーバーヘッド以外にも、AVX512 は電力を多く消費し、CPU 周波数スケーリングを引き起こす。詳しい内容と、それがどれほど微妙になり得るかは [1]、[2] を見るとよい。
    [1] https://www.intel.com/content/dam/www/central-libraries/us/e...
    [2] https://www.intel.com/content/www/us/en/developer/articles/t...

    • それは特定の Intel CPU モデルでだけ当てはまる。
  • ほぼあらゆる形の プロセス間通信 は「遅い」。安全性のために性能コストを払うことを選んでいるのだ。

    • そこまで大きなコストを払う必要はない。パイプが提供するものはほとんどないのだから、コストもほとんどないべきだ。
      特に、最速のプロセス間通信が長い関数呼び出しより遅くなければならない理由はあまりない。
    • パイプは安全性のために存在するのではなく、既存のプログラム同士でデータを受け渡すための 最適化 として存在している。
  • また Hacker News の ハグ・オブ・デス を受けている。WordPress のページキャッシュのおかげで前回よりはましだが、それでもページ読み込みに数秒かかるかもしれないので了承してほしい。

  • そもそも splice がなぜそこまで遅くなければならないのか、よく理解できなかった。vmsplice より遅い理由としてバッファ割り当てとスカラー命令の使用を挙げていたが、なぜそれが必要なのか分からない。
    なぜ splice を単に vmsplice のように再実装できないのだろうか。きっと正当な理由があるはずだが、見落としているようだ。

  • io_uring を使った版も見ると面白そうだ。カーネルとバッファをあらかじめ共有して一部のコピーを避けられそうだし、システムコールのオーバーヘッドも避けられそうだ。ただしここでは後者はほぼ無視できるように見える。

  • 読み込みに 20 秒くらいかかるブログが言うには、かなり 大胆な主張 だ。

    • この記事は Hacker News のトップまで上がったのだから、少し大目に見るべきだと思う。
      記事自体は素晴らしそうで、内部で何が起きているのか学べることが多い。