😃

Python 3.12における新しい低負荷のモニタリング API

2024/11/14に公開

本記事はJetBrains公式ブログより引用した内容となります。

Python 3.12における新しい低負荷のモニタリング API

Python 3.12 では、新しい低影響モニタリング API が PEP 669に基づいて追加され、デバッガ、プロファイラ、および同様のツールがほぼフルスピードでコードを実行できるようになりました。以下で説明するように、これにより、従来のAPIと比較して最大20倍のパフォーマンス向上が得られる場合があります。

以前は、デバッガの便利な機能である「例外発生時に停止」や「失敗したテストでデバッガに突入」の機能をデフォルトでオフにしていました。しかし、PEP 669 により、PyCharm ではこれらの機能を有効にできるようになり、さらに多くのことが可能になりました。

では、実際にどのように動作しているのか見ていきましょう。

一般的なデバッグフレームワーク

どのプログラミング言語を使用しても、プログラムをデバッグするためには、その言語のランタイムがイベントに反応する手段を提供する必要があります。たとえば、関数を呼び出す、次の行に進む、例外を発生させる、などです。これは通常、ランタイムがイベントが発生したときに呼び出すコールバックを定義することで実現されます。

イベントに反応して呼び出されるコールバック関数は、プログラムの現在の実行状態にアクセスし、この状態をユーザーに報告します。状態には、現在の関数名、現在のスコープで定義された変数とその値、発生した例外に関する情報などが含まれます。

以下は、プログラムのデバッグを可能にするランタイムと、そのための機能の例です:

  • ptrace (Linux)
  • sys.settraceと(Python)sys.monitoring
  • set_trace_funce(Ruby)
  • CPU割り込み

この記事では、Pythonのデバッグ機能とPEP 669で導入された新しいイベントモニタリングベース APIが、古いトレース関数ベースのアプローチの多くの問題をどのように解決するのかに焦点を当てます。

PEP 669からの変更がなぜそれほど重要なのかをよりよく理解するために、古いアプローチについて簡単に「復習」しましょう。

古いトレース関数API

何十年もの間、sys.settrace(tracefunc)はPythonデバッガを実装するデフォルトの方法でした。

この関数は、tracefuncをパラメータとして受け取り、それをグローバル・トレース関数として設定します。Pythonインタプリタは関数が呼び出されるたびにこのグローバルコールバックを呼び出します。

このtracefuncには、 の 3 つの引数があります。
frameevent,、そしてarg

frameオブジェクトは、現在実行中の関数に関する情報をカプセル化し、関数名、この関数が定義されているPythonモジュール、その内部で使用可能なローカル変数とグローバル変数などへのアクセスを提供します。frame

eventは、常にグローバル・トレース関数の「呼び出し」です。

最後に、argはイベントの種類に応じて何でもかまいませんが、「call」の場合、常にNoneです。

上記を実行するために、すべての関数呼び出しに関するレポートをstdoutに出力する簡単なデバッガーを作成しましょう。

import os.path
import sys
 
def trace_call(frame, event, arg):
   print("calling {} in {} at line {:d}".format(
       frame.f_code.co_name,
       os.path.basename(frame.f_code.co_filename),
       frame.f_lineno))

def f():
   pass

def g():
   f()

def h():
   g()

if __name__ == '__main__':
   sys.settrace(trace_call)
   h()

このシンプルなデバッガーは、グローバル トレース関数を設定し、コードを実行します。また、関数名とそれが定義されているファイルの名前に関する情報を含むコード オブジェクトへのアクセスも示します。

このスクリプトを実行すると、次の出力が生成されます。

calling h in trace_function_calls.py at line 20
calling g in trace_function_calls.py at line 16
calling f in trace_function_calls.py at line 12

ここで、トレース関数ベースの API は、次の規約を定義します。グローバルトレース関数が別の関数を返す場合、この関数が現在のスコープのトレースに使用されます。ローカルトレース関数は、「call」イベントによって制限されず、「line」、「exception」、「return」、および「opcode」イベントを受け取ります。

前の例を変更して、関数の呼び出しだけでなく行もトレースしてみましょう。

import os.path
import sys

def trace_call(frame, event, arg):
   print("calling {} in {} at line {:d}".format(
       frame.f_code.co_name,
       os.path.basename(frame.f_code.co_filename),
       frame.f_lineno))
   return trace_local_events

def trace_local_events(frame, event, arg):
   if event == "line":
       print("line {} in {}".format(
           frame.f_lineno,
           frame.f_code.co_name
       ))

def f():
   pass

def g():
   f()

def h():
   g()

if __name__ == '__main__':
   sys.settrace(trace_call)
   h()

次の出力が生成されます。

