門脇
はじめに
PythonとRustはそれぞれ異なる特性を持つプログラミング言語です。Pythonはシンプルな構文で初学者にも親しみやすく、データサイエンス、Web開発など高レイヤーのライブラリ群が充実しています。しかし、パフォーマンスが要求される部分ではCやRustに比べて劣ることがあります[1]。
一方、Rustはメモリやスレッドの安全性に重点を置いて設計されており、CPUやメモリなどの低レイヤーの処理効率に優れています。プログラムを書くこと自体が難しいとされている低レイヤーの処理を、パフォーマンスを損なわず書くことができる言語として広く利用されるようになりました。
2つの言語の特徴を生かし、Pythonでパフォーマンスが要求される処理を全てRustで置き換えることができれば性能向上が期待できますが、それにはRustの学習コストもあり大変です。
全てのプログラムをRust化するのは難しくとも、場合によっては
Pythonにおけるその他の高速化方法
Pythonの高速化には、他にも以下のような手法が知られています。それぞれに特徴がありますが、今回紹介するmaturinはRustを使用した比較的新しい手法です。この機会にぜひ知っていただけたらと思います。
手法 | 概要 |
---|---|
Cython | Pythonライクなコードを、C言語に変換することで高速化を行う言語 |
numba | PythonやNumPyで書かれた数値計算コードを高速に実行するためのJITコンパイラ |
pypy | Pythonの実装の1つで RPython によるJITコンパイラによって高速化される。 |
PyO3とmaturinについて
本記事では、Rustで書かれたプログラムをPythonから呼び出して実行するために、PyO3とmaturinを使用します。それぞれ以下のような役割があります。
PyO3はRustとPythonの相互運用を実現するためのライブラリです。PyO3によってRustからPythonのオブジェクトや関数を呼び出す、またはその逆の操作も行えます。
maturinはPythonの拡張モジュールをビルドし、Rustのクレート
それぞれの関係を簡単な図で表すと以下のようになります。

このように、maturinによってビルドが簡単になり、開発者は複雑なビルドプロセスを気にすることなくRustとPythonを使用した開発に集中することができます。なお、PyO3とmaturinのGitHubリポジトリは、以下のようにどちらもPyO3配下で開発が進められています。
以下にGitHubのリンクも掲載しておきます。
最近ではPythonのデータ分析関連、バリデーションツール、暗号化ライブラリなど、さまざまなプロジェクトでPyO3やRustを使用している事例も増えてきており、PythonとRustがより親和性の高い言語になっていることが窺えます。
参考までに、PyO3を使用しているプロジェクトには以下があり、Pythonのサードパーティライブラリの高速化にRustの特性が生かされています。
- Pydantic-core:バリデーションツール
- V2からPyO3によるバインディングに変更される
- 参考:Pydantic V2 Plan
- 参考:PyCon US 2023 Talks - Samuel Colvin: How Pydantic V2 leverages Rust's Superpowers
- 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
インストールを実行すると以下のようにインストール方法のオプション選択が表示されます。標準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/
ファイルの再読み込みが必要です。標準出力の結果に記載があるとおり、以下のコマンドで行います。
$ 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_
関数において引数で指定された文字列について英字、数値、その他の文字列ごとにカウントした結果を返します。このサンプルコード自体をPythonでさらに最適化する方法もあると思いますが、今回はこのスクリプトをRust化してみます。なお、このサンプルスクリプトは後述の説明でも使用しますので、example_
として保存しておきます。
# 文字列を引数として英字、数値、その他の文字列をカウントした結果を返す
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プロジェクトを作成します
コマンドを実行するとバインディングの種類選択が要求されます。先頭にある
(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
ディレクトリを見てみると、以下のようなファイルやディレクトリが作成されています
$ tree -a . ├── example_base.py # pythonのサンプルスクリプト ├── strcounter │ ├── .gitignore │ ├── Cargo.toml │ ├── pyproject.toml │ ├── src │ │ └── lib.rs │ ├── .github │ │ └── workflows │ └── CI.yml
作成された主なファイルの概要は以下の通りです。
ファイル名 | 概要 |
---|---|
Cargo. |
Rustのビルドツールであるcargoの定義ファイル |
pyproject. |
Pythonパッケージのビルドに必要な情報の定義ファイル |
src/ |
Pythonバインディング用のscaffold |
.github/ |
GitHub Actions用ワークフロー定義ファイル |
Cargo.
注目すべきはRustスクリプトを記述するsrc/
1 use pyo3::prelude::*;
2
3 /// Formats the sum of two numbers as string.
4 #[pyfunction]
5 fn 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]
11 fn 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_
関数#[pymodule]
属性でマークされたstrcounter()
関数strcounter()
関数が行っていることはsum_
関数をモジュールとして登録することだけです。
sum_
は文字列の連結を行う関数ですが、この部分を、本記事のサンプルコードcount_
関数をRust化して置き換えるのが次のステップです。
関数をRust化して書き換える
先述のとおり、Rustを理解して書き進めていくにはそれなりの学習時間を必要とします。詳細な解説を本記事で行っていくには限界があるため、本記事では最低限必要な部分の説明のみとしています。とはいえ、本サンプルの関数自体は数行のコードで、Pythonのサンプルコードともほぼ同じような構成です。Rustのコードを初めて見るという方もいると思いますが、とりあえず雰囲気で読んでみてください
Rust化にあたり、lib.
sum_
関数を削除してas_ string() count_
関数を作成chars() - 27行目の
wrap_
をpyfunction!(sum_ as_ string, m) wrap_
に変更pyfunction!(count_ chars, m)
修正後のlib.
1 use pyo3::prelude::*;
2
3 #[pyfunction]
4 fn 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]
26 fn 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パッケージがダウンロード、コンパイルされ、仮想環境
(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.
とあるように、Pythonのwheel形式
ビルドしたRustパッケージをPythonバインディングとしてインポートする
それでは実際に使用してみましょう。インポートはimport strcounter
とするだけです。関数として呼び出すにはstrcounter.
のようにします。特別な使用方法もなく、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_time.
モジュールを使用し、100回実行した平均値を比較します。
また、計算に使用する文字列が少ないとそれほど差が出ないので、長めの文字列を使用してみます。今回は先月
計測用のコードでは、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/
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.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.[dependencies]
属性に以下のように依存関係を追記する必要があります。
[dependencies]
pyo3 = "0.19.0"
regex = "1" # regexを追加
lib.maturin develop --release
コマンドでビルドを行い、計測用のスクリプトを以下のように作成しました。計測は前回と同じくtime.
モジュールで行っています。
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で書き換えてみるきっかけになれば幸いです。