原文はこちら。
The original article was written by Štěpán Šindelář (Technical lead of the R (“FastR”) runtime of GraalVM developed by Oracle Labs).
https://medium.com/graalvm/hpy-debugging-features-with-kiwisolver-1467d691663d
一連のHPyのエントリのうち、前回のエントリでは、HPyが複数のCPythonのバージョンや、GraalPyのような複数のPython実装の間でバイナリ互換性を提供する方法について見てきました。
HPy: binary compatibility and API evolution with Kiwisolver
https://medium.com/graalvm/hpy-binary-compatibility-and-api-evolution-with-kiwisolver-7f7a811ef7f9
https://logico-jp.io/2023/03/11/hpy-binary-compatibility-and-api-evolution-with-kiwisolver/
GraalPython
https://graalvm.org/python/
これは、Kiwisolver Pythonパッケージを使用して実証されました。今回は、再びKiwisolverパッケージのHPy移植版を使用し、HPyのもう一つの大きな特徴であるデバッグ機能を実証します。
前回のエントリで述べたように、Kiwisolverのソース・コードのHPy APIへの移行は、Matplotlibの移行する経験と似ています。ただし、オリジナルのKiwisolverは、PyObjectの参照カウントのためのResource Acquisition is Initializationパターン(RAII)を提供するcppy C++ライブラリを使用している点に注目が必要です。
Resource acquisition is initialization (RAII)
https://en.wikipedia.org/wiki/Resource_acquisition_is_initialization
https://ja.wikipedia.org/wiki/RAII
Kiwisolver HPyの最初の移植では、cppyの使用を削除し、HPyハンドルのライフタイム管理を手動で実装しました。リソースの手動管理はエラーが発生しやすい作業であり、これによりHPyのデバッグコンテキスト機能を広範囲に使用できました。
【注意】HPy APIに対して同様のC++ラッパーを提供できますが、現時点では将来にとっておきます。
KiwisolverのすべてのユニットテストがHPy移植版で成功したら、各テストをLeakDetectorコンテキストマネージャでラップして、HPyのデバッグ機能を有効にしました。これは次のような擬似コードで説明できます。
| from hpy.debug.pytest import LeakDetector | |
| with LeakDetector() as ld: | |
| run_the_test() |
入力時に、LeakDetectorは現在開いているHPyハンドルやその他のHPy関連リソースをすべて記憶し、終了時に、新たに開いているHPyハンドルやその他のリソースがないこと、つまり、run_the_test()が適切に後始末をしたことをチェックします。
実際には、conftest.pyにpytest fixtureを作成してこれを行います。
pytest fixtures: explicit, modular, scalable
https://docs.pytest.org/en/6.2.x/fixture.html
| @pytest.fixture(autouse=True) | |
| def auto_hpy_debug(request): | |
| with LeakDetector() as ld: | |
| yield ld |
HPyはhpy_debugというpytest fixtureを公開しており、このfixtureで今回行ったようにLeakDetectorを有効にします。将来的にはさらにHPyのデバッグ機能を有効化することになるかもしれません。
LeakDetector クラスの機能は、HPy 拡張モジュールが HPy デバッグコンテキストでロードされた場合にのみ有効になります。これは、環境変数 HPY_DEBUG=1 をエクスポートすることで実現できます。
さらに、デバッグコンテキスト機能は、拡張モジュールが HPy “ユニバーサル” モードでビルドされている場合にのみ機能します。HPyの CPython ABI モードでは動作しません。HPy API の呼び出しはすべて、コンパイル時に対応する CPython API を呼び出すように書き換えられ、実行時に変更できないからです。環境変数 HPY_LOG=1 をエクスポートすることで、拡張機能をどのモードでロードしているかを確認できます。
HPyへの移植の第一段階が終わり、HPyのデバッグ機能を有効化する前に、Kiwisolverのテストを実行した結果、以下のような出力が得られました。
| $ HPY_LOG=1 PYTHONPATH=`pwd` pytest -s py/tests/ | |
| ============== test session starts ============== | |
| platform linux -- Python 3.8.13+, pytest-7.1.2, pluggy-1.0.0 | |
| rootdir: /some/path/to/kiwi | |
| collecting ... Loading 'kiwisolver' in HPy universal mode | |
| collected 49 items | |
| py/tests/test_constraint.py ........ | |
| py/tests/test_expression.py ............ | |
| py/tests/test_solver.py ........ | |
| py/tests/test_strength.py .. | |
| py/tests/test_term.py ............ | |
| py/tests/test_variable.py ....... | |
| ============== 49 passed in 0.39s =============== |
では、デバッグ機能を有効化しましょう。
| $ HPY_DEBUG=1 HPY_LOG=1 PYTHONPATH=`pwd` pytest -s py/tests/ | |
| ============== test session starts ============== | |
| platform linux -- Python 3.8.13+, pytest-7.1.2, pluggy-1.0.0 | |
| rootdir: /path/to//kiwi | |
| collecting ... Loading 'kiwisolver' in HPy universal mode with a debug context | |
| collected 49 items | |
| py/tests/test_constraint.py .E.E.E.E.E.E.E.E | |
| py/tests/test_expression.py .E.E.E.E.E.E.E.E.E.E.E.E | |
| py/tests/test_solver.py ..E.E.E.E.E.E.E | |
| py/tests/test_strength.py .. | |
| py/tests/test_term.py .E.E.E.E.E.E.E.E.E.E.E.E | |
| py/tests/test_variable.py .Fatal Python error: Segmentation fault |
おっと、まだやることがあるようです。リリース後に発見するよりも、今すぐ発見した方が良いですからね。このSegmentation Faultは、内部状態の不整合に起因しています。C言語で物事がうまくいかないときに常に起こりうるものです(出力を一部省略しています)。
| $ HPY_DEBUG=1 HPY_LOG=1 PYTHONPATH=`pwd` pytest -s -k test_constraint_creation \ | |
| py/tests/test_constraint.py | |
| ============== test session starts ============== | |
| platform linux -- Python 3.8.13+, pytest-7.1.2, pluggy-1.0.0 | |
| rootdir: /path/to//kiwi | |
| collecting ... Loading 'kiwisolver' in HPy universal mode with a debug context | |
| collected 8 items | |
| py/tests/test_constraint.py .E.E.E.E.E.E.E.E | |
| ============== ERRORS ============================ | |
| _____ ERROR at teardown of test_constraint_creation[==] _____ | |
| ... | |
| > raise HPyLeakError(leaks) | |
| E hpy.debug.leakdetector.HPyLeakError: 46 unclosed handles: | |
| E <DebugHandle 0x55646e8155b0 for 'foo'> | |
| E To get the stack trace of where it was allocated use: | |
| E hpy.debug.set_handle_stack_trace_limit |
hpy.debug.set_handle_stack_trace_limitを試せとのことなので、conftest.pyで使ってみます。すると、例えばこのようなスタックトレースが表示されます。
| > raise HPyLeakError(leaks) | |
| E hpy.debug.leakdetector.HPyLeakError: 10 unclosed handles: | |
| E <DebugHandle 0x5606bb9f81b0 for 1 * foo> | |
| E Allocation stacktrace: | |
| E python3.8/site-packages/hpy/universal.cpython-38d-x86_64-linux-gnu.so(debug_ctx_New+0x60) [0x7f4af12793c1] | |
| E kiwisolver.hpy.so(+0x67fc5) [0x7f4af0eaffc5] | |
| E kiwisolver.hpy.so(_ZN10kiwisolver15new_from_globalEP13_HPyContext_s9HPyGlobalPv+0x55) [0x7f4af0eb236f] | |
| E kiwisolver.hpy.so(_ZN10kiwisolver9BinaryMulclIPNS_8VariableEdEE6_HPy_sP13_HPyContext_sT_T0_S4_S4_+0x4e) [0x7f4af0ebbc4e] | |
| E kiwisolver.hpy.so(_ZN10kiwisolver9BinaryAddclIPNS_8VariableEdEE6_HPy_sP13_HPyContext_sT_T0_S4_S4_+0x5e) [0x7ff3e41cdba4] |
このHPyの機能は、glibcのbacktraceとbacktrace_symbols関数を使用しています。行番号やより有用な情報を表示させるための様々な方法があります。しかし、スタックトレースを見ると、上記の省略したスニペットの最後にある「BinaryAdd」と「Variable」を含むC++のmangled nameも認識できます。これは、このC++のテンプレート・メソッドに違いありません。
| template<> inline | |
| HPy BinaryAdd::operator()( HPyContext *ctx, Variable* first, double second, HPy h_first, HPy h_second ) | |
| { | |
| HPy temp = BinaryMul()( ctx, first, 1.0, h_first, HPy_NULL ); | |
| if( HPy_IsNull(temp) ) | |
| return HPy_NULL; | |
| return operator()( ctx, Term_AsStruct( ctx, temp ), second, temp, h_second ); | |
| } |
前フレームのスタックトレースの関数のmangled nameに “BinaryMul “が含まれています。つまり、BinaryMul()(…)の呼び出しで返されたハンドルtempをリークしているはずです。実際、tempハンドルは結果として返されず、HPy_Close(ctx, temp)コールも存在しません。修正したメソッドは次のようになります。
| template<> inline | |
| HPy BinaryAdd::operator()( HPyContext *ctx, Variable* first, double second, HPy h_first, HPy h_second ) | |
| { | |
| HPy temp = BinaryMul()( ctx, first, 1.0, h_first, HPy_NULL ); | |
| if( HPy_IsNull(temp) ) | |
| return HPy_NULL; | |
| HPy result = operator()( ctx, Term_AsStruct( ctx, temp ), second, temp, h_second ); | |
| HPy_Close(ctx, temp); | |
| return result; | |
| } |
さらに何度か繰り返して、ようやくテストをパスします。
Debug mode for CPython API?
CPythonのAPIを使用する際に、PyObject*に対して同様のことを実装できないか、という疑問があるかもしれません。そこで、HPyを参照カウントから切り離し、HPyハンドルをPythonオブジェクト自体から切り離すというHPyの設計が活きてきます。
HPyハンドルを返すHPy APIコールはすべて、呼び出し元が閉じる必要のある新しいハンドルを返します。そのハンドルは一意です。同じPythonオブジェクトを参照するハンドルが複数ある場合もありますが、それらはすべて切断されています。そのうちの1つが漏れたとき、そのハンドルが作成された場所を正確に突き止めることができます。一方、参照カウントの場合、カウントのバランスが悪いと、カウントを増やした場所と減らし忘れた場所をすべて調べなければなりません。どの場所が悪いかは、人間以外には判断できません。なぜなら、、decrefが自動化ツールが使えるようなincrefに続くという明確な定義がないからです。
Conclusion
このエントリでは、HPy デバッグコンテキスト機能について説明しました。この機能は、HPy API 呼び出しを拡張し、API 使用時の潜在的なバグを発見する追加チェックを行います。HPyデバッグ・コンテキスト機能の有効化のために、エクステンションを再コンパイルする必要はありません。
より具体的には、HPyのLeakDetectorというPythonクラスを使って、HPyハンドルのリークを発見できることを紹介しました。HPyのハンドルリークは、CPython APIの参照カウントの問題に似ていますが、HPyでは、Pythonオブジェクトへのネイティブな参照はすべて明確なHPyハンドルであるため、リークの根本的な原因を絞り込むのに大いに役立ちます。
GraalPyを始めるなら、以下のリンクからどうぞ。
GraalPython
https://graalvm.org/python/