22 ポイント 投稿者 GN⁺ 2025-06-24 | 3件のコメント | WhatsAppで共有
  • Linuxで実装されたUnixパイプの性能を、段階的な最適化を通じて分析
  • 最初の単純なパイププログラムの帯域幅は約3.5GiB/sで、プロファイリングとシステムコールの変更によってこれを20倍以上改善していく過程を扱う
  • vmsplicespliceのようなZero-Copyシステムコールを活用し、不要なデータコピーを減らし、ページサイズを大きくするなど、さまざまな最適化手法を説明
  • Huge Pageの利用とビジーループ(busy loop)手法の適用によってボトルネックを解消し、最大62.5GiB/sの処理速度を記録
  • パイプ、ページング、同期コスト、Zero-Copyなど、高性能サーバーやカーネルプログラミングで重要な要素への洞察を提供

概要と導入

  • この記事は、LinuxでUnixパイプがどのように実装されているかを、パイプを通じてデータを読み書きするテストプログラムを自作しながら、段階的に性能を最適化していく過程として扱う
  • 最初はおよそ3.5GiB/sの帯域幅を持つ単純なプログラムから始め、さまざまな最適化を経て約20倍の性能向上を達成
  • 各段階の最適化は、perfツールを使ったプロファイリング結果に基づいて決定され、関連ソースコードは GitHub - pipes-speed-test で公開されている
  • 着想のきっかけは、高性能FizzBuzzプログラム(36GiB/s)で、パイプを使ったデータ処理速度を見たことだった
  • C言語の基礎レベルの知識があれば、内容の理解に大きな支障はない

パイプ性能の測定: 最初の遅いバージョン

  • 高性能FizzBuzzプログラムの実行例では、パイプ経由で毎秒36GiBのデータを処理していることを確認できる
  • FizzBuzzはL2キャッシュサイズ(256KiB)のブロック単位で出力し、メモリアクセスとIOオーバーヘッドのバランスを取っている
  • この記事で作成したパイプ性能テストプログラムも、256KiBブロック単位で繰り返し出力(read/write)を行い、測定のためにreadとwriteの両端を直接実装している
  • write.cpp は同じ256KiBバッファを繰り返し書き込み、read.cpp は10GiBを読み込んで終了し、スループットを表示する構成
  • テスト結果では、パイプ経由のread/writeは3.7GiB/sで、FizzBuzzと比べて10倍遅いことがわかる

write動作のボトルネックと内部構造

  • **perf**ツールでプログラム実行時のコールグラフを追跡した結果、全体時間の半分近くがパイプ書き込み、すなわち pipe_write の段階で消費されていることを確認
  • pipe_write の内部では、大半の時間が**メモリページのコピーと割り当て(copy_page_from_iter__alloc_pages)**に費やされる
  • Linuxパイプは**リングバッファ(ring buffer)**の形で実装されており、各エントリは実データが格納されたページを参照する
  • パイプ全体のバッファサイズは固定で、パイプが満杯になるとwriteはブロックし、空になるとreadがブロック状態になる
  • C構造体(pipe_inode_infopipe_buffer)では headtail がそれぞれ書き込み位置と読み取り位置を表し、個別ページのオフセットと長さの情報を含む

パイプの読み書きロジック

  • pipe_write は次のような順序で動作する
    • パイプが満杯なら、空きができるまで待機
    • 現在の head に残っている空間を優先して埋める
    • さらに空間があれば、新しいページを割り当て、バッファにデータをコピーし、head を更新
  • すべての操作はロックで保護されるため、同期オーバーヘッドが発生する
  • 読み取り(read)は同じ構造で tail を進め、読み終えたページを解放する
  • 本質的には、ユーザーメモリからカーネルへ、そしてカーネルから再びユーザー空間へと2回コピーが行われるため、大きなオーバーヘッドが発生する

Zero-Copy: splice/vmsplice への最適化

  • 高速IOのための一般的な方法論は、カーネルをバイパスするか、コピーを最小限に抑えること
  • Linuxは、splicevmsplice システムコールによって、パイプとユーザー空間の間でデータを移動する際にコピーを省略できるようにしている
    • splice: パイプとファイルディスクリプタの間でデータを移動
    • vmsplice: ユーザーメモリとパイプの間でデータを移動
  • どちらのシステムコールも、参照だけを移し、実際のデータ移動なしに処理できる
  • たとえば vmsplice を使う場合、256KiBバッファを半分に分割し、ダブルバッファリング方式で各半分を交互にパイプへ vmsplice する
  • 実際に vmsplice を適用すると3倍以上高速化(約12.7GiB/s)し、read側に splice を適用すると32.8GiB/sまでさらに向上する

