PyO3入門|RustでPythonを高速化する手順をゼロから解説

プログラミング

※この記事にはプロモーションが含まれます

Rustは前から「気になってるものリスト」に入れたままだったんですが、PythonからRustを呼べる「PyO3」というものがあると知って、これなら普段のPythonを活かしつつ入門できそうだなと思ったのが調べ始めたきっかけです。

調べてみると、PyO3は2026年10月に0.30系が出たばかりで、ネットにある古い記事とは書き方がけっこう変わっていました。なので、今の書き方で自分用にまとめ直しておきます。

この記事でわかること

  • PyO3とmaturinの役割(何が何をしてくれるのか)
  • 2026年10月時点のPyO3・maturinの最新状況
  • Rustで関数を書いてPythonからimportするまでの手順
  • 「Rustにすれば何でも速くなる」わけではない理由と注意点

PyO3とは?maturinとの関係を整理

PyO3は、RustとPythonをつなぐためのライブラリ(Rustのクレート)です。主な使い方は次の2つです。

  • Rustで書いた関数やクラスを、Pythonの拡張モジュールとして公開する(今回はこっち)
  • RustのプログラムからPythonインタプリタを呼び出す

で、セットでよく出てくるのが maturin です。こちらはビルドツールで、Rustのコードをコンパイルして、Pythonの pip install で入れられるwheelにまとめるところまでやってくれます。ざっくり分けると、PyO3は「コードを書くためのもの」、maturinは「ビルドしてPythonに渡すためのもの」です。

実はPyO3を知らなくても、もうお世話になっている人は多いと思います。pydantic-core、Polars、orjson、tokenizersあたりの「Python用なのに中身がRust」系ライブラリでも、PyO3が使われています。uvやRuffもRust製ですが、あちらはPythonの拡張モジュールではなく単体のバイナリなので、少し立ち位置が違いますね。

2026年10月時点のPyO3・maturinの最新状況

PyO3とmaturinは変化が早いので、執筆時点(2026年10月)の情報をまとめておきます。

PyO3 0.30

  • 10月13日に0.30.0がリリース。Cargo.tomlには 0.30 と書いておけば問題ないはずです
  • Python 3.15.0の正式版でテスト済み
  • Python 3.8のサポート終了。Rustの最低バージョン(MSRV)は1.85に上がっています
  • 型スタブ(.pyi)を自動生成する実験的な機能 experimental-inspect の改善が中心
  • meson-python向けのビルドファイルも入りました

ひとつ前の0.29(2026年6月)

  • Python 3.7のサポート終了。リンク方式の変更(raw-dylib)も入っていて、0.30まではその関連の修正が多かったようです

maturin 1.15以降

maturinは8月に1.15.0がリリースされています。少し前の1.13では、スタブ生成やPGO(プロファイルに基づく最適化)ビルドの機能が追加されていました。

古い記事を参考にするときの注意として、PyO3は0.2x系の間にAPIがかなり変わっています。昔の書き方では通用しなくなった部分が多いので、検索して出てきたコードがコンパイルできなかったら、まずバージョンを疑うといいかもしれません。自分も最初は、記事ごとに書き方がバラバラで混乱しました。

Rustで関数を書いてPythonからimportするまで

題材は「n以下の素数を数える」にしました。CPUだけを使う単純なループなので、Pythonが苦手な処理の代表例として扱いやすいと思います。

まずPython版で基準を作る

import time

def is_prime(k: int) -> bool:
    if k < 2:
        return False
    i = 2
    while i * i <= k:
        if k % i == 0:
            return False
        i += 1
    return True

def count_primes(n: int) -> int:
    return sum(1 for k in range(2, n + 1) if is_prime(k))

start = time.perf_counter()
result = count_primes(200_000)
elapsed = time.perf_counter() - start
print(f"primes: {result}")
print(f"elapsed: {elapsed:.3f} sec")

実際に実行した結果
▲実際にこのブログの裏側で実行してみた結果です

試し割りなのでアルゴリズムとしては素朴です。「エラトステネスの篩を使えば?」というツッコミはごもっともなんですが、それをやるとRust化の比較にならないので、今回はこのままいきます。

プロジェクトを作る

Rustのツールチェーン(rustup)が入っている前提です。

python3 -m venv .venv
source .venv/bin/activate
pip install maturin

maturin new -b pyo3 fastprime
cd fastprime

-b pyo3 を付けると、PyO3用のテンプレートで Cargo.toml、pyproject.toml、src/lib.rs がまとめて作られます。Cargo.tomlで大事なのは次の部分です。

[lib]
name = "fastprime"
crate-type = ["cdylib"]

[dependencies]
pyo3 = "0.30"

テンプレートの中身はmaturinのバージョンで少しずつ変わるので、生成されたものをベースにして、pyo3のバージョンだけ確認するのが安全そうです。uvを使っている人向けには、maturin側にuv連携のオプションもあるようです(今回は無難にpipでやっています)。

src/lib.rs を書く

use pyo3::prelude::*;

#[pymodule]
mod fastprime {
    use pyo3::prelude::*;

    fn is_prime(k: u64) -> bool {
        if k < 2 {
            return false;
        }
        let mut i = 2;
        while i * i <= k {
            if k % i == 0 {
                return false;
            }
            i += 1;
        }
        true
    }

