2026年11月: Python 3.15の新プロファイラーTachyonの紹介(takanory)

鈴木たかのり(@takanory)です。 今月の「Python Monthly Topics」では、Python 3.15で追加された新しいプロファイラー「Tachyon」(profiling.sampling)を紹介します。

はじめに/Tachyonとは

Tachyon(タキオン)は、Python 3.15で標準ライブラリに追加された統計的サンプリングプロファイラーです。 利用するときのモジュール名はprofiling.samplingで、Tachyonはこのモジュールのコードネームです。

Tachyonのロゴ

Tachyonのロゴ

プロファイラー[1]とは、プログラムのどの部分にどのくらい時間がかかっているかを調べるツールです。 Tachyonは「統計的サンプリング」という方法で計測します。 プログラムの実行中に、一定の間隔でコールスタック(いま実行中の関数と、その呼び出し元の並び)のスナップショットを採取し、各関数がスナップショットに現れた回数から、かかった時間を推定します。 この記事では、採取したスナップショットを「サンプル」と呼びます。 集めたサンプルが多いほど、推定は正確になります。

Tachyonは、計測対象のプロセスの外側からコールスタックを読み取ります。 そのため、計測によるオーバーヘッドはほとんどありません。 開発中のスクリプトだけでなく、本番環境で動いているプログラムの計測にも使えます。 また、コードを書き換えたりプログラムを再起動したりせずに、実行中のプロセスにアタッチして計測することもできます。 アタッチとは、実行中のプロセスに接続することです。

Tachyonを試すには、Python 3.15以降が必要です。 次の章では、Tachyonが追加された背景を、PEP 799とあわせて見ていきます。

Tachyonの作者による、PyCon USでの発表動画もあります。

PEP 799とprofilingパッケージ

Tachyonは、Python 3.15で新設されたprofilingパッケージの一部として提供されています。 このパッケージを提案したのが、PEP 799です。

従来のプロファイラーの課題

Python 3.14までの標準ライブラリには、profileとcProfileという2つのプロファイラーがありました。 どちらも、すべての関数の呼び出しと復帰を漏れなく記録する「トレース型(決定論的)」のプロファイラーです。 PEP 799では、トレース型プロファイラーの課題として以下が挙げられています。

  • すべての呼び出しを記録するため、オーバーヘッドが大きく、PEP 659などインタープリターの最適化を妨げることがある

  • cProfileはメインスレッドしか観測しないため、並行処理を使う現代のプログラムでは使いにくい

  • profileという名前が標準のツールに見えるが、実際にはPythonだけで実装された古く遅いモジュールで、名前から誤解しやすい

新しいprofilingパッケージの構成

そこでPEP 799では、プロファイラーを手法ごとに整理したprofilingパッケージの新設を提案しました。

  • profiling.tracing: トレース型(決定論的)のプロファイラー。cProfileの実装の移設先

  • profiling.sampling: 統計的サンプリング型のプロファイラー。本記事で紹介するTachyonのこと

手法の名前をそのままモジュール名にしたことで、ツールを見つけやすくなり、それぞれの特徴も分かりやすくなりました。 将来、別の手法のプロファイラーを追加しやすいことも利点です。

cProfileとprofileのこれから

cProfileは、後方互換性のためにエイリアスとして残ります。 ただしPython 3.15以降は、profiling.tracingを使うことが推奨されています。

profileモジュールは、段階的に削除されます。 Python 3.15ではimport時にDeprecationWarningが出力され、3.16ではすべての利用で警告が出ます。 そして3.17で標準ライブラリから削除される予定です。

トレース型とサンプリング型の使い分け

2つの方法の違いを、表にまとめます。

トレース型とサンプリング型の比較

項目

トレース型(profiling.tracing)

サンプリング型(profiling.sampling)

計測方法

すべての関数の呼び出しと復帰を記録する

一定間隔でサンプルを採取する

オーバーヘッド

あり(中程度)

ほとんどない

呼び出し回数

正確

統計的な推定

向いている用途

呼び出し回数や呼び出し関係の確認

ホットスポット探し、本番環境の計測

実行中のプロセスへのアタッチ

できない

できる

