Porting Matplotlib from C API to HPy

原文はこちら。
The original article was written by Mohaned Qunaibit (Senior Researcher at Oracle).
https://medium.com/graalvm/porting-matplotlib-from-c-api-to-hpy-aa32faa1f0b5

GraalVMは、Python、JavaScript、Rubyを含む複数の言語をサポートする真のpolyglot runtimeです。GraalVMチームの仕事は、言語の実装とサポートにとどまらないことが多く、ランタイム上で実行される言語のエコシステムへの積極的な貢献もしています。その一環として、Pythonのエコシステムで、既存のCPython C APIの代替となるHPyプロジェクトにコントリビュートしています。

Python/C API Reference Manual
https://docs.python.org/3/c-api/index.html

CPython C APIは、Python言語周辺の広範なエコシステムに大きく貢献しました。CPythonの言語実装と密接に結びついたC APIとの直接的なやりとりは、Pythonの多くの取り組みの妨げになってきました。C APIを使ったモジュール拡張は、CPythonの実装と密接に結合してしまい、CPythonの進化を難しくしています。

Pythonのバージョンや、異なるPythonの実装間でもバイナリの互換性を提供するものをCPython C APIの代替として提案しており、それがHPyです。これにより、CPythonの内部に直接依存せずに、PythonのリリースからC拡張のバイナリが切り離されます。したがって、CPythonの内部が変更されても、あなたのバイナリが壊れることはないはずです。

Port to HPy assurance

HPyでは、C APIとHPyの両方を並行して動作させるインクリメンタル・ポーティング・プロセスが可能です。

HPy overview
https://docs.hpyproject.org/en/latest/overview.html

次の例は、C API と HPy の両方を持っている様子を示しています。

#include <Python.h>
static PyObject *classA_foo(PyObject *self, PyObject *arg)
{
Py_INCREF(arg);
return arg;
}
HPyDef_METH(classA_bar, "bar", classA_bar_impl, HPyFunc_NOARGS)
static HPy classA_bar_impl(HPyContext *ctx, HPy self)
{
return HPyLong_FromLong(ctx, 1234);
}
static PyMethodDef classA_methods[] = {
{"foo", classA_foo, METH_O},
{NULL, NULL} /* Sentinel */
};
static PyType_Slot classA_type_slots[] = {
{Py_tp_methods, classA_methods},
{0, 0},
};
static HPyDef *classA_type_defines[] = {
&classA_bar,
NULL
};
static HPyType_Spec classA_type_spec = {
.name = "ClassA",
.legacy = true,
.legacy_slots = classA_type_slots,
.defines = classA_type_defines
};
view raw HPyCAPI.c hosted with ❤ by GitHub

Example of a type implemented using both C API and HPy

この例でわかるように、fooはCのAPIで、barはHPyで実装されており、どちらのメソッドもClassA型に対してアクセス可能です。この機能は、一度に全面的な書き換えを必要とせず、ひとつひとつHPyへ移植作業ができるようになります。さらに、HPyにはデバッグモードという、メモリリークやuse-after-free(領域開放後の利用)などを検出できるキラー機能があります。詳しくは以下をご覧ください。

Debug Mode
https://docs.hpyproject.org/en/latest/debug-mode.html#debug-mode

Why Matplotlib?

Matplotlibは最も人気のあるPythonパッケージの1つで、特にデータサイエンス用途に適しています。

Matplotlib
https://matplotlib.org

Matplotlibは、使いやすいAPIでグラフやアニメーションを作成する、包括的なPythonツールです。Matplotlibの重い計算のほとんどはC/C++で実装されており、部分的にはC APIを呼び出しています。さらに、計算の一部を高速化するために、NumPyのネイティブAPIを使用しています。このように、MatplotlibのHPyへの移植は、性能、互換性、複雑性の面で、いくつかのユニークな課題があります。

Porting Matplotlib C API to HPy

HPyへのMatplotlibの移植にあたり、C APIを使用して実装された10のモジュールがあります。

  • backend_agg
  • _c_internal_utils
  • _contour
  • _image
  • _path
  • _tkagg
  • _ttconv
  • ft2font
  • _qhull
  • _tri

