
2026/09/14 0:24
PyO3 を使った Python 内での Rust ライブラリの実行
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
本テキストは、Rust の大規模なデータ構造を完全な Python オブジェクトに変換する行為が、PyO3 を通じて JSON パーサーを実装する際に深刻なパフォーマンスボトルネックを引き起こすことを論じています。単純なチュートリアルではスカラー値を返すだけであっても、複雑な JSON ツリーを扱うためには Rust の列挙型を
IntoPyObject トrait 経由でネイティブの Python オブジェクトとして曝すことが必要であり、この変換は意外にコストが高く、大規模ドキュメントの処理においては、解析そのものよりも総実行時間に大きく影響します。pydantic-core のようなフレームワークもこの境界面で同様のトレードオフに直面しています。これを解決するには、過剰な Python オブジェクトを蓄積するのではなく、遅延評価に基づく Rust によって支えられたビューを採用することで、完全な材料化(フルマテリアライゼーション)を避けることに優先度を置く必要があります。このアーキテクチャ的転換は、型安全性と Python との相互運用性を両立しようとする高性能ライブラリにとって不可欠であり、速度を犠牲にせずに堅牢な Rust ベンディングを統合するための明確な業界標準のモデルを確立します。本文
PyO3 で Rust JSON パーサーを Python にエクスポートする
Pydantic v2 などのデータ検証ライブラリの背後には、Rust の拡張機能が実際に重い計算を担当しています。特に
pydantic-core は PyO3 を用いて構築されており、同様の橋渡し(バインド)を構築する方法について解説します。
本稿のゴールは、Rust で記述した JSON パーサーを Python にエクスポートし、通常のパッケージとして
import できるようにすることです。
Rust の結果を Python オブジェクトに変換するステップは、移植を行う前に理解すべき最も重要なポイントであり、変換コストがパースコストを上回る場合さえあります。
Rust から Import までの 4 つのステップ
Rust コードを Python に取り込むには、以下の手順が必要です。
- Rust モジュールを作成する。
マクロで関数やモジュールに**注釈(アノテーション)**を付ける。PyO3
:Python から呼び出すための「接着剤」として型変換と参照カウントを処理します。#[pyfunction]
:PyO3 モジュールとしてパッケージ化する際にも使われます。#[pymodule]
を使ってコンパイルし、インストールさせる。maturin- クレートを共有ライブラリ(
,.so
,.dylib
など)に変換し、Python 仮想環境に配置します。.dll
- クレートを共有ライブラリ(
- Python から import する。
詳細なセットアップ手順については、「PyO3 と maturin を使って Python で Rust を動かす方法」を参照してください。
パーサーがまず Rust 値を生成する
このパーサーは、
json モジュールよりも高速な処理能力を持っており、学生チームが開発したゼロから書き込まれた JSON パーサーの実走例です(serde を使用せず、手動トークナイザと降下再帰型パーサーを採用)。
返り値の構造:JsonValue
JsonValueこのパーサーは単一の 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 の層はこのツリーに対する薄いアダプター(変換器)の役割を果たすに過ぎません。
一つの関数を公開する
Python に関数を公開するには、以下の 2 行のコードがすべてを網羅します。
#[pyfunction] fn parse_json<'py>(py: Python<'py>, input: &str) -> PyResult<Bound<'py, PyAny>> { parse(input)?.into_pyobject(py) }
関数署名のポイント
py: Python<'py>- Python インターpreter アクセスを表すトークンです。
- PyO3 API で Python オブジェクトを扱う際に必須で、従来のビルドでは GIL(グローバルインターロッキングロック)の管理と関連付けられます。
Bound<'py, PyAny>- 任意の型の Python オブジェクトへのハンドルです。Rust 側の
に相当します。PyObject
- 任意の型の Python オブジェクトへのハンドルです。Rust 側の
PyResult<T>
の型です。正常時は値を返し、エラー時には PyO3 が処理した Python 例外を返します。Result<T, PyErr>
オペレーターにより、パース失敗時に早期終了し Python 側で例外として認識されます。?
処理の流れは以下の通りです:
:実際のパース処理を実行。parse(input)?
:呼び元が要求する Python オブジェクトを構築する。.into_pyobject(py)- 注意: この最後の呼出が最もコストがかかります。ツリーノードに対して Python オブジェクトを作成する作業量は、入力によってはパース自体の作業量を上回ることがあります。
帰りの旅は高価です:変換コストについて
なぜこの変換が無料ではないのか?
.into_pyobject メソッドは、JsonValue ツリー全体を歩き回り、それをネイティブな Python オブジェクトとして再構築します(dict, list, float/str など)。これは IntoPyObject トrait を実装することで提供されます。
変換ロジックの実装例
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()) }, // オブジェクト (Map) の処理 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()) }, // ... 配列、文字列、ブール値も同様に処理 } } }
メリットとデメリットの比較
| 規模 | パース完了後 | メリット・デメリット |
|---|---|---|
| 小さなデータ | ノード数少 | コストは軽微で問題なし |
| 大きなドキュメント | 10 万件の Python オブジェクト作成 | 変換プロセスがエンドツーエンド時間の支配要因になる可能性あり |
特に大きなファイルの場合、パース自体よりも「マテリアライゼーション(具現化)」ループに時間がかかっているケースがあります。
エラーも同様に境界を横切ります
戻り値だけでなく、エラー処理も境界面で翻訳する必要があります。パース失敗は Rust 側では型付きエラーですが、Python 側では例外が必要です。
From トrait を実装し、? オペレーターに任せることで自動的に変換されます。
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}") ), // ... 他のエラーバリエーションも同様に処理 } } }
- 不正な入力が入力位置付きの
として投げられます。ValueError - ファイル読み取りエラー (
) も自動的に対応する Python 例外(例:不存在の場合はstd::io::Error
)に変換されます。FileNotFoundError - これにより、呼び出し元は Python のセマンティクスを得られ、Rust 側の詳細が漏れ出すことはありません。
あなたが自前のポートに意味することを考える場合
移植する Rust 関数の返り値によって戦略を変えましょう。
1. スカラーを返す場合
- 単一の値(数値、文字列など)を返すなら、単純な移植で十分です。
- 境界面のオーバーヘッドは通常無視できます。
2. 大きな構造体を返す場合(重要)
- 巨大なツリー全体を Python オブジェクトに「具現化」するコストが実際の問題となります。
- パース自体が高速化した後、最適化するべきはこの変換プロセスです。
アーキテクチャ的アプローチ:遅延評価
限界を超えるためのより大きな勝利はアーキテクチャの変更にあります。
- ツリー全体を具現化せず、呼び出し元がアクセスする部分のみを変換します。
- Rust によるバックエンドを持つビューを提供し、必要な時にのみ Python オブジェクトを構築してください(遅延評価)。
結論: PyO3 を採用する際は、アルゴリズムだけでなく**「境界面のプロファイリング」**も行いましょう。Rust が高速でも、「帰りの道」(変換ステップ)が遅いと移植の恩恵は薄れます。