3月10日、Nelson Elhage氏が「Python 3.14 末尾呼び出しインタープリタのパフォーマンス(Performance of the Python 3.14 tail-call interpreter)」と題したブログ記事を公開し、大きな話題を読んでいる。
この記事では、Python 3.14で導入された新しいバイトコードインタープリタ実装が、従来の実装よりも10~15%程度高速化すると報告されたが、実際にはLLVM 19環境に限られていたこと、そして正しくチューニングした環境で検証すると1~5%程度の速度向上であることなどが詳しく解説されている。
※編集: 本記事は非常に専門的な内容のため、なるべく正確な記述を心がけたものの、誤りを含んでいる可能性があります。誤りを発見した場合は本記事のコメントにてご報告いただけると幸いです。
Python 3.14のインタープリタにまつわる背景
約1か月ほど前、CPythonプロジェクトがバイトコードインタープリタの新しい実装戦略を導入した。最初の報告では、多様なベンチマークで10~15%の性能向上があったとされていた。
しかし詳しく調べると、それらの大きな性能向上はLLVM 19環境で 偶然 得られていた可能性が高く、LLVM 19以外のコンパイラ(clang-18やGCC、あるいはチューニングフラグを用いたLLVM 19など)を用いると速度向上はおおむね1~5%程度に落ち着くという。
なお、今回のtail-call(末尾呼び出し)インタープリタ自体は有望なアプローチであり、従来の実装より堅牢である可能性があるとも記事では述べられている。ここでは、これがどのような背景で起きたかを整理していく。
(筆者による)パフォーマンス計測結果
Nelson Elhage氏は複数のコンパイラ(GCC、clang-18、clang-19など)や設定オプション(LTO、PGOを有効化)を組み合わせてCPythonをビルドし、ベンチマークを行った。その結果をまとめたのが下記の表である。
プラットフォーム | clang18 | clang19 | clang19.taildup | clang19.tc | gcc |
---|---|---|---|---|---|
Raptor Lake i5-13500 | (ref) | 1.09x slower | 1.01x faster | 1.03x faster | 1.02x faster |
Apple M1 Macbook Air | (ref) | 1.12x slower | 1.02x slower | 1.00x slower | N/A |
注目すべきは、clang-19によるビルドがclang-18に比べて約9~12%ほど遅くなっている点だ。一方tail-callインタープリタはclang-18よりやや速いか、あるいはほぼ同等の速度を示す。つまり、clang-19同士で比較すると大きな速度改善とも言えるが、clang-18と比較すると当初の「10~15%高速化」という数字ほどの大きな改善ではないことがわかる。
LLVM 19における問題
こうした結果を引き起こしたのは、LLVM 19のある特性だ。
クラシックなバイトコードインタープリタは以下のような構造を持つことが多い。
while (true) {
opcode_t this_op = bytecode[pc++];
switch (this_op) {
case OP_IMM: {
// push an immediate onto the stack
break;
}
case OP_ADD: {
// handle the add
break;
}
// etc
}
}
ほとんどのコンパイラは、このswitch
文をジャンプテーブル(間接ジャンプ)に変換することで命令を切り替える。一方、現代のインタープリタ(CPythonもそうであった)は、computed goto
(C言語の拡張でラベルのアドレスを取り扱う)を使うことで、各オペコードブロックの末尾で直接次の処理に飛ばす最適化を行う。たとえば、以下のようなコードだ。
static void *opcode_table[256] = {
[OP_IMM] = &&TARGET_IMM,
[OP_ADD] = &&TARGET_ADD,
// etc
};
#define DISPATCH() goto *opcode_table[bytecode[pc++]]
DISPATCH();
TARGET_IMM: {
// push an immediate onto the stack
DISPATCH();
}
TARGET_ADD: {
// handle the add
DISPATCH();
}
しかし、コンパイル性能(コンパイラそのものの速度)などの都合により、LLVM/Clangは内部でいったん単一のindirectbr
命令にまとめ、最終的に最適化パスで再度ブロックを複製( tail duplication )して元の意図に近い形に戻すという仕組みをとっている。これはLLVMブログなどでも解説されている挙動であり、言わば「合体->再分割」というプロセスだ。
ところがLLVM 19で、複雑なケースでのコンパイル時間やメモリ使用量の抑制を目的として、tail duplicationに上限を設けるパッチが導入された結果、CPythonのように命令数が多いインタープリタでは最適化が途中で打ち切られ、すべての命令が単一のindirectbr
を共有してしまう形になった。 つまりLLVM-19では computed goto
による効果が台無しになり、性能が大幅に低下していた のである。
実際にオブジェクトファイルを逆アセンブルして、どれだけの数の間接ジャンプ(jmp *
)があるかを調べると、clang-18を使った場合は332カ所、clang-19では3カ所しか確認できなかったことが記事で示されている。
すでにLLVMの開発コミュニティでは該当バグ修正のPull Requestが提出されており、マージ待ちの状態だという。また、LLVM 19ではオプションを通じてtail duplicationの制限値を大きくすることで回避できるとも説明されている。
computed gotoは本当に必要か
ちなみに、computed gotoの効果自体にも筆者は懐疑的だ。従来、computed gotoを導入すると20~100%もの高速化が得られると報告される場合もあったが、近年のCPUでは分岐予測が高度化しており、2~4%程度の向上にとどまる例もあるという。
実際にswitch
ベースのインタープリタを構成オプションでオン・オフしてみると、clang-18との差分はわずか2%程度しかなかったと記事では述べられている。LLVM 19では、computed gotoなしでも特定の状況下ではcomputed gotoありのものより速いという奇妙な結果も得られたことが紹介されている。
実は、clang-18や適切なフラグをつけたclang-19では、switch
文によるインタープリタであっても自動的にtail duplicationを行い、実質的にcomputed goto
と同等の命令分散をしている場合がある。GCCではそうした最適化は行われないため、computed gotoが依然有用だが、Clangでは意図したほどの差がない状況が一部で生じているようだ。
まとめ
新しいtail-callインタープリタは、LLVM 19の特殊な動作が主な要因だったために、初期報告のような10~15%もの性能向上が得られるかは疑わしい。computed goto
とswitch
での最適化挙動は非常に複雑で、LLVM 19の制限によりtail duplicationが失敗したことで予想外の結果が生み出されたと思われる。
とは言えtail-callインタープリタ自体は依然として有望な手法であり、musttail
のような新しい属性を活用することで、より直接的に「何を最適化してほしいのか」をコンパイラに伝えられる点が重要だと述べている。
また、ベンチマークの比較対象(ベースライン)選びや、複数の環境での検証がいかに重要かも再認識できるケースだとまとめられている。
詳細はPerformance of the Python 3.14 tail-call interpreterを参照していただきたい。
Some things I can't understand yet. But I will learn more! Head Soccer