はじめに
前回の記事「C++ 4種 vs Rust で行列積をベンチマーク比較してみた」では、C++ 4種(naive/OpenMP/Eigen/OpenBLAS)と Rust pure 実装を比較しました。
結果として pure Rust は C++ naive より遅いという結論でした。
「Rust って OpenMP とか Eigen とか OpenBLAS 使えないの?」
そこで今回は、Rust の代替ライブラリを使って同じ土俵で戦えるか検証します。
| C++ | Rust 代替 |
|---|---|
| OpenMP | Rayon |
| Eigen | nalgebra |
| OpenBLAS | cblas FFI |
環境
| 項目 | バージョン |
|---|---|
| OS | Ubuntu 22.04 (Dev Container) |
| rustc / cargo | 1.94.1 |
| rayon | 1.11.0 |
| nalgebra | 0.33.3 |
| cblas | 0.4.0 |
| OpenBLAS | 0.3.20(システム) |
設計の判断
なぜ Rayon は into_par_iter() なのか
OpenMP は #pragma omp parallel for というコンパイラ指示子ですが、Rust にそれはありません。Rayon はイテレータを並列化するライブラリとして同じ役割を果たします。
// 外側ループを並列化(OpenMP の #pragma omp parallel for に相当)
c.par_chunks_mut(n)
.enumerate()
.for_each(|(i, row_c)| {
for k in 0..n {
let a_ik = a[i * n + k];
for j in 0..n {
row_c[j] += a_ik * b[k * n + j];
}
}
});
par_chunks_mut(n) で結果行列を行単位に分割し、各行を並列処理します。
データ競合が型システムで保証されるのが Rust らしい特徴です。
nalgebra の列優先(column-major)に注意
nalgebra は列優先(Fortran 順)でメモリを管理します。Eigen のデフォルト(ColMajor)と同じです。
// from_fn は (row, col) でコールバックを受け取る
let a = DMatrix::<f64>::from_fn(n, n, |_, _| next_f64());
let b = DMatrix::<f64>::from_fn(n, n, |_, _| next_f64());
// * 演算子で行列積(内部で matrixmultiply クレートが SIMD 最適化)
let c = &a * &b;
内部では matrixmultiply クレートが使われており、SIMD 最適化が入ります。
OpenBLAS の FFI と build.rs
Rust から C ライブラリを呼ぶには build.rs でリンク指定が必要です。
// rust/build.rs
fn main() {
println!("cargo:rustc-link-lib=openblas");
println!("cargo:rustc-link-search=/usr/lib/x86_64-linux-gnu");
}
実際の呼び出しは unsafe ブロック内で行います:
use cblas::{dgemm, Layout, Transpose};
unsafe {
dgemm(
Layout::RowMajor,
Transpose::None,
Transpose::None,
n as i32, n as i32, n as i32,
1.0,
a, n as i32,
b, n as i32,
0.0,
c, n as i32,
);
}
C++ の cblas_dgemm と引数は実質同じです。unsafe が必要な点が唯一の違いです。
ハマりどころ
libopenblas-dev が入らない(前回からの引き続き)
このプロジェクトの Dev Container 環境では libopenblas-dev が apt install できません。
E: Package 'libopenblas-dev' has no installation candidate
libopenblas0 は入っているので .so シンボリックリンクを手動作成することで解決しました(詳細は前回記事参照)。
sudo ln -sf /usr/lib/x86_64-linux-gnu/libopenblas.so.0 \
/usr/lib/x86_64-linux-gnu/libopenblas.so
Rayon のスレッドプール初期化コスト
最初の計測が遅くなるケースがあります。Rayon はスレッドプールを遅延初期化するため、1回目の par_iter 呼び出し時にオーバーヘッドが発生します。3回計測して中央値を取る方式が有効です。
ベンチマーク結果
| 実装 | N=512 (ms) | N=1024 (ms) | N=2048 (ms) |
|---|---|---|---|
| C++ naive | 17.61 | 178.30 | 2830.72 |
| C++ openmp (4threads) | 12.82 | 91.57 | 1522.50 |
| C++ eigen | 9.40 | 59.09 | 475.61 |
| C++ openblas | 2.00 | 26.67 | 206.46 |
| Rust pure | 34.28 | 310.29 | 4505.59 |
| Rust + Rayon (4threads) | 31.20 | 264.91 | 2552.34 |
| Rust + nalgebra | 7.64 | 56.88 | 425.69 |
| Rust + OpenBLAS | 2.42 | 29.95 | 177.93 |
考察
**rust_openblas が全実装中で最速(N=2048: 177ms)**になりました。C++ OpenBLAS の 206ms を上回っています。これはビルドプロファイルの差(lto=true, codegen-units=1)が影響している可能性があります。
rust_nalgebra は C++ Eigen とほぼ同等(425ms vs 476ms)。内部の matrixmultiply クレートが SIMD 最適化を行っており、書き方も &a * &b と簡潔です。
Rayon は N=512 では pure より僅かに速い程度(31ms vs 34ms)。N=2048 では 2552ms vs 4506ms と約 1.8× の改善。C++ OpenMP の 1522ms には及ばないものの、並列化の恩恵は確かにあります。
pure Rust が最も遅いという結果は変わりませんでした。C++ の -march=native による SIMD 自動ベクトル化に対し、Rust の opt-level=3 だけでは追いつかないようです。
対応関係まとめ
| C++ | Rust | 速度比較(N=2048) |
|---|---|---|
| naive (2831ms) | pure (4506ms) | C++ が 1.6× 速い |
| OpenMP (1523ms) | Rayon (2552ms) | C++ が 1.7× 速い |
| Eigen (476ms) | nalgebra (426ms) | ほぼ同等(Rust が僅かに速い) |
| OpenBLAS (206ms) | rust_openblas (178ms) | Rust が 1.2× 速い |
まとめ
- rust_openblas(cblas FFI)は C++ OpenBLAS と同等以上。FFI 呼び出しのオーバーヘッドはほぼゼロ。
-
rust_nalgebra は C++ Eigen と互角。
matrixmultiplyの SIMD 最適化が効いている。 - Rayon は OpenMP の代替になるが、倍速にはならない。並列化の恩恵はあるが C++ OpenMP ほどではない。
-
pure Rust は C++ に劣る。
-march=nativeの SIMD 自動ベクトル化の差が大きい。
Rust で高速な行列演算が必要なら nalgebra か cblas FFI を使いましょう。