プログラミング
Python 3.15 の超低オーバーヘッドインタープリタープロファイリングモード
Python 3.15’s Ultra-Low Overhead Interpreter Profiling Mode (fidget-spinner.github.io)
要約
Python 3.15 に搭載される JIT (Just-In-Time) コンパイラは、インタープリターと比較して控えめな速度向上をもたらします。その鍵となるのが、JIT コンパイルのために実行を記録する、低オーバーヘッドの新しいインタープリタープロファイリング手法です。この手法は、既存のインタープリターに大きな変更を加えることなく、 dispatch table を切り替えることで実現されています。これにより、プロファイリング時のオーバーヘッドが大幅に削減され、Python のパフォーマンス向上が期待されます。
全文翻訳
Ken Jin's Blog Python 3.15 の超低オーバーヘッドインタープリタープロファイリングモード 2026年7月1日 Python 3.15 の JIT は、インタープリターと比較して控えめな速度向上をもたらします。その鍵となるのが、私の知る限り、JIT コンパイルのためにインタープリターでの実行を記録するために開発された、新しい形式のインタープリタープロファイリングです。この形式のプロファイリングは、インタープリターに対するオーバーヘッドをあまり導入しないため、低オーバーヘッドの JIT コンパイルを可能にします。誰かが以前にこれを発見していたとしても驚きませんが、私が調べた限りでは、どこにも文書化されていません。簡単な背景 以前の投稿で、CPython 3.15 JIT におけるトレース記録について説明しました。その中心的な考え方は、プログラムの実際の実行中に命令ストリームを記録し、終了条件に達したら、それを JIT コンパイルのために送信することです。当然、これにはインストルメント化された/プロファイリングされたインタープリターが必要です。インタープリターのプロファイリング 通常、インタープリターのプロファイリングには2つの方法があります: 1. 2つのカスタムインタープリターを用意する(1つは実行用、もう1つはプロファイリング用)。 2. 「プロファイリングモード」を実装する。 アプローチ 1: 2つのインタープリター 通常のインタープリターは、プロファイリングインタープリターへの呼び出しポイントを持ち、プロファイリングインタープリターが通常のインタープリターに戻る方法を持ちます。これにより、インタープリター間を切り替えることができます。これがCPythonにおけるトレース記録の初期実装でした。私は、テールコーリングインタープリターにはこれで十分でしたが、computed goto インタープリターには遅すぎると感じました(pyperformance で約6%の減速!)。主な理由は、computed goto の場合、C言語で実装された実際のインタープリターのサイズが2倍になったためだと推測しています。なぜなら、その機能は関数スコープだからです。Cバイナリのサイズを2倍にすることは、さまざまな理由でパフォーマンスにとって通常は良くありません。 アプローチ 2: プロファイリングモード プロファイリングインタープリターを実装するもう1つの一般的な方法は、「プロファイリングモード」を持つことです。これは、ブール値(例: bool profile)に基づいて一部のプロファイリングロジックを条件付けます。これにより、コードの肥大化を最小限に抑えられますが、通常のインタープリターに過度に影響を与え、CPUがほぼ常に分岐を正しく予測するにもかかわらず、実行速度を低下させると判断しました。デュアルディスパッチ 両方の利点を最大限に引き出すために、最後の代替手段があります。それはディスパッチテーブルをスワップアウトすることです。インタープリターは、多くの場合、オペコード(命令ID)を関数ポインタ/ラベルアドレスにマッピングすることで、次の命令にディスパッチします。これは、インタープリターに各命令の行き先を指示します。代わりに、ここでは2つのディスパッチテーブルを用意します。1つは通常の実行用、もう1つはプロファイリングインタープリター用です。実行時には、ローカル変数 dispatch_table_var に値を割り当てるだけで、どの「モード」にいるかを決定できます。分岐は不要です!私はこれが初めてではないと確信しています。しかし、ナイーブな実装では、実際には1つのインタープリターで2つのインタープリターと同等のコードが得られ、前述のように非常に遅くなります。その場合の主な改善点は、2番目のテーブルのすべての命令を単一の記録/プロファイリング命令にマッピングすることです。この命令は、必要なすべてのプロファイリングを実行し、固定された最初のテーブルを使用して、実行のための実際の次の命令にディスパッチします。これは、ファンイン(単一命令への)ファンアウトモデルと考えることができます。プロファイリングモードに入ることは、データ構造を初期化するだけで、インタープリターの観点からはディスパッチテーブルをスワップアウトするだけです!プロファイリングを終了することは、再びデータ構造を最終化し、ディスパッチテーブルをスワップアウトすることです。これがCPython 3.15の実際のコードです: # define ENTER_TRACING() \ DISPATCH_TABLE_VAR = TRACING_DISPATCH_TABLE; # define LEAVE_TRACING() \ DISPATCH_TABLE_VAR = DISPATCH_TABLE; マクロは、computed goto/tail calling インタープリターを使用する際に、異なるテーブルを処理するためだけです。結果 私のシステムで動的周波数スケーリングをオフにし、テストスクリプト(付録参照)を実行したところ、40回の実行におけるインタープリターのプロファイリングオーバーヘッドを測定した中央値は以下のようになりました: # プロファイリングなし(インタープリターのみ) 1.72e-06s # インタープリター + プロファイリング + JITコンパイル 7.47e-06s これは実質的に、CPython 3.15 でインタープリターをプロファイリングすることが、私たちのトイベンチマークでは最大で4.5倍遅いだけであることを意味します!PyPy のような他のトレーシングシステムでは、900倍〜1000倍の減速があります!もちろん、これは公平な比較ではありません。PyPy はメタトレーシングであり、同じプログラムに対して私たちよりもはるかに多くのコードをトレースするため、自然に多くのコードをトレースします(インタープリター自体をトレースする必要があります)。しかし、トレーシングが実際にどれほど遅くなるかの例を示すために、ここに置きました。考察 私たちはCPython のプロファイリングインタープリターのために考案したものに非常に誇りを持っています。このアプローチは、トレース記録に限定されるものではありません。他の応用としては、大幅な書き換えなしにインタープリターの低オーバーヘッドプロファイリングを導入したり、実行中に観測されたインタープリターの型プロファイルを記録したりすることが考えられます。このブログ記事を書いている理由の一部は、技術的な知識を文書化し、誰かが役立つと思う場合に共有することを信じているからです。しかし、時々自問します。私たちがCPython で考案したこの魔法のようなシステムは、その複雑さに見合う価値があるのでしょうか?私はエレガントでシンプルなシステムが好きですが、これはエレガントではあるものの、確かに理解するのはそれほど簡単ではありません。トレーシングについては、将来さらに詳しく書きたいと思います。付録 ベンチマークスクリプトはオーバーヘッドを測定するために使用されます。JITコンパイルをトリガーするために、PYTHON_JIT_RESUME_INITIAL_VALUE=1 を使用します: def foo(x): x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x x + x foo(1) foo(1) foo(1) import sys import time start = time.time() foo(1) end = time.time() print(end - start) # sys._dump_tracelets("hello.gvz")