PyO3とMaturinでRustを活用してPythonを高速化:高性能ネイティブ拡張モジュールの構築

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

背景と目的

Pythonは迅速なAPI開発やデータパイプラインの構築、インフラ連携において類まれな強みを発揮します。しかし、ペイロードのハッシュ化やカスタムバイナリ形式のアンパック、数値変換処理など、サービスがCPUの限界に直面した途端、インタプリタのオーバーヘッドとGIL(Global Interpreter Lock)がスループットの大きなボトルネックとなります。

従来、開発チームは2つの苦渋の選択を迫られていました。1つは、検証済みのビジネスロジックを捨て、既存のライブラリ連携を犠牲にしてバックエンド全体をGoやC++で書き直すこと。もう1つは、CPythonのC-APIを利用してC拡張モジュールを作成することです。しかし後者のアプローチは、メモリリークやバッファオーバーフロー、スタックトレースも残さずに本番ワーカーを停止させるセグメンテーション違反(segfault)を頻発させるリスクを伴いました。

Rustはここに第3の選択肢を提供します。所有権モデルとコンパイル時の借用チェッカーを備えたRustは、未定義動作を引き起こすことなくC言語に匹敵する実行速度を実現します。言語バインディングを提供するPyO3と自動ビルドツールのMaturinを組み合わせれば、ボトルネックとなっている特定の関数だけをコンパイル済みのネイティブモジュールに置き換えることが可能です。FastAPIのルーティングやCeleryワーカー、デプロイツーリングには一切手を加える必要がありません。最も負荷のかかる内部ループだけがネイティブマシンコードで実行されます。

毎秒45,000件のJSONペイロードを処理する弊社のテレメトリ取り込みサービスにおいて、SHA-256バッチ検証ループをPyO3モジュールに置き換えたところ、ワーカーのCPU使用率が88%から14%へと大幅に削減されました。数週間にわたる連続稼働の間もメモリ使用量は完全に横ばいを維持しています。

インストール

Python環境(3.8以上)と、正常に動作するRustツールチェーンが必要です。Rustのインストーラであるrustupを使用すると、コンパイラ、パッケージマネージャ、および標準ライブラリのターゲットがセットアップされます。

1. Rustツールチェーンのセットアップ

公式スクリプトを使用してコンパイラとCargoをインストールします:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
rustc --version
cargo --version

2. Python仮想環境の準備

システムパッケージに影響を与えないよう、ネイティブビルドを隔離します:

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

3. Maturinのインストール

MaturinはCargoとPython Wheelの橋渡しを行うツールです。クレートをコンパイルし、Wheel成果物をビルドし、バイナリを仮想環境へ直接リンクできます:

pip install maturin patchelf

設定と実装

ここではfast_engineという拡張モジュールを作成します。大きな整数の素因数を計算するCPUバウンドなタスクを扱い、Pythonインタプリタのオーバーヘッドの差を明確に確認します。

1. プロジェクト構造の初期化

Maturinを使ってクレートのスケルトンを生成します:

maturin new -b pyo3 fast_engine
cd fast_engine

このコマンドにより以下の構成が生成されます:

fast_engine/
├── Cargo.toml
├── pyproject.toml
└── src/
    └── lib.rs

2. Cargo.tomlで依存関係を設定

Cargo.tomlを更新します。PyO3でスタンドアロンのバイナリではなく共有ライブラリ(Linuxでは.so、macOSでは.dylib)を生成するには、extension-moduleフラグが必要です:

[package]
name = "fast_engine"
version = "0.1.0"
edition = "2021"

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

[dependencies]
pyo3 = { version = "0.22", features = ["extension-module"] }
rayon = "1.10"

また、rayonも追加します。これにより、GILの影響を受けることなく複数のCPUコアに計算処理を分散させることができます。

3. Rustネイティブロジックの実装

src/lib.rsを開き、以下のロジックを追加します。ここでは2つのPython関数をエクスポートします。シングルコア実装と、明示的にGILを解放するマルチスレッド実装です:

use pyo3::prelude::*;
use rayon::prelude::*;

fn count_prime_factors(mut n: u64) -> u64 {
    let mut count = 0;
    let mut d = 2;
    while d * d <= n {
        while n % d == 0 {
            count += 1;
            n /= d;
        }
        d += 1;
    }
    if n > 1 {
        count += 1;
    }
    count
}

/// Rustによるシングルスレッド計算
#[pyfunction]
fn count_factors_sequential(numbers: Vec<u64>) -> PyResult<Vec<u64>> {
    let results = numbers.into_iter().map(count_prime_factors).collect();
    Ok(results)
}