ページ関連のボトルネックとHuge Pageの活用

  • perf の分析結果によると、vmsplice のボトルネックは**パイプロック(mutex_lockページ取得(iov_iter_get_pages)**に集中している
  • iov_iter_get_pages は、ユーザーメモリ(virtual address)を実際の物理ページ(physical page)に変換し、その参照をパイプ内に保存する役割を担う
  • Linuxのページングは4KiB単位のページだけでなく、アーキテクチャに応じて2MiB(huge page)などさまざまなサイズをサポートする
  • Huge Page(例: 2MiB)を使うと、ページテーブル管理と参照回数が減るため、ページ変換オーバーヘッドが大きく低減される
  • プログラムでhuge pageを適用すると、最大スループットが51.0GiB/sまで上がり、さらに約50%増加する

ビジーループ(busy loop)の適用

  • 残るボトルネックは、パイプに書き込み可能な空きができるのを待つ wait や、reader を起こす wake といった同期処理である
  • SPLICE_F_NONBLOCK オプションを使用し、EAGAIN 発生時にビジーループで繰り返し呼び出すことで、カーネルのスケジューリングオーバーヘッドを除去する
  • この手法を適用すると、最大スループットは62.5GiB/sとなり、さらに25%向上する
  • ビジーループはCPU資源を100%消費するが、高性能サーバーではよく使われるパターンである

まとめとその他の事項

  • perf とLinuxソースコードの分析を通じて、パイプ性能を段階的に大幅改善する方法を説明している
  • パイプ、splice、ページング、Zero-Copy、同期コストなど、高性能プログラミングの主要課題を実例とともに体験できる
  • 実際のコードでは、バッファを異なるページに割り当てて refcount contention を減らすなど、追加の性能チューニングも行われている
  • テストでは、各プログラムプロセスを個別のコアに固定(taskset)して実行している
  • splice 系は設計上危険になりうるとされ、一部のカーネル開発者の間では長年の論争の対象でもある

3件のコメント

 
iolothebard 2025-06-27

うわー! 面白いですね! (何の話なのかさっぱり分かりませんが……)

 
doolayer 2025-06-26

|

 
GN⁺ 2025-06-24
Hacker Newsの意見
  • Linux のパイプベースのアプリケーションを Windows に移植した経験が忘れられない。POSIX 標準なのだから性能もそれほど変わらないだろうと思っていたが、とてつもなく遅かった記憶がある。パイプ接続を待つ状況では Windows 全体がほとんど止まるレベルにまでなった問題もあった。数年後に Win10 で C# を使って同じものを再実装したときは少し改善していたが、性能差は依然としてかなり恥ずかしいものだった記憶

    • ここ数年で Windows に AF_UNIX ソケットが追加されたと思うが、Win32 パイプと比べてどちらの性能が良いのか気になる。予想ではそちらのほうが良いのではと思う

    • 「性能がひどかった」と言うとき、パイプがすでに接続された後の I/O のことを言っているのか、それとも接続前の過程のことなのか気になる。すでに接続された後なら意外だが、接続/切断の繰り返しが問題なら OS が最適化していない可能性は認められる。実際にはそうする必要がほとんどないので、ユースケース次第で受け止め方は変わる

    • 最近確認したところでは、Windows ではローカル TCP の性能がパイプよりはるかに優れている

    • POSIX は動作だけを定義していて性能は定義していない、各プラットフォームと OS には固有の性能上の癖があることを思い出させる

    • 昔は逆のケースを経験したことがある。パイプではないが、Linux で PHP アプリが .NET ベースの SOAP API と通信したとき、.NET 実装側の応答速度のほうが良かった記憶がある

  • 参考までに readv() / writev(), splice(), sendfile(), funopen(), io_buffer() など複数の方法がある。splice() はパイプと UNIX ソケットの間で zero-copy により大容量データを転送する際に非常に優れているが Linux 専用だ。splice() はデータ転送時にユーザー空間のメモリ割り当て、追加のバッファ管理、memcpy()、iovec の走査なしに直接処理する最速の方法だ。BSD 系ではパイプに対して readv()/writev() が最適なのかどうかの確認依頼もあった。いずれにせよ、この記事は非常に印象的だという評価

    • sendfile() はファイル→ソケットの zero-copy 方式で非常に高い性能を提供し、Linux と BSD の両方で利用できる。ただしファイル→ソケットしかサポートしない。sendmsg() は一般的なパイプでは使えず、UNIX ドメイン/INET/その他のソケット用だ。ちなみに Linux では sendfile が内部的に splice で実装されているおかげで、ファイル→ブロックデバイス転送にも実際に使った経験がある

    • splice() は Linux でパイプ間の超高速な大容量データ転送において最高だが、io_uring を適切に使えば同等か、あるいはそれを上回る性能も期待できる

    • shm_open のような共有メモリとファイルディスクリプタ受け渡し方式のほうが実際には速く、しかも完全にポータブルだ

  • 過去の HN でこの記事について活発に議論されていたリンクとして、https://news.ycombinator.com/item?id=31592934(200 コメント)、https://news.ycombinator.com/item?id=37782493(105 コメント)を案内

  • 本当に素晴らしい記事で、定期的にまた話題に上がるのもとても嬉しい

    • 誤字訂正として、comes → comes up と明記
  • まだコメントがひとつもなくて残念だという感想と、splice をもっと使いたいが、記事の最後で触れられていたセキュリティや ABI 互換性の問題が気になるという話。splice が今後も維持され続けるのか、また性能向上のためにデフォルトのパイプが常に splice を使うようにパッチする難易度も気になるという指摘

  • 最新の Linux に SunOS の Doors に似たものがあるのか質問。レイテンシに非常に敏感な小さなデータ交換が必要な組み込みアプリケーションで、AF_UNIX より良い技術を探している状況

    • 共有メモリがレイテンシの面では最速だが、タスクのウェイクアップ(通常は futex を利用)が必要。Google が FUTEX_SWAP システムコールを開発していて、あるタスクから別のタスクへ直接ハンドオフできる予定だったが、その後どうなったかはよく分からない

    • 'Doors' はあまりに一般的な単語なので検索しづらく、説明を求める声

    • 現在 AF_UNIX の何が問題なのか、必要な機能がないのか、望むよりレイテンシが高いのか、あるいはサーバー/クライアントのソケット API 構造が合っていないのか、追加情報を求める声

  • 記事の執筆時期が 2022 年であることを簡潔に追記