2 ポイント 投稿者 GN⁺ 2023-11-30 | 1件のコメント | WhatsAppで共有
  • Apache OpenDALのPythonバインディングでのファイル読み込みが、Python組み込みのopen().read()より遅いという報告が出発点だったが、ボトルネックはOpenDALやPyO3自体ではなかった
  • 64MiBファイル読み込みベンチマークでは、python-fs-readは約15〜19ms、Rustのstd::fsとC実装は約23msで、Rust/CのほうがPythonより遅く見えた
  • straceeBPFperfで追跡すると、差はreadシステムコールの書き込み先バッファがページ内のどこに置かれるかというオフセットに結びついており、0x10付近で性能低下が再現した
  • AMD Ryzen 9 5900X、Ryzen 7 5700X、Ryzen 9 5900HX系で同様の現象が確認され、カーネル内の_copy_to_iterにおける**rep movsb**実行性能が重要な手がかりだった
  • Pythonが本質的に速いわけではなく、AMD Zen 3の**FSRM/rep movsb**に関するCPUバグとメモリオフセットの偶然が生んだ結果であり、jemallocによる改善もアロケータ自体ではなく、別のオフセットになったことが理由だった

OpenDAL Pythonバインディングから始まった奇妙なベンチマーク

  • Apache OpenDALは、複数のストレージサービスでデータを統一的に読み書きするためのデータアクセス層であり、PythonバインディングはPyO3を通じて提供されている
  • ユーザーは、OpenDAL Pythonバインディングで150MBファイルを読むコードが、Python組み込みのファイル読み込みより遅いと報告した
    • Python組み込みのopen(...).read() 100回: 4.470868484000675
    • OpenDAL Pythonバインディング 100回: 8.993250704006641
  • 単純化した64MiBファイル読み込みでも、OpenDALバインディングのほうが遅かった
    • python-fs-read: 平均15.9ms
    • python-opendal-read: 平均32.9ms
    • Python組み込み読み込みは、OpenDALバインディングより2.07倍速く測定された

Rust OpenDALとstd::fsまで掘り下げた追跡

  • 同じロジックをRustのOpenDAL fsサービスで実装しても、Python組み込み読み込みより遅かった
    • rust-opendal-fs-read: 平均23.8ms
    • python-fs-read: 平均15.6ms
    • Python組み込み読み込みは、Rust OpenDAL実装より1.52倍速く測定された
  • OpenDALのfsサービスはRustのstd::fsを使っているため、OpenDAL自体のコストを確認する目的で、std::fsベースの実装を別に作成した
  • Rust std::fsの直接実装でも同じ傾向が続いた
    • rust-std-fs-read: 平均23.1ms
    • python-fs-read: 平均15.2ms
    • Python組み込み読み込みは、Rust std::fsより1.52倍速く測定された

straceで見たシステムコールとmmap

  • straceの分析では、RustとPythonのどちらも大きなバッファ割り当てに**mmap**を使っていた
  • Rust std::fsの実行は、/tmp/fileを開き、64MiBを1回読み、EOF確認用のreadを呼んでから閉じる流れだった
  • Python組み込み読み込みは、newfstatatioctllseekなど、より多くのシステムコールを実行していたが、全体時間はむしろ短かった
  • mmap(NULL, 67112960, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)呼び出しは、ファイルマッピングではなく匿名メモリ割り当てに使われていた
    • 67112960は64MiBに4KiBを足した大きさ
    • MAP_ANONYMOUSは、ファイルに関係のないメモリ割り当てを意味する
  • Rustのx86_64-unknown-linux-gnu既定ビルドはglibcmallocを使い、glibcは大きな割り当てでmmapを使うことがある

jemallocで速くなったRustと覆った中間結論

  • Rustのグローバルアロケータをjemallocator::Jemallocに変えると、Pythonより速くなった
    • rust-std-fs-read-with-jemalloc: 平均9.7ms
    • python-fs-read: 平均15.8ms
    • jemallocを使ったRust実装は、Pythonより1.64倍速く測定された
  • この時点ではmmapまたは既定メモリアロケータが原因のように見えたが、その後の更新で解釈は修正された
  • 2023-12-01時点の更新によれば、jemallocpymallocmimallocglibc mallocより本質的に速いことで生じた差ではなかった
  • 実際の差は、アロケータが作ったバッファのページ内オフセットから来ていた
    • rust-std-fs-read: mmap開始アドレスから0x10オフセットに読み込み
    • rust-std-fs-read-with-jemalloc: mmap開始アドレスから0x740オフセットに読み込み
  • 問題の区間はページ内の0x00..0x10範囲に整理され、jemallocでも同じ問題を再現できる

