PyO3 で Python 拡張モジュールを作る — maturin のビルドから wheel 配布まで
Python エコシステムの最近の潮流の 1 つが「外は Python、中は Rust」です。データフレームの Polars、リンターの Ruff、バリデーションライブラリ pydantic のコアがすべてこの構造で、Python のパッケージマネージャー uv まで Rust 製です。この組み合わせを可能にする橋が PyO3(Rust と Python インタープリタのバインディング)と maturin(ビルド・パッケージングツール)です。この記事は「遅い Python 関数を 1 つ Rust に差し替える」最小経路を最初から最後まで歩きます。
いつ切るカードか #
まず判断基準です。Python が遅いコードの多くはアルゴリズムか IO の問題で、Python の中で解決できます。Rust のカードが有効なのは、プロファイリングでボトルネックが CPU 演算だと確認済みで、かつ NumPy のような既存のネイティブライブラリでは表現できないカスタムロジックのときです(ボトルネック探しは py-spy の回で扱いました)。全面的な書き直しではなく、ボトルネック関数 1 つから。それがこの経路の精神です。
始まり — maturin が骨組みを作る #
pip install maturin
maturin new --bindings pyo3 fastcount
cd fastcount生成される構造は、Rust プロジェクトに Python の包装が付いた形です。
fastcount/
├── Cargo.toml # crate-type = ["cdylib"] — 動的ライブラリとしてビルド
├── pyproject.toml # ビルドバックエンドが maturin
└── src/
└── lib.rs # 拡張モジュール本体pyproject.toml のビルドバックエンドが maturin に指定されているので、Python 側から見ればこのプロジェクトは普通のパッケージです。pip install . も通りますし、後で見るように wheel の配布も標準の手順そのままです。
Rust 関数を Python に公開する #
単語の頻度を数える関数を例にします。純粋な Python のループでは遅くなりがちな、典型的な CPU バウンドのループです。
use pyo3::prelude::*;
use std::collections::HashMap;
/// テキスト内の単語頻度を数える
#[pyfunction]
fn word_count(text: &str) -> HashMap<String, u64> {
let mut counts: HashMap<String, u64> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word.to_lowercase()).or_insert(0) += 1;
}
counts
}
#[pymodule]
fn fastcount(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(word_count, m)?)?;
Ok(())
}#[pyfunction] が Rust 関数に Python の呼び出し規約を着せ、#[pymodule] がモジュールに関数を登録します。注目すべきは、型変換がシグネチャだけで解決されている点です。Python の str が &str として入ってきて、返した HashMap<String, u64> は Python の dict[str, int] になって出ていきます。Vec ↔ list、Option ↔ None を許す引数、といった対応が同じ原理で動き、変換できない型を渡すと Python 側に TypeError が発生します。Rust の Result を返せば Err が Python の例外に変換されるところまで、2 つの言語のエラーモデルも自然につながります。
開発サイクル — maturin develop #
python -m venv .venv && source .venv/bin/activate
maturin develop --release
python -c "import fastcount; print(fastcount.word_count('a b a')['a'])" # 2maturin develop は、コンパイルした拡張モジュールを現在の仮想環境に直接インストールしてくれるコマンドで、「直して、ビルドして、Python で確認」の反復の輪を作ります。性能を見るときは --release を付けること(実践第 4 回の規則)がここでもそのまま適用されます。この種の文字列処理ループなら、純粋な Python 比で数十倍水準の改善が一般的な期待値で、正確な倍率はいつでも自分のデータで測ってから言うべきです。
free-threaded Python との相性 #
Python 側の大きな変化である free-threading(GIL なしビルド)との関係も押さえておく必要があります。伝統的に拡張モジュールは GIL のおかげでスレッド安全をただで手に入れてきましたが、free-threaded ビルドではその前提が消えます。PyO3 は free-threaded Python のサポートを備えていて、Rust 側のコードはそもそもコンパイラがデータ競合を遮断するので(実践第 5 回)、この転換で有利な位置にいます。「GIL のない Python で本当に並列な拡張モジュール」という組み合わせが、この経路の次の章です。
配布 — wheel まで #
maturin build --release # 現在のプラットフォーム用 wheel を生成
maturin publish # PyPI へのアップロードまでmaturin が作った wheel は PyPI への公開手順にそのまま乗ります。注意点はネイティブ拡張の宿命であるプラットフォーム別ビルドです。純粋な Python の wheel と違って OS・アーキテクチャ別の wheel が必要なので、実際の配布では CI のマトリックスビルド(Rust 実践第 8 回と同じ構図)で Linux・Mac・Windows 用を並列生成するのが慣例です。maturin はそのための GitHub Actions ワークフロー生成コマンド(maturin generate-ci)まで提供しています。
まとめ #
- 「外は Python、中は Rust」は Polars・Ruff・pydantic が検証した標準経路で、PyO3 + maturin がその道具です。
- 有効な状況は、プロファイリングで確認された CPU ボトルネック + 既存のネイティブライブラリでは無理なカスタムロジックです。ボトルネック関数 1 つから始めます。
#[pyfunction]・#[pymodule]で公開し、型変換はシグネチャが担当します(str↔&str、dict↔HashMap、例外↔Result)。- 開発は
maturin develop --releaseの反復の輪、配布はmaturin buildで wheel を作って PyPI の標準手順に乗せます。プラットフォーム別 wheel は CI マトリックスが定石です。 - free-threaded Python の時代には拡張モジュールのスレッド安全がただではなくなりますが、データ競合をコンパイル時に防ぐ Rust は、この転換の有利な席にいます。