🐍

Pyodide で独自C/C++拡張パッケージを使うためのパッケージ exodide を開発した話

2022/07/02に公開

1. 前提: Pyodide

PyodidePython公式実装のCPythonにパッチをあて、EmscriptenでJavaScript + WebAssembly にコンパイルし、Pythonのインタープリターをブラウザ内で動かせるようにしたプロジェクトです。

データ分析や機械学習などでしばしば用いられているPythonですが、ローカルに環境をセットアップせずともブラウザ内で簡単に利用できるとなると、非Pythonユーザーへの配布の観点で便利そうであり、私だけでなく(おそらく)多くの方がPyodideに対して期待感を持っているのではないかと思います。

先日もPyodideでできることについてわかりやすい記事が出ていました。
https://zenn.dev/zat/articles/475fb09ca55068

さて、WebAssemblyの(ブラウザの?)制約により一部機能は無効化されていますが、CPythonのインタープリターが動作しているため、pureなPythonのコードは(ほとんど)全て実行することができます。
Pyodideは micropip なるパッケージインストール用のパッケージを用意しており、pureなPythonのパッケージであれば、PyPIやその他のサーバーからダウンロードしてきて、インストールすることができます。(micropip.install("パッケージ名"))

一方、CPythonのC APIを利用して作られたC/C++拡張モジュールを含むパッケージの利用はPyodideが用意しているパッケージに限られます。

Pyodideが用意しているC/C++拡張モジュールもブラウザ内で実行するためにWebAssembly形式であり、PythonのPEPで合意された形式でないためPyPIにもアップロードもできない事が、Pyodideの制約の原因なのだと思います。

Pyodideが公開しているドキュメントによると、新しいC/C++拡張パッケージを利用するためには、元のソースに適用するパッチを用意して、パッチやその他定義を記載した meta.yaml を作成し、Pyodideの公式レポジトリに取り込んでもらう必要があります。

私も試してみようとしましたが、パッケージのビルドがPyodide本体のビルドと一体化したMakefileになっており、先にCPython/Emscriptenにパッチをあてつつビルドする必要がある(たぶん)など、大変すぎて四苦八苦した後に諦めました。
(もしうまくできた人がいて、簡単な方法があるのでしたら教えてもらえると嬉しいです。)

もっと簡単にPyodide向けに独自C/C++拡張パッケージを作れるようにしたいというのが、この記事の首題にもなっているexodideの開発のモチベーションです。

2. exodideの開発

https://github.com/ymd-h/exodide

原理的に言えば、適切なヘッダーファイル、ライブラリを用意して、適切なコンパイラ+コンパイルオプションでコンパイルすれば、欲しい形式のバイナリファイルが作れるはずと調査・開発を進めました。

2.1 動的リンクの仕組み

幸い、Emscripten自体にDynamic Linkが整備されており、Pyodideもその機能を利用していたので、その点では分かりやすかったです。
ざっくり言うと

  • メインモジュール・サイドモジュールの概念があり、システムライブラリはメインモジュールのみにリンクされる
    • C/C++拡張パッケージにはリンク不要 (-sSIDE_MODULEオプションを付ける)
  • WasmのCustom Sectionに dylink/dylink.0 なるセクションを配置して動的リンク用のメタデータを配置する
    • これ自体はLLVM/Emscriptenがよしなにしてくれるので普通は知らなくても良いのですが、仕様策定途中であり、dylink -> dylink.0 の変更とEmscriptenのバージョンで嵌りました。。。新しいEmscriptenと、新しいEmscriptenでコンパイルされたPyodideを使いましょう。[1]
  • dlopen() / dlsym() 等のLinux関数をEmscriptenが実装しており、(仮想)ファイルシステムにファイルを展開しておけば、後はLinuxのように動的リンクを実現できる[2]

2.2 ビルドシステム構築

案外面倒だったのがヘッダーでした。
Pyodideはパッチしか管理しておらず、またパッチ適用後のヘッダーの再配布もしていませんでした。
そのため、CPython・NumPy・Pyodide を gitのsubmodule として取り込み自分でパッチを適用しました。

また、パッチを適用するだけではだめで、CPythonでは configure を実行する必要があり、NumPyもビルド時に自動生成されるヘッダーが存在したため、ヘッダー自動生成部分だけ切り出して独立して動作するように改造しました。

こうして生成したヘッダーファイル群を(元ライセンスと伴に)exodideパッケージに内包して再配布することで、exodideユーザーは適切なヘッダーにアクセスできるようになっています。
https://github.com/ymd-h/exodide/blob/cc473661c6888d06839959d0694706d9190a0110/setup.py#L17-L25