公式ドキュメントでは、多くの場合サンプリング型プロファイラーを使うことが勧められています。 正確な呼び出し回数が必要な場合や、ごく短い関数の呼び出しも漏らしたくない場合は、トレース型を使用してください。

基本的な使い方

この章では、Tachyonの基本的な使い方として、スクリプトを計測するrunコマンドと、実行中のプロセスにアタッチして計測するattachコマンドを説明します。 コマンド例は公式ドキュメントと同じくpythonと書きます。pythonがPython 3.15以降を指す環境で実行してください。

基本サンプルプログラム

計測の題材として、標準ライブラリだけで動く小さなサンプルプログラムを用意しました。 この記事では、次章「さまざまな出力形式」以降もこのサンプルプログラムを使います。 素数を数えるCPU処理と、time.sleep()による待ち時間を含み、関数を複数の階層で呼び出します。

実行時の引数で動作を変更できます。 第1引数は素数を探す上限値で、デフォルトは1,000,000です。 第2引数を指定すると、指定した秒数が経過するまで処理を繰り返し実行します。 第2引数を省略すると、1回だけ実行して終了します(デフォルトは約3秒かかります)。

サンプルプログラム ─ script.py
import os
import sys
import time


def is_prime(n: int) -> bool:
    """n が素数なら True を返す"""
    if n < 2:
        return False
    i = 2
    while i * i <= n:
        if n % i == 0:
            return False
        i += 1
    return True


def count_primes(limit: int) -> int:
    """limit 未満の素数の個数を返す"""
    return sum(1 for n in range(limit) if is_prime(n))


def process(limit: int) -> int:
    """素数を数えたあと、2秒待つ(I/O待ちの代わり)"""
    count = count_primes(limit)
    time.sleep(2)
    return count


def main() -> None:
    """第1引数は素数を探す上限、第2引数は繰り返し実行する秒数"""
    print(f"PID: {os.getpid()}")
    limit = int(sys.argv[1]) if len(sys.argv) > 1 else 1_000_000
    duration = float(sys.argv[2]) if len(sys.argv) > 2 else 0
    start = time.time()
    while True:
        count = process(limit)
        print(f"{limit:,} 未満の素数は {count:,} 個です")
        if time.time() - start >= duration:
            break


if __name__ == "__main__":
    main()

スクリプトを計測する(run)

runコマンドは、スクリプトを起動して終了まで計測します。 スクリプトに渡す引数は、対象のスクリプトのあとに続けて書きます(例: run script.py 300000)。

script.pyをTachyonで計測する
$ python -m profiling.sampling run script.py
PID: 97867
1,000,000 未満の素数は 78,498 個です
Captured 3316 samples in 3.32 seconds
Sample rate: 999.97 samples/sec
Error rate: 1.03
Profile Stats:
       nsamples   sample%   tottime (s)    cumul%   cumtime (s)  filename:lineno(function)
      2005/2005      61.1         2.005      61.1         2.005  script.py:26(process)
        634/634      19.3         0.634      19.3         0.634  script.py:11(is_prime)
        507/507      15.4         0.507      15.4         0.507  script.py:12(is_prime)
(以下省略)

デフォルトでは、計測結果が表で表示されます。表の読み方は次の章で説明します。

実行中のプロセスにアタッチして計測する(attach)

attachコマンドは、実行中のプロセスにアタッチして計測します。 コードの変更やプログラムの再起動は不要です。 実際に動作を試すためには、別のターミナルでサンプルプログラムを第2引数付きで起動し、プログラムが実行している間にアタッチします。 アタッチするには、起動時に表示されるプロセスID(PID)をattachコマンドに渡します。

script.pyを第2引数付き起動し120秒動作させる
$ python script.py 1000000 120
PID: 98453
1,000,000 未満の素数は 78,498 個です
(繰り返し実行される)
別のターミナルからscript.pyのプロセスにアタッチする
$ python -m profiling.sampling attach -d 10 98453
Captured 10000 samples in 10.00 seconds
Sample rate: 1000.00 samples/sec
Error rate: 0.45
Profile Stats:
       nsamples   sample%   tottime (s)    cumul%   cumtime (s)  filename:lineno(function)
      6001/6001      60.3         6.001      60.3         6.001  script.py:26(process)
      1890/1890      19.0         1.890      19.0         1.890  script.py:11(is_prime)
(以下省略)

-dは計測する時間を秒数で指定するオプションで、-d 10は10秒間だけ計測するという指定です。

なお、別のプロセスのメモリを読み取るため、attachコマンドにはroot権限などが必要です。 各OSごとの要件は以下の公式ドキュメントを参照してください。

そのほかの基本操作

ここではその他のコマンドやオプションを簡単に紹介します。

dumpコマンドは、実行中のプロセスの現在のスタックを1回だけ表示します。 計測せずに、いま何をしているかを確認したいときに便利です。

実行中のプロセスのスタックを表示する
$ python -m profiling.sampling dump 98453
Stack dump for PID 98453, thread 879110 (main thread, idle; most recent call last):
  File "script.py", line 44, in <module>
    main()
  File "script.py", line 37, in main
    count = process(limit)
  File "script.py", line 26, in process
    time.sleep(2)

runコマンドやattachコマンドでは、-rオプションを指定してサンプリングレートを指定できます。サンプリングレートは、1秒間にコールスタックのサンプルを採取する回数で、デフォルトは1 kHz(1秒間に1,000回)です。-r 5khzのように指定します。

また、-dオプションを指定すると、計測する時間を秒数で指定できます。デフォルトは、対象のプログラムが終了するまでです。 次の章では、計測結果の読み方と、フレームグラフなどの出力形式を見ていきます。

さまざまな出力形式

Tachyonでは、計測結果をテキストだけでなく、さまざまな形式で確認できます。 目的に応じて使い分けましょう。 この章では、それぞれの使い方を簡単に紹介します。 題材は、前の章と同じscript.pyです。

テキスト出力(pstats)

出力形式を指定しない場合(デフォルト)、計測結果はpstats形式のテキストで表示されます。 script.pyを計測した結果の抜粋を次に示します(数値は実行のたびに変わります)。

script.pyの計測結果(抜粋)
$ python -m profiling.sampling run script.py
PID: 68437
1,000,000 未満の素数は 78,498 個です
Captured 3313 samples in 3.31 seconds
Sample rate: 999.97 samples/sec
Error rate: 0.75
Profile Stats:
       nsamples   sample%   tottime (s)    cumul%   cumtime (s)  filename:lineno(function)
      2005/2005      61.0         2.005      61.0         2.005  script.py:26(process)
        617/617      18.8         0.617      18.8         0.617  script.py:11(is_prime)
        513/513      15.6         0.513      15.6         0.513  script.py:12(is_prime)
        120/120       3.6         0.120       3.6         0.120  script.py:14(is_prime)
        16/1282       0.5         0.016      39.0         1.282  script.py:20(count_primes.<locals>.<genexpr>)
(中略)
         0/3288       0.0         0.000     100.0         3.288  script.py:37(main)
(以下省略)
ターミナルでscript.pyをTachyonで計測した結果(pstats形式)

pstats形式の出力

処理の多かった関数のサンプル数や推定した処理時間などが表で表示されます。 表の各列の意味は次のとおりです。

  • nsamples: サンプルの数。direct/cumulativeの形で表示される。direct(直接)は、その関数自身が実行中だったサンプルの数、cumulative(累積)は、呼び出し先の関数も含めて、コールスタック上にあったサンプルの数

  • sample%: 全サンプルのうち、directのサンプルが占める割合

  • tottime: directのサンプルから推定した、その関数自身の実行時間(秒)

  • cumul%、cumtime: cumulativeのサンプルが占める割合と、そこから推定した実行時間(秒)

filename:lineno(function)の行番号は、サンプルを採取したときに実行していた行です。 そのため、同じ関数でも行ごとに別の行として表示されます(is_primeの11、12、14行目など)。

結果を読むときは、まずdirectの値が大きい行を探します。 この例では、time.sleep(2)を呼ぶprocessの26行目が約61%で、待ち時間が最も大きいと分かります。 次に、素数を判定するis_primeの行が続き、行ごとの割合を合計すると約4割です。 count_primesの内部の式(<genexpr>)は、directが16に対してcumulativeが1,282です。 この式自身はほとんど実行されていませんが、呼び出し先のis_primeを含めると約39%です。

出力の末尾には、列の凡例(Legend)と、注目すべき関数の要約(Summary of Interesting Functions)も表示されます。

フレームグラフ

--flamegraphを指定すると、インタラクティブなフレームグラフをHTMLファイルとして出力します。 デフォルトではflamegraph_<PID>.htmlというファイル名に出力されます。

フレームグラフを出力する
$ python -m profiling.sampling run --flamegraph script.py

フレームグラフは、コールスタックを入れ子の四角形で表したもので、横幅がかかった時間(サンプル数)に比例します。 幅の広い四角形が、時間を使っている関数です。 四角形をクリックするとその部分を拡大でき、関数名での検索や、スレッドごとの表示の絞り込みもできます。 --browserを付けると、出力したHTMLが自動でブラウザーで開きます。

出力先のファイル名を変更したいときは、-oオプションで指定します。

出力先のファイル名を指定する
$ python -m profiling.sampling run --flamegraph -o flamegraph.html script.py
ブラウザーで開いたTachyonのフレームグラフ

フレームグラフをブラウザーで開いた画面

ヒートマップ

--heatmapを指定すると、ソースコードの行ごとのサンプル数を色で表したHTMLを出力します。

ヒートマップを出力する
$ python -m profiling.sampling run --heatmap script.py

デフォルトではheatmap_<PID>というディレクトリに出力され、その中のindex.htmlをブラウザーで開きます。 サンプルが少ない行は寒色、多い行は暖色で表示されます。 フレームグラフは「どの関数に時間がかかっているか」を、ヒートマップは「その関数のどの行に時間がかかっているか」を調べるのに向いています。

ブラウザーで開いたTachyonのヒートマップ

ヒートマップをブラウザーで開いた画面

Gecko形式(Firefox Profiler)

--geckoを指定すると、Firefox Profiler向けのJSONファイルを出力します。 デフォルトではgecko_<PID>.jsonというファイル名に出力されます。

Gecko形式で出力する
$ python -m profiling.sampling run --gecko script.py

Firefox Profilerは、ブラウザーで動くWebツールです。 サイトを開いて出力したJSONファイルを読み込むと、タイムラインやコールツリーで計測結果を確認できます。 Gecko形式では、GILやCPUの状態が自動で記録されるため、プロファイリングモード(次の章で解説します)を指定する--modeオプションは使用できません(指定するとエラーになります)。

Firefox ProfilerでGecko形式のJSONファイルを開いた画面

Firefox ProfilerでGecko形式のファイルを開いた画面

バイナリ形式とreplay

--binaryを指定すると、計測結果をバイナリ形式のファイルに記録できます。 デフォルトではbinary_<PID>.binというファイル名に出力されます。 記録したファイルは、replayコマンドでフレームグラフなどの別の形式に変換できます。

バイナリ形式で記録し、replayコマンドで他の形式に変換する
$ python -m profiling.sampling run --binary script.py
$ python -m profiling.sampling replay binary_86637.bin
$ python -m profiling.sampling replay --flamegraph binary_86637.bin
$ python -m profiling.sampling replay --heatmap binary_86637.bin

上の例の86637の部分は、記録したときのプロセスIDに読み替えてください。 replayコマンドに形式を指定しない場合はpstats、--flamegraphではフレームグラフ、--heatmapではヒートマップに変換されます。 replayで変換するときは再計測が不要なため、1回の計測結果から複数の形式を試せます。

次の章では、--modeオプションで計測する対象を切り替える、プロファイリングモードを見ていきます。

プロファイリングモード

--modeオプションを指定すると、なにを計測するかを切り替えられます。 デフォルトはウォールクロックモード(--mode wall)です。 この章では、script.pyを題材に、ウォールクロックモードとCPUモードの違いと使い分けを説明します。

ウォールクロックモードとCPUモード

ウォールクロックモード(--mode wall)は、実際の経過時間(ウォールクロック時間)を計測します。 time.sleepなどの待ち時間も含めて、すべてのサンプルを記録します。 CPUモード(--mode cpu)は、CPUで実行中のサンプルだけを記録します。 同じscript.pyを両方のモードで計測した結果の抜粋を次に示します。

