Rustのstd::fsはPythonより遅いのか? いや、ハードウェアの問題だった
(xuanwo.io)- Apache OpenDALのPythonバインディングでのファイル読み込みが、Python組み込みの
open().read()より遅いという報告が出発点だったが、ボトルネックはOpenDALやPyO3自体ではなかった - 64MiBファイル読み込みベンチマークでは、
python-fs-readは約15〜19ms、Rustのstd::fsとC実装は約23msで、Rust/CのほうがPythonより遅く見えた strace、eBPF、perfで追跡すると、差は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
- Python組み込みの
- 単純化した64MiBファイル読み込みでも、OpenDALバインディングのほうが遅かった
python-fs-read: 平均15.9mspython-opendal-read: 平均32.9ms- Python組み込み読み込みは、OpenDALバインディングより2.07倍速く測定された
Rust OpenDALとstd::fsまで掘り下げた追跡
- 同じロジックをRustのOpenDAL
fsサービスで実装しても、Python組み込み読み込みより遅かったrust-opendal-fs-read: 平均23.8mspython-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.1mspython-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組み込み読み込みは、
newfstatat、ioctl、lseekなど、より多くのシステムコールを実行していたが、全体時間はむしろ短かった mmap(NULL, 67112960, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)呼び出しは、ファイルマッピングではなく匿名メモリ割り当てに使われていた67112960は64MiBに4KiBを足した大きさMAP_ANONYMOUSは、ファイルに関係のないメモリ割り当てを意味する
- Rustの
x86_64-unknown-linux-gnu既定ビルドはglibcのmallocを使い、glibcは大きな割り当てでmmapを使うことがある
jemallocで速くなったRustと覆った中間結論
- Rustのグローバルアロケータを
jemallocator::Jemallocに変えると、Pythonより速くなったrust-std-fs-read-with-jemalloc: 平均9.7mspython-fs-read: 平均15.8ms- jemallocを使ったRust実装は、Pythonより1.64倍速く測定された
- この時点では
mmapまたは既定メモリアロケータが原因のように見えたが、その後の更新で解釈は修正された - 2023-12-01時点の更新によれば、
jemalloc、pymalloc、mimallocがglibc 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を
always、madvise、neverに切り替えると絶対値は変わるが、相対比率は維持された core_affinityで特定CPUコアに固定しても結果は同じだった
- Linuxカーネルの
- eBPFベースの
readシステムコール遅延測定でも、Rust側のほうが遅かった- Python
read file: 8,134,049ns - Rust
std::fsread file: 24,636,975ns
- Python
- 観察結果から、OpenDAL、PyO3、Rust標準ライブラリだけでは差を説明しにくく、システムコールの時点ですでに時間差が生まれていた
C実装で見えてきたメモリオフセットの手がかり
- 同じ64MiBファイル読み込みをCの
fopen/malloc/freadで実装しても、Pythonより遅かったc-fs-read: 平均23.8mspython-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実装で同じようにオフセットを調整すると、性能が大きく改善した
c-fs-read-with-offset: 平均8.9ms- Pythonより2.15倍、従来のC実装より2.68倍速い
- この問題はAMD Ryzen 9 5900XとAMD Ryzen 7 5700Xでも再現した
- RustコミュニティのStd::fs::read slow?でも類似現象が報告されており、メモリ領域オフセットとシステムコール性能の関連が指摘されている
perf解析が指し示したrep movsb
- カーネル開発者が
AMD Ryzen 9 5900HXでc-fs-readとオフセット適用版を再現し、perfで解析した - オフセットの有無によって、
L1-dcache-prefetchesとL1-dcache-loadsの値に大きな差があった- オフセットなし:
L1-dcache-loads約127,845,213、L1-dcache-prefetches約1,843,493 - オフセットあり:
L1-dcache-loads約13,965,813、L1-dcache-prefetches約395,578
- オフセットなし:
- ホットスポットはカーネルの
read経路内で、shmem_file_read_iter→copy_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 movsbとrep 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の作業が進行中である
再現コードと関連資料
- Xuanwo/when-i-find-rust-is-slow: 使用されたコード断片とスクリプト集
- Std::fs::read slow?: Rustコミュニティでの類似報告
- Terrible memcpy performance on Zen 3 when using rep movsb: Ubuntu glibcに報告されたZen 3
rep movsb性能問題 - binding/python: rust std fs is slower than python fs: OpenDAL Pythonバインディング関連のIssue
1件のコメント
Hacker News の意見
REP STOS/MOVが高速で、memset/memcpyの短い命令シーケンスとして使えることを示す専用の CPU 機能フラグ が2つもある。新しい CPU 世代が出るたびに最適化ルーチンを手で書き直さなければならない苦痛が何十年も続いているのに、いまだにこんな状況とは。CPU ベンダーのタイミングテストスイートに入っているべきではないかと思う。
ページアラインされた高速な
rep movsに問題があったか、何らかの攻撃に脆弱だったため無効化された可能性がある。修正はどうあるべきなのか、ランタイム検査 のようなものが必要なのか分からない。
より速い「ソフトウェア」実装があるなら、
REP MOVSが少なくとも マイクロコード 内で同じことをするようにしない理由が気になる。関連する glibc のバグはここにある。ただしこちらは Zen 4: https://sourceware.org/bugzilla/show_bug.cgi?id=30994
最初は記事を読んで、筆者が
std::fsを間違って使っているのだろうと笑う準備をしていたが、実際には デバッグのウサギ穴 とミステリーが続く楽しい記事だった。よく書けていて、とても興味深かった。
前提が少し分かりにくい。純粋な 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 が速い ということだ。実装が別の言語でできているからなのか、他の理由なのかはあまり重要ではない。
元記事で起きたことは、ほとんど純粋な偶然に近い。CPython の C コードは
constの一貫性すら気にしておらず、動的メモリアロケーションや補助的・便宜的な呼び出しが多い。算術のようなものですら動的メモリアロケーションを行う。CPython と一緒に仕事をした経験があれば、普通は性能が良いとは期待しない。性能を改善したいときは、そこで提供される機能を迂回しようとする。
また Python には標準がないので、厳密に言えば標準ライブラリもなく、一緒に配布されるライブラリの大半は Python で書かれている。一部は C で書かれているが、その C コードの中にも、実質的には Python コードを機械的に C へ移したものがかなり多い。たとえば Python の二分探索実装は、もともと Python で書かれていて、後に Python C API を使って C に翻訳された。
期待できるのは、OS 機能に単純に対応する機能には比較的薄いラッパーが付く、という程度だ。つまりファイル読み込みは本質的に システムインターフェース に直接入るので、バインディングコードはあまり必要ないはずだ。
似たような記事が何十回も上がった後で、みんなそれに気づいた。
記事自体は素晴らしく、この問題に関連する興味深い情報が多い
ただ、より気になり、懸念されるのは、この問題がどのように報告・記録され、コミュニケーションがどのように扱われているかという点だ
報告は Discord で行われているが、プロプライエタリな環境で、インデックスされず、検索もしにくく、保存もされない。議論は Discord と Telegram で行われており、この文脈では Telegram のほうがさらに悪いかもしれない
このブログ記事と GitHub リポジトリが、その痕跡として残ったすべてだ。Xuanwo がブログに書いていなければ、タイムラインの中に消えていただろう。かなり興味深い状況だ
デフォルトで公開アクセス可能なログをインデックス・検索してくれるメッセンジャーはほとんどない。すべての IRC サーバーが公開ログを提供しているわけでもなく、Matrix グループも同様だ。そこでの議論がなぜタイムラインの中に消えないと見なせるのか分からない
公開ログを提供できる理由は、プロプライエタリではないからではなく、ロギングを許可する API があるからだ。Telegram にもそのような API があり、私たちの議論グループも検索可能なログをここで見られる: https://luoxu-web.vercel.app/#g=1264662201
公開インデックスがないのは主に プライバシー のためであって、プラットフォームがプロプライエタリだからではない
以前はすべての投稿を DejaNews、のちには Google できれいに検索できた
インターネット/WWW スタックや中核的なプログラミングツール・ライブラリのような重要なオープンソースプロジェクトの重要なコミュニケーションは、オープン標準に戻るべきだ
今週読んだ記事の中で最も興味深かった。素晴らしいまとめだ
当然やるべきことは、
copy_user_genericカーネルメソッドにパッチを送ることのように見える問題のある CPU が検出され、メモリアライメントが遅くなるバグを引き起こす場合には、別の メモリコピー実装 を使わせればよい
カーネル経験のない人に受け入れられるような修正は些細なものではないだろう。より重要なのは、回避策をどのように有効化すべきかも明白ではないことだ。おそらくブート時に測定するのが最善だろうが、そうでなければどのモデルとステッピングが影響を受けるのかをどう知ればよいのか曖昧だ
ソフトウェアによる緩和策も複雑になるだろう。カーネルは 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
現在は
jemallocがMADV_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 のような低レベル言語では、こうしたアロケータを選べるべきだ
注意点の一つはバイナリサイズだ。カスタムアロケータは実行ファイルにバイト数を追加する
jemallocをデフォルトで使っていたが、2018 年ごろに再びシステムのmallocに戻った[0]今は Rust に
GlobalAllocトレイトと#[global_allocator]属性があるので、アプリが望めばjemallocをアロケータとして使える。ユーザーがLD_PRELOADのような方法で上書きできるかはよく分からないjemallocがすべてのワークロードやユースケースで常に最善というわけではない。システムアロケータは完璧には程遠いことが多いが、少なくとも汎用アロケータとして広くテストされてきた[0] https://github.com/rust-lang/rust/issues/36963
jemallocがあるアプリケーションには適切な選択かもしれないが、別の場合には別のアロケータのほうが速いかもしれない。あるいは、たとえ遅くても、より少ないダーティメモリ、より良い観測可能性、特定のセキュリティ保証といった目標により合う場合もあるこの内容を適切な人たちに送った