ソフトウェア設定より、機種ごとの再現性が大きかった問題

  • 議論が進む中で、RustがPythonより遅い現象は、著者のマシンで特に顕著だと確認された
  • 著者のCPUはAMD Ryzen 9 5950X 16-Core Processorで、メモリはDDR4 3200 MT/s 16GB DIMM構成だった
  • さまざまな設定を変えても、相対的な性能差は消えなかった
    • Linuxカーネルのmitigations=offを再び有効にしても結果は変わらなかった
    • Transparent Hugepageをalwaysmadviseneverに切り替えると絶対値は変わるが、相対比率は維持された
    • core_affinityで特定CPUコアに固定しても結果は同じだった
  • eBPFベースのreadシステムコール遅延測定でも、Rust側のほうが遅かった
    • Python read file: 8,134,049ns
    • Rust std::fs read file: 24,636,975ns
  • 観察結果から、OpenDAL、PyO3、Rust標準ライブラリだけでは差を説明しにくく、システムコールの時点ですでに時間差が生まれていた

C実装で見えてきたメモリオフセットの手がかり

  • 同じ64MiBファイル読み込みをCのfopen/malloc/freadで実装しても、Pythonより遅かった
    • c-fs-read: 平均23.8ms
    • python-fs-read: 平均19.1ms
    • Python組み込み読み込みは、C実装より1.25倍速く測定された
  • strace -e raw=read,mmapでポインタアドレスを確認すると、CとPythonではバッファ開始オフセットが異なっていた
    • C: mmap返却アドレスから0x10オフセットにread
    • Python: mmap返却アドレスから0x30オフセットにread
  • C実装で同じようにオフセットを調整すると、性能が大きく改善した
    • c-fs-read-with-offset: 平均8.9ms
    • Pythonより2.15倍、従来のC実装より2.68倍速い
  • この問題はAMD Ryzen 9 5900XAMD Ryzen 7 5700Xでも再現した
  • RustコミュニティのStd::fs::read slow?でも類似現象が報告されており、メモリ領域オフセットとシステムコール性能の関連が指摘されている

perf解析が指し示したrep movsb

  • カーネル開発者がAMD Ryzen 9 5900HXc-fs-readとオフセット適用版を再現し、perfで解析した
  • オフセットの有無によって、L1-dcache-prefetchesL1-dcache-loadsの値に大きな差があった
    • オフセットなし: L1-dcache-loads127,845,213L1-dcache-prefetches1,843,493
    • オフセットあり: L1-dcache-loads13,965,813L1-dcache-prefetches395,578
  • ホットスポットはカーネルのread経路内で、shmem_file_read_itercopy_page_to_iter_copy_to_iterへと続いていた
  • _copy_to_iter内部の中核アセンブリは**rep movsb**で、サンプルの大半がこの命令に集中していた
  • その後の分析では、L1プリフェッチそのものよりも、ページ整列したデータでrep movsb性能が悪く、ページ整列が崩れるとむしろ良くなる現象のほうが重要な手がかりとして整理された

FSRMとAMD Zen 3の問題

  • 共有されたUbuntu glibcバグ報告 Terrible memcpy performance on Zen 3 when using rep movsbでも、rep movsbの性能問題が扱われている
  • その報告の例では、2113バイトコピーでrep movsb経路が約3.2GB/sしか出ず、サイズを2111バイトに変えると100GB/s超まで上がると説明している
  • FSRMはFast Short REP MOVの略で、rep movsbrep movsdを高速化するための機能である
  • FSRMはIntelで始まった機能で、AMDにも導入されており、対応を宣言したCPUではglibcが既定でFSRMを使う
  • したがって、PythonがC/Rustより本質的に速いのではなく、AMD CPUバグのために特定のメモリオフセットでC/Rustの読み込み経路が遅くなっていた、と整理できる

