🌀 Mandelbrotでマルチスレッド入門
Rust で 5つの並列化手法を徹底比較してみた
Rust の並列処理は強力ですが、実際に複数の手法を比較しながら学べる教材は意外と多くありません。
そこで今回は、Jim Blandy 著「プログラミングRust 第2版」に登場する Mandelbrot 描画プログラムをベースに、5つのマルチスレッド手法を実装し、速度比較まで行いました。
- シングルスレッド
- 固定バンド × スレッド(crossbeam)
- Mutex を使ったタスクキュー
- ロックフリーな Atomic タスクキュー(unsafe あり)
- rayon による超簡単並列化
コードはすべて GitHub に公開しています。
- オリジナル:
https://github.com/programmingrust(github.com in Bing) - 本記事のコード: https://github.com/tnagata/mandelbrot
🎯 本記事の対象読者
- Rust の並列処理を体系的に学びたい
- Mutex / Atomic / rayon の違いを理解したい
- 実際に速度差がどれくらい出るのか知りたい
- unsafe を使った lock-free 実装に興味がある
🧩 1. シングルスレッド版(基準)
まずは基準となる 1 スレッド版。
最もシンプルで、後の比較の基準になります。
fn main() {
let start = Instant::now();
let bounds = (1200, 800);
let upper_left = Complex::new(-2.2, 1.2);
let lower_right = Complex::new(1.0, -1.2);
let mut pixels = vec![0; bounds.0 * bounds.1];
render(&mut pixels, bounds, upper_left, lower_right);
write_image("mandelbrot.png", &pixels, bounds).unwrap();
let elapsed = start.elapsed();
println!("処理時間: {:.3} 秒", elapsed.as_secs_f64());
}
🧩 2. 固定バンド × スレッド(crossbeam)
画像を 8 バンドに分割し、各バンドを 1 スレッドが担当します。
ただし 重いバンドを担当したスレッドがボトルネックになる のが弱点。
let bands: Vec<&mut [u8]> =
pixels.chunks_mut(rows_per_band * bounds.0).collect();
crossbeam::scope(|spawner| {
for (i, band) in bands.into_iter().enumerate() {
let top = rows_per_band * i;
let height = band.len() / bounds.0;
let band_bounds = (bounds.0, height);
let band_upper_left =
pixel_to_point(bounds, (0, top), upper_left, lower_right);
let band_lower_right =
pixel_to_point(bounds, (bounds.0, top + height),
upper_left, lower_right);
spawner.spawn(move |_| {
render(band, band_bounds, band_upper_left, band_lower_right);
});
}
}).unwrap();
🧩 3. Mutex を使ったタスクキュー
バンドを細かく分割し、スレッドがタスクを奪い合う方式。
ただし Mutex の lock/unlock がボトルネック になり、期待ほど速くならないケースも。
let bands = Mutex::new(pixels.chunks_mut(band_rows * bounds.0).enumerate());
crossbeam::scope(|scope| {
for _ in 0..threads {
scope.spawn(|_| {
loop {
match {
let mut guard = bands.lock().unwrap();
guard.next()
} {
None => return,
Some((i, band)) => {
let top = band_rows * i;
let height = band.len() / bounds.0;
let band_bounds = (bounds.0, height);
let band_upper_left =
pixel_to_point(bounds, (0, top), upper_left, lower_right);
let band_lower_right =
pixel_to_point(bounds, (bounds.0, top + height),
upper_left, lower_right);
render(band, band_bounds, band_upper_left, band_lower_right);
}
}
}
});
}
}).unwrap();
🧩 4. ロックフリー Atomic タスクキュー(unsafe あり)
ここからが本記事のハイライト。
AtomicUsize を使い、Mutex を完全に排除した lock-free 実装です。
特徴
- タスクは AtomicUsize のインクリメントで管理
- 描画領域は重ならないため &mut の競合は起きない
-
unsafeは「生ポインタ → &mut [u8]」の 1 箇所のみ - Ordering は SeqCst ではなく Relaxed / Acquire / Release を使用
pub struct AtomicWorkQueue {
next: AtomicUsize,
end: usize,
step: usize,
}
impl AtomicWorkQueue {
pub fn next(&self) -> Option<(usize, usize)> {
loop {
let current = self.next.load(Ordering::Relaxed);
if current >= self.end {
return None;
}
let new = (current + self.step).min(self.end);
if self.next.compare_exchange(
current, new,
Ordering::Relaxed, Ordering::Relaxed
).is_ok() {
return Some((current, new));
}
}
}
}
unsafe ブロックはこの 1 行だけ:
let band = unsafe {
std::slice::from_raw_parts_mut((pixels_ptr as *mut u8).add(start), len)
};
🧩 4b. SendPtr でより安全にしたバージョン
生ポインタを usize にキャストする“コンパイラ騙し”をやめ、
Send なラッパー構造体で安全性を高めた版。
#[derive(Copy, Clone)]
struct SendPtr<T>(*mut T);
unsafe impl<T> Send for SendPtr<T> {}
impl<T> SendPtr<T> {
fn as_ptr(self) -> *mut T { self.0 }
}
Ordering も Acquire / Release を使用し、より慣習的な形に。
🧩 5. rayon 版(最も簡単で高速)
rayon の魔法。
1 行書き換えるだけで並列化が完了します。
pixels
.chunks_mut(bounds.0)
.enumerate()
.collect::<Vec<_>>()
.into_par_iter()
.for_each(|(i, band)| {
let top = i;
let band_bounds = (bounds.0, 1);
let band_upper_left = pixel_to_point(bounds, (0, top), upper_left, lower_right);
let band_lower_right = pixel_to_point(bounds, (bounds.0, top + 1),
upper_left, lower_right);
render(band, band_bounds, band_upper_left, band_lower_right);
});
⚡ 速度比較(実測)
| 手法 | 説明 | 実行時間 |
|---|---|---|
| single-threaded | 1 スレッド | 0.106 秒 |
| bands | 固定バンド × 8 | 0.034 秒 |
| experiment | 標準クレート版 | 0.032 秒 |
| task-queue | Mutex でタスク分配 | 0.022 秒 |
| lockfree | Atomic + SeqCst | 0.015 秒 |
| lockfree-simple | Atomic + Relaxed | 0.015 秒 |
| lockfree-Send | Acquire/Release | 0.015 秒 |
| rayon | 1 行で並列化 | 0.014 秒 |
✔ 結論
- rayon が最速かつ最も簡単
- lock-free 実装は高速だが、rayon との差はわずか(測定誤差内)
- Mutex 版は lock-free に比べると遅い
- 固定バンド方式は負荷分散が弱い
📦 まとめ
- Rust の並列処理は手法によって複雑さも性能も大きく変わる
- Atomic を使った lock-free 実装は高速で学習価値が高い
- rayon は圧倒的に簡単で、実用上は最強クラス
- Mandelbrot は並列化の教材として非常に面白い
📚 ソースコードと詳細解説
本記事のコードと、より詳しい解説は GitHub にまとめています。
ぜひ clone して試してみてください。
https://github.com/tnagata/mandelbrot