備忘録 blog

Docker/Machine Learning/Linux

Python から Rust を呼ぶライブラリを比べる

tl;dr

Rust で実装されたライブラリに対して Python 側から関数呼び出しをしたいと考えたとき、Rust に対して Python binding を提供してくれるライブラリとして maturin、setuptools-rust、milksnake があり、それぞれのツールの間で微妙に特徴が違うので、その試行錯誤の過程を紹介する。

基本的な流れ

  1. ユーザー自身の環境で、dylib を生成するオプションで rust をコンパイルし、 共有ライブラリを作る。
  2. python 側から、 FFI を介して共有ライブラリに対して関数呼び出しを行う。
  3. 上記を、pip install を叩いたときにワンストップでインストールできるような状態にしておく。

github.com

上記のライブラリでは、Python の構造体やクラスなどを、Rust から透過的に扱うことのできるようなインターフェースを提供している。Python のある一部分のコードを高速化するために Rust を呼び出すというケースを想定していると思われるが、今回はより一般的に、Rust で実装したライブラリを pub extern "C" したコードを、 Python 側から呼び出すということを想定している。上記のコードを1つ1つ実装してもそれほど手間ではないかもしれないが、先人が踏んだ落とし穴をできるだけすり抜けたいと思うと、ライブラリを利用するのが最もシンプルなアイディアだろう。

結論から述べておくと、maturin と setuptools-rs は、python3 の環境があることが前提となっている。

maturin

github.com

いわゆるzero-conf 系のツールであり、必要最小限の定義だけすると、あとはよしなに調節してビルドしてくれるというツールである。python 3.5+対応。上記のスクリプトが、 wheel を自動で生成してくれるので、これを使ってみることにする。

$ pip3 install maturin
$ maturin build

これをmacos で実行すると、 target/wheels/<script 名>-0.1.0-py2.py3-none-macosx_10_8_x86_64.whl が作られる。ただし、 wheel のビルドは環境依存なので、単純にそのwheel をlinux 環境に持って行っても動かない。そこで、一つの方法としては、様々な環境で動作する wheel をそれぞれ作るという方針が考えられる。

$ docker pull konstin2/maturin:master
$ docker run --rm -v $(pwd):/io konstin2/maturin build

上記のコマンドを叩けば、一応あらゆる環境で動作可能なバイナリが作れるらしい。

そうでない場合は、ユーザーの手元でビルドしてもらうようにする必要がある。つまりwheel を pre-built しておくのではなく、相手の環境でビルドできるような状態でソースコードを頒布したいと考えた時、 pyproject.toml に記述するという方法がある。そうすると、 pip install . コマンドで、python library としてインストールすることができるようになる。なお、伝統的にpythonにおいてライブラリの設定は setup.py が利用されており、pipのバージョンがあまりにも古い場合は pyproject.toml が動作しないが、ユーザがpip コマンドは最新のものを入れているだろうという想定で、これでいくことにする。

[build-system]
requires = ["maturin"]
build-backend = "maturin"

[tool.maturin]
bindings = "cffi"
manylinux = "off"

公式サイトによれば、bindings は、 cffi のほか、pyo3, rust-cpythonなどを利用できるとなっている。 manylinux を off にしないと、以下のようなエラーメッセージがでて終了することがある。

  💥 maturin failed
    Caused by: Failed to ensure manylinux compliance
    Caused by: Your library is not manylinux compliant because it links the following forbidden libraries: ["libz.so.1"]

maturin は、外部ライブラリにリンクしている状況を検知して、 multilinux ビルドできないことを検知しているようだ。各ユーザーがpip install で入れる場合には、multilinux 対応する必要が無いので、その機能はoffにすると、とりあえずビルドできるようになった。これで、ユーザーのlinux/mac/windows 環境でもビルドしてそれぞれでインストールしてもらえるようになった。

ところで、もう少し凝ったことをする必要がある場合は、 setup.py に相当するものを自分で書けた方が良い*1。そこで、次の2つのツールも試す。

setuptools-rust

github.com

