1. 目的
本記事では、Python(3.11)とRustという二つの異なるプログラミング言語における並列処理の実装方法について学びます。
また、RustのコードをPythonにモジュール化をしてパフォーマンスを比較し、なぜRustが優れているのかを探ります。
2. Pythonの並列処理
Pythonでは、標準ライブラリとしてconcurrent.futures
モジュールが提供されており、その中でもThreadPoolExecutor
はスレッドベースの並列処理を簡単に実装できる便利なツールです。以下は、ThreadPoolExecutor
を用いた簡単な並列処理の例で,0からn-1までの数値の二乗を計算する関数を並列に実行し配列に格納しています。
import concurrent.futures
def parallel_square_computation(n):
def compute_square(i):
return i * i
with concurrent.futures.ThreadPoolExecutor() as executor:
results = list(executor.map(compute_square, range(n)))
return results
3. Rustの並列処理
Rustはシステムプログラミング言語として、高いパフォーマンスと安全性を両立しています。並列処理においても強力なサポートがあり、特にrayon
クレートを利用することで簡潔かつ効率的に並列処理を実装できます。
3.1. Rayonの利用とその必要性
rayon
はデータ並列処理を簡単に実現できるクレートであり、スレッドプールの管理やタスクの分割を自動的に行ってくれます。以下は、rayon
を用いた並列処理の例です。
use rayon::prelude::*;
fn parallel_computation(n: usize) -> Vec<usize> {
(0..n).into_par_iter()
.map(|i| i * i)
.collect()
}
3.2. スレッドを用いた並列処理の難しさ
Rustでは標準ライブラリのstd::thread
を用いて並列処理を行うことも可能ですが、スレッドの生成や共有データの管理は手動で行う必要があり、コードが複雑化しがちです。例えば、以下のような実装はosレベルのスレッドの生成数が多くなり悪化します。
use std::thread;
use std::sync::{Arc, Mutex};
fn parallel_computation(n: usize) -> Vec<usize> {
let results = Arc::new(Mutex::new(vec![0; n]));
let mut handles = vec![];
for i in 0..n {
let results = Arc::clone(&results);
let handle = thread::spawn(move || {
let mut data = results.lock().unwrap();
data[i] = i * i;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
Arc::try_unwrap(results).unwrap().into_inner().unwrap()
}
fn main() {
let n = 1000;
let squares = parallel_computation(n);
println!("{:?}", squares);
}
このように、スレッド数が増加するとオーバーヘッドが大きくなり、パフォーマンスが低下する可能性があります。(この内容はRustに限らず、並列処理全般に言えることです)。
3.3. Rayonを用いた効率的な並列処理
rayon::iter
などを利用することで、タスクを効率的に分散し上記の問題を解決してくれます。
- IntoParallelIterator in rayon::iter - Rust
- ParallelIterator in rayon::iter - Rust
- rayon::iter - Rust
4. 比較
Rustのコードをmaturinを用いてPythonのモジュールとしてビルドし、Pythonから呼び出すことで、両者のパフォーマンスを比較しました(詳細なコード)。
実験内容としては、数字n
を与えた際に0
からn-1
のそれぞれについて二乗を計算し配列に入れるということを100回行いました。
maturinの利用については本ドキュメントでは扱いませんが非常に簡単にできるためmaturinの公式ドキュメントや上記の詳細コードのREADME.mdなどを参考にしてください。
下の画像はそれぞれのn
の100
回の試行に対する時間の分布です。
Rustのrayon
を用いた並列処理はPythonのThreadPoolExecutor
よりも高速であることが確認できます。
5. 考察
なぜ、同じ並列処理でここまでパフォーマンスに差が出るのか考察します。
5.1. PythonのGILの影響
PythonのGIL(Global Interpreter Lock)は、「一度に Python の バイトコード を実行するスレッドは一つだけであることを保証する仕組み(用語集より抜粋)」です。これにより、スレッドセーフな操作が保証されますが、一方でマルチスレッドによるCPUバウンドな処理の並列化を制限してしまいます。ThreadPoolExecutor
を用いても、実際にはGILの影響でスレッドが同時に動作することができず、シングルスレッドと同程度のパフォーマンスとなることが多いです。
- GILについての公式ドキュメントはこちら: 用語集 — Python 3.13.0 ドキュメント
- I/Oのタスクの場合はGILの影響を減らせることがこちらに記述されています: concurrent.futures --- 並列タスク実行 — Python 3.13.0 ドキュメント
補足: pythonでは GILを無効化させるオプションが追加されました。今回は試せていないですが、そちらを利用した場合、今回の結果は変わると思います(参考: 小門さんGILを無効化したPythonを早速試してみた (2024/06 更新))
5.2. Rustの所有権と型システムによる安全性
Rustは所有権と借用の概念を通じて、コンパイル時にデータ競合やメモリ安全性を保証します。このため、ランタイムでのロック管理が不要となり、rayon
のようなライブラリが効率的に並列処理を行うことが可能です。また、Rustの高いパフォーマンスは、低レベルの最適化が可能な言語設計に起因しています。
- なぜRustは速いのか書かれた記事: 所有権とライフタイム
- 所有権の存在意義について書かれた記事: @nirasanさん Rust の所有権システムが難しくて一度挫折したあの日の自分のためにわかりやすい読み物があるとよいと思ったのだ。 #Rust - Qiita
6. 最後に
並列処理を実装する際、Pythonはその簡潔さと豊富なライブラリにより、多くの場面で有用です。しかし、CPUバウンドなタスクや高いパフォーマンスが求められる場合、Rustは非常に強力な選択肢となります。Rustの高性能な並列処理ライブラリを活用することで、効率的かつ安全に並列処理を実現できます。
もし、高速な並列処理が必要なプロジェクトに取り組んでいるなら、PythonだけでなくRustの利用も検討してみてはいかがでしょうか。
今回並列処理と言っても、スレッドにおける極めて簡単な数値計算に注目をしましたがSIMDやGPUなどの他の並列手法やI/Oに関する並列の内容もあるため、興味があればそれらについても確認をしてみてはいかがでしょうか。
- SIMDに関して: std::simd - Rust
- GPUに関して: EmbarkStudios/rust-gpu: 🐉 Making Rust a first-class language and ecosystem for GPU shaders 🚧
7. おまけ
実は、今回の処理の場合はPythonの場合は並列実行しないほうが良いことがわかります
(rust-parallel-with-python/only_python.py at main · makinzm/rust-parallel-with-pythonを可視化したのが以下の内容です)。
おそらくオーバーヘッドの関係で並列処理をしないほうが良いということなのだと思います。
ただ、Pythonの並列処理をしていないものとRustの並列処理を比較しても、結局Rustのほうが速いことがわかります(実験結果をPlotするスクリプトの実行内容)。