calling h in trace_calls_and_lines.py at line 29
line 30 in h
calling g in trace_calls_and_lines.py at line 25
line 26 in g
calling f in trace_calls_and_lines.py at line 21
line 22 in f

これがPythonで個々のイベントトレースを実現する方法です。トレース関数は任意のロジックを実装できるため、プログラムの状態に関する情報をIDE、そしてstdoutなどに報告するために使用できます。情報自体は、トレース関数のframe引数から抽出できます。

トレース関数ベースのデバッグの欠点

sys.settrace API をよく見ると、パフォーマンスに大きな影響を与える可能性がある小さな問題に気付くでしょう。グローバルトレース関数がローカルトレース関数を返すとすぐに、このコールバックはあらゆる種類のローカルイベントを受け取ることを思い出してください。関心のないイベントは無視できるため、これは大したことではないように思えるかもしれません。しかし、これについてもう少し深く考えると、もっと重要なことがあることに気付きます。

発生したすべての例外をトレースするとします。これを実現するには、ローカルトレース関数をすべてのスコープに設定する必要があります。つまり、プロジェクト コード、依存関係、標準ライブラリのいずれであっても、すべての関数で「line」、「return」、「exception」などのすべてのローカルイベントがトレースされます。したがって、すべての行でローカルトレース関数の呼び出しがトリガーされます。「line」イベントが無視され、コールバックがすぐに返されたとしても、関数呼び出しはどのプログラミング言語でも比較的コストのかかる操作であるため、パフォーマンスは低下します。

前述のシナリオでは、例外が発生していない場合でも、デバッガーはパフォーマンスの低下を被ります。このペナルティは、実行されるコードの行数に比例します。

ここで、次の関数について考えてみましょう。

def f():
   res = 0
   for i in range(100_000):
       res += i
   return res  # breakpoint

ブレークポイントが return ステートメントの行に設定されているとします。f() が呼び出されると、正しい行にいるかどうかを確認し、正しい場合は実行を一時停止するローカル トレース関数が設定されるはずです。問題は、ブレークポイントのある行の上のループのために、ローカル トレース関数が 200,000 回呼び出されることにあります。

トレースするイベントの種類を細かく制御できないことが、トレース関数ベースのデバッグの主な欠点です。

この問題は理論的なものではありません。実際にPyCharmに何度も影響を与えています。とくに有名な例として、テスト機能が失敗し、PyCharmがデバッガーにドロップしてしまうケース (PY-44270)があります。PyCharmがテストで失敗したアサーションで停止するようにするため、PyCharmデバッガーは発生した例外をトレースします。アサーション例外が検出されると、PyCharmは例外が発生した行で停止し、エディターインレイにトレースバックを表示します。

これらの問題解決

ほとんどの問題を対処するイベントモニタリングベースのデバッグに進む前に、PEP 523で導入されたCPythonフレーム評価 API を活用してデバッグ パフォーマンスを向上させる興味深い試みについて説明しましょう。

フレーム評価は、CPython C API を通じて利用できる低レベル API です。要するに、CPythonバイトコード用のカスタム評価子を定義することです。つまり、関数が呼び出されると、そのバイトコードを取得して好きなように評価することができます。

PyCharm デバッガーはこのトリックを使用して、元の関数バイトコードに「stop on breakpoint」ロジックを含むバイトコードを挿入します。先ほどの例をもう一度見てみましょう。

def f():
   res = 0
   for i in range(100_000):
       res += i
   # The debugger logic will be injected here!
   return res  # breakpoint

フレーム評価がなければ、ローカルトレース関数は 200,000 回も呼び出されることになります。フレーム評価は、ローカルトレース関数に依存せず、ブレークポイントがヒットしたかどうかを確認する代わりに、注入されたコードでこのイベントを処理します。

残念ながら、コード注入では最適化されたブレークポイントでの停止しか実現できません。ステップ実行を開始したり、例外が発生したりすると、例外をトレースする必要があり、その場合はローカルトレース関数に戻るしかありません。

フレーム評価自体は大きなトピックです。もし詳しく学びたい場合は、こちらで説明されています。

Python 3.12での低負荷モニタリング

PEP 669は、Python 3.12から新しい低負荷モニタリング APIを導入します。従来のようにすべてのイベントを受け取る単一のローカルトレース関数を使用する代わりに、新しいAPIではイベントタイプごとに別々のコールバックを定義できるようになっています。また、ツール識別子が導入され、複数のツールが互いに干渉することなくランタイムイベントを受け取れるようになっています。

さらに、トレースできるイベントの種類が増え、より細かい制御が可能になりました。これにより、C拡張からのリターン、条件分岐の実行、関数からの yieldなど、さまざまなイベントをトレースできるようになります。