    #[pyfunction]
    fn count_primes(py: Python<'_>, n: u64) -> u64 {
        // 計算中はインタプリタから離れる
        py.detach(|| (2..=n).filter(|&k| is_prime(k)).count() as u64)
    }
}

ポイントは3つあります。

  • #[pymodule] mod fastprime のモジュール名を、Cargo.tomlの [lib] name と揃えること。ずれているとimportの時点でエラーになります
  • Pythonに公開したい関数にだけ #[pyfunction] を付けます。is_prime は内部用なので付けていません
  • py.detach で、Pythonのオブジェクトを触らない重い計算の間はインタプリタから離れます。こうしておくと、Python側で別スレッドを動かしているときに相手を止めずに済みます

引数の n: u64 は、Pythonの int から自動で変換されます。逆にマイナスの値を渡すと、変換の段階で OverflowError になることがあるようです。型チェックをRust側が勝手にやってくれるのはありがたいですね。

ビルドして呼び出す

maturin develop --release   # ここ注意: --release を忘れない
import time
import fastprime

start = time.perf_counter()
print(fastprime.count_primes(200_000))
print(f"{time.perf_counter() - start:.3f} sec")

maturin develop は、ビルドしたものを今のvenvにそのままインストールしてくれます。ここで一番ハマりやすいらしいのが --release の付け忘れです。付けないとデバッグビルドになって最適化がかからないので、「Rustにしたのに思ったより速くない…」となりがちだそうです。

どのくらい速くなるかは処理の中身やマシンによってかなり変わります。こういう単純な数値ループだと、実行時間が大幅に短縮されるケースも珍しくないと言われています。ただ、測り方は環境やコンパイル設定に左右されるので、自分の環境で測るのが一番確実だと思います。

「Rustにすれば速い」とは限らない話

調べていて一番大事だと思ったのがここです。PyO3で速くなるのは、Rust側でまとまった量の計算をするときです。

呼び出しのオーバーヘッド

PythonからRustの関数を呼ぶたびに、引数の変換などの固定コストがかかります。なので、こういう使い方だとあまり効果が出ないかもしれません。

# これは効果が薄い
total = sum(1 for k in range(2, 200_001) if fastprime.is_prime(k))

# 仕事をまとめて渡すほうがいい
total = fastprime.count_primes(200_000)

(上の is_prime は今回は公開していないので、あくまでイメージです)

データ変換のコスト

Pythonの list を Vec<i64> として受け取ると、要素をひとつずつ変換してコピーすることになります。数百万件のデータを毎回渡していると、変換だけで時間を食いそうです。NumPy配列を扱うなら、rust-numpy というクレートを使うとコピーせずに中身を参照できるらしいので、次はそっちも調べてみたいです。

I/O待ちが中心の処理は速くならない

API呼び出しやDBアクセスがメインの処理は、待ち時間がほとんどなので、Rustにしてもほぼ変わらないはずです。余談ですが、以前LambdaでAWSの日次コストを集計する仕組みを作ったときも、処理時間の大半はAPIの応答待ちでした。あれをRustにしてもまったく意味がないですね。というか、表示部分を3か月放置しているので、そっちを先に何とかすべきです…。

その前に検討したいこと

  • プロファイラ(cProfileやpy-spyなど)で、本当にどこが遅いのか確認する
  • アルゴリズムを見直す(今回の素数なら篩を使うだけで劇的に速くなります)
  • NumPyでベクトル化できないか考える
  • Numbaで済まないか試す。数値計算なら、デコレータひとつでかなり速くなる場合もあります

個人的には、PyO3は「ここだけはどうしても遅い」という部分がはっきりしてから使うものかなと思っています。もうひとつ、Rustの型システムのおかげで、Python側からおかしな値が来たときに早めにエラーで止まってくれるのも地味にいいところです。速さ以外のメリットとして、もう少し評価されてもいい気がします。

配布するときの注意

Rustの拡張モジュールはネイティブバイナリなので、OSやCPUアーキテクチャごとにwheelを用意する必要があります。maturinには maturin generate-ci github というコマンドがあって、マルチプラットフォーム向けのGitHub Actionsのワークフローを作ってくれるらしいです。Macで作ったものをLambda(Linux)に載せたい場合はクロスビルドが必要になるので、ここは正直まだちゃんと理解できていません。Dockerを使うか、maturinの --zig オプションを使うか、どちらが楽なのかは今後試してみたいところです。

PR(アフィリエイト広告)

そういえば、PyO3で高速化したPythonスクリプトをいよいよ公開しようと思って、ついでにサーバーも見直し中なんですよね。お名前.com レンタルサーバーが月額990円〜って出てきて、ちょっと気になってます。

まとめ

  • PyO3はRustとPythonをつなぐライブラリ、maturinはそれをビルドしてPythonに渡すツール
  • 2026年10月時点の最新はPyO3 0.30系とmaturin 1.15。古い記事とはAPIがかなり違うので注意
  • maturin new -b pyo3 → maturin develop --release の流れで、意外とあっさりimportまでいける
  • 効果が出るのはCPUを使う重い処理をまとめて渡すとき。細かい関数を何度も呼ぶ使い方やI/O待ちの処理には向かない
タイトルとURLをコピーしました