ビルドして生成されるC/C++拡張モジュール/パッケージはプラットフォーム依存なので、プラットフォームタグが付与されるのですが、Pyodide用はPEPで策定されていないので、Pyodideを真似しました。Pyodideではビルド後に再度wheelを展開してリネームしているのですが、exodideでは setuptools をハックしてプラットフォーム名を書き換えました。
https://github.com/ymd-h/exodide/blob/cc473661c6888d06839959d0694706d9190a0110/exodide/build.py#L37-L45

2.3 インストール手法確立

Pyodideが提供する、loadPackage関数および、micropip パッケージは共に適切なバイナリフォーマットかどうかに関わらず、意図的に知らないC/C++拡張パッケージをブロックしています。

幸いPyodideの内部APIにアクセスできたので、うまく組み合わせて、自由なURLからダウンロードしてきた wheel を展開して import できるようにできました。

Pyodideに限った話ではないですが、インタープリター実行中にライブラリをインストールした場合は、importlib.invalidate_caches() を呼びましょう。

3. exodide の利用

3.1 C/C++拡張パッケージのビルド

PythonとEmscriptenが利用できる環境で、pip等でexodideをPyPIからインストールしてください。
(ターゲットとしている)PyodideがPython3.10を利用しているので、独自C/C++パッケージはPython3.10で動作する必要がありますが、ビルド時のヘッダーはexodideに内包された物を使うので、Pythonのバージョンにはそんなに影響が出ないとは思います。(Python 3.10でしか試していませんが。)

pip3 install exodide

exodide.buildモジュールの中に、distutils/setuptoolsのコマンドを継承したカスタムコマンドを用意しています。
setup()関数のcmdclassオプションに指定することでヘッダーやコンパイルオプションを適切に指定するコマンドに置き換えます。

setup.py
from setuptools import setup
from exodide import build

# 省略

setup(
  # 省略
  cmdclass = build.cmdclass()
)

あとは、

CC=emcc CXX=em++ python3 setup.py bdist_wheel

で、distディレクトリに、Pyodide用のwheelが生成されます。

3.2 PyodideにC/C++拡張パッケージのインストール

exodide.install.fetch_install関数を使います。
exodide自体はpureなPythonパッケージなので、micropipでインストールできます。

<script type="module">など
const pyodide = await loadPyodide();
await pyodide.loadPackage("micropip");
await pyodide.runPythonAsync(`
import micropip
await micropip.install("exodide")

from exodide.install import fetch_install
await fetch_install("example.com/your-package.whl")

import your_package
# 省略
`);

https://github.com/ymd-h/exodide/blob/cc473661c6888d06839959d0694706d9190a0110/example/index.html

3.3 C/C++拡張モジュールの調査 (デバッグ用途)

python3 -m exodide.inspect your-module.so # .whl ではなく、内部の .so 直接

で、dylink/dylink.0の情報をダンプします。
(exodide開発時に生成したバイナリが正常かを確認するために作成しましたが、通常はあまり見る用途が無さそうです。そのうち開発時に必要になったら新しい情報を出力するようになるかも?)

出力例
File Size: 751241
Endian:  6d736100
  Little Endian
Section: dylink.0
  Address: [14, 32) byte
  Sub-Section: WASM_DYLINK_MEM_INFO
  Memory:
    Size: 98552
    Align: 4
  Table:
    Size: 312
    Align: 0
  Necessary Dynamic Libs: []
  TLS Export: set()
  Weak Import: set()

4. 最後に

色々調査・試行錯誤して、なんとか独自C/C++拡張パッケージをPyodideで利用することができるところまで開発ができたので、パッケージを公開して紹介記事としてまとめました。

ただ、出来上がったばかりで、C/C++拡張パッケージの内容によっては、うまく動かない例にも遭遇しています。(例えば、stackoverflowっぽいエラー等)

WebAssembly/Pyodideの機能制約によるものなのか、exodide側で何か考慮不足が有るのか等、引き続き調査・開発を続けていこうと思っています。

もし、この記事を読んで興味を持ってくれた人がいれば、ぜひ使ってみてくれると嬉しいです。

<再掲>
https://github.com/ymd-h/exodide

脚注
  1. stableなPyodide v0.20.0 は古いEmscripten v2.0.27を利用しており、dylink.0非対応です。Pyodide v0.21.0-alpha2 なら、Emscripten v3.1.14を採用しており無問題でした。 ↩︎

  2. Chromiumの制約により4kB以上のWebAssemblyはメインスレッドで同期的にコンパイルすることができないため、同期的なPythonのimport文が呼ばれるよりも前に、非同期にWebAssemblyをコンパイルしてメモリ上に展開しておく必要があります。そのため、exodide (およびPyodide)では、実際にそのモジュール (.so) が import されるかどうかに関わらず、インストールされた時点でWebAssemblyにコンパイルしてメモリ上に準備しておきます。 ↩︎

Discussion