「Python 3.13」で追加されたJITコンパイラとは?:Python最新情報キャッチアップ
Pythonは以前からその実行速度が欠点とされていた。これを大きく改善する可能性を持つJITコンパイラがPython 3.13では実験的にサポートされた。これがどんなものかを見てみよう。
前回はPython 3.13の新機能から新しいREPLと、フリースレッドモードについて見た。今回は実験的に追加された機能である「JITコンパイラ」について見てみよう。
実験的にサポートされたJITコンパイラ
Python 3.13(CPython 3.13)ではJITコンパイラのサポートが実験的に追加された。ただし、python.orgからダウンロード可能な処理系にはJITコンパイラは含まれていない。そのため、JITコンパイラを実際に試してみるには自分でCPython 3.13をビルドする必要がある。
JITコンパイラを有効にしたCPythonをビルドする方法はCPythonのリポジトリで説明されている。そこで、その情報を基に筆者も実際に以下の環境でCPython 3.13をビルドして、JITコンパイラが有効な状態でちょっとしたコードを実行して、Pythonプログラムの実行がどのくらい高速になるかを調べてみた。
- Mac mini 2020(実体はMac mini 2018)
- CPU:Intel Core i3(4コア4スレッド)
- メモリ:16GB
- OS:macOS Sequoia 15.1
リンク先ではLLVM 19をインストールするように指示があるが、筆者が試したところではLLVM 18をインストールする必要があった。「ビルドに失敗したぞ」という方がいたら気にしてみてほしい。それ以外はconfigureコマンド(スクリプト)に「--enable-experimental-jit」オプションを指定するところも含めて、ドキュメントの指示に従うことでビルドが完了した(ただし、SSL関連の機能についてはビルドできていないと思われる。が、ここでは無視した。なんか懐かしいエラーのような気も)。
ビルドしたCPythonは「make install」コマンドにより/usr/local/bin/python3.13(およびそのシンボリックリンクである/usr/local/bin/python3)としてインストールされるが、筆者の環境には既にpython.orgからダウンロードしたPython 3.13がインストールされていて、同じ「python3」コマンドあるいは「python3.13」コマンドを実行すると、そちらが起動されてしまうため、ここでは自分でビルドしたCPython(へのシンボリックリンク)のファイル名を「/usr/local/bin/python3j」と変更した(もちろん「j」は「JIT」を意味する)。
実際に試してみたコードは以下だ。芸もなくまたもフィボナッチ数を再帰で計算する関数を定義して、fib(40)呼び出しの実行時間の計測を10回繰り返して、その平均時間を求めている。
import time
def fib(n):
if n == 0 or n == 1:
return 1
return fib(n - 1) + fib(n - 2)
if __name__ == '__main__':
diffs = []
for n in range(10):
print(f'#{n} ', end='')
st = time.time()
fib(40)
ed = time.time()
diff = ed - st
print(f'time: {diff}')
diffs.append(diff)
print(f'\navg: {sum(diffs) / len(diffs)}')
筆者の手元には幾つかのバージョンのPythonがインストールされているので、それらを使って上記プログラム(sample.py)を実行してみた。
Python 3.10とJITコンパイラを有効にしたPython 3.13ではかなりの速度差があることが分かる。なお、Python 3.10以降の各バージョンで実行時間を計測した結果は以下のようになった(筆者の環境での計測値。同じプログラムを読者が別の環境で実行した場合には、その値も異なるものになるだろう。ただし、全体的な傾向は同様になると思われる)。
バージョン | 実行時間(10回の平均値) |
---|---|
Python 3.10 | 37.9秒 |
Python 3.11 | 18.8秒 |
Python 3.12 | 21.4秒 |
Python 3.13 | 21.4秒 |
Python 3.13(JITコンパイラを有効化) | 18.1秒 |
Pythonのバージョンごとの実行時間 |
Python 3.10では40秒近くかかっていたのが、Python 3.11では大きく高速化されている。Python 3.12とPython 3.13(JITコンパイラが有効でないもの)では、速度が若干低下しているが、その理由は筆者には推測できない。しかし、JITコンパイラを有効にしたPython 3.13ではPython 3.11と同じくらいに高速になっている。
Python 3.13でJITコンパイラが無効か有効かという観点で見れば、約3秒高速化されている。大ざっぱに考えれば、約15%の速度向上といえるだろう。このようにJITコンパイルにより実行速度が向上する場面としては、ループ内で同一の型のデータを何度も処理する場合や同じ関数を何度も呼び出す場合などが挙げられる。
Python 3.11以降でなぜこれほどの高速化がかなったかというと、Faster CPythonというプロジェクトによるものだ。
JITコンパイラ登場までの道のり
Faster CPythonプロジェクトではもともと、Python 3.10以降の4バージョンで1.5倍ずつの高速化を目指していた。Python 3.10では「adaptive, specializing interpreter」の導入が計画されていたが、実際にはPython 3.11で「Specializing Adaptive Interpreter」として導入されている(PEP 659)。
Specializing Adaptive Interpreterが何をするかの前に、Pythonコードの実行について簡単にまとめると、Pythonコードはそのままインタープリタが実行するのではなく、その前にバイトコードに変換され、それがVM上で実行される(バイトコードをファイルにキャッシュしたものが.pycファイルである)。どんなバイトコード命令があるかはバイトコードを逆アセンブルするためのdisモジュールのドキュメントにある「Pythonバイトコード命令」で参照できる。
このようなバイトコードは(バイトコードに限らないが)、頻繁に実行される部分とそれなりに実行される部分、ほとんど実行されない部分に分けられる。Faster CPythonプロジェクトではこれらをそれぞれホットコード(hot code)、ウォームコード(warm code)、コールドコード(cold code)と呼んでいる。また、まとまったコード範囲を表すリージョン(region)という概念もある。
加えて、バイトコードの実行段階をTier 0からTier 3の4つの層に分けている(デバッグやプロファイリングを目的としたTier -1もあるがここでは省略)。簡単にまとめると、Tier 0はコールドコードを実行する層、Tier 1はウォームコードを実行する層、Tier 2とTier 3はホットコードを実行する層となる。Tier 0でコールドコードが実行された後、もう一度Tier 0でそのコードが実行されると、それはウォームコードとなりTier 1に移動する(これはモジュールのトップレベルにある関数定義やクラス定義など、一度しか実行されないことが確実なコードとそれ以外のコードを分けるのに役立つ)。Tier 2とTier 3の違いは、メモリやリソースの消費に関係する。ホットコードは高速に実行すべきであり、JITコンパイルやそれに伴う最適化の対象となるが、メモリやリソースを過剰に消費しないことを考慮するならTier 2で、そうでなければTier 3で実行される(ただし、Python 3.13で導入されるJITコンパイラはTier 2を対象とする)。
Specializing Adaptive Interpreter(特殊化適応的インタープリタ)
Python 3.11のSpecializing Adaptive Interpreter(特殊化適応的インタープリタ)はこのうちTier 1で動作するものと思われる(というのは、「What's New In Python 3.13」には「We start with specialized Tier 1 bytecode」という記述があり、この「specialized」がSpecializing Adaptive Interpreterによる処理が行われたことを意味しているからだ)。
Specializing Adaptive Interpreterが何をするかというと、「動的に変化するオブジェクト型に対応してさまざまな処理をするバイトコードを、特定の型に特化したバイトコードへと特殊化(置き換える)」ことだと考えられる。ここでは2項演算子「+」に対応するバイトコードがどう変化するかを見てみよう。以下に簡単な例を示す。
def foo(a, b):
return a + b
これをdisモジュールのdis関数で逆アセンブルするとそのバイトコードを確認できる(show_cachesパラメーターはキャッシュを、adaptiveパラメーターはSpecializing Adaptive Interpreterによって特殊化されたバイトコードを表示するかどうかを指定する)。ここではJITコンパイラを有効化したPython 3.13を使用する。
import dis
dis.dis(foo, show_caches=True, adaptive=True)
これを実行した結果を以下に示す。
ここでは2項演算子「+」に対応するバイトコードが「BINARY_OP」であることが分かる。次にこの関数を二度呼び出してからdis.dis関数を再度呼び出しみる。
foo(0, 1)
foo(0, 1)
dis.dis(foo, show_caches=True, adaptive=True)
実行結果を以下に示す。
ご覧のようにバイトコードが「BINARY_OP_ADD_INT」に変化した。BINARY_OPはさまざまなオブジェクトの型に対応した(ポリモルフィックな)バイトコードだが、BINARY_OP_ADD_INTはint型のオブジェクトの加算に特化した2項演算子である。被演算子(オペランド)が常に整数型であれば、型チェックと型に応じた処理のディスパッチなどが不要であり、整数型に特化したバイトコードを実行することでその実行速度が高まると期待できる(このような振る舞いの結果がPython 3.10とPython 3.11での冒頭のフィボナッチ数を計算するコードの速度差につながっていると推測できる)。なお、整数型以外のオブジェクトが出現したときには以前のポリモルフィックなコードへと実行はフォールバックされる。
また、呼び出し前の関数はコールドコードであり、それを二度呼び出したことでこの関数はウォームコードになり、Tier 1に移動している点も覚えておこう。
今見たようにPythonは動的型付け言語であるが、ある処理を行う特定部分に着目したとき、オブジェクトやその型が変化することは多くない(これを「型安定性」「type stability」という)。このことを活用して、ある処理に対応したバイトコードをそのときに扱っているオブジェクトの型に合わせて特殊化しようというのがSpecializing Adaptive Interpreterの役割といえる。
JITコンパイラ
以下はPython 3.13の「What's New In Python 3.13」に書いてあるJITコンパイルの概要の一部を筆者が適当に訳したものだ(Python 3.13におけるJITコンパイルの設計方針の概要については「PEP 744」が詳しい)。
- 特殊化されたTier 1のバイトコードがあるとする
- Tier 1のバイトコードがホットコードだと判断されると、ピュアな中間表現(intermediate representation、IR)に変換される。これをTier 2 IRとか「micro-ops」(uops)と呼ぶことがある
- Tier 2 IRはTier 1と同じスタックベースのVMを使用するが、その命令フォーマットはマシンコードへの変換により適したものになっている
- Tier 2 IRには幾つかの最適化手法があり、マシンコードへの変換前にそれらが適用される
- JITが遊行されている場合、最適化されたTier 2 IRの実行時にマシンコードに変換され、実行される
- マシンコードへの変換時には、コピー&パッチと呼ばれる手法が使われる
特殊化されたTier 1のバイトコードとは、上で見たように型安定性を活用して特殊化されたバイトコードのことだ。特殊化されたTier 1のバイトコードを含むコードが頻繁に実行され(例えば、冒頭のフィボナッチ数を計算する関数が再帰実行で何度も呼び出されたり、その中で整数加算が何度も行われたりすることで)、ホットコードであると判断されると、一連の処理を行うバイトコードはより小さな処理単位である「micro-ops」を含んだバイトコードに変換される*1。
Specializing Adaptive Interpreterによる特殊化は単一のバイトコードを型に特化したバイトコードに特殊化するものだが、この段階で行われるのはより大きな一連の処理を対象とする(例えば、forループとその内部で行われる処理など)。そして、micro-opsを含むようになったバイトコードは、その実行前に最適化された後にマシンコードに変換されて実際に実行される。
バイトコードのマシンコードへの変換では「コピー&パッチ」と呼ばれる手法が使われる。コピー&パッチとはバイトコードごとに用意されている定型のバイナリコード(これをテンプレートとかステンシルと呼ぶ)をメモリ上のどこかにコピーして、それを実行されるマシンコードとしてしまおうというものだ。ただし、テンプレート(ステンシル)には、バイトコードのオペランドに相当する部分に「穴」が空いている。そして、その穴をそのときどきに応じた値(2項の整数加算演算であれば2つの整数値)で埋めることで(パッチ)、実際に実行可能なコードとする。メモリ上のどこにテンプレートがコピーされるかは分からないので、テンプレートのコードはリロケータブル(アドレスに依存せずに再配置可能)になっている(ジャンプは絶対アドレスではなく相対アドレスを使用するなど)。
これらをざっくりと図にまとめたのが以下だ。
*1 Python 3.12以降では、C言語ライクなDSL(Domain Specific Language)を使って記述されたバイトコードのインストラクション定義からバイトコードインタープリタが生成されるようになっている。この恩恵の一つとして、バイトコードをmicro-opsに変換するためのテーブルの自動生成も可能になっている。
PythonにおけるJITのサポートはまだ実験的なものではあるが、本記事の冒頭で見たようにある程度の高速化が望めるものになっている。その一方で、Pythonコードのどこがホットなのかを判断するにはコードのトレースが必要だったり、大量のTier 2 IRの最適化にはそれなりの時間がかかったり、コストもそれなりにかかるはずだ。Python 3.13でJITコンパイラがデフォルトで無効化されているのは、高速化とそれにかかるコストがどの辺でバランスするのかがまだ見通せていないからかもしれない。Python 3.14では、生成されるマシンコードのハンドリングの改善、生成されるコードの品質の改善、最適化の改善、JITコンパイルで実行されるバイトコードの増加などが予定されている。将来的には何らかの判断とともにJITコンパイラが有効化された処理系が登場するはずだ。
次回はこれまでに取り上げていない新機能の中から幾つかを落ち穂拾い的に取り上げていく予定だ。
Copyright© Digital Advantage Corp. All Rights Reserved.