2023年7月: PythonとRustの融合:PyO3/maturinを使ったPythonバインディングの作成入門(kadowaki)¶
門脇@satoru_kadowakiです。 今月のPython Monthly Topicsでは、PythonとRustの融合を可能にする PyO3 とmaturinについて紹介します。
はじめに¶
Python とRustはそれぞれ異なる特性を持つプログラミング言語です。 Pythonはシンプルな構文で初学者にも親しみやすく、データサイエンス、Web開発など高レイヤーのライブラリ群が充実しています。 しかし、パフォーマンスが要求される部分ではCやRustに比べて劣ることがあります。 [1]
一方、Rustはメモリやスレッドの安全性に重点を置いて設計されており、CPUやメモリなどの低レイヤーの処理効率に優れています。 プログラムを書くこと自体が難しいとされている低レイヤーの処理を、パフォーマンスを損なわず書くことができる言語として広く利用されるようになりました。
2つの言語の特徴を生かし、Pythonでパフォーマンスが要求される処理を全てRustで置き換えることができれば性能向上が期待できますが、それにはRustの学習コストもあり大変です。
全てのプログラムをRust化するのは難しくとも、場合によっては「ある特定の処理だけでもパフォーマンスを改善したい」ということもあるかと思います。 そこで本記事では、Pythonで書かれた簡単なプログラムの一部(関数)をRustで書き換え、maturinを使用してPythonバインディングとして呼び出す方法を紹介します。
Pythonにおけるその他の高速化方法¶
Pythonの高速化には、他にも以下のような手法が知られています。 それぞれに特徴がありますが、今回紹介するmaturinはRustを使用した比較的新しい手法です。 この機会にぜひ知っていただけたらと思います。
PyO3とmaturinについて¶
本記事では、Rustで書かれたプログラムをPythonから呼び出して実行するために、PyO3とmaturinを使用します。 それぞれ以下のような役割があります。
PyO3はRustとPythonの相互運用を実現するためのライブラリです。 PyO3によってRustからPythonのオブジェクトや関数を呼び出す、またはその逆の操作も行えます。
maturinはPythonの拡張モジュールをビルドし、Rustの クレート(Crate) というパッケージ化を行うためのツールです。 内部でPyO3を使用しており、RustのコードをPythonから利用できる形にビルドするためのインターフェースを提供します。
それぞれの関係を簡単な図で表すと以下のようになります。
このように、maturinによってビルドが簡単になり、開発者は複雑なビルドプロセスを気にすることなくRustとPythonを使用した開発に集中することができます。 なお、PyO3とmaturinのGitHubリポジトリは、以下のようにどちらも PyO3配下 で開発が進められています。
以下にGitHubのリンクも掲載しておきます。
maturin: https://github.com/PyO3/maturin
最近では、Pythonのデータ分析関連、バリデーションツール、暗号化ライブラリなど、様々なプロジェクトでPyO3やRustを使用している事例も増えてきており、PythonとRustがより親和性の高い言語になっていることが伺えます。
参考までにPyO3を使用しているプロジェクトには以下があり、Pythonのサードパーティライブラリの高速化にRustの特性が生かされています。
Pydantic-core: バリデーションツール
V2からPyO3によるバインディングに変更される
参考:
Polars: 高速データフレームライブラリ
ruff: 静的コード解析ツール
Robyn: 非同期WEBサーバフレームワーク
その他の事例は PyO3のExamples を参照
インストール¶
Rustのコードをビルドできるようにするために、Rustとmaturinをインストールします。 ビルドに必要な環境のセットアップは上記2つのみでPyO3自体のインストールは必要ありません。
rustupのインストール¶
まず最初に、Rustの開発環境をインストールします。 インストールは、Rust公式のインストーラー rustup を使用します。
本記事では以下の環境にインストールを行っています。
OS: Ubuntu 20.04.5 LTS
Python: 3.11.4
Linux、Unix系OS、macOSについては、Install Rust ページに記載されている手順で行います。 Windowsなど、その他のOSについては Other Rust Installation Methods ページでインストーラーが提供されていますので、確認してみてください。
rustupをインストールすると以下のような機能が提供されます。
Rustのバージョン管理: 特定のバージョンのインストールなどを行う
ツールチェーンの管理: コンパイルに必要なツールチェーンのインストールなどを行う
コンポーネントの管理: 標準ライブラリなどの管理を行う
rustupのインストールは以下のコマンドで行います。
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
インストールを実行すると以下のようにインストール方法のオプション選択が表示されます。
標準(default)インストールである 1
を入力してEnterキーを押下します。
Welcome to Rust!
(省略)
Current installation options:
default host triple: x86_64-unknown-linux-gnu
default toolchain: stable (default)
profile: default
modify PATH variable: yes
1) Proceed with installation (default)
2) Customize installation
3) Cancel installation
>1
インストールが続行され、以下のように必要なモジュール等のダウンロードとインストールが行われます。
info: profile set to 'default'
info: default host triple is x86_64-unknown-linux-gnu
info: syncing channel updates for 'stable-x86_64-unknown-linux-gnu'
info: latest update on 2023-06-01, rust version 1.70.0 (90c541806 2023-05-31)
info: downloading component 'cargo'
6.9 MiB / 6.9 MiB (100 %) 2.6 MiB/s in 3s ETA: 0s
(省略)
Rust is installed now. Great!
To get started you may need to restart your current shell.
This would reload your PATH environment variable to include
Cargo's bin directory ($HOME/.cargo/bin).
To configure your current shell, run:
source "$HOME/.cargo/env"
インストールが完了すると、環境変数を再読み込みするために、シェルのリスタートまたは $HOME/.cargo/env
ファイルの再読み込みが必要です。
標準出力の結果に記載があるとおり、以下のコマンドで行います。
$ source "$HOME/.cargo/env"
ここで念のために rustup
コマンドが使用可能であることを確認します。
バージョン確認として -V
オプションを設定しています。
$ rustup -V
rustup 1.26.0 (5af9b9484 2023-04-05)
info: This is the version for the rustup toolchain manager, not the rustc compiler.
info: The currently active `rustc` version is `rustc 1.70.0 (90c541806 2023-05-31)`
maturinのインストール¶
続いてmaturinをpipコマンドでインストールします。
$ pip install maturin
以上でインストールは完了です。
Rust関数をPythonでバインディングする¶
ここからは、Pythonで作成した関数をRust化して実行する方法について説明します。
使用するPythonスクリプトのサンプル¶
以下のサンプルコードでは count_chars()
関数において、引数で指定された文字列について英字、数値、その他の文字列ごとにカウントした結果を返します。
このサンプルコード自体をPythonでさらに最適化する方法もあると思いますが、今回はこのスクリプトをRust化してみます。
なお、このサンプルスクリプトは後述の説明でも使用しますので、 example_base.py
として保存しておきます。
# 文字列を引数として英字、数値、その他の文字列をカウントした結果を返す
def count_chars(text):
# カウント用変数定義
alphabet_count = 0 # 英字カウント用
digit_count = 0 # 数値カウント用
other_count = 0 # その他の文字カウント用
for char in text: # 文字列をループで繰り返し
if char.isalpha() and char.isascii(): # アルファベットかどうか
alphabet_count += 1
elif char.isdigit(): # 数値かどうか
digit_count += 1
else:
other_count += 1
return alphabet_count, digit_count, other_count
if __name__ == "__main__":
text = "Python Monthly Topics: 2023年7月"
alphabet_count, digit_count, other_count = count_chars(text)
print(f"アルファベットの数: {alphabet_count}")
print(f"数字の数: {digit_count}")
print(f"それ以外の文字数: {other_count}")
このサンプルスクリプトの実行結果は以下の通りです。
$ python example_base.py
アルファベットの数: 19
数字の数: 5
それ以外の文字数: 6
maturinで最初のステップ¶
最初にPython拡張モジュールにするためのディレクトリを作成します。
文字列カウントのスクリプトですので、ディレクトリ名を strcounter
としました。
作成後、strcounter
ディレクトリに移動します。
$ mkdir strcounter
$ cd strcounter
ディレクトリに移動後、以下のように maturin init
コマンドを実行してRustのCargoプロジェクトを作成します。
(Cargoプロジェクトとは、Cargoによって管理されるRustのソフトウェアプロジェクトです。)
コマンドを実行するとバインディングの種類選択が要求されます。 先頭にある「pyo3」を選択した状態でEnterキーを押下します。
(strcounter)$ maturin init
? 🤷 Which kind of bindings to use?
📖 Documentation: https://maturin.rs/bindings.html ›
❯ pyo3
rust-cpython
cffi
uniffi
bin
✔ 🤷 Which kind of bindings to use?
📖 Documentation: https://maturin.rs/bindings.html · pyo3
✨ Done! Initialized project /home/ubuntu/..(省略)../strcounter
maturin init
コマンドで表示されたバインディングの種類からもわかりますが、PyO3 以外のバインディングを選択することもできます。
その他のバインディングについては maturinのドキュメント - Bindings に説明がありますので、興味のある方は確認してみてください。
さて、 strcounter
ディレクトリを見てみると、以下のようなファイルやディレクトリが作成されています。
(下記ではPythonで記載されたサンプルコードとの位置関係を分かりやすくするために、example_base.pyを含めて表示しています。)
$ tree -a
.
├── example_base.py # pythonのサンプルスクリプト
├── strcounter
│ ├── .gitignore
│ ├── Cargo.toml
│ ├── pyproject.toml
│ ├── src
│ │ └── lib.rs
│ ├── .github
│ │ └── workflows
│ └── CI.yml
作成された主なファイルの概要は以下の通りです。
ファイル名 |
概要 |
---|---|
Cargo.toml |
Rustのビルドツールであるcargoの定義ファイル |
pyproject.toml |
Pythonパッケージのビルドに必要な情報の定義ファイル |
src/lib.rs |
Pythonバインディング用のscaffold(足場) |
.github/workflows/CI.yml |
GitHub Actions用ワークフロー定義ファイル |
Cargo.tomlにはデフォルトのメタデータとPyO3の依存関係(バージョン)などが記載されています。 また、pyproject.tomlにはビルドツールとしてmaturinが使用されることなどがあらかじめ定義されています。
注目すべきはRustスクリプトを記述するsrc/lib.rsファイルです。 以下のようなscaffold(足場)が最初から記載されています。
1use pyo3::prelude::*;
2
3/// Formats the sum of two numbers as string.
4#[pyfunction]
5fn sum_as_string(a: usize, b: usize) -> PyResult<String> {
6 Ok((a + b).to_string())
7}
8
9/// A Python module implemented in Rust.
10#[pymodule]
11fn strcounter(_py: Python, m: &PyModule) -> PyResult<()> {
12 m.add_function(wrap_pyfunction!(sum_as_string, m)?)?;
13 Ok(())
14}
Pythonであれば、関数は def
キーワードが使用されますが、Rustでは fn
キーワードが使用されます。
scaffoldの主要部分は、Python関数であることを表す #[pyfunction]
属性でマークされた sum_as_string()
関数(4-5行目)と、Pythonモジュールであることを表す #[pymodule]
属性でマークされた strcounter()
関数(10-11行目)です。
strcounter()
関数が行っていることは、sum_as_string()
関数をモジュールとして登録することだけです。
sum_as_sting()
は文字列の連結を行う関数ですが、この部分を本記事のサンプルコード count_chars()
関数をRust化して置き換えるのが次のステップです。
関数をRust化して書き換える¶
先述のとおり、Rustを理解して書き進めていくにはそれなりの学習時間を必要とします。 詳細な解説を本記事で行っていくには限界があるため、本記事では最低限必要な部分の説明のみとしています。 とはいえ、本サンプルの関数自体は数行のコードで、Pythonのサンプルコードともほぼ同じような構成です。 Rustのコードを初めて見るという方もいると思いますが、とりあえず雰囲気で読んでみてください。(笑) maturinをきっかけにRustを少し勉強してみるきっかけになれば幸いです。(筆者もまだまだビギナーです!) [2]
Rust化にあたり、lib.rsファイルの変更点は以下の2点です。
sum_as_string()
関数を削除してcount_chars()
関数を作成27行目の
wrap_pyfunction!(sum_as_string, m)
をwrap_pyfunction!(count_chars, m)
に変更
修正後のlib.rsファイルは以下のようになります。
1use pyo3::prelude::*;
2
3#[pyfunction]
4fn count_chars(s: &str) -> (usize, usize, usize) {
5 // カウント用変数定義
6 let mut alphabet_count = 0; // 英字カウント用
7 let mut digit_count = 0; // 数値カウント用
8 let mut other_count = 0; // その他の文字カウント用
9
10 for c in s.chars() { // 文字列をループで繰り返し
11 if c.is_ascii_alphabetic() { // アルファベットかどうか
12 alphabet_count += 1;
13 } else if c.is_ascii_digit() { // 数値かどうか
14 digit_count += 1;
15 } else {
16 other_count += 1;
17 }
18 }
19
20 (alphabet_count, digit_count, other_count)
21}
22
23
24/// A Python module implemented in Rust.
25#[pymodule]
26fn strcounter(_py: Python, m: &PyModule) -> PyResult<()> {
27 m.add_function(wrap_pyfunction!(count_chars, m)?)?; // sum_as_stringをcount_charsに変更
28 Ok(())
29}
以上でPythonで書かれていたプログラムを、同じ処理を行うRust版のコードに書き換える作業は完了です。
作成したRustのコードをビルドする¶
作成した strcounter
パッケージをビルドするには、コマンドで maturin develop
を実行します。
ビルドを実行すると、Rustパッケージがダウンロード、コンパイルされ仮想環境(venv)にインストールされます。
(strcounter)$ maturin develop
Updating crates.io index
(省略)
Downloaded 6 crates (1.6 MB) in 0.80s
🔗 Found pyo3 bindings
(省略)
Compiling strcounter v0.1.0 (/home/ubuntu/..../strcounter)
Finished dev [unoptimized + debuginfo] target(s) in 18.15s
📦 Built wheel for CPython 3.11 to /tmp/.tmpyBRxla/strcounter-0.1.0-cp311-cp311-linux_x86_64.whl
🛠 Installed strcounter-0.1.0
作成したスクリプトに問題がある場合には、コンパイルに失敗しエラーが表示されます。
コンパイルが問題なく完了すると、上記のように Installed....
のようなメッセージが表示され終了します。
ちなみに、成功した際のメッセージに Built wheel for CPython 3.11 to /tmp/.tmpyBRxla/strcounter-0.1.0-cp311-cp311-linux_x86_64.whl
とあるように、Pythonのwheel形式(.whl)のバイナリが作成され、パッケージ化されていることがわかります。
ビルドしたRustパッケージをPythonバインディングとしてインポートする¶
それでは実際に使用してみましょう。
インポートは import strcounter
とするだけです。
関数として呼び出すには strcounter.count_chars()
のようにします。
特別な使用方法もなく、Pythonを普段から使用しているやり方と同じなのは親近感が湧きます。
import strcounter
if __name__ == "__main__":
text = "Python Monthly Topics: 2023年7月"
alphabet_count, digit_count, other_count = strcounter.count_chars(text) # Pythonバインディングを使用
print(f"アルファベットの数: {alphabet_count}")
print(f"数字の数: {digit_count}")
print(f"それ以外の文字数: {other_count}")
実行結果は以下のとおりです。 結果を見ると正しく動作していることが確認できます。
$ python example1.py
アルファベットの数: 19
数字の数: 5
それ以外の文字数: 6
最適化ビルド¶
バインディングのビルドは maturin develop
コマンドを使用して行いました。
このコマンドは、Rustクレートのdevバージョンをビルドするもので、コンパイル時間を短縮するために最適化はスキップされています。
開発したパッケージのパフォーマンスを最大化するには、最適化してビルドを行う必要があります。
最適化を行うには、strcounterディレクトリに戻って maturin develop --release
と実行します。
--release
フラグを付けることで、デバック情報や使用されていないデッドコードの除去などが行われ、最適化されたバイナリが作成されます。
ただし、最適化はビルド時間が長くなります。
そのため、開発中やデバッグ時には --release
フラグを使わず、最終的なリリースの際に使用するのが一般的です。
フラグを付け忘れてしまうとパフォーマンスが出ません。
実際にリリースを行う際には、最適化を忘れないようにしましょう。
(strcounter)$ maturin develop --release
(省略)
Compiling strcounter v0.1.0 (/home/ubuntu/.../strcounter)
Finished release [optimized] target(s) in 1m 35s
📦 Built wheel for CPython 3.11 to /tmp/.tmpHVs9Jg/strcounter-0.1.0-cp311-cp311-linux_x86_64.whl
🛠 Installed strcounter-0.1.0
標準出力に Finished release [optimized] target(s) in 1m 35s
と表示されているように、ビルドに約1分半かかりました。
devバージョンのビルドは20秒弱で終了していたことと比較しても、最適化には時間がかかることがわかります。
パフォーマンス比較¶
最適化が完了したところで、実際にパフォーマンスがどれほど違うか見てみます。
example_base.pyを修正して、元々のPythonのコードとRust化されたPythonバインディングの両方の関数を実行するコードを作成します。
計測には time.timeit
モジュールを使用し、100回実行した平均値を比較します。
また、計算に使用する文字列が少ないとそれほど差が出ないので、長めの文字列を使用してみます。今回は先月(2023年6月)のPython Monthly Topicsの記事 を使用してみることにしました。
計測用のコードでは、requestsモジュールを使用して上記記事URLのテキストを取得し計算します。 事前にrequestsモジュールを以下のコマンドでインストールします。
$ pip install requests
import requests
import strcounter
from timeit import timeit
# 文字列を引数として英字、数値、その他の文字列をカウントした結果を返す
def count_chars(text):
# カウント用変数定義
alphabet_count = 0 # 英字カウント用
digit_count = 0 # 数値カウント用
other_count = 0 # その他の文字カウント用
for char in text: # 文字列をループで繰り返し
if char.isalpha() and char.isascii(): # アルファベットかどうか
alphabet_count += 1
elif char.isdigit(): # 数値かどうか
digit_count += 1
else:
other_count += 1
return alphabet_count, digit_count, other_count
if __name__ == "__main__":
# 特定のURLからテキストを抽出
url = "https://gihyo.jp/article/2023/06/monthly-python-2306"
res = requests.get(url)
res.encoding = "utf-8"
text = res.text
loop = 100 # 繰り返し実行回数
# 本スクリプト内のPython関数を実行結果を表示
p_res = timeit(lambda: count_chars(text), number=loop)
p_time = p_res / loop * 1_000_000 # 1回あたりの平均実行時間をマイクロ秒で計算
print(f"Python Avg: {p_time:.2f} μs/call")
# PythonバインディングによるRustの実行結果を表示
r_res = timeit(lambda: strcounter.count_chars(text), number=loop)
r_time = r_res / loop * 1_000_000 # 1回あたりの平均実行時間をマイクロ秒で計算
print(f"Rust Avg: {r_time:.2f} μs/call")
なお、計測では実行結果を表示していませんが、どちらも同じ計算結果になることを確認しています。 参考までに出力結果は以下のとおりです。
アルファベットの数: 21188
数字の数: 4911
それ以外の文字数: 20102
計測の実行結果は以下のようになりました。 なんと驚きです!Pythonバインディングを経由した方が約40倍も速い結果となりました! たったこれだけの手間で数倍から数十倍の性能向上が期待できるなら、自分が書いたあんなロジックやこんなロジックもRustに任せることができるかもしれません!
$ python example2.py
Python Avg: 7723.11 μs/call
Rust Avg: 193.54 μs/call
「Python 🤝 Rust」は意外と簡単、それでも「銀の弾丸など無い」¶
maturinは想像以上にPythonへの取り回しがよく、しかも圧倒的なパフォーマンスを得られる可能性があることはとても魅力的です。
しかし、いくらRustが速いとは言えPyO3/maturinであらゆるロジックを置き換えられるというわけではありません。安易に使用したらパフォーマンスが得られなかった、ということもあります。
1つ例をあげてみます。以下のようなPythonのreモジュールを使用して、正規表現パターン、文字列、置換する文字列を引数として置換を行う関数を作成しました。
(サンプルスクリプトの実行結果は、 Hello-Python-Rust-with-maturin
と表示されるだけです。)
import re
# 引数で指定された正規表現を使用して、テキストを置換する関数
def replace_with_pattern(pattern, text, replacement):
return re.sub(pattern, replacement, text)
if __name__ == "__main__":
pattern = r"\d+" # 正規表現パターン
text = "Hello01Python23Rust45with678maturin" # 置換対象文字列
replacement = "-" # 置換する文字列
# 本スクリプト内のPython関数を実行結果を表示
print(replace_with_pattern(pattern, text, replacement))
これをmaturinで置き換えると以下のようなlib.rsで実現できます。
最初のサンプルと異なるのは、 replacer
という名前で作成し、Rustで正規表現を使用するために regex
クレートを指定しています。
use pyo3::prelude::*;
use regex::Regex;
// 引数で指定された正規表現を使用して、テキストを置換する関数
#[pyfunction]
fn replace_with_pattern(pattern: &str, text: &str, replacement: &str) -> String {
let re = Regex::new(pattern).unwrap();
re.replace_all(text, replacement).to_string()
}
/// A Python module implemented in Rust.
#[pymodule]
fn replacer(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(replace_with_pattern, m)?)?;
Ok(())
}
以下は参考情報ですが、 regex
クレートを使用する場合は、Cargo.tomlの [dependencies]
属性に以下のように依存関係を追記する必要があります。
[dependencies]
pyo3 = "0.19.0"
regex = "1" # regexを追加
lib.rsファイルにRustのコードを記載したら maturin develop --release
コマンドでビルドを行い、計測用のスクリプトを以下のように作成しました。
計測は前回と同じくtime.timeit
モジュールで行っています。
import re
import replacer
from timeit import timeit
# 引数で指定された正規表現を使用して、テキストを置換する関数
def replace_with_pattern(pattern, text, replacement):
return re.sub(pattern, replacement, text)
if __name__ == "__main__":
pattern = r"\d+" # 正規表現パターン
text = "Hello01Python23Rust45with678maturin" # 置換対象文字列
replacement = "-" # 置換する文字列
loop = 100 # 繰り返し実行回数
# 本スクリプト内のPython関数を実行
p_res = timeit(lambda: replace_with_pattern(pattern, text, replacement), number=loop)
p_time = p_res / loop * 1_000_000 # 1回あたりの平均実行時間をマイクロ秒で計算
print(f"Python Avg: {p_time:.2f} μs/call")
# Pythonバインディングによる実行
r_res = timeit(lambda: replacer.replace_with_pattern(pattern, text, replacement), number=loop)
r_time = r_res / loop * 1_000_000 # 1回あたりの平均実行時間をマイクロ秒で計算
print(f"Rust Avg: {r_time:.2f} μs/call")
計測結果は以下のように、圧倒的にPythonの方が速い結果となりました。
$ python regex_sub2.py
Python Avg: 5.76 μs/call
Rust Avg: 822.12 μs/call
これはPythonバインディングを使用する際にオーバーヘッドがあるために起きています。 つまり、Pythonでもそれほど処理コストがかからない処理にRustを使用しても、Rustのメリットを受けられないということです。 他にも巨大なリストをPythonバインディング経由で処理することも試してみましたが、こちらはほぼ同じくらいの処理時間になり、それほどメリットがある結果にはなりませんでした。
このような結果から、maturinを使用する際に考慮するべきこととして、以下のことが言えます。
Python-Rust間のオーバーヘッド
Pythonで処理負荷が低いものをRust化してもメリットが出ないことがある
比較的大きいオブジェクトのやりとりは、オーバーヘッドを考慮して実装する必要がある
CPUバウンドな処理が多い場合にはRustの恩恵を受けられる可能性が高い
IOバウンドな処理に採用してもメリットが出ないことがある
IOバウンドな処理+その後の処理負荷を考慮して検討する
まとめ¶
本記事では、maturinを使用してRustによるPythonバインディングの作成方法を紹介しました。 また、Rustに書き換える前のPythonコードと処理時間を計測して、高速化が行われていることを確認しました。 Rustはデータ分析など計算コストが高いプログラムを高速に処理することが得意です。 maturinを使用すれば、Pythonの使いやすさを維持したまま、Rustを取り入れたパフォーマンス向上を効率的に行うことができるようになります。
もちろん、わざわざRustを使用せずにPythonで高速化を行えるのが一番よいですが、maturinによってPythonとRustがより身近になり、パフォーマンスを向上させる手法が1つ増えたと言えます。
本記事がPythonのパフォーマンスで重要な処理を、Rustで書き換えてみるきっかけになれば幸いです。