特定のイベントタイプをトレースするには、次の手順で実行します。

  1. sys.monitoring.use_tool_idを使用してツール ID を設定します。
  2. sys.monitoring.set_eventsを呼び出して、ツールのイベント配信を有効にします。
  3. sys.monitoring.register_callbackを使用して、特定のツール ID とイベント タイプの組み合わせのコールバックを登録します。

以下は、新しいモニタリングAPIを使用する関数呼び出しトレーサーの更新バージョンです。

import os.path
import sys

DEBUGGER_ID = sys.monitoring.DEBUGGER_ID

def init_tracing():
   sys.monitoring.use_tool_id(DEBUGGER_ID, "pep669_based_debugger")
   sys.monitoring.set_events(DEBUGGER_ID, sys.monitoring.events.PY_START)

   def pep_669_py_start_trace(code, instruction_offset):
       frame = sys._getframe(1)
       print("calling {} in {} at line {:d}".format(
       code.co_name,
           os.path.basename(code.co_filename),
           frame.f_lineno))

   sys.monitoring.register_callback(
       DEBUGGER_ID,
       sys.monitoring.events.PY_START,
       pep_669_py_start_trace)

def f():
   pass

def g():
   f()

def h():
   g()

if __name__ == '__main__':
   init_tracing()
   h()

CALLイベント タイプの代わりにPY_STARTが使用されている点に注意してください。新しい規則によると、CALLイベントは関数呼び出しの前に発生し、PY_STARTはその直後に発生します。これは、sys.settraceの「call」イベントと同じ挙動です。また、コールトレースのコールバックは、グローバルトレース関数とは異なる一連のパラメーターを受け入れます。次の例でわかるように、異なるイベントタイプのコールバックには、イベントタイプに関連する異なるパラメーターがあります。

実行された行トレーサーの更新バージョンは、新しいAPIによってローカル コンテキストのイベントトレースを有効にできることを示しています。

import os.path
import sys

DEBUGGER_ID = sys.monitoring.DEBUGGER_ID

def init_tracing():
   sys.monitoring.use_tool_id(DEBUGGER_ID, "pep669_based_debugger")
   sys.monitoring.set_events(DEBUGGER_ID, sys.monitoring.events.PY_START)

   def pep_669_py_start_trace(code, instruction_offset):
       frame = sys._getframe(1)
       print("calling {} in {} at line {:d}".format(
           code.co_name,
           os.path.basename(code.co_filename),
           frame.f_lineno))
       sys.monitoring.set_local_events(
           DEBUGGER_ID, code, sys.monitoring.events.LINE)

   def pep_699_line_trace(code, line_number):
       print("line {} in {}".format(line_number, code.co_name))

   sys.monitoring.register_callback(
       DEBUGGER_ID,
       sys.monitoring.events.PY_START,
       pep_669_py_start_trace)

   sys.monitoring.register_callback(
       DEBUGGER_ID,
       sys.monitoring.events.LINE,
       pep_699_line_trace
   )

def f():
   pass

def g():
   f()

def h():
   g()

if __name__ == '__main__':
   init_tracing()
   h()

上記のコードから明らかなように、ローカルイベントのトレースを有効にするには、コードオブジェクトが必要です。

新しいAPIは少し冗長に見えるかもしれませんが、制御性が大幅に向上し、長年にわたるパフォーマンスの問題が解決され、デバッグ以外の用途、つまり影響の少ないプロファイリング、カバレッジなどが可能になります。

イベントモニタリングベースAPIのパフォーマンスへの影響

一般的に、デバッガにイベントモニタリングベースのAPIを使用することは良いアイデアです。PEP 669ではその理由が説明されています。

LINEを使用するなど、高度にインストルメント化されたコードの場合、パフォーマンスはsys.settraceよりも優れているはずですが、パフォーマンスはコールバックに費やされる時間によって左右されるため、それほど大きな差はありません。”

新しいモニタリング API の最も強力な機能のひとつは、特定のコード行に対してイベントの発行を無効にする能力です。フレーム評価のセクションで紹介した関数を思い出してください。

def f():
   res = 0
   for i in range(100_000):
       res += i
   return res  # breakpoint

行トレースのコールバックは、次のようにロジックを実装できます。

def line_callback(code, line):
   if line == 5:
       # Handle breakpoint
   else:
       return sys.monitoring.DISABLE

sys.monitoring.DISABLEをコールバックで返すことは、その行でこれ以上イベントを発行しないように指示します。これにより、コールバックの呼び出し回数が減り、パフォーマンスが向上します。上記の例では、2、3、4行目のLINEイベントだけが1回だけ発行されることを意味します。このあぷろーしはフレーム評価に似ていますが、低レベルのC APIを扱ったり、バイトコードにパッチを適用したりする必要はありません。

しかし、特定の場所でイベントを無効にすることが常に可能というわけではありません。例えば、発生した例外をトレースしたい場合、例外が特定の行で発生するかどうかを予測することはできません。なぜなら、関数はあるセットのパラメータで失敗することもあれば、別のセットでは問題なく動作することがあるからです。

