pyo3 を使って Rust に Python を組み込む

# はじめに

本記事は Rust にスクリプト言語を組み込む方法について調査したメモです。

本記事では弊社での実務に使用することを念頭に、

- 簡単に使えること
- Rust ←→ スクリプト言語間のデータのやり取りが簡単に行えること
- パフォーマンスが悪くないこと
- 安全であること
- スクリプト言語のライブラリが利用できること

の観点で調査を行います。

なお、弊社では Rust からスクリプト言語を実行するケースしかないため、スクリプト言語のモジュールを Rust で実装する方法については調査を行っていません。


# 調査対象のクレート

今回は pyo3 について調べます。
pyo3 は Rust に Python を組み込むためのライブラリです。

https://crates.io/crates/pyo3

**Repository**
[PyO3/pyo3: Bindings to Python interpreter](https://github.com/PyO3/pyo3)

**調査時のバージョン**
`pyo3 = "0.16.5"`

# 前提条件

本記事では他の調査との環境の差異を無くすために、検証はすべて以下のコマンドで起動した docker のコンテナ内で行います。

```sh
docker run --rm -it \
  -v $PWD:/home \
  -w /home \
  rust:1.62.0 \
  /bin/bash
```


# 調査内容
## 導入方法

利用には Python の共有ライブラリが必要なので、 `python-dev` をインストールします。

```sh
$ apt install python3-dev
```

その上で pyo3 をインストールするのですが、その際には

- auto-initialize
- serde

の2つの feature フラグを付けておくと良いです。

```sh
cargo add --features auto-initialize --features serde pyo3
```

features の説明は以下のドキュメントをご参照ください。

https://pyo3.rs/latest/features.html

## Rust からスクリプト言語を実行する
### 簡単なスクリプトの実行

まずは簡単なスクリプトの例です。
Rust と Python のやり取りは `Python::with_gil` のブロック内で実行するようになっており、 `let fun = PyModule::from_code(` で Python のメソッドを記述したうえで `fun.call1((n,))` のように書くことで Rust から引数を渡したうえで Python のメソッドを実行することができます。

```rust
fn fibonacci_py(n: i64) -> PyResult<i64> {
    Python::with_gil(|py| {
        let fun = PyModule::from_code(
            py,
            r#"
def fibonacci(n):
    a, b = 1, 0
    for _ in range(n):
        a, b = b, a + b
    return b
            "#,
            "",
            "",
        )?
        .getattr("fibonacci")?;

        fun.call1((n,))?.extract()
    })
}
```


### serde_json::Value 型のデータを渡す

これは少し調べた感じでは出来そうにありませんでした。
`serde_json::Value` 型から　`PyObject` 型に変換する処理を書ければ良さそうなのですが、これは相当頑張る必要がありそうです。

現実的にはJSON型を文字列で渡して Python 側で dict に変換して処理する、という感じでしょうか。

```rust
use pyo3::prelude::*;
use serde_json::json;

fn use_json_in_py(json_str: &str) -> PyResult<bool> {
    Python::with_gil(|py| {
        let fun = PyModule::from_code(
            py,
            r#"
import json

def is_adult(data):
    data = json.loads(data)
    return data['age'] >= 20
            "#,
            "",
            "",
        )?
        .getattr("is_adult")?;

        fun.call1((json_str,))?.extract()
    })
}

fn main() {
    let john = json!({
        "name": "John Doe",
        "age": 43,
        "phones": [
            "+44 1234567",
            "+44 2345678"
        ]
    });

    dbg!(use_json_in_py(&serde_json::to_string(&john).unwrap()));
}
```


### スクリプト言語のライブラリを利用する

普通に pip を使ってインストールしたライブラリが利用可能です。

この記事の主題からは外れるので簡単に紹介すると、以下の手順で pip をインストールして、

```sh
$ wget https://bootstrap.pypa.io/get-pip.py
$ python3 get-pip.py
```

その後は pip を使用してライブラリをインストールします。

```sh
$ pip install numpy
```

これで、 pyo3 からも numpy が使用できるようになります。

```rust
use pyo3::prelude::*;

fn use_library() -> PyResult<()> {
    Python::with_gil(|py| {
        py.run("import numpy", None, None)?;
        Ok(())
    })
}

fn main() {
    dbg!(use_library());
}
```

莫大な Python の資産を簡単に利用できるのはとても良いですね！


## パフォーマンス

先ほどのフィボナッチ数列を求めるプログラムの実行速度を計測します。

<details><summary>計測に利用したコード</summary>

```rust
use pyo3::prelude::*;
use std::time::Instant;

fn fibonacci_rs(n: i64) -> i64 {
    let (mut a, mut b) = (1, 0);
    for _ in 0..n {
        (a, b) = (b, a + b);
    }
    return b;
}

fn fibonacci_py(n: i64) -> PyResult<i64> {
    Python::with_gil(|py| {
        let fun = PyModule::from_code(
            py,
            r#"
def fibonacci(n):
    a, b = 1, 0
    for _ in range(n):
        a, b = b, a + b
    return b
            "#,
            "",
            "",
        )?
        .getattr("fibonacci")?;

        fun.call1((n,))?.extract()
    })
}

fn main() {
    let start = Instant::now();
    for n in 1..=64 {
        let _ = fibonacci_rs(n);
    }
    println!(
        "Rust の実行時間\t：{:?}",
        Instant::now().duration_since(start)
    );

    let start = Instant::now();
    for n in 1..=64 {
        let _ = fibonacci_py(n).unwrap();
    }
    println!(
        "Python の実行時間\t：{:?}",
        Instant::now().duration_since(start)
    );
}
```

</details>

```sh
$ cargo run --release
    Finished release [optimized] target(s) in 0.01s
     Running `target/release/pyo3-example`
Rust の実行時間         ：50ns
Python の実行時間       ：12.945586ms
```

より効率の良い書き方はありそうですが、気にするほど遅くは無さそうです。


## 安全性

`Python::with_gil` は `PyResult` 型を返すようになっており、例えば Python のコード内で例外が発生しても panic することなく適切に処理をすることが可能です。
エラーメッセージも特別分かりづらいことは無く、十分実用レベルの安全性かなと感じました。


# まとめ

pyo3 はパフォーマンス・安全性は問題がなく、Python の資産を気軽に利用できる点でも有力なクレートだと思いました。
Python であればメンバーの学習コストも比較的小さく済む可能性があり、[以前調査した rlua](https://qiita.com/kyamamoto9120/items/1120f3f7423fe0fb37d5) と比べても悪くないです。

一方で、`serde_json::Value` 型を直接渡せそうにないのは現在想定しているユースケース的にはちょっと辛いです。
記事で紹介した方法も悪くはないのですが、より良い解決策があれば一気に最有力な候補になりそうかなと考えています。