更新: AMDの認識状況とglibc側の対応

  • 2023-12-01時点の更新によれば、AMDはこのバグを2021年から認識していたとみられる
  • 記事公開後、複数の読者がAMDにリンクを送ったため、AMDはこの問題を認識していると考えられる
  • 著者はAMDがamd-ucodeでこのバグを責任を持って修正すべきだと考えているが、未確認情報ではZen 3でamd-ucode修正が難しい可能性もある
  • 現実的な望みは、glibc側で必要に応じてFSRMを無効化する方法である
  • glibc側ではx86: Improve ERMS usage on Zen3の作業が進行中である

再現コードと関連資料

1件のコメント

 
GN⁺ 2023-11-30
Hacker News の意見
  • REP STOS/MOV が高速で、memset/memcpy の短い命令シーケンスとして使えることを示す専用の CPU 機能フラグ が2つもある。
    新しい CPU 世代が出るたびに最適化ルーチンを手で書き直さなければならない苦痛が何十年も続いているのに、いまだにこんな状況とは。CPU ベンダーのタイミングテストスイートに入っているべきではないかと思う。

    • 完全な推測だが、土壇場またはリリース後の マイクロコードアップデート で入ったバグ修正の影響かもしれない。
      ページアラインされた高速な rep movs に問題があったか、何らかの攻撃に脆弱だったため無効化された可能性がある。
    • 理解が正しければ、特定のコンパイル時点のビルドごとに実行ファイルを2つ作る必要があるという意味なのか、それとも特定のハードウェア上でコンパイルしなければならないという意味なのか気になる。
      修正はどうあるべきなのか、ランタイム検査 のようなものが必要なのか分からない。
    • CPU ベンダーこそ自社 CPU を最もよく理解している、と考えがちだ。
      より速い「ソフトウェア」実装があるなら、REP MOVS が少なくとも マイクロコード 内で同じことをするようにしない理由が気になる。
  • 関連する glibc のバグはここにある。ただしこちらは Zen 4: https://sourceware.org/bugzilla/show_bug.cgi?id=30994

  • 最初は記事を読んで、筆者が std::fs を間違って使っているのだろうと笑う準備をしていたが、実際には デバッグのウサギ穴 とミステリーが続く楽しい記事だった。
    よく書けていて、とても興味深かった。

    • 本当に良い記事だった。テストプログラムを作ってレイヤーを1つずつ剥がしていく デバッグ手法 が賢く、結論は興味深く予想外で、文章も明快で追いやすかった。
  • 前提が少し分かりにくい。純粋な Python コードとネイティブの C/Rust コードを比較したのではなく、ネイティブコード上の Python ラッパーである Python のファイル読み込みメソッドと、別のネイティブコードのラッパーである OpenDAL を比較している。
    性能差があること自体は依然として興味深いが、それを「Python より遅い」と表現するのはかなり奇妙だ。Python 標準ライブラリがすべて純粋な Python で書かれていると期待していたのだろうか、と思ってしまう。むしろ Python 標準ライブラリの関数実装はネイティブで、個別に高度に最適化されているだろうと予想する。
    結論がネイティブコードの動作に関わるものだったことは驚きではなかったが、具体的な答えは意外だった。ただ、導入が分かりにくかっただけで、記事自体は非常に興味深かった。
    また “C is slower than Python with specified offset” というタイトルも、ネイティブ話者には「オフセットを指定した状態でも C が Python より遅い」と読める。実際には逆で、Python で使っていたオフセットを C にも指定したところ C が速くなった、という意味だった。

    • なぜ分かりにくいのか、むしろよく分からない。
      ファイル読み込みのような単純な処理が、Rust 標準ライブラリで Python 標準ライブラリより遅いというのは驚くべきことだ。こうした Python 標準ライブラリ呼び出しが C で書かれていると分かっていても、Rust 標準ライブラリの呼び出しも同程度の速度だろうと期待する。
      だから普通は、使い方が間違っているか、Rust 標準ライブラリに奇妙な挙動があるのだろうと予想するはずだが、今回はどちらでもなく、特定のハードウェアでアロケーションのアラインメントによって生じる 性能の崖 だった。
      ファイルシステム読み込みが Python でよく最適化されているだろうとは期待するが、Rust でも同じだと思うので、Rust 側がはるかに遅かった点は驚きだし、とりわけハードウェアとアロケータに依存していた点はさらに驚きだ。
    • Python は遅いときには遅い言語だと叩かれるのに、速いときには「本物の Python ではない」という理由で認められないのは理解できない。
      Python で書いたコードが速いなら、私にとっては Python が速い ということだ。実装が別の言語でできているからなのか、他の理由なのかはあまり重要ではない。
    • 「個別に高度に最適化されている」と期待する理由が何なのか分からない。
      元記事で起きたことは、ほとんど純粋な偶然に近い。CPython の C コードは const の一貫性すら気にしておらず、動的メモリアロケーションや補助的・便宜的な呼び出しが多い。算術のようなものですら動的メモリアロケーションを行う。
      CPython と一緒に仕事をした経験があれば、普通は性能が良いとは期待しない。性能を改善したいときは、そこで提供される機能を迂回しようとする。
      また Python には標準がないので、厳密に言えば標準ライブラリもなく、一緒に配布されるライブラリの大半は Python で書かれている。一部は C で書かれているが、その C コードの中にも、実質的には Python コードを機械的に C へ移したものがかなり多い。たとえば Python の二分探索実装は、もともと Python で書かれていて、後に Python C API を使って C に翻訳された。
      期待できるのは、OS 機能に単純に対応する機能には比較的薄いラッパーが付く、という程度だ。つまりファイル読み込みは本質的に システムインターフェース に直接入るので、バインディングコードはあまり必要ないはずだ。
    • 指摘ありがとう。タイトルを直した。
    • 前提は、「Python が Rust より速い」のような文言を使えば、事実でなくても ページビュー が取れるということだ。
      似たような記事が何十回も上がった後で、みんなそれに気づいた。
  • 記事自体は素晴らしく、この問題に関連する興味深い情報が多い
    ただ、より気になり、懸念されるのは、この問題がどのように報告・記録され、コミュニケーションがどのように扱われているかという点だ
    報告は Discord で行われているが、プロプライエタリな環境で、インデックスされず、検索もしにくく、保存もされない。議論は Discord と Telegram で行われており、この文脈では Telegram のほうがさらに悪いかもしれない
    このブログ記事と GitHub リポジトリが、その痕跡として残ったすべてだ。Xuanwo がブログに書いていなければ、タイムラインの中に消えていただろう。かなり興味深い状況だ

    • プロプライエタリなプラットフォームであることは確かで、よくない。だが、インデックスや検索ができないという非難は納得しにくい
      デフォルトで公開アクセス可能なログをインデックス・検索してくれるメッセンジャーはほとんどない。すべての IRC サーバーが公開ログを提供しているわけでもなく、Matrix グループも同様だ。そこでの議論がなぜタイムラインの中に消えないと見なせるのか分からない
      公開ログを提供できる理由は、プロプライエタリではないからではなく、ロギングを許可する API があるからだ。Telegram にもそのような API があり、私たちの議論グループも検索可能なログをここで見られる: https://luoxu-web.vercel.app/#g=1264662201
      公開インデックスがないのは主に プライバシー のためであって、プラットフォームがプロプライエタリだからではない
    • USENET の衰退を惜しむたびに「今は Discord があるじゃないか」という答えを受け入れない理由がまさにこれだ
      以前はすべての投稿を DejaNews、のちには Google できれいに検索できた
      インターネット/WWW スタックや中核的なプログラミングツール・ライブラリのような重要なオープンソースプロジェクトの重要なコミュニケーションは、オープン標準に戻るべきだ
  • 今週読んだ記事の中で最も興味深かった。素晴らしいまとめだ

  • 当然やるべきことは、copy_user_generic カーネルメソッドにパッチを送ることのように見える
    問題のある CPU が検出され、メモリアライメントが遅くなるバグを引き起こす場合には、別の メモリコピー実装 を使わせればよい

    • 当然ではない。マイクロコードで修正できるなら、カーネルに実質的にソフトウェアでパッチ可能な問題の修正コードをまき散らすより、人々に更新済みのマイクロコードを使わせるほうがよさそうだ
      カーネル経験のない人に受け入れられるような修正は些細なものではないだろう。より重要なのは、回避策をどのように有効化すべきかも明白ではないことだ。おそらくブート時に測定するのが最善だろうが、そうでなければどのモデルとステッピングが影響を受けるのかをどう知ればよいのか曖昧だ
    • 些細な修正ではない。AMD がページアライメントに近いアドレスで エイリアシング がなぜ壊れるのかを把握する必要があるため、修正はマイクロコード側になる可能性が高い
      ソフトウェアによる緩和策も複雑になるだろう。カーネルは ERMS を使えないとき、通常は代替経路で使うベクタ命令を実際には使えないからだ
  • jemalloc は 2018 年まで Rust のデフォルトアロケータだった
    https://internals.rust-lang.org/t/jemalloc-was-just-removed-...

  • 「Rust 開発者は性能向上のために jemallocator へ切り替えることを検討できる」という部分が気になる
    誰でもほぼ無料で性能向上を得られるのか、それとも注意点があるのか分からない。C のコードベースも恩恵を受けられるのか、今は単に取り逃している性能なのかが気になる

    • jemalloc を使うと、MADV_FREE のせいで観測可能性の問題が生じることを知っておく必要がある。htop が実際に使用中のメモリをもはや正確に表示しなくなる
      https://github.com/jemalloc/jemalloc/issues/387#issuecomment...
      https://gitlab.haskell.org/ghc/ghc/-/issues/17411
      現在は jemallocMADV_FREE の 10 秒後に MADV_DONTNEED を呼ぶようだ: https://github.com/JuliaLang/julia/issues/51086#issuecomment...
      そのためこの問題を「直し」はするが、メモリを解放した時点と、その事実を htop で観測する時点との間に、紛らわしい遅延が生じる
      ただし https://jemalloc.net/jemalloc.3.html によると、opt.muzzy_decay_ms = 0 を設定すれば遅延をなくせる
      それでも musl の作者は jemalloc をデフォルトにすることには慎重だ: https://www.openwall.com/lists/musl/2018/04/23/2
      深刻な肥大化、ASLR の弱体化、メモリ使用量を気にせず最大限高速にすることへ偏った最適化の問題がある、という趣旨だ。上記のチューニング値である程度は緩和できるだろうが、性能とメモリ使用量のどちらに重点を置くかという全体的な傾向は、依然としてトレードオフとして残る可能性が高い
    • ほぼ無料で取り逃している性能だと思う。バイナリサイズが少し増えるコストはある
      あらゆる状況で必ず速くなるわけではないが、ほとんどの場合は速くなるはず。Rust も以前は jemalloc をデフォルトで使っていたが、デフォルトとしては意外だと感じる人たちがいたため変更された
    • デフォルト以外のアロケータに変えれば常に性能が上がるわけではない
      ワークロードに大きく依存するため、プロファイリングとベンチマークが必要だ。それでも C/C++/Rust のような低レベル言語では、こうしたアロケータを選べるべきだ
      注意点の一つはバイナリサイズだ。カスタムアロケータは実行ファイルにバイト数を追加する
    • Rust は以前 jemalloc をデフォルトで使っていたが、2018 年ごろに再びシステムの malloc に戻った[0]
      今は Rust に GlobalAlloc トレイトと #[global_allocator] 属性があるので、アプリが望めば jemalloc をアロケータとして使える。ユーザーが LD_PRELOAD のような方法で上書きできるかはよく分からない
      jemalloc がすべてのワークロードやユースケースで常に最善というわけではない。システムアロケータは完璧には程遠いことが多いが、少なくとも汎用アロケータとして広くテストされてきた
      [0] https://github.com/rust-lang/rust/issues/36963
    • 性能は、プログラムが「遅い」から「速い」へ移動する一次元の尺度ではない。常に他の要素も一緒に作用する
      jemalloc があるアプリケーションには適切な選択かもしれないが、別の場合には別のアロケータのほうが速いかもしれない。あるいは、たとえ遅くても、より少ないダーティメモリ、より良い観測可能性、特定のセキュリティ保証といった目標により合う場合もある
  • この内容を適切な人たちに送った

    • AMD 側に送ったという意味?