HPy: binary compatibility and API evolution with Kiwisolver

原文はこちら。
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
view raw kiwisolver.py hosted with ❤ by GitHub

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.sokiwisolver.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_kiwisolverinit_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);
view raw hpy-2-structs.c hosted with ❤ by GitHub

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
GraalVMDownload 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の構造体定義をコピーしてソースに貼り付けたり、ハードコードされたサイズに依存するなど、非常に危険なことをしない限り、です。

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中