これはSetuptools pluginである。 setuptools-rust/html-py-ever at master · PyO3/setuptools-rust · GitHub の例にあるように、setup.py, MANIFEST.in, pyproject.toml, build-wheels.sh を記述する必要がある。build-wheels.sh の中に、cp27 という文字列があるので、一見すると python2 の環境でも動きそうだが、実際にはpython2 しかない環境でビルドしようとする場合、以下のエラーが出た。

       Running `rustc --crate-name build_script_build --edition=2018 ~/.cargo/registry/src/github.com-1ecc6299db9ec823/pyo3-0.8.5/build.rs --error-format=json --json=diagnostic-rendered-ansi --crate-type bin --emit=dep-info,link -C opt-level=3 --cfg 'feature="default"' --cfg 'feature="extension-module"' --cfg 'feature="python3"' -C metadata=c13070d14f8e5f9c -C extra-filename=-c13070d14f8e5f9c --out-dir /tmp/pip-req-build-sf23o2hn/target/release/build/pyo3-c13070d14f8e5f9c -L dependency=/tmp/pip-req-build-sf23o2hn/target/release/deps --extern lazy_static=/tmp/pip-req-build-sf23o2hn/target/release/deps/liblazy_static-1cc38642f95bd1c1.rlib --extern regex=/tmp/pip-req-build-sf23o2hn/target/release/deps/libregex-e4869bcab7e72b4a.rlib --extern serde=/tmp/pip-req-build-sf23o2hn/target/release/deps/libserde-198eb2bbcb756b12.rlib --extern serde_json=/tmp/pip-req-build-sf23o2hn/target/release/deps/libserde_json-7bf7d5b71c87fe3c.rlib --extern version_check=/tmp/pip-req-build-sf23o2hn/target/release/deps/libversion_check-65476cb62a0489dd.rlib --cap-lints allow`
       Running `/tmp/pip-req-build-sf23o2hn/target/release/build/pyo3-c13070d14f8e5f9c/build-script-build`
  error: failed to run custom build command for `pyo3 v0.8.5`

  Caused by:
    process didn't exit successfully: `/tmp/pip-req-build-sf23o2hn/target/release/build/pyo3-c13070d14f8e5f9c/build-script-build` (exit code: 101)
  --- stderr
  thread 'main' panicked at 'Python 3.x interpreter not found', ~/.cargo/registry/src/github.com-1ecc6299db9ec823/pyo3-0.8.5/build.rs:28:9
  note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

python3 interpreter がないといってエラーが出ている。setuptools-rust は pyo3 というライブラリに依存していて、 pyo3 はpython3 のinterpreter を要求しているようにみえる。上記で見てきたように、pyo3 organization 以下のライブラリはどれもpython3 しか対応していない。いかに python2 がoutdated であるとはいえ、少しの改変でpython2 も対応しているほうが望ましいという立場から、silksnake も試してみることにする。

もし、各環境に応じたwheel を作れば良いという立場であれば、 docker image を使ってビルドすれば、特に問題は無い。

milksnake

github.com

python2, python3 のどちらの環境でもビルドを通るようにしたい場合は、このライブラリを使うのが一番簡単である。といってもやっていることはそれほど複雑ではなく、 setup.py にフックして cargo build --release を実行するというのがポイントである。こちらも、setuptools-rust と同様に記述しなければならないスクリプトは多いが、特に落とし穴は少ない。

python からは、以下のようにコードを呼び出すことができる。

from example import lib

print(lib)
print(lib.hello_rust())
print(lib.test())

ビルド時に cbindgen というライブラリがC のヘッダファイルを出力し、そのヘッダファイルをもとに、cffi を介してビルドされた共有ライブラリにアクセスして、関数呼び出しを行うという仕組みになっていると考えられる。milksnake は最近活発に更新されていないというのが心配だが、その点を除いては問題無く動作した。

他の言語へのバインディング

ここはメモ程度に、検索した結果を貼っておく。

users.rust-lang.org

Swig(?) の例でC++, Java につなげる。

github.com

neon: JavaScript でつなげる場合。ただしこのライブラリでは、 JxResult という JavaScript に返す型を定義しなければならないので、Rust にも手を加える必要があってつらいようにみえる。

https://github.com/neon-bindings/neon

https://github.com/neon-bindings/examples

fn make_an_array(mut cx: FunctionContext) -> JsResult<JsArray> {
    // Create some values:
    let n = cx.number(9000);
    let s = cx.string("hello");
    let b = cx.boolean(true);

    // Create a new array:
    let array: Handle<JsArray> = cx.empty_array();

    // Push the values into the array:
    array.set(&mut cx, 0, n)?;
    array.set(&mut cx, 1, s)?;
    array.set(&mut cx, 2, b)?;

    // Return the array:
    Ok(array)
}

register_module!(mut cx, {
    cx.export_function("makeAnArray", make_an_array)
})

*1:例えば、cargo が入っていない環境ではrustupで自動的にrust をインストールしてほしいという場合など