sys.settraceのアプローチでは、すべてのイベントタイプを処理する単一のローカルトレース関数が責任を持つため、そのローカルトレース関数を呼び出すことは問題を引き起こします。しかし、sys.monitoringAPIは、必要なイベントだけをトレースできるようにすることで、この問題を解決します。

パフォーマンスの向上がどれほど大きいかを確認するために、簡単なベンチマークを実行してみましょう。このベンチマークコードは、PEP 669 のオリジナルの著者である Mark Shannon によって提供されたスニペットを修正したものです。

import timeit
import sys

def foo():
   for i in range(100_000):
       if i == 50_000:
           pass
   try:
       raise RuntimeError("Boom!")
   except RuntimeError:
       # Ignore.
       pass

print("No debug")

print(timeit.timeit(foo, number=100))

DEBUGGER_ID = sys.monitoring.DEBUGGER_ID

print("PEP 669")

raise_count = 0

def pep_669_exception(code, instruction_offset, exception):
   global raise_count
   raise_count += 1

sys.monitoring.use_tool_id(DEBUGGER_ID, 'debugger')
sys.monitoring.register_callback(DEBUGGER_ID, sys.monitoring.events.RAISE,
                                pep_669_exception)
sys.monitoring.set_events(DEBUGGER_ID, sys.monitoring.events.RAISE)

print(timeit.timeit(foo, number=100))

sys.monitoring.set_local_events(DEBUGGER_ID, foo.__code__, 0)

print("Exception raised", raise_count, "times")

raise_count = 0
# Use sys.settrace, this is about as fast as a sys.settrace debugger can be if written in Python.

print("sys.settrace")

foo_code = foo.__code__

def sys_settrace_exception(frame, event, arg):
   global raise_count
   if frame.f_code is not foo_code:
       return None
   if event == "raise":
       raise_count += 1
   return sys_settrace_exception

sys.settrace(sys_settrace_exception)

print(timeit.timeit(foo, number=100))

sys.settrace(None)

print("Exception raised", raise_count, "times")

結果は次の通りです。

No debug (baseline) 0.28 s
PEP 669 0.28 s
sys.settrace 5.48 s

ご覧の通り、sys.settracesys.monitoringの約20倍も遅くなっています。このパフォーマンスの低下は、for ループ内でsys_settrace_exception()が30万回呼び出されることによるものです。一方、pep_669_exception()コールバックは、実行時例外が発生したときに1回だけ呼び出されます。

即時のメリット

PyCharm 2023.3 は、Python 3.12 インタープリターを使用するプロジェクトに対してデフォルトで有効になる新しい低負荷モニタリング APIの初期サポートを追加しました。前のセクションで紹介した機能はすべて動作するはずですので、「Drop into debugger on failed tests」機能を有効にしておくと良いでしょう。

次のテストを見てみましょう。

def test_arithmetic_progression():
   acc = 0
   for i in range(1000):
       acc += i
   assert acc == 1000 * 999 // 2

そして、PyCharm のデバッガでpytestpytest-repeatを使って1,000回実行します。ベンチマークの結果は以下の表に示されています。

Without drop into deugger With drop into debugger
No debugger 1.29s 1.29s
sys.settrace 4.41s 8.85s
sys.monitoring 4.60s 4.98s

表から明らかなように、新しいAPIのパフォーマンスペナルティは10%未満であるのに対し、古いAPIではコードの実行速度が2倍遅くなっていました。

例外のトレースにも同様のことが言えます。以前は、「On raise」アクティベーションポリシーがパフォーマンスの大きな低下を引き起こすことがあり、特にフレーム評価と比較すると顕著でした。しかし、Python 3.12 とsys.monitoringを使用することで、このアクティベーションポリシーはほぼペナルティなしで動作します。

結論

sys.monitoringAPIが安定したため、今後はPEP 669がもたらしたすべての優れた追加機能を活用し、デバッガのパフォーマンスをさらに調整していきます。細かく分割されたイベントタイプは、実行時に起こるすべての出来事を正確にトレースするための新たな可能性を開きます。

デバッガに関して問題があれば、PyCharmのYouTrack イシュートラッカーに報告してください。

古いsys.settraceアプローチから新しいAPIへのツールの移行は、Pythonコミュニティにとって重要な取り組みです。これにより、ツール同士がうまく連携し、ユーザーを混乱させるエラーを防ぐことができます。

この記事が、PythonのさまざまなトレースAPIを使用するためのしっかりとした基盤を提供し、Pythonアプリケーションをデバッグする際に「裏で何が起こっているのか」を垣間見る機会になれば幸いです。

株式会社NATTOSYSTEM

Discussion