/// GILを解放したRustによるマルチスレッド計算
#[pyfunction]
fn count_factors_parallel(py: Python<'_>, numbers: Vec<u64>) -> PyResult<Vec<u64>> {
    // 他のPythonスレッドが並行動作できるようGILを解放
    let results = py.allow_threads(|| {
        numbers.into_par_iter().map(count_prime_factors).collect()
    });
    Ok(results)
}

/// Python用に関数をモジュールに登録
#[pymodule]
fn fast_engine(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(count_factors_sequential, m)?)?;
    m.add_function(wrap_pyfunction!(count_factors_parallel, m)?)?;
    Ok(())
}

4. 開発時のビルドとインストール

1つのコマンドで拡張モジュールをコンパイルし、アクティブな仮想環境にインストールします:

maturin develop --release

Cargoが最適化されたバイナリを生成し、.venv/lib/python3.x/site-packages/fast_engine/配下にリンクします。これで通常のPythonライブラリと同様にインポートできるようになります。

検証とモニタリング

純粋なPythonループとRustによる両実装のパフォーマンスを直接比較するベンチマークを実行してみましょう。

1. ベンチマークスクリプトの作成

以下のコードをbenchmark.pyとして保存します:

import time
import fast_engine

def py_count_factors(n: int) -> int:
    count = 0
    d = 2
    while d * d <= n:
        while n % d == 0:
            count += 1
            n //= d
        d += 1
    if n > 1:
        count += 1
    return count

def run_benchmark():
    # 250,000個の大きな64ビット整数を生成
    sample_data = [9999999900000001 + i for i in range(250000)]
    print(f"{len(sample_data):,}件の大きな整数を処理中...\n")

    # 1. 純粋なPython(数分待たされるのを避けるため20,000件のサブセットで実行)
    start = time.perf_counter()
    _ = [py_count_factors(x) for x in sample_data[:20000]]
    py_duration = time.perf_counter() - start
    print(f"純粋なPython (20k件):          {py_duration:.3f}s")

    # 2. Rust シーケンシャル(全250,000件)
    start = time.perf_counter()
    res_seq = fast_engine.count_factors_sequential(sample_data)
    rust_seq_duration = time.perf_counter() - start
    print(f"Rust シーケンシャル (全250k件): {rust_seq_duration:.3f}s")

    # 3. Rust パラレル(Rayon、GIL解放、全250,000件)
    start = time.perf_counter()
    res_par = fast_engine.count_factors_parallel(sample_data)
    rust_par_duration = time.perf_counter() - start
    print(f"Rust パラレル (全250k件):       {rust_par_duration:.3f}s")

    assert res_seq == res_par, "結果が一致していません"

if __name__ == "__main__":
    run_benchmark()

2. 実行と結果の検証

テストを実行します:

python benchmark.py

AMD Ryzen 7 7840U(8コア、16スレッド)でテストした結果、圧倒的な性能差が確認できました:

250,000件の大きな整数を処理中...

純粋なPython (20k件):          2.418s
Rust シーケンシャル (全250k件): 0.285s
Rust パラレル (全250k件):       0.052s

Pythonの20,000件の実行結果を250,000件にスケール換算すると、推定で約30.2秒かかります。一方、シングルスレッドのRustは同じバッチ処理をわずか285ミリ秒で完了し、およそ105倍の高速化を達成しました。さらにRayonによってすべての利用可能なコアへ負荷を分散させたところ、実行時間は52ミリ秒まで短縮され、純粋なPythonと比較して580倍の性能向上を記録しました。

3. パッケージングと本番環境へのデプロイ

Maturinのビルドコマンドを使用して本番環境向けのWheelを作成します:

# デバッグシンボルを削除してビルド
maturin build --release --strip

# 生成されたWheelファイルを確認
ls -lh target/wheels/

Dockerデプロイの場合は、Dockerfileをマルチステージビルド(2ステージ)として構成します。rust:1.80-slimのビルダー層でWheelをコンパイルし、生成された.whl成果物をpython:3.11-slimのランタイムコンテナにコピーしてpipでインストールします。このパターンを採用することで、本番サーバーにビルドツールを持ち込むことなく、コンテナイメージサイズを通常120MB未満に抑えることができます。

4. ランタイムモニタリング

標準的なPrometheusのランタイムメトリクスであるprocess_cpu_seconds_totalやprocess_resident_memory_bytesを用いてワーカーを監視します。ワーカープロセスごとにメモリフットプリントが複製されるPythonのmultiprocessingとは異なり、PyO3のスレッドはヒープメモリを安全に共有します。これにより物理メモリ使用量(RSS)はフラットに保たれ、継続的なトラフィック下でPythonを悩ませるガベージコレクション(GC)の急激なレイテンシスパイクも回避できます。

Share: