プログラミング
PyO3 を使って Python 内で Rust ライブラリを実行する
Libraries Run Rust Inside Python (With PyO3) (belderbos.dev)
要約
この記事では、Rust で書かれた JSON パーサーを PyO3 を使用して Python から利用可能にする方法を解説しています。Rust のコードを Python から呼び出すためのマクロ(#[pyfunction], #[pymodule])や、Rust の値を Python オブジェクトに変換する際のパフォーマンスコストについて、特に構造体を返す場合の注意点が詳述されています。Rust で高速な処理を実現するだけでなく、Python 側でのオブジェクト生成コストを考慮することの重要性が強調されています。
全文翻訳
Pydantic v2 のようなデータ検証ライブラリの多くは、内部で Rust の拡張機能を利用しています。そのコアである pydantic-core は、ここで紹介する PyO3 と同じツールチェーンで構築されています。この記事では、Rust で書かれた JSON パーサーを Python からインポート可能なパッケージとして公開する、比較的小規模なブリッジを構築します。Rust の結果を Python オブジェクトに変換する最後のステップは、何かをポートする前に理解しておくべき点です。このようなパーサーの場合、解析自体よりもコストがかかることがあります。
Rust からインポートまでの4つのステップ
Rust コードを Python に取り込むには4つのステップが必要です。
1. 通常の Rust モジュールを記述する。
2. PyO3 マクロでアノテートする。
3. maturin でコンパイルしてインストールする。
4. 結果をインポートする。
#[pyfunction] と #[pymodule] は、配線を行う2つの Rust マクロです。Rust の属性マクロは Python のデコレータに似ています。これは、関数を書き換え、Python から呼び出せるようにするグルーを追加し、境界での型変換や参照カウントを処理します。Maturin はその後、クレートを共有ライブラリ(.so, .dylib, .dll)にコンパイルし、仮想環境に配置するため、インポートがそのまま機能します。
このセットアップ全体を、cargo new から最初のインポートまで、私は「How to run Rust in Python with PyO3 and Maturin」で解説しています。最初のチュートリアルでは単一の数値を返します。この記事では、スカラーではなく構造体を返す場合に興味深い部分が始まるため、そこから話を続けます。
パーサーはまず Rust の値を生成する
このパーサーが返す構造体は JSON ツリーであり、この記事の残りの部分で実行される例となります。Python から Rust への学習グループでは、学生は6週間かけて Rust でゼロから JSON パーサーを書き、serde を使わずに手動でトークナイザーと再帰下降パーサーを実装し、PyO3 を通じて Python に公開します。Josh のバージョンは実際のフィクスチャで CPython の C json モジュールを上回り、Jochen のバージョンは Python バージョンよりも最大3.5倍高速でした。公開されているリファレンス実装、つまり学生が開始するクリーンなバージョンは、ここで私が説明するコードです。
パーサーはプレーンな Rust の enum を生成します。Rust の enum は複数の形状のいずれかを含み、各バリアントはデータを保持できるため、JSON ツリーをきれいにマッピングできます。
pub enum JsonValue {
Null,
Boolean(bool),
Number(f64),
String(String),
Array(Vec<JsonValue>),
Object(HashMap<String, JsonValue>),
}
このツリーは完全に Rust 内に存在します。Python はそれを決して見ません。PyO3 レイヤーは、その上に薄いアダプターとして機能します。
1つの関数を公開する
関数を Python に公開するには2行が必要です。
#[pyfunction] fn parse_json<'py>(py: Python<'py>, input: &str) -> PyResult<Bound<'py, PyAny>> {
parse(input)?.into_pyobject(py)
}
Python の読者にとって、シグネチャが最も興味深い部分です。py: Python<'py> は Python インタープリタへのアクセスを表すトークンであり、Python オブジェクトへのアクセスが必要な PyO3 API に渡すものです。従来の Python ビルドでは、このアクセスは GIL を保持することに関連付けられています。PyO3 はそれをあなたに渡し、あなたは Python オブジェクトに触れるあらゆる場所にそれを渡します。Bound<'py, PyAny> は、任意の型の Python オブジェクトへのハンドルであり、PyObject と考えるものの Rust 側です。PyResult<T> は Result<T, PyErr> です。値を返すか、PyO3 が Python 例外として発生させるエラーを返します。? はそのエラーを伝播させます。parse が失敗した場合、関数は早期にリターンし、Python は例外を認識します。そうでなければ、JsonValue をアンラップして続行します。
したがって、parse(input)? が実際の作業を行い、.into_pyobject(py) が呼び出し元が要求した Python オブジェクトを構築します。最後の呼び出しがコストのかかる部分です。ツリー内のノードの Python オブジェクトを作成する必要があり、大きなドキュメントでは、解析自体よりも多くの作業が追加される可能性があります。
リターンパスがコストのかかる部分
この変換が無料ではない理由はここにあります。.into_pyobject は、JsonValue ツリー全体をウォークし、ネイティブ Python オブジェクトとして再構築します。オブジェクトごとに dict、配列ごとに list、リーフごとに float または str です。この翻訳は、Rust の値を Python の値に変換するために PyO3 が呼び出す IntoPyObject トレイトを実装することで提供されます。
impl<'py> IntoPyObject<'py> for JsonValue {
fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
match self {
JsonValue::Null => Ok(py.None().into_bound(py)),
JsonValue::Number(n) => Ok(n.into_pyobject(py)?.to_owned().into_any()),
JsonValue::Object(obj) => {
let py_dict = PyDict::new(py);
for (k, v) in obj {
py_dict.set_item(k, v.into_pyobject(py)?)?;
// 再帰
}
Ok(py_dict.into_any())
}
// ...配列、文字列、ブール値
}
}
}
100,000 個の値を持つドキュメントは、境界で約100,000個の Python オブジェクトが作成されることを意味します。これらはすべて、解析が完了した後に行われます。大きなドキュメントでは、このマテリアライゼーションループが、解析自体ではなく、エンドツーエンドの時間を支配する可能性があります。
エラーも同様に境界を越える
戻り値だけが変換されるわけではありません。解析の失敗は型付けされた Rust エラーであり、Python は例外を期待します。1つの From 実装、Rust がある型を別の型に変換するために使用するトレイトは、? が機能するようにします。
impl From<JsonError> for PyErr {
fn from(err: JsonError) -> PyErr {
match err {
JsonError::UnterminatedString { position } => PyValueError::new_err(
format!("Unterminated string starting at position {position}")
),
// ...エラーバリアントごとに1つのアーム、位置が保持される
}
}
}
これで、不正な入力はオフセットを伝える ValueError を発生させます。ファイル読み取りパスは、無料で同じ扱いを受けます。std::io::Error は既に一致する Python 例外に変換されるため、パスが見つからない場合は FileNotFoundError が発生します。呼び出し元は、Rust レイヤーが漏洩することなく Python のセマンティクスを取得します。
あなた自身のポートにとっての意味
ポートする Rust 関数がスカラーを返す場合は、ポートして続行してください。境界は通常、無視できるほど小さいです。大きな構造体を返す場合は、変換が実際のコストであり、パーサー自体が高速になったら最適化する次の項目です。PyDict の事前割り当ては、わずかながら役立ちますが、より大きな利点はアーキテクチャにあります。呼び出し元がすべてにアクセスしない場合は、ツリー全体をマテリアライズしないでください。遅延された Rust ベースのビューを返し、オンデマンドで Python オブジェクトを構築します。
したがって、PyO3 を使用する際は、アルゴリズムだけでなく、境界をプロファイルしてください。Rust を高速に実行できるようにすることは、簡単な半分です。途中、Rust の値から Python オブジェクトへの旅は、ポートが価値があったかどうかを決定する半分です。
Rust を学習中ですか?私は、PyO3 バインディングを備えたパフォーマンスの高い JSON パーサーを構築する6週間の Python から Rust への学習グループを共同で運営しています。
rust python performance
共有: AI にアウトソースすべきでないことに関する無料ガイドを入手: エンジニアにする判断力を手放さずに AI を使用する3つの実際のケーススタディ。その後、Python、Rust、AI に関するメール。
購読
読み続ける
ガードレール コードベースを保護する。あなたの判断力を保護するものは?AI コーディングは2つの異なるものを侵食します。あなたのスキルとあなたのコードです。ガードレールはコードを保護します。難しい決定を再導出することだけが、あなたの判断力を鋭く保ちます。
AI 時代に新しいスキルを学ぶ (vBrownBag)
AI が思考を終える前にコードを書くことができる時代に、新しい言語やスキルを学ぶことについて vBrownBag ポッドキャストに参加しました。
Rust、AI、開発者の考え方 (Develpreneur Podcast)
Jim Hodapp と共に Develpreneur ポッドキャストに参加し、Rust 開発者の考え方と、なぜコンパイラが AI 生成コードの優れたガードレールであるかについて話しました。