ウォールクロックモードで計測する(デフォルト)
$ python -m profiling.sampling run --mode wall script.py
PID: 3470
1,000,000 未満の素数は 78,498 個です
Captured 3328 samples in 3.33 seconds
Sample rate: 999.96 samples/sec
Error rate: 0.93
Profile Stats:
       nsamples   sample%   tottime (s)    cumul%   cumtime (s)  filename:lineno(function)
      2005/2005      60.8         2.005      60.8         2.005  script.py:26(process)
        590/590      17.9         0.590      17.9         0.590  script.py:11(is_prime)
        514/514      15.6         0.514      15.6         0.514  script.py:12(is_prime)
        141/141       4.3         0.141       4.3         0.141  script.py:14(is_prime)
(以下省略)
CPUモードで計測する
$ python -m profiling.sampling run --mode cpu script.py
PID: 3922
1,000,000 未満の素数は 78,498 個です
Captured 3311 samples in 3.31 seconds
Sample rate: 999.96 samples/sec
Error rate: 1.00
Profile Stats:
       nsamples   sample%  tottime (ms)    cumul%   cumtime (s)  filename:lineno(function)
        609/609      47.8       609.000      47.8         0.609  script.py:11(is_prime)
        508/508      39.9       508.000      39.9         0.508  script.py:12(is_prime)
        122/122       9.6       122.000       9.6         0.122  script.py:14(is_prime)
        14/1273       1.1        14.000     100.0         1.273  script.py:20(count_primes.<locals>.<genexpr>)
(以下省略)

ウォールクロックモードでは、time.sleep(2)を呼ぶprocess関数の26行目(表の一番上)が約61%で最大です。 CPUモードでは、待ち時間はCPUを使用していないため記録されず、この行は表から消え、素数を判定するis_prime関数の行が上位を占めます。 なお、CPUモードのsample%は、CPUで実行中のサンプルだけを100%として計算されます。 そのため、ウォールクロックモードの割合とは直接比べられません。 tottimeの単位は、値の大きさに応じて自動で選ばれます。

2つの結果を比べると、次のように読み分けられます。

  • ウォールクロックモードとCPUモードの両方で大きい関数: 計算が原因(この例ではis_prime関数)

  • ウォールクロックモードだけで大きい関数: 待ち時間が原因(この例ではtime.sleepを呼ぶprocess関数)

そのほかのモード

GILモード(--mode gil)は、GILを保持している間のサンプルだけを記録します。 GILは、Pythonのコードを実行している間だけ保持されます。 そのため、I/O待ちやC拡張の処理中は計測の対象になりません。 Pythonのコード自体の処理時間を調べたいときや、GILを占有している処理を探したいときに使います。

例外モード(--mode exception)は、例外を処理している間のサンプルだけを記録します。 例外処理にかかる時間を調べたいときに使います。

GILモードと例外モードで計測する
$ python -m profiling.sampling run --mode gil script.py
$ python -m profiling.sampling run --mode exception script.py

次の章では、計測結果をリアルタイムに表示するライブモードを見ていきます。

ライブモード

--liveオプションを指定すると、topコマンドのように、計測結果をリアルタイムに表示できます。 どの関数が多くのサンプルを集めているかを、画面を見ながら確認できます。

ライブモードを起動するには、runコマンドに--liveを付けてスクリプトを実行します。 script.pyは引数なしだと約3秒で終わってしまうため、第2引数に秒数を指定して、画面を操作できる時間を確保します。

script.pyをライブモードで計測する
$ python -m profiling.sampling run --live script.py 1000000 10
ターミナルで実行したTachyonのライブモードの画面

ライブモードの画面

attachコマンドでも、同様に--liveオプションが使えます。 別のターミナルでscript.pyを起動し、表示されたPIDを渡します。 attachコマンドに権限が必要なことは、「基本的な使い方」の章と同じです。

script.pyを起動し、別のターミナルからライブモードでアタッチする
$ python script.py 1000000 120
PID: 98453
1,000,000 未満の素数は 78,498 個です
(繰り返し実行される)

$ python -m profiling.sampling attach --live 98453

ライブモードの画面は、キーで操作できます。 主なキー操作は次の表を参照してください。 ほかのキー操作は、hキーのヘルプで確認できます。

ライブモードの主なキー操作

キー

操作

q

終了

s

並べ替えの順序の切り替え

