Python の JIT コンパイラ、ついに実現へ
Python 3.15 搭載の copy-and-patch 方式 JIT コンパイラを解説。仕組み、期待できる高速化の幅、CPython で実装に時間がかかった理由まで。

Python は存在してからずっと「遅い」と言われ続けてきました。Python コミュニティの定番の答えは「ホットなループは C 拡張で書けばいい」というもので、これは言語の標準的な実行モデルが根本的な限界を抱えていることの裏返しでした。CPython はバイトコードを一命令ずつ解釈し、各命令は switch 文でディスパッチされます。シンプルで移植性が高く、デバッグもしやすい設計です。一方で、計算負荷の高い処理ではコンパイル済みの C に比べて約 100 倍遅くなります。
Python 3.15 でこの状況が変わります。数年にわたる実験的な取り組みを経て、copy-and-patch JIT コンパイラがデフォルト有効の機能として出荷されます。Python を C と同じ速さにすることはできません。静的コンパイルでもない限り無理でしょう。それでも初期のベンチマークでは、実際のコードで 15〜30% の高速化が確認されており、特定のパターンではさらに大きな改善が見られます。「遅い部分だけ C で書き直せ」が 30 年にわたって Python のパフォーマンスの定番解決策だったことを考えると、純粋な Python コードを実際に高速化できる JIT は大きなマイルストーンです。
CPython に JIT がなかった理由
誰も試さなかったわけではありません。PyPy は 10 年以上前から JIT を備えており、CPython より 5〜10 倍速く Python コードを実行することも珍しくありません。ただ、PyPy は独自のランタイムを持つ別実装であり、CPython のシェアを奪うには至っていません。理由は、NumPy、pandas、scikit-learn など、Python をデータサイエンスの言語たらしめている C 拡張エコシステムが CPython の C API に強く依存しているためです。
CPython 自体に JIT を組み込む試みは、これまでも何度も議論され、実際に試されてきました。その難しさはよく知られています。CPython のアーキテクチャは JIT コンパイルを困難にしています。バイトコードは動的型付けなので、効率的なコードを生成するには型情報が必要ですが、Python はそれを静的に提供しません。また、C API を使うと C コードが Python オブジェクトを直接操作でき、JIT の前提を崩してしまいます。さらに、参照カウント方式のガベージコレクタが管理コストを生み、JIT ではそれを簡単には取り除けません。
過去の試みとして、Unladen Swallow(Google、2009 年)や Pyston(Dropbox、2014 年)は、LLVM ベースの JIT を CPython に後付けしようとしました。しかし、どちらも LLVM のコンパイルオーバーヘッドが Python の典型的なワークロードには重すぎると判明しました。LLVM は大規模コードベースの事前コンパイル(AOT)向けに設計されており、短い Python 関数を JIT でコンパイルすると、実行にマイクロ秒しかかからない処理に数ミリ秒のコンパイル時間が加わります。結果として、コンパイルのコストが高速化の効果を上回ってしまったのです。
Copy-and-Patch:発想の異なる JIT
copy-and-patch 技法は 2021 年の研究論文で紹介されたもので、JIT コンパイルへのアプローチが根本的に異なります。バイトコードを中間表現に変換して最適化パスを回す(LLVM のやり方)代わりに、copy-and-patch は事前コンパイル済みのコードテンプレートを使います。
基本的な考え方はこうです。バイトコード命令(LOAD_FAST、BINARY_ADD、CALL_FUNCTION など)ごとに、コンパイラがあらかじめ C の実装を機械語にコンパイルしておきます。その際、レジスタ割り当てや定数値、メモリオフセットといった変数固有のデータを埋める「穴(hole)」を残しておきます。実行時の JIT コンパイルは、テンプレートをコピーし、その関数に合った値で穴を埋めるだけです。最適化パスも、レジスタ割り当てアルゴリズムも、命令選択もありません。まさに memcpy して patch するだけです。
Traditional JIT pipeline:
Python bytecode
→ Parse into IR
→ Type inference
→ Optimization passes (CSE, DCE, loop unrolling, inlining...)
→ Register allocation
→ Instruction selection
→ Machine code
Total: 1-100ms per function
Copy-and-patch pipeline:
Python bytecode
→ For each instruction, copy pre-compiled template
→ Fill in holes (constants, offsets)
→ Done — machine code ready
Total: 1-100μs per function (1000x faster compilation)
トレードオフはコード品質にあります。LLVM は高度に最適化された機械語を生成しますが、copy-and-patch が生成するのは、本質的にはコンパイル済みのインタプリタループです。各バイトコード命令は依然として個別のテンプレートであり、命令間の最適化はわずかです。それでも、ディスパッチのオーバーヘッドや switch 文がなくなり、分岐予測も改善されるので、インタプリタよりは確実に速くなります。ただし、フルの最適化コンパイラが出すコードには及びません。
Python の場合、このトレードオフは非常に相性が良いと言えます。Python の関数は通常短く、何度も呼び出され、1 回あたりの実行時間はマイクロ秒単位です。マイクロ秒でコンパイルでき、実行を 20〜30% 速くできる JIT の方が、ミリ秒かけてコンパイルし 200% 速くする JIT よりも価値があります。前者はコンパイルのオーバーヘッドがほぼすぐに回収できるからです。
何が速くなるのか
JIT はすべての Python コードを一律に速くするわけではありません。どこが効くのかを理解するには、インタプリタが時間を何に使っているかを知る必要があります。
バイトコードのディスパッチオーバーヘッド。 インタプリタでは、バイトコード命令ごとに次のオペコードの取得、デコード、switch 文による対応ハンドラへのジャンプが必要です。このディスパッチのオーバーヘッドは、タイトなループでは全体の実行時間の 30〜50% に達することもあります。JIT はこれを完全に取り除きます。命令は直接ジャンプを使う連続した機械語にコンパイルされるからです。
型特化された演算。 Python 3.11 で導入された specializing adaptive interpreter は、実際に使われた型を観測したうえで、汎用的な演算を型特化版に置き換えます。たとえば、2 つの整数を加算する場合 BINARY_ADD は BINARY_ADD_INT になります。JIT はこの特化された命令を効率的な機械語にコンパイルします。整数の加算は関数呼び出しではなく、1 つの add 命令になります。
分岐予測。 インタプリタの中央ディスパッチループ(数百のケースを持つ switch)は、CPU の分岐予測器にとって悪夢です。JIT はこれを、CPU が正確に予測できる直接的な制御フローに置き換えます。分岐予測の失敗が 15〜20 サイクルのコストになる現代の CPU では、これだけでも高速化の大きな部分を占めます。
速くならないもの:C 拡張の呼び出し(NumPy、pandas)、I/O 操作(ネットワーク、ディスク)、大量の小さなオブジェクト生成のようなメモリ割り当てが支配的な処理です。プログラムの 95% の時間が C 拡張で、5% が純粋な Python だとすると、JIT が高速化するのは 5% の部分だけです。測定可能ではあるものの、劇的な変化にはなりません。
特化パイプライン
JIT は単独で機能するわけではありません。Python 3.11 の specializing interpreter から始まり、3.12〜3.14 での段階的な改善を経て、最後の段階として組み込まれた性能パイプラインの一部です。
- Tier 0:インタプリタ。 すべてのコードはここから始まります。適応的な特化を伴う標準的なバイトコード解釈です。関数が数回呼ばれると、ホットな命令は型特化版に置き換えられます。
- Tier 1:JIT コンパイル済みバイトコード。 copy-and-patch JIT が特化済みのバイトコードを機械語にコンパイルします。これによりディスパッチのオーバーヘッドがなくなり、コンパイル済みテンプレート内で定数畳み込みや不要コードの除去といった基本的な最適化が可能になります。
- Tier 2(将来):トレースベースの最適化。 ホットなコードパスの実行トレースを記録し、関数の境界をまたいだトレース全体を最適化された機械語にコンパイルします。これは計画段階で、まだ出荷されていません。
この階層型のアプローチは、Ruby の YJIT など、近年の言語ランタイムの多くが採用しているものと似ています。まずは高速な解釈から始め、コードがホットになったら高速なコンパイルに移行し、コストの高い最適化は最もホットなパスに限定します。根底にある考え方は同じです。ほとんどのコードは最適化する価値がないので、コンパイルの予算は最も頻繁に実行されるコードに使うべきだ、ということです。
メモリと起動時間への影響
JIT コンパイラはコンパイル済みのコードのためにメモリを消費します。copy-and-patch JIT のメモリオーバーヘッドは控えめです。コンパイル済みコードはバイトコードより大きいものの、LLVM ベースの JIT が生成するものより小さく済みます(最適化による肥大化がないため)。現在の実装では、置き換えるバイトコードの約 1.5〜3 倍のメモリを使用し、恩恵を受けるほど頻繁に呼ばれる関数だけをコンパイルします。
短命な Python スクリプトでは起動時間が問題になります。JIT はテンプレートライブラリの読み込みとコンパイル基盤の初期化にオーバーヘッドを加えます。1 秒未満で終わるスクリプトでは、JIT のオーバーヘッドが高速化の効果を上回る可能性があります。CPython は、関数が一定回数呼ばれてから初めて JIT コンパイルすることでこれに対処しています。短命なスクリプトはインタプリタのまま動き、JIT のコストを払わずに済みます。
これは設定可能です。-X jit フラグで JIT の動作を制御し、環境変数でコンパイルのしきい値を調整できます。起動時間が重要なサーバーレス関数や CLI ツールでは、しきい値を上げるか JIT を完全に無効にできます。定常状態の性能が重要な長時間稼働のサーバーやデータ処理スクリプトでは、デフォルトのままで十分にうまく動きます。
Python エコシステムへの影響
JIT によって Python のパフォーマンスの序列が変わるわけではありません。計算負荷の高い処理では、C、Rust、Go、Java が依然として圧倒的に速いです。変わるのは、Python 開発者がそれらの選択肢に手を伸ばす必要が出てくる境界線です。
純粋な Python コードで 20〜30% 高速化すると、これまで C 拡張や書き直しが必要だったワークロードの一部が、純粋な Python で十分な速度になります。10 分かかっていたデータ処理スクリプトが 7 分で終わり、毎秒 1000 リクエストを処理していた Web サーバーが 1300 リクエストを捌けるようになります。革命的な数字ではありませんが、「Python で十分速い」と「これは Go で書き直そう」の分かれ目になり得ます。
さらに重要なのは、JIT の基盤が将来の最適化の土台になることです。copy-and-patch 方式は、より良いテンプレート、より多くの特化、そしていずれはトレースベースのコンパイルへと拡張できます。Python の各バージョンは、JIT の根本的なアーキテクチャを変えることなく、より良いテンプレートを出荷できます。3.15 の 20〜30% という高速化は天井ではなく、床です。
30 年にわたって世界で最も人気があり、かつ最も遅い言語の一つと言われてきた CPython が、ついに本格的にパフォーマンスへの投資を始めました。JIT が「Python は遅すぎる」派を納得させることはないでしょう。ワークロードによっては、JIT の有無にかかわらず Python が本当に遅いのは事実だからです。しかし、「まあ速いけど、もう少しほしい」と感じられていた大多数の Python コードにとっては、JIT によって「本当に快適」に近づきます。見た目以上に大きな出来事です。


