9月13日、Bob Belderbosが「How Libraries Run Rust Inside Python (with PyO3)」と題した記事を公開した。PythonからRustコードを呼び出すための橋渡しライブラリPyO3の仕組みと、その境界コストの実態について詳しく解説している。
Pydantic v2、Polars、Ruff——近年のPythonエコシステムで「10倍速い」と話題になるツールの多くは、コア処理をRustで書き、PyO3でPythonに公開するという構成を採っている。しかし「Rustで書けば速い」という話は半分しか正しくない。Rustの計算結果をPythonオブジェクトに変換して返す工程が、アルゴリズム自体より重いボトルネックになることがある。本記事はその構造を、自作JSONパーサーを題材に解剖する。
RustコードをPythonに公開するまでの4ステップ
手順は4つに整理できる。
- 通常のRustモジュールを書く
- PyO3マクロでアノテーションを付ける
- maturinでコンパイル・インストールする
- Pythonから
importする

中心的な役割を果たすのが#[pyfunction]と#[pymodule]という2つのRustマクロだ。RustのAttributeマクロはPythonのデコレータに近く、関数を書き換えてPythonからの呼び出し、型変換、参照カウントの処理を追加する。
maturinはそのクレートを共有ライブラリ(.so、.dylib、.dll)にコンパイルし、仮想環境に配置する。あとはimportするだけだ。Pydanticのコア実装であるpydantic-coreもこの構成を採っており、データバリデーション処理をRust拡張が担っている。
JSONパーサーを題材に選んだ理由
スカラー値(数値1つなど)を返す関数なら境界コストはほぼ無視できる。問題になるのは構造体を返す場合だ。
今回の例はRustで書いたJSONパーサー。公開リファレンス実装を使い、パーサーが返すRustのenumをPythonオブジェクトに変換するまでの全工程を追う。パーサーが返す型は以下のとおりだ:
pub enum JsonValue {
Null,
Boolean(bool),
Number(f64),
String(String),
Array(Vec<JsonValue>),
Object(HashMap<String, JsonValue>),
}
このツリー構造はRustのメモリ上にのみ存在し、Pythonは直接参照できない。
関数を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インタープリタへのアクセストークン。Pythonオブジェクトに触れるAPIに渡す - **
Bound<'py, PyAny>**:任意の型のPythonオブジェクトへのハンドル(PyObjectに相当) - **
PyResult<T>**:Result<T, PyErr>のエイリアス。エラー時はPython例外として送出される - **
?**:エラー伝播演算子。parseが失敗すれば即座にリターンし、PythonはExceptionを受け取る
parse(input)?が実処理で、.into_pyobject(py)がPythonオブジェクトの生成を行う。コストはすべてこの最後の呼び出しに集中する。
最も重要なポイント:変換コストが支配的になる
.into_pyobjectはJsonValueツリーを走査し、Pythonネイティブのオブジェクトを再構築する。この変換は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())
}
// Boolean、String、Array も同様に再帰的に変換する
}
}
}
100,000要素のJSONドキュメントなら、約100,000個のPythonオブジェクトが境界で生成される。 この「マテリアライゼーション」ループが、パース処理そのものよりも支配的なコストになりうる。
著者の学習コミュニティの受講生が実装したパーサーでは、実際のJSONファイルでCPythonのjsonモジュールを上回る実装や、Python版より最大3.5倍高速な実装も登場したという。ただしその数字はパース処理単体での比較であり、境界変換コストが加わると話は変わる。
エラーも同じ経路で変換される
戻り値だけでなく、エラーも変換が必要だ。Fromトレイトを1つ実装するだけで?がエラーを自動変換する:
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}")
),
}
}
}
不正なJSONが入力されると、破綻した位置情報を持つValueErrorが上がる。std::io::Errorはすでに対応するPython例外に変換されるため、存在しないパスを渡せばFileNotFoundErrorが発生する。Rustの層がPython側に漏れることはない。
自分のコードをポーティングする前に知っておくこと
- スカラーを返す関数ならポーティングしてそのまま使える。境界コストは無視できる
- 大きな構造体を返す場合、変換処理が真のボトルネックになる。
PyDictの事前確保はわずかに改善するが、本質的な解決策はlazy view(呼び出し元が実際に触れる部分だけをその時点でPythonオブジェクトに変換する設計)の導入だ。PolarsがRustのDataFrameを即座に全変換せずPython側に公開しているのも、この考え方に基づいている
アルゴリズムを速くするのは前半戦。Rustの値をPythonオブジェクトに変換する工程をどう設計するかが、ポーティングの成否を分ける。プロファイルすべきはアルゴリズムではなく、境界だ。
詳細はHow Libraries Run Rust Inside Python (with PyO3)を参照していただきたい。