p

表示の更新の一時停止・再開(計測は続く)

/

関数名での絞り込み(cで解除)

h

ヘルプの表示

計測が終わったあとも、画面は最終結果のまま止まります。 このときも並べ替えや絞り込みができ、qを押すと終了します。

ライブモードはcursesを使うためWindowsでは利用できません。 また、対話的な画面を使うため、--flamegraphなどの出力形式のオプションとは同時に指定できません。

次の章では、非同期処理への対応など、Tachyonのその他の機能を紹介します。

Tachyonのその他の機能

ここまでの章で紹介しなかった、Tachyonのその他の機能を簡単に紹介します。

--async-awareオプションを指定すると、asyncioを使ったプログラムを、タスクの単位でスタックを再構成して表示します。 通常のスタックにはイベントループの内部処理が現れがちですが、このオプションを使うとawaitの流れに沿って確認できます。 なお、--modeオプションとは同時に指定できません。 次のasync_script.pyは、asyncioを使ったスクリプトの例です。

asyncioのタスク単位で計測する
$ python -m profiling.sampling run --async-aware script.py

デフォルトでは、メインスレッドのみを計測します。 -a(--all-threads)を指定するとすべてのスレッドを、--subprocessesを指定すると子プロセスも計測できます。 --subprocessesでは、子プロセスごとに別の出力ファイルが生成されます。

すべてのスレッドと子プロセスを計測する
$ python -m profiling.sampling run -a script.py
$ python -m profiling.sampling run --subprocesses script.py

--diff-flamegraphオプションを使うと、2回の計測を比べる差分フレームグラフを作れます。 基準となる計測は、--binaryで記録したバイナリファイルを指定します。 コードを変更する前に記録しておき、変更後に比較します。

基準を記録し、差分フレームグラフで比較する
$ python -m profiling.sampling run --binary -o baseline.bin script.py
# script.pyを変更する
$ python -m profiling.sampling run --diff-flamegraph baseline.bin script.py

ここでは素数の計算にWheel factorization[2]を使用して処理速度を改善してみました。 差分フレームグラフでは赤は遅くなった関数、青は速くなった関数を表します。 is_prime()関数が速くなっていることが確認できます。

差分フレームグラフ

差分フレームグラフ

--opcodesオプションは、バイトコードの命令レベルで計測します。 実行時に特殊化された命令も確認できます。 出力形式はフレームグラフ、ヒートマップ、Gecko形式、ライブモードが指定できます。

--nativeオプションは、C拡張などのネイティブコードの呼び出しを<native>のフレームとして表示します。

バイトコードとネイティブコードを計測する
$ python -m profiling.sampling run --opcodes --flamegraph script.py
$ python -m profiling.sampling run --native script.py

次の章では、Tachyonを利用するときの注意点を紹介します。

利用時の注意点

Tachyonを利用するときの注意点をまとめます。

  • attachコマンドでアタッチするには、プロファイラーと計測対象のPythonが同じマイナーバージョン(例: どちらもPython 3.15)である必要がある。free-threadedビルドと通常ビルドも揃える必要がある

  • attachコマンドに必要な権限とOSごとの要件は、公式ドキュメントのプラットフォームの要件を参照する

  • 統計的な推定のため、実行ごとに揺れがある。1秒未満で終わるような短い処理ではサンプルが不足するため、繰り返し実行して計測時間を延ばす

まとめ

本記事では、Python 3.15で追加された統計的サンプリングプロファイラー「Tachyon」(profiling.sampling)を紹介しました。 Tachyonは、Python 3.15に同梱されており、計測によるオーバーヘッドが小さいプロファイラーです。

runコマンドで開発中のスクリプトを、attachコマンドで実行中のプロセスを計測できます。 計測結果は、「さまざまな出力形式」の章で紹介したフレームグラフやヒートマップで確認することで、ボトルネックを見つけやすくなります。 ライブモードを使用して実行中の様子をリアルタイムに確認もできます。

また、筆者がパーソナリティを務めるYouTubeライブ、PyCon JP TVでもTachyonについて扱っています。 実際に動いている画面などが見られるので、こちらもぜひ参考にして見てください。

Pythonプログラムのプロファイリングに、Tachyonを活用してみてください。

参考資料