Python 3.14の末尾呼び出しインタープリタの性能
(blog.nelhage.com)- CPythonの新しい末尾呼び出しインタープリタは、当初は pyperformance の平均で 10〜15% 向上したように見えたが、ベースラインを調整すると実際の改善幅は設定次第で約 1〜5% に縮小する
- 大幅な改善は新実装そのものの効果というより、LLVM 19 の回帰を回避した結果に近く、Clang 19 が従来の computed goto ディスパッチを適切に再現できなかったことが主要因だった
- Intel Raptor Lake i5-13500 では clang19 ビルドが clang18 より 1.09 倍遅く、clang19.tc は 1.03 倍速かったが、Apple M1 MacBook Air では clang19 が 1.12 倍遅く、clang19.tc も 1.00 倍遅い程度にとどまった
- LLVM のtail duplication 制限により、間接ジャンプ数が clang18 の 332 個から clang19 の 3 個へ減少し、computed goto ベースのインタープリタが意図した構造は事実上失われた
- 末尾呼び出し方式は依然として意味のある改善だが、
musttailのようにコンパイラ最適化の実行を明示的に要求するアプローチのほうが、性能に敏感なコードではより堅牢である可能性がある
性能向上に見えたベースライン効果
- CPython プロジェクトは約 1 か月前、バイトコードインタープリタの新しい実装戦略をマージした
- 初期結果では、さまざまなプラットフォームと pyperformance ベンチマークで平均10〜15% の性能向上が示された
- その後の分析で、この大きな向上は主にLLVM 19 の回帰を偶然回避した結果であることが分かった
- GCC、clang-18、または特定のチューニングフラグを適用した LLVM 19 と比較すると、改善幅は約 1〜5% 程度に下がる
- 末尾呼び出しインタープリタ自体は実際に速度向上をもたらすが、初期の数値が示唆したほど改善幅は大きくない
- clang-19 以降でビルドしていた場合、従来経路は実際には 10〜15% 遅くなっていた可能性がある
- Simon Willison は
python-build-standaloneビルドと Python 3.13 の比較で 10% の高速化を再現した
- Simon Willison は
ベンチマーク構成と主要な数値
- 複数の CPython ビルドを Intel サーバーと Apple M1 MacBook Air で比較した
- Intel サーバーは Hetzner 上で稼働するRaptor Lake i5-13500
- すべてのビルドでLTOとPGOを使用した
- ビルド再現には nix 構成を利用した
- 比較対象は次のとおり
clang18: Clang 18.1.8、computed gotogcc: GCC 14.2.1、computed goto、Intel のみclang19: Clang 19.1.7、computed gotoclang19.tc: Clang 19.1.7、新しい末尾呼び出しインタープリタclang19.taildup: Clang 19.1.7、computed goto と回帰回避用-mllvmチューニングフラグ
clang18をベースラインにした pyperformance 平均結果は以下のとおり- Raptor Lake i5-13500:
clang19: 1.09 倍遅いclang19.taildup: 1.01 倍速いclang19.tc: 1.03 倍速いgcc: 1.02 倍速い
- Apple M1 MacBook Air:
clang19: 1.12 倍遅いclang19.taildup: 1.02 倍遅いclang19.tc: 1.00 倍遅い
- Raptor Lake i5-13500:
- 末尾呼び出しインタープリタは clang-18 比で一部高速化を示したが、clang-19 への移行で発生した性能低下よりは小さかった
clang18.tcは測定できなかった- 末尾呼び出しインタープリタがClang 19 に新たに加わったコンパイラ機能に依存しているためである
- この制約のため、状況を理解するにはより多くのベンチマーク組み合わせが必要だった
LLVM 19 回帰が引き起こしたディスパッチ崩壊
- 従来のバイトコードインタープリタは、
whileループ内のswitch文で opcode を処理する- コンパイラは通常、この
switchをジャンプテーブルと間接ジャンプにコンパイルする
- コンパイラは通常、この
- 昔から、各 opcode 本体にディスパッチロジックを複製すると、この種のインタープリタを高速化できることが知られている
- opcode の終端でループ先頭に戻る代わりに、次命令のデコードとジャンプテーブル参照のロジックを opcode ごとに別々に持たせる
- C コンパイラはラベルのアドレスを取得してcomputed gotoとして使う機能を提供しており、CPython も末尾呼び出し化以前はこの方式のインタープリタループを使っていた
- Clang/LLVM はコンパイラ性能上の理由から、computed goto の複数の
gotoを内部的に 1 つの indirectbr LLVM instruction に統合する- その後、コード生成段階でtail duplicationを行い、分岐ロジックを各位置に再複製する
- この流れは LLVM の古いブログ記事で高レベルに説明されている
- LLVM 19 は、特定条件での深刻なコンパイル時間増加やメモリ使用量増大を避けるために tail duplication pass 制限を導入した
- CPython ではこの制限により、Clang がディスパッチジャンプを統合したまま残した
- その結果、computed goto ベース実装の狙いは事実上無効化された
- この問題は、似たインタープリタループを持つ他言語実装で先に特定されていたが、CPython に影響することは知られていなかった
- オブジェクトコードを逆アセンブルして間接ジャンプ数を数えると、その差は直接確認できる
- clang18 ビルドの
_PyEval_EvalFrameDefault:jmp *が 332 個 - clang19 ビルドの
_PyEval_EvalFrameDefault:jmp *が 3 個
- clang18 ビルドの
computed goto が置かれていた曖昧な立場
- tail duplication ロジックの変化が回帰を引き起こしたことは、修正後に clang-18 水準の性能が回復することで確認された
- ただし、回帰の大きさは完全には説明されていない
- 歴史的には opcode ディスパッチの複製でインタープリタが 20% から 100% 高速化すると言われてきた
- しかし、現代プロセッサの分岐予測器の改善により、最近の研究では約 2〜4% 程度の小さい向上が示されている
- Python は設定オプションで単一の
switch文を使う旧来型インタープリタもサポートしているclang18.nocg: clang18 比で 1.01 倍速いclang19.nocg: clang18 比で 1.02 倍遅いclang19: clang18 比で 1.09 倍遅い
clang19.nocgがclang19より速かった点は、さらに逆説的である- Clang 18 または適切なフラグを付けた Clang 19 は、
switchベースのインタープリタでもディスパッチロジックを各 opcode 本体へ複製していた
- Clang 18 または適切なフラグを付けた Clang 19 は、
- 間接ジャンプ数の比較からもこの違いは分かる
clang18: 332 個clang18.nocg: 306 個clang19.nocg: 3 個clang19: 3 個
- 現代の Clang では、computed goto インタープリタ全体が不要な複雑さになっている可能性がある
- コンパイラが
switchベースコードでも同じ変換を適用できるためである - 一方で computed goto 自体も、その変換を保証するには不十分だった
- コンパイラが
- GCC 14.2.1 は
switchを複製しなかったが、computed goto を使った場合には意図どおりの動作を実装していた
修正と回避策
- LLVM pull request 114990 はこの記事の公開直後にマージされ、回帰を修正した
- マージ前のベンチマークでも、その修正が期待どおりの性能回復をもたらすことが確認されていた
- 修正前のリリースでは、回帰を引き起こした PR が追加したチューニングオプションで tail duplication の停止しきい値を調整できる
- clang-19 でその制限を非常に大きな値に設定すると、類似の動作を取り戻せる
- LTO ビルドではこのオプションの受け渡しが複雑になる
- tail duplication はコード生成中に起こり、LTO ビルドのコード生成はコンパイル時ではなくリンク時に行われる
- そのためフラグはコンパイラだけでなく
lldにも渡す必要がある
- 使用した設定例では、
./configure段階でOPTとLDFLAGSに-mllvm -tail-dup-pred-size=5000を渡している
ベンチマークが露呈させたベースライン問題
- ベンチマークは特定ビルド間の性能差を正確に測定できるが、その結果を「一般的な性能向上」へ拡張するには追加の仮定が必要になる
- 末尾呼び出しインタープリタのベンチマークでは、従来の computed goto インタープリタに対して 10〜15% 高速という結果が出たが、より広い結論へ一般化するにはベースラインが複雑すぎた
- 性能改善の作業では、何をベースラインとして比較するかが繰り返し難しい問題になる
- 現時点で最良とされるアプローチを理論的に理解していても、実際の OS・コンパイラオプション・各種フラグを適切に調整するのは別問題である
- 古いハードウェアや再現しにくい規模で得られた公開ベンチマークは、直接比較には向かないことがある
- 機械学習の論文でも、アルゴリズム改善を主張する際に「何をしたか」より「どのベースラインと比較したか」が先に重要な問いになることが多い
- 調整の不十分なベースラインと比べると、印象的な結果は容易に得られてしまう
最適化コンパイラと musttail
- computed goto の事例は、最適化コンパイラに対する期待が互いに衝突しうることを示している
- コンパイラはプログラマの意図を尊重し、同じ動作を保たなければならない
- 同時にコードを高速化するため、複雑で直感的ではない変換も実行しなければならない
- clang-19 は computed goto インタープリタをプログラム動作の観点では正しくコンパイルしたが、最適化意図とはまったく異なる出力を生成した
- 別の Clang バージョンは、単純な
switch()ベースのインタープリタにも意図していたのと同じ最適化を適用した - ソースコードレベルの computed goto と、機械語レベルのディスパッチ複製は、ほぼ直交した概念のように見える
- 実行結果が同じであるため、現在のツールではこの違いを一貫して表現しにくい
- 末尾呼び出しインタープリタは musttail attribute に基づいている
musttailは従来の意味で観測可能なプログラム動作を変えるものではないが、最適化器との対話に近い- コンパイラは特定最適化を実行できなければならず、それが行われない場合はコンパイル失敗を要求する
- このような方式は、コンパイラが進化しても性能に敏感なコードをより堅牢に書くスタイルになりうる
- 仮想的な
[[clang::musttailduplicate]]のような属性で、インタープリタwhileループの computed goto を置き換えられるか検討する価値があるかもしれない
nix がもたらした再現性と限界
nixは複数の Python インタープリタビルドを管理するうえで非常に役立った- 実験では
gcc、clang-18、clang-19、clang-20の 4 コンパイラと、さまざまなフラグの組み合わせで数十個の Python インタープリタをビルドしてベンチマークした
- 実験では
nixにより、それらの並行バージョンを再現可能かつ分離された形で維持できた- 特定ビルドがどのコンパイラとフラグから作られたかを確信できた
- ビルドマトリクスの定義も短い抽象化で管理できた
- バグ修正パッチを適用したカスタム LLVM をビルドし、そのコンパイラで Python ビルドを行うことも約 10 行のコードで可能だった
- 欠点も残る
nixは一般的なソフトウェア利用方法と異なる点があり、その違いがベンチマークや結論に影響した可能性を完全には排除しにくい- たとえば nix はデフォルトで特定の hardening フラグ付きでプロジェクトをビルドするが、このフラグが末尾呼び出しインタープリタに不均衡な影響を与えることが初期段階で分かった
- Nix は拡張性とカスタマイズ性に優れる一方、特定のカスタマイズ方法を見つけるには多くの試行錯誤と
nixpkgsソース探索が必要だった
1件のコメント
Hacker News の意見
CPython に末尾呼び出しインタープリタを入れた PR の作者です
まず、この問題の原因を突き止めるのにほぼ1か月を費やした Nelson に感謝します
ベースラインに使ったコンパイラにそんなバグがあるとは、私も、おそらく CPython チームも予想しておらず、大きなミスをしてしまって非常に恥ずかしく、申し訳なく思っています
謝罪文も公開しました: https://fidget-spinner.github.io/posts/apology-tail-call.htm...
10〜15% の性能向上と発表していたものの、バグのないコンパイラでは 1〜5% に近いという話で、その数値も完全に間違っていたわけではなく、特定の条件でだけ正しかっただけだ
改善を作り、測定し、PR もレビューを受けたのだから、やるべきことはやったと言える。たまたま測定に使った clang のバージョンの問題で数値が誤解を招いたわけだが、誰でも陥り得る妥当なミスに見える
それでも意味のある性能改善をもたらし、コンパイラのリグレッションも見つけたのだから、誤った数値はそれに比べれば小さく見える。この件で実際に誰が被害を受けたのかもよく分からないし、謝罪までする必要はなさそうだ
Python インタープリタのような長く存在するシステムで 3〜5% が維持されるなら、それ自体が大きな成果なので、十分誇ってよい
30年ほど経ってみると、長く存在しているシステムで意味のある性能向上、特に 1% を超える向上は、まず疑ってかかるようになる
実際の向上もあるにはあるが珍しく、しばしば時間を別の場所へ移しただけで、ベンチマークには捉えられないことがある。またベンチマークは効果を分離するために制御された環境で行うが、実際のソフトウェアは VM やデスクトップ上で、ほかのあらゆるものと一緒に動く
分離された環境では明らかに大きく見えた改善が、運用環境に入ると消えたりマイナスになったりする例を多く見てきた
CPython はさまざまな環境を対象にしなければならないのでさらに難しく、「本番で速くならないなら本当に速くなったとは言えない」と言える単一の運用対象もない。そういう世界で性能を改善しようとするのは本当に大変だ
結局、性能チューニングと測定は非常に難しく、申し訳なく思うべきことがあるとすれば、その事実を学んだことくらいだ
間違うことを恐れないでほしい。どうせ誰でも間違える。今回のように「これは私たちが失敗したようだ」と言い、どう対処するか、今後どう避けるかを見つければいい
[1] 性能だけでなく、人間のプロセスでもよくある。たとえばコードレビュー・ツールのチームが「コードレビュー時間を 15% 削減し、全員の作業フローを速くした」と言っても、実際にはシステムの別の部分に仕事を増やしており、全体の流れは速くなっておらず、測定していない場所へ 15% を移しただけかもしれない
理論上は、このような制御フローグラフとプロファイルがあれば、コンパイラは従来の
switch()ベースのインタープリタに対して最適なコードを生成するのに十分な情報を持っているはずだ。しかし実際には、関数がこれほど大きく、相互につながっていると、コンパイラと戦うことになるレジスタに保持してほしい重要な変数をスピルし、fallback 関数呼び出しの周辺に縮めて入れたいスタックフレーム操作を前方へ持ち上げ、分岐予測のために分離しておきたかった同一コードパスを結合してしまう。手袋をしたままピアノを弾くような感覚になり得る
ここでもまさにその「同一コードパスの結合」が起き、「バグのある」コンパイラが同一パスを結合して性能を悪化させた
「修正済み」のコンパイラはもうそうしないが、その修正も結局はコンパイラ内部のヒューリスティックを調整したものに近い。このコンパイラや別のコンパイラが、今後も私たちに有利な形でヒューリスティックを維持する保証はない
一方、末尾呼び出しインタープリタでは、望ましい機械語パターンをインタープリタ自体に表現できる。
musttail、noinline、preserve_none属性を組み合わせると、最適化器のヒューリスティックにずっと振り回されにくいように問題を制約できるだから末尾呼び出しインタープリタの利点は、単なる 3〜5% の性能向上以上のものであり、一部のコンパイラではそれより大きな信頼できる性能向上になり得る
同じようなことを防ぐためにベンチマークを改善できるだろうか?
[0] https://github.com/faster-cpython/benchmarking-public
ベンチマークを正しく行うのは本当に気が狂うほど難しい。人を欺く要素が多すぎる。
最近、あるアルゴリズムを約15%高速化する方法を見つけたと思っていた。少なくとも、すべてのベンチマークはそう示していた。
ところがテストハーネスに、より高速な関数を複製して入れ、実際には呼び出さず元の遅いバージョンだけを呼び出していたにもかかわらず、それでも15%速かった。実行されてもいないコードが元のコードを速くしたというわけだ。
当然ながら、コードとメモリ配置の問題で、何かが移動したことでCPUキャッシュによりうまく収まったのだ。
得られた速度向上が、コードが実際に「より良くなった」ためなのか、それともどこかで運よくより良いアラインメントを得ただけなのかを見極めるのは本当に難しい。
Casey MuratoriがSubstackで、この種のテーマについて非常に興味深いシリーズを書いている。
実際の事情を掘り下げて明らかにした記事の著者に賛辞を送りたい。Python 3.14の末尾呼び出しインタプリタは今なお良い改善であり、言語ランタイムで数パーセントの向上を得るのは苦労して勝ち取る成果だ。
ただし、魔法のような15%のタダ飯ではなかった。
より重要なのは、今回の件がベンチマークの厳密さと、複数の環境でテストすることの重要性をよく示した点だ。誰にとっても利益になり得るコンパイラのバグも明らかにした。
次に大きな性能向上の主張が出たときに、もう一度確認したくなる類の深掘り分析だ。さらに考えるべき問いは、現在出ている数多くの「X%高速」という結果のうち、どれほどが実際にはベンチマークのアーティファクトや未知のリグレッションなのか、ということだ。
今後こうした落とし穴をよりうまく避けるには、どうすればよいのだろうか。
コンパイラ自体はベンチマークしないのだろうか。コンパイラ側やPython側の既存ベンチマークが、そのコンパイラを使っていなかったのだろうか。
これは、Cが「機械に近い」とか「移植可能なアセンブリ」だという言い方がどれほど当てはまらないかを示す良い例だ。現代の最適化器は、観察可能な効果がなければロジックを大胆に変えてしまう。
記事でも「clang-19はcomputed-gotoインタプリタを、結果のバイナリが期待された値をすべて出すという意味では『正しく』コンパイルするが、同時にその出力は最適化の意図と完全に食い違っている。さらに他のコンパイラバージョンは『素朴な』
switch()ベースのインタプリタに最適化を適用し、私たちがソースコードを書き直して『意図していた』ものとまったく同じ最適化を実装する」と述べていた。Cの
a += 1は数値を増やすものだと信じられるが、C++の同じ式はメモリを割り当てたり、コールスタックを巻き戻したり、何をするか分からないこともあり得る。同様に、a = "a"はCでは単純なポインタ代入だが、C++ではメモリ割り当てなどが起こり得る。「Cは移植可能なアセンブリ」という表現は、各文が同等の機械語へ直接コンパイルされるという意味ではない。
コンパイラがループ構造をいじった結果、末尾呼び出しインタプリタ全体が発表ほど効果的ではなくなったとしても驚きではない。
偏執的なインタプリタ実装では、再びアセンブリを直接書くこともある。LuaJITは、非常に効率的なアセンブリループ実装をアーキテクチャ間で移植可能にするためにマクロシステムを実装したことで有名だ。だからこそ、こういうものを触ってみるのは面白くもある。
数年前、よく使われるインタプリタループ実装方式についての記事とテストも作っておいた。
https://github.com/vkazanov/bytecode-interpreters-post
最新の分岐予測器は、実行区間が十分に長く、解釈されるコード自体の挙動が安定していれば、単一の間接ジャンプでもほぼ正確に予測できる。
これを実ハードウェアと特定のシミュレーション分岐予測器の両方で研究した論文がある: https://inria.hal.science/hal-01100647/document
このプロジェクトで行った実験も、逸話的には同じ結論を支持している。記事には入れられなかったが、ハードウェアCPUカウンタと
perf statでいくつかのインタプリタを見たところ、分岐予測ミスは支配的な要因としては現れなかった。Pythonビルドの性能を評価するのは極めて難しい。性能を改善し得るビルド手法があまりにも多いからだ。
最近astral側でも、conda-forgeビルドが多くのものより目に見えて速いことを示し、この問題に直面していた。
https://github.com/astral-sh/python-build-standalone/pull/54...
既存の他のビルド最適化と併用したとき、末尾呼び出しインタプリタがどう動くのか気になる。
筆者は遺伝的アルゴリズムで、複数のコンパイラと最適化フラグの組み合わせを試している。
関連する議論:
https://docs.python.org/3.14/whatsnew/3.14.html#whatsnew314-... --> https://news.ycombinator.com/item?id=42999672 (66 points | 25日前 | 22コメント)
https://blog.reverberate.org/2025/02/10/tail-call-updates.ht... --> https://news.ycombinator.com/item?id=43076088 (124 points | 18日前 | 92コメント)
良い記事だと思う。ひとつ細かい点が目に留まった。
参照されている記事のひとつである https://simonwillison.net/2025/Feb/13/python-3140a5/ で、「3.14.0a5 はベンチマークで 3.13 より 1.12倍速かった。私の極度に過負荷な M2 MacBook Pro 上で」と書いている。
この部分はかなり紛らわしい。別のプロセスでコンピュータが過負荷になっている状態でベンチマークを走らせたという意味なのだろうか? それなら結果はまったく信頼できないのでは?
こうしたベンチマークは、外部変数を排除するために非常に管理された環境で行うものだと思っていた。
CPython 側については https://github.com/faster-cpython/benchmarking-public を見ればよい。
ここで 10% を「大きい」、1% を「正常」と言っている人もいるが、二重再帰 Fibonacci の部分的インライン化のような最適化は、実際の作業量と時間を指数関数的に減らし得る。
2桁の引数では 10倍以上、つまり数千パーセントもあり得る。厳密には、問題サイズではなく再帰の深さの差に対して指数的である [1]。
C コンパイラもコードのインライン化指標に非常に敏感に反応することがあり、その途方もない速度向上が実際に現れるかどうかは、コードの形に大きく左右され得る。
そのため問題の一部は CPU が非常に高度で複雑になったことにあるが、別の側面として、
-O0や-O1を超えたコンパイラも高度で複雑になっている。この記事は良く、読む価値があるが、複雑なもの同士が相互作用すると非常に驚くような結果が出る、という数多くの例のひとつでもある。これはコンピューティングの外でも成り立つ。
人々はこの教訓が何度繰り返されても、過度に単純化しようとする傾向が強い。
さらに、記事では少なくとも Intel と Apple M1 の2つの CPU、gcc と clang の2つのコンパイラを使っているが、実際の配布環境にははるかに多くの Intel、AMD、ARM の世代や実装、そして別のコンパイラがあり得る。全体の複雑さのごく一部だけを標本にしているにすぎない。
より科学的に行うなら、特に「1.01倍」のような差では、時間測定に何らかの形のエラーバーが必要だ。平均の標準偏差、あるいはこの場合は最小値の標準偏差のほうがよいかもしれない [2]。
測定誤差を減らすには、OS で CPU コア固定スケジューリングも必要になる可能性が高い。
[1] https://stackoverflow.com/questions/360748/computational-com...
[2] https://github.com/c-blake/bu/blob/main/doc/tim.md
最近 Python 3.9 から 3.13 までをベンチマークしてみたところ、3.11 まではずっと改善していた。
ところが Python 3.12 と 3.13 は 3.11 より約 10% 遅かった。
自作のベンチマークが十分によくないのだろうと思ったが、それでも中核サービスにデプロイしてみたところ、収集した指標でも同じ変化が見られた。
同じ問題を経験した人はいるだろうか?
[0]: https://github.com/python/cpython/issues/123540