原文はこちら。
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-binary-compatibility-and-api-evolution-with-kiwisolver-7f7a811ef7f9
前回のエントリでは、Matplotlib Pythonパッケージを例に、CPython APIからHPyへの移植作業について見てきました。
Porting Matplotlib from C API to HPy
https://medium.com/graalvm/porting-matplotlib-from-c-api-to-hpy-aa32faa1f0b5
今回は、Kiwisolver Pythonパッケージを例に、バイナリの互換性とそれを維持しながらのAPIの進化について見ていきたいと思います。
HPyは、Python拡張のための標準CPython C APIに代わるものです。以前のブログ記事で、HPyを簡単に紹介しています。
HPy: Better Python C API in Practice
https://medium.com/graalvm/hpy-better-python-c-api-in-practice-79328246e2f8
Kiwisolverは、Cassowary制約解消アルゴリズムの効率的なC++実装のためのPythonバインディングを提供するもので、Matplotlibパッケージの依存関係です。
KiwisolverをHPyに移植するプロセスは、Matplotlibの場合と同じでした。このエントリでは、HPyのバイナリ互換性とAPIの進化の側面だけに焦点を当てることにします。
API vs ABI primer
バイナリの互換性について語るとき、APIとABIの違いを理解することが重要です。この2つの用語にすでに慣れている方は、このセクションを読み飛ばしてもかまいません。
APIとは、アプリケーション・プログラミング・インターフェースのことで、2つのシステム間の通信のためのインターフェースをソースコードレベルで定義したものです。例えば、ソースコードレベルでエクスポートされたC関数の名前とその引数の型から構成されますが、具体的なマシン表現ではありません。
ABI(アプリケーション・バイナリ・インターフェース)は、このインターフェースをバイナリレベル、すなわちマシンコードレベルで定義するものです。例えば、使用する構造体の正確なメモリレイアウトや、エクスポートされる関数の引数のマシン固有の型などで構成されます。
PyObject
というC構造体の先頭に新しいフィールドが追加された場合を考えてみましょう。これはAPIを変更するものではなく、既存のPython拡張は引き続き動作しますが、再コンパイル後にのみ動作します。これは、PyObject
構造体のメモリレイアウト、より具体的にはそのフィールドのオフセットを、コンパイル時にPython拡張バイナリに組み込んだからです。この変更により、ABIの互換性が失われました。
ABIの安定性の重要な意味は、ABIが変わらないか、ABIと互換性のある方法で変更された場合、既存のバイナリを再コンパイルする必要がないことです。例えば、PyObject
構造体の末尾に新しいフィールドを追加しても、既存のPyObject
構造体のフィールドのオフセットは変化しないので、ABIの互換性を壊すことはないでしょう。
Introduction
Matplotlibとは異なり、Kiwisolverは、まだHPyに移植されていないNumpy APIに依存していません。したがって、Kiwisolverから従来のCPython C APIコールを完全に取り除き、HPy APIをサポートするあらゆるPython実装(CPython 3.8+、GraalPython、PyPy)上で実行可能なHPy “ユニバーサル” バイナリディストリビューションを作成できました。。1つのバイナリで、再コンパイルすることなく、複数のPythonで動作します。
では、どのようなものでしょうか?これから “ユニバーサル” HPy ABIを使用して、KiwisolverのHPy移植版をビルドしていきます。
python setup.py --hpy-abi=universal build_ext
このコマンドは、kiwisolver.hpy.so
というバイナリと、kiwisolver.py
というファイルをビルドします。後者はHPy拡張のロードを担当し、次のようにインポートできます。
import kiwisolver
以下はkiwisolver.py
のコードの簡略版です。
def __bootstrap__(): | |
from sys import modules | |
from hpy.universal import load | |
from pkg_resources import resource_filename | |
ext_filepath = resource_filename(__name__, 'kiwisolver.hpy.so') | |
m = load('kiwisolver', ext_filepath, debug=is_debug) | |
modules[__name__] = m |
hpy.universal.load
ビルトインは、PyPIで利用でき、HPyとCPythonの間のブリッジを提供するHPyパッケージに由来します。
hpy · PyPI
https://pypi.org/project/hpy/
GraalPythonやPyPyなどの他のサポートされているPython実装には、HPy APIと内部との橋渡しをするHPyインターフェースの独自の実装があります。言い換えれば、HPyの実装はそのコアの一部であり、hpy.universal.load
ビルトインは外部拡張なしで利用できます。
How it works
kiwisolver.hpy.so
とkiwisolver.cpython-38d-x86_64-linux-gnu.so
の中身を比較してみます。後者は標準的なPython C APIで用意されているものです。共有ライブラリのすべてのシンボルをリストアップできるnm
ユーティリティと、そのオプションである-u
を使って、未定義のシンボル、つまり共有ライブラリに必要なシンボルのみをリストアップします。
$ nm -u ./kiwisolver.cpython-38d-x86_64-linux-gnu.so | grep Py
U PyBaseObject_Type
U PyBool_Type
U PyBytes_FromStringAndSize
U _Py_Dealloc
...
おわかりのように、共有ライブラリはPyBytes_FromStringAndSize
のようなCPythonが提供する多くのシンボルを必要とします。HPyバイナリではどうでしょうか。
$ nm -u ./kiwisolver.hpy.so | grep Py
$ # no output
HPyユニバーサルバイナリは、CPython APIに依存していません。未定義のシンボルをすべて調べれば、HPyに固有のものは何もないことがわかるでしょう。では、HPyはどのように動作するのでしょうか?バイナリにはHPyInit_kiwisolver
という関数があり、最初の引数としてHPyContext*
(HPy関数の実装へのポインタを持つ構造体へのポインタ)を受け取ります。PythonはHPyInit_kiwisolver
関数をその名前で検索して、HPyContext
構造体を作成し、それを使ってHPyInit_kiwisolver
を呼び出します。
以下は、コードでの表現を簡略化したものです。
typedef struct { | |
// ... | |
HPy (*ctx_Absolute)(HPyContext *ctx, HPy h1); | |
int (*ctx_IsTrue)(HPyContext *ctx, HPy h); | |
HPy (*ctx_Type_FromSpec)(HPyContext *ctx, HPyType_Spec *spec, HPyType_SpecParam *params); | |
// ... | |
} HPyContext; | |
// ... | |
HPyInit_kiwisolver(HPyContext *ctx) { | |
// ... | |
ctx->ctx_Type_FromSpec(ctx, &my_type_spec, ...); | |
} |
【注意】実際には、HPy_MODINIT
マクロがHPyInit_kiwisolver
を生成し、HPyInit_kiwisolver
はinit_kiwisolver_impl(HPyContext *ctx)
を呼び出します。実際の動作をするinit_kiwisolver_impl(HPyContext *ctx)
はユーザーが実装します。
常にctx->foo(ctx, ..arguments…)
と書くのは面倒なので、HPyのヘッダーファイルにはHPy_IsTrue
のような簡単なインラインC関数が用意されています。
HPyAPI_FUNC int HPy_IsTrue(HPyContext *ctx, HPy h) { | |
return ctx->ctx_IsTrue ( ctx, h ); | |
} |
しかしながら、これらの関数は拡張機能にインライン化されているので、実のところバイナリ依存関係ではありません。唯一のバイナリ依存関係はHPyContext
構造体のレイアウトです。
対して、HPy拡張をCPython ABI mode”でビルドすると、HPyにはそうしたヘルパー関数の異なる実装が含まれます。例えば、HPy_IsTrue
はただPyObject_IsTrue
へとフォワードするこのシンプルな関数になります。
static inline int HPy_IsTrue(HPyContext *ctx, HPy h) | |
{ | |
return PyObject_IsTrue(_h2py(h)); | |
} | |
// In CPython ABI mode, HPy handles == PyObject* | |
static inline PyObject* _h2py(HPy h) { | |
return (PyObject*) h._i; | |
} |
コンパイラを実行後、HPy_IsTrue
の痕跡はなくなるはずで、拡張機能はCPython APIを使った場合と同じように直接PyObject_IsTrue
をコールします。とはいえ、ユニバーサルモデルに戻りましょう。
But The Times They Are A-changin’
例えば、HPyContext
構造体の末尾に関数ポインタを追加するなど、新しいHPyのバージョンで新しいAPIが追加されたらどうしますか?問題ありません。拡張機能が以前の短いHPyContext
を期待しているのであれば、何の違いも感じず、以前と同じように動作し続けます。再コンパイルは必要ありません。
もし、HPyが破壊的変更を必要とする場合、例えばAPI関数のセマンティクスやシグネチャの変更の場合、どうすればよいでしょうか?例えば、JavaScriptに近づけるために、HPy_Absolute
を変更して、文字列も受け付けるようにし、文字列を数値に変換するようにしたいとします。しかし、古い拡張機能では、そのような場合に例外を発生させるHPy_Absolute
に依存しているかもしれません。通常、このような変更は、既存のパッケージを更新し、最終的にHPy_Absolute
を実際に変更するという、骨の折れるプロセスを必要とします。CPythonのプレリリース版で積極的にメンテナンスされなかったり、テストされていないパッケージは、運が悪いとしか言いようがありません。
これはまだ実装されていませんが、概念的にはHPyにとって問題ではありません。HPyInit_{name}
関数と並んで、HPyPreInit_{name}
のような他の「pre-init」関数を公開する拡張があることが期待されています。HPyPreInit_{name}
はHPyContext*の引数を取りませんが、Pythonエンジンに、その拡張が期待するHPyContext
のバージョンと、例えばサブインタープリタをサポートしているかどうかなどの情報を伝達できます。
これにより、Python エンジンは概念的に HPyContext
構造体のインスタンスを2個持つことができ、異なるHPyバージョンを期待する2個のHPy拡張を初期化できます。例えば、レガシーなパッケージは古いバージョンを期待し、新しいパッケージは文字列の絶対値を取得したい、つまり新しいバージョンを期待するといった場合です。PythonのランタイムがCコードで行うことの概要です。
HPyContext ctx_ver1 = { | |
// ... | |
.ctx_Absolute = &legacy_absolute, | |
}; | |
HPyContext ctx_ver2 = { | |
// ... | |
.ctx_Absolute = &new_fancy_absolute, | |
}; | |
// ... | |
MyLegacyPackage_Init(&ctx_ver1); | |
MyNewShinyPackage_Init(&ctx_ver2); |
MyLegacyPackage
が10年前のPythonパッケージで、作者がその後失踪し、そのソースがどこにあるのか誰も知らないと想像してください。しかし、どうしても新しいパッケージが欲しい、そのためには新しいHPyのバージョンが必要だとしましょう。このアプローチでは心配ありません。1つのプロセスで複数のバージョンのHPyを実行し、それぞれのパッケージに異なるHPyのバージョンをロードできます。
Demo time
理論的なことはもういいとして、実際に使ってみましょう。KiwisolverのHPyポートをユニバーサルモードでビルドしたことを思い出してください。
python setup.py --hpy-abi=universal build_ext
CPythonとGraalPythonの両方で、同じKiwisolverネイティブ拡張をロードできます。
$ python -c 'import kiwisolver; print(kiwisolver.Solver())'
<kiwisolver.Solver object at 0x7f5ca447b400>
$ graalpython -c 'import kiwisolver; print(kiwisolver.Solver())'
<kiwisolver.Solver object at 0x2b12488>
どうすれば再現できるでしょうか?必要なものは以下です。
必要なもの | 備考 |
---|---|
CPython 3.7+ のインストール | 通常お使いのシステムのPythonで十分です |
Kiwisolver HPy portのソースコード | hpyproject/kiwi-hpy: Efficient C++ implementation of the Cassowary constraint solving algorithm https://github.com/hpyproject/kiwi-hpy/tree/HPy-1.3.2 |
GraalVM | Download GraalVM https://graalvm.org/downloads/ |
まず、HPy for CPythonをインストールします。これは、NumPyなどの他のPyPIパッケージを通常インストールするのと同じ方法で行う必要があります。
python -m pip install hpy
ソースコードからKiwisolverをビルドするので、テスト実行のためにsetuptools
とpytest
の両パッケージが必要です。これらはすでにインストール済みかと思いますが、念のため。
python -m pip install setuptools pytest
これでKiwisolverのビルドを進めることができます。現在の作業ディレクトリをKiwisolverリポジトリのルートディレクトリに変更して、実行します。
$ cd /the/local/clone/of/kiwisolver
$ python setup.py --hpy-abi=universal build_ext
作業ディレクトリを変更せず、Kiwisolverをテストできます。
$ PYTHONPATH=. python -m pytest py/tests
GraalVM Python、別名GraalPythonで同じパッケージを実行するにはどうすればいいでしょうか?ディスクのどこかでGraalVMディストリビューションを展開します。ここで、$GRAALVM_HOME
が解凍されたディレクトリへのパスであるとします。まず、GraalPythonをインストールします。
$ GRAALVM_HOME/bin/gu install Python
最初にGraalPythonのためのvenv
を作成することが最善の方法です。それはあなたのシステムのPythonの環境を妨害しないようにするためです。次に、GraalPython固有のツールginstall
を使用して、pytest
パッケージ(setuptools
は常にGraalPythonにプリインストールされて付属しています)をインストールします。pip
もGraalPython上で動作しますが、ginstall
はGraalPythonと互換性のあるパッケージのバージョンをインストールします。パッケージをインストールする前に追加のGraalPython固有のパッチを適用する場合があります。
$ GRAALVM_HOME/bin/graalpython -m venv /path/to/graalpy/venv
$ /path/to/graalpy/venv/bin/graalpython -m ginstall install pytest
これで、CPythonによって生成された同じKiwisolverビルドを使用して、同じテストを実行できるようになりました。
$ cd /the/local/clone/of/kiwisolver
$ PYTHONPATH=. /path/to/graalpy/venv/bin/graalpython -m pytest py/tests
他の方法でも試すことができます。GraalPyでパッケージをビルドして、CPythonで結果を実行できます。PyPyを混ぜることもできます。そうすれば、テストする組み合わせが増えますね。
Summary
HPyがバイナリ互換と進化のためにどのように設計されているか、HPyContext
構造体とAPIを実装する関数へのポインタを用いて調べてきました。HPyのABIは、HPyContext構造体のレイアウトとそのポインタの型として現れます。HPyContext
の具体的な実装は、拡張エントリーポイントに引数として渡されるため、非常に柔軟に対応できます。例えば、異なるHPyContext
のレイアウトやセマンティクスを必要とする2つの拡張機能を1つのプロセスでロードし実行できます。
HPyが取ったこのアプローチは、他の成熟したプロジェクトの経験を基にしたものです。
Java Native Interface Specification: 4 – JNI Functions
https://docs.oracle.com/en/java/javase/13/docs/specs/jni/functions.html#interface-function-table
Java Native Interface仕様: 4 – JNI関数
https://docs.oracle.com/javase/jp/13/docs/specs/jni/functions.html#interface-function-table
(*) PyObjectの構造体定義をコピーしてソースに貼り付けたり、ハードコードされたサイズに依存するなど、非常に危険なことをしない限り、です。