これらの Matplotlib モジュール上に、2つの C 拡張モジュールの依存性があります。

NumPyはそれ自体が非常に大きいので、HPyによる可能になっている漸進的な移植を利用し、Matplotlib のNumPy APIを使用する部分のC APIを維持しようと考えています。一方Kiwisolverは、完全にHPyに移植しました。

移植作業の大部分はかなり単純です。HPyの命名規則により、既存のPython/C APIに相当するHPyが何であるかは一目瞭然です。以下にいくつかの例を挙げます。

// src/_c_internal_utils.c
+ static HPy 
- static PyObject* 
mpl_SetCurrentProcessExplicitAppUserModelID(
+    HPyContext *ctx, 
+    HPy module,
-    PyObject* module,
+    HPy arg
-    PyObject* arg
) {
#ifdef _WIN32
+    wchar_t* appid = HPyUnicode_AsWideCharString(ctx, arg, NULL); 
-    wchar_t* appid = PyUnicode_AsWideCharString(arg, NULL);
    ...
}
// src/qhull_wrap.c
+ static HPy 
- static PyObject*
delaunay(
+    HPyContext *ctx, 
+    HPy h_self,
-    PyObject *self,
+    HPy* args, 
+    HPy_ssize_t nargs
-    PyObject *args
) {
    HPy xarg;
    HPy yarg;
    ...
+    if (!HPyArg_Parse(ctx, NULL, args, nargs, "OO", &xarg, &yarg)) {
-    if (!PyArg_ParseTuple(args, "OO", &xarg, &yarg)) {
+        HPyErr_SetString(ctx, ctx->h_ValueError, "expecting x and y arrays");
-        PyErr_SetString(PyExc_ValueError, "expecting x and y arrays");
+        return HPy_NULL;
-        return NULL;
    }
...
}
view raw Example1_mpl.md hosted with ❤ by GitHub

Snippet of the Matplotlib HPy port

例に示すように、相違点は単に古いルーチンやターゲット関数の名前に影響を与えるだけにすぎません。変更点をまとめると以下のようです。

  • C API関数の前にHを追加
  • 第1引数としてHPyContext * 型のctxを持つ
  • タプル arg を配列形式の* argsと配列長nargsで置換
  • PyObject *HPyで置換

The HPyContext * 引数はPythonからCへのダウンコールのたびにHPyが自動的に提供します。HPyContext は全ハンドルが属するPythonインタプリタを表します。これにはPythonの標準グローバル変数が含まれます。

HPy API introduction
https://docs.hpyproject.org/en/latest/api.html#hpycontext

したがって、標準例外PyExc_ValueErrorctx->h_ValueErrorに置き換わります。

Arguments Parser

HPyの引数は、タプルではなく配列として、callee (呼び出される側)に渡されます。これにより、HPyArg_ParseHPyArg_ParseKeywordsのパース処理が容易になります。

Argument Parsing
https://docs.hpyproject.org/en/latest/api-reference/argument-parsing.html#argument-parsing

HPyの引数の解析は、C APIのPyArg_ParseTupleの処理と比較すると、一般的なユースケースでより簡素化されたルーチンで独自の内部ツールを使用して行われます。

hpy/argparse.c
https://github.com/hpyproject/hpy/blob/master/hpy/devel/src/runtime/argparse.c#L2
cpython/getargs.c
https://github.com/python/cpython/blob/main/Python/getargs.c

HPyの引数パーサーのシンプルさの結果、手動で実装する必要があるいくつかの重要でない機能を欠くという代償を伴います。以下はその例です。

// src/tri/_tri_wrapper.cpp
static HPy PyTriangulation_calculate_plane_coefficients(
     HPyContext *ctx, HPy h_self, HPy* args, HPy_ssize_t nargs, HPy kwds)
{
     Triangulation::CoordinateArray z;
+    PyTriangulation* self = PyTriangulation_AsStruct(ctx, h_self);
+    HPy h_z;
+    if (!HPyArg_Parse(ctx, NULL, args, nargs, "O:calculate_plane_coefficients", &h_z)) {
+        return HPy_NULL;
+    }
+    if (!z.converter(HPy_AsPyObject(ctx, h_z), &z)) {
         ...
+        return HPy_NULL;
+    }
-    if (!PyArg_ParseTuple(args, "O&:calculate_plane_coefficients",
-                          &z.converter, &z)) {
-        return NULL;
-    }    
    ...
}
view raw Example2_mpl.md hosted with ❤ by GitHub

C API の PyArg_ParseTuple関数と&コマンドを使用すると、関数ポインタとして渡される変換関数の助けによって Python オブジェクトを変換します。

Parsing arguments and building values
https://docs.python.org/3/c-api/arg.html#other-objects

この機能はHPyでは利用できないので、この場合はHPyArg_Parseコマンドと&を削除し、上の例のz.converterと同様に引数をパースした後に手動で変換を行う必要があります。

From Pointers to Handles

HPyオブジェクトはハンドルを表します。それゆえ、HPyのHはPythonオブジェクトを指しています。

HPy API introduction
https://docs.hpyproject.org/en/latest/api.html#handles

PyObject *を直接使わずにポインタをハンドルの中にカプセル化するのには利点があります。

  • Pythonオブジェクトの実装の詳細をホストのPythonインタープリタから抽象化できるため、例えば、moving GCのような異なるGC実装を持つことができる

Garbage collection (computer science)
https://en.wikipedia.org/wiki/Garbage_collection_(computer_science)
Tracing garbage collection
https://en.wikipedia.org/wiki/Tracing_garbage_collection

  • メモリ解析ツールがリークしたハンドルや閉じた後に使用されたハンドルのケースを検出するにあたり、より多くの可視性を提供する。

Debug Mode
https://docs.hpyproject.org/en/latest/debug-mode.html#debug-mode

HPyのアプローチは、Pythonオブジェクトの割り当てと解放について顕著な違いがあります。関数から返されるハンドルは決して借用されず、関数の引数として渡されるハンドルは決して盗まれることはありません。つまり、HPy API関数の呼び出しからハンドルを取得した場合、HPy_Closeを使ってハンドルを閉じる責任があります。一方、引数として渡されたハンドルは、決して閉じてはいけません。代わりに、ハンドルを返す必要がある場合は、ハンドルのコピー、つまりHPy_Dupを作成する必要があります。HPyオブジェクトを扱うときに注意しなければならないことがあります。

HPyは短命のハンドルで、PythonからHPy拡張関数が1回呼び出される間しか生存できません。

Porting guide
https://docs.hpyproject.org/en/latest/porting-guide.html

もう一つの顕著な違いは、複数のハンドルが同じPythonオブジェクトを指している場合でも、それぞれのハンドルは独立して動作することです。さらに、ハンドル同士を直接比較することはできませんが、代わりにHPy_Isを使用するか、ハンドルがNULLを指しているかどうかをチェックするためにHPy_IsNullを使用できます。詳細は以下を参照してください。

HPy API introduction
https://docs.hpyproject.org/en/latest/api.html#handles-vs-pyobject

Handling Type Fields

C API を使って実装されたいくつかの Python型は、高速アクセスのため、および/または Python 以外のオブジェクトを格納するために、構造体の中にフィールドの値を格納します。これらの型はデータ構造へのポインタ、すなわちPyObject_HEADメンバマクロを使用してPyObjectとレイアウトを共有するstruct(構造体)を持つことに依存しています。一方、HPyでは、型の構造体をPythonオブジェクトから分離し、それにアクセスするためのAPIを提供します。このように分離することで、ホストPythonインタプリタのガベージコレクタ(GC)が副作用に遭遇することなくデータを自由に移動できます。しかし、型オブジェクトがPyObject*を参照している場合、HPyの寿命は短く、参照されてはならないので、置き換えはHPyではなく、HPyFieldでなければなりません。以下に、この動作の例を示します。

// src/ft2font_wrapper.cpp
typedef struct
{
-   PyObject_HEAD
    FT2Font *x;
+   HPyField fname;
-   PyObject *fname;
+   HPyField py_file;
-   PyObject *py_file;
    FT_StreamRec stream;
    HPy_ssize_t shape[2];
    HPy_ssize_t strides[2];
    HPy_ssize_t suboffsets[2];
} PyFT2Font;

+ HPyType_HELPERS(PyFT2Font)

static HPy PyFT2Font_new(HPyContext *ctx, HPy type, HPy* args, HPy_ssize_t nargs, HPy kwds)
{
    PyFT2Font *self;
+   HPy h_self = HPy_New(ctx, type, &self);
-   self = (PyFT2Font *)type->tp_alloc(type, 0);
    self->x = NULL;
+   HPyField_Store(ctx, h_self, &self->fname, HPy_NULL);
-   self->fname = NULL;
+   HPyField_Store(ctx, h_self, &self->py_file, HPy_NULL);
-   self->py_file = NULL;
    memset(&self->stream, 0, sizeof(FT_StreamRec));
    return h_self;
}

...
static HPy PyFT2Font_get_num_glyphs(HPyContext *ctx, HPy h_self)
{
+   PyFT2Font* self = PyFT2Font_AsStruct(ctx, h_self);
+   return HPyLong_FromLong(ctx, self->x->get_num_glyphs());
-   return PyLong_FromLong(self->x->get_num_glyphs());
}
...
static HPy PyFT2Font_fname(HPyContext *ctx, HPy h_self, void *closure)
{
+   PyFT2Font* self = PyFT2Font_AsStruct(ctx, h_self);
+   HPy fname = HPyField_Load(ctx, h_self, self->fname);
+   if (!HPy_IsNull(fname)) {
-   if (self->fname) {
+      return fname;
-      return self->fname;
    }

    return HPy_Dup(ctx, ctx->h_None);
}
view raw Example4_mpl.md hosted with ❤ by GitHub

上記の例では、PyFT2Font型には複数のフィールドがあり、いくつかフィールドはPythonオブジェクト、いくつかはそうではないものです。During object instantiation, i.e.HPy_New, of PyFT2Fontのオブジェクトのインスタン化中(つまりHPy_New中)、HPyが構造体を割り当てて、Pythonオブジェクトとその構造体を関連づけます。構造体内で参照されるオブジェクト(namepy_file)には、HPyFieldとして表現され、HPyField_StoreHPyField_Loadを使ってアクセスする必要があります。HPyType_HELPERSマクロの助けを借りて、HPyハンドルを使って型の構造体を取得するヘルパー関数PyFT2Font_AsStructを生成します。

What about NumPy usage on Matplotlib?

Matplotlibは、C APIを使って実装されたNumPyネイティブ APIやりとりします。NumPyのHPyへの移植は現在進行中なので、これは障害にはなりません。

GitHub – hpyproject/numpy-hpy
https://github.com/hpyproject/numpy-hpy

HPyには、HPyのハンドルをPyObject*に変換する、あるいはその逆のためのAPIが用意されています。以下はその例です。

Example of converting an HPy handle to a PyObject *:

// src/_path_wrapper.cpp
static HPy Py_is_sorted(
        HPyContext *ctx,
        HPy self, 
+       HPy obj) {
-       PyObject *obj) {
    npy_intp size;
    bool result;

    PyArrayObject *array = (PyArrayObject *)PyArray_FromAny(
+       HPy_AsPyObject(ctx, obj), 
-       obj, 
        NULL, 1, 1, 0, NULL);
    ...
}

Example of converting a PyObject * to an HPy handle:

// src/ft2font_wrapper.cpp
static HPy convert_xys_to_array(HPyContext *ctx, std::vector<double> &xys)
{
    npy_intp dims[] = {(npy_intp)xys.size() / 2, 2 };
    if (dims[0] > 0) {
+       return HPy_FromPyObject(ctx, PyArray_SimpleNewFromData(2, dims, NPY_DOUBLE, &xys[0]));
-       return PyArray_SimpleNewFromData(2, dims, NPY_DOUBLE, &xys[0]);
    } else {
+       return HPy_FromPyObject(ctx, PyArray_SimpleNew(2, dims, NPY_DOUBLE));
-       return PyArray_SimpleNew(2, dims, NPY_DOUBLE);
    }
}
view raw Example3_mpl.md hosted with ❤ by GitHub

What about missing APIs needed for Matplotlib in HPy?

移植前のHPyには、Matplotlibの重要なAPIがいくつか欠けていました。MatplotlibのソースのほとんどはC++を使って実装されており、プリプロセッシング(前処理)ルールがより制限されています。一方、HPyヘッダは、C++コンパイラと互換性のないCの機能をいくつか使っていました。そのため、HPyプロジェクトでは互換性を持たせるための調整提案が歓迎されました。

[gh-174] Add C++ support
https://github.com/hpyproject/hpy/pull/283

また、HPyのAPIにはunicode、longおよびタプル演算が欠落していましたが、これも追加されました。

Add support for more HPyUnicode_* functions
https://github.com/hpyproject/hpy/pull/291
Add support for HPyLong_(AsVoidPtr, AsDouble) & HPyErr_WriteUnraisable
https://github.com/hpyproject/hpy/pull/295
Add support for dict in HPy_BuildValue
https://github.com/hpyproject/hpy/pull/293

しかし、HPyが新しいAPIや機能を追加する基準は、上位4000のPyPi Pythonパッケージの中で、それらの機能がどれだけ一般的に使われているかに基づいています。Matplotlibは一般的に使われていない機能を使用しているため、それらの機能、例えばPyArg_ParseTupleでネストしたタプルをパースする機能は、移植の一部としてMatplotlibに実装する必要がありました。

GitHub – hpyproject/top4000-pypi-packages: Dump of Python/C API usage in the top 4000 Python packages
https://github.com/hpyproject/top4000-pypi-packages
Support parsing nested tuples in HPyArg_Parse
https://github.com/hpyproject/hpy/pull/284

Performance

Kiwisolver

Matplotlibの依存関係としてKiwisolverが完全にHPyに移植されたので、完全移植済みのバイナリ上で、リポジトリに含まれるベンチマークを実行できました。

kiwi-hpy/benchmarks
https://github.com/hpyproject/kiwi-hpy/tree/main/benchmarks

以下の構成を使いベンチマークを実行しました。ベンチマーク実行ハードウェアおよびOSはすべて同一環境で、以下の構成です。

Intel Core i9–10885H (2.40GHz) 、Linux kernelバージョン5.10.60.1

Kiwisolver構成ビルドに利用するAPI
rev 1.3.2$ git checkout 1.3.2
$ python setup.py install
CPython C API
rev HPy-1.3.2$ git checkout HPy-1.3.2
$ python setup.py install
CPython HPy CPython ABI
rev HPy-1.3.2$ git checkout HPy-1.3.2
$ python setup.py –hpy-abi=universal install
CPython HPy Universal ABI

以下の対話型グラフの凡例です。

  • X軸:連続実行回数
  • Y軸:所要時間(秒)
kiwi.suggestValue: CPython vs HPy

ご覧の通り、C APIとHPyのいずれも、性能に目立った影響を与えることなく、同じように動作しました。

Matplotlib

以下の構成のCPython 3.8の複数のモードに対して、mpl-bench basic benchmarkを実行しました。

matplotlib/mpl-bench
https://github.com/matplotlib/mpl-bench
mpl-bench/basic.py
https://github.com/matplotlib/mpl-bench/blob/master/benchmarks/basic.py

Matplotlib構成ビルドに利用するAPI
rev v3.4.x$ git checkout v3.4.x
$ python setup.py install
CPython C API
rev HPy-V3.4.x$ git checkout HPy-V3.4.x
$ python setup.py install
CPython HPy CPython ABI
rev HPy-V3.4.x$ git checkout HPy-V3.4.x
$ python setup.py –hpy-abi=universal install
CPython HPy Universal ABI

以下の対話型グラフの凡例です。

  • X軸:連続実行回数
  • Y軸:所要時間(秒)
basic.time_plot: CPython 3.8 C API vs HPy
basic.time_subplots[1]: CPython 3.8 C API vs HPy
basic.time_subplots[2]: CPython 3.8 C API vs HPy
basic.time_subplots[10]: CPython 3.8 C API vs HPy
basic.time_savefig: CPython 3.8 C API vs HPy
basic.time_projection[‘rectilinear’]: CPython 3.8 C API vs HPy
basic.time_projection[‘polar’]: CPython 3.8 C API vs HPy
basic.time_projection[‘mollweide’]: CPython 3.8 C API vs HPy
basic.time_projection[‘lambert’]: CPython 3.8 C API vs HPy
basic.time_projection[‘hammer’]: CPython 3.8 C API vs HPy

basic.time_projection[‘aitoff’]: CPython 3.8 C API vs HPy

ベンチマーク結果でご覧の通り、MatplotlibのHPyへの移植は、C APIと比較して、CPython ABIとUniversal ABIのバイナリでは、ほぼ同じかやや上回る性能を示しています。

Performance outcome on other Python implementations

HPyのUniversal ABIは、生成されたバイナリをPyPyやGraalVM Pythonなどの様々なPython実装でロードして実行できるようにするためのポータブルなアプローチを促進します。

PyPy
https://pypy.org
Python
https://graalvm.org/python/

さらに、HPyは代替のPython実装で大幅に性能を向上させることができます。

PyPyとGraalVM Pythonはジャストインタイム(JIT)コンパイラを有しており、これにより最も頻繁に実行されるコード、つまりホットパスをその場でマシンコードにコンパイルできます。

Just-in-time compilation
https://en.wikipedia.org/wiki/Just-in-time_compilation

しかし、一般にJITコンパイラの場合、JITコンパイラがホットパスを識別し、ピーク性能に達するように機械語を生成するためのウォームアップ実行を必要とします。GraalVM PythonはHPyの最新リビジョンを採用しているため、以下の構成でKiwisolverベンチマークを実行することが可能でした。

GraalVM Python HPy Universal ABI (Sulong)

LLVMバックエンドを使用してKiwisolverのHPy (rev HPy-1.3.2)への移植版を実行しました。モジュールバイナリをビットコード形式に再コンパイルする必要がありました。

GraalVM Python HPy Universal ABI (Native) 

ネイティブインターフェースバックエンドを使用してKiwisolverのHPyへの移植版(rev HPy-1.3.2)を実行しました。モジュールバイナリは、CPython HPy Universal ABIを実行したものと同じです。

kiwi.suggestValue: CPython, HPy and GraalVM Python

このように、結果は非常に有望です。HPyへの完全な移植は、CPython上で以前と同じパフォーマンスを持つだけでなく、GraalVM PythonがCPythonと同じパフォーマンスを達成するようになったことを示しています。

一方、Matplotlibは、その計算の多くをNumPyに依存しているため、HPyへの完全な移植ではありません。NumPyのHPyへの移植は前述の通り作業中の状態であり、GraalVM Pythonはまだ完全なサポートはしていません。

GitHub – hpyproject/numpy-hpy
https://github.com/hpyproject/numpy-hpy

GraalVM Pythonでは以下の設定でbasic.time_plotを実行することができました。

GraalVM Python C APIMatplotlibの非HPyバージョン(rev v3.4.x)を実行
GraalVM Python HPy Universal ABIHPyへの移植バージョンのMatplotlib (rev HPy-V3.4.x)を実行

両GraalVM Pythonモードでは、Matplotlibバイナリのbitcodeビルドを必要とし、GraalVM LLVM(Sulong)バックエンドを使用して実行しました。

LLVM Bitcode File Format
https://llvm.org/docs/BitCodeFormat.html
LLVM
https://en.wikipedia.org/wiki/LLVM
graal/sulong
https://github.com/oracle/graal/tree/master/sulong

以下のグラフは、ベンチマーク結果を示しています。

basic.time_plot: GraalVM Python and CPython 3.8

グラフからわかるように、GraalVM PythonはC APIと比較して、HPy版のMatplotlibで大幅に性能が向上しました。MatplotlibのHPy実装は、ピーク時とウォームアップ時の両方で、C APIよりも約4倍高速でした。しかし、GraalVM Pythonは、NumPyのC API実装のため、このベンチマークではまだCPythonより遅いです。

Final note

この記事で、モジュール内でHPyに移行することの利点をご理解いただけたと思います。HPy は CPython から切り離し、他の実装をターゲットにする可能性を提供するだけでなく、素晴らしいパフォーマンスをもたらす可能性があります。

Florian Angerer、Alina Yurenko、Bernard Horanに感謝します。

コメントを残す

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

WordPress.com ロゴ

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

Twitter 画像

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

Facebook の写真

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

%s と連携中