前回の fannkuch-redux に続き、The Benchmarks Game の mandelbrot で C++ と Rust の性能差を調査した。
結論から言うと、Benchmarks Game の上位実装は言語によらず SIMD intrinsics をしっかり書いているものが占めている。Rust でも intrinsics を使えば C++ と同等の性能が出る。言語間の差に見えるものの正体は、intrinsics を使っているかどうかの差であることが多い。本記事では、intrinsics を使わずに [f64; 8] の要素ごと演算で書いた場合になぜ 1.4 倍遅くなるのか、その原因と対策を掘り下げる。
環境
| 項目 | 値 |
|---|---|
| CPU | Intel Core i7-8650U (Kaby Lake R) |
| target-cpu | ivybridge (AVX のみ、AVX2 なし) |
| rustc | 1.94.0 (LLVM 21.1.8) |
| g++ | 15 (Debian 12.2.0-14) |
| OS | Linux 6.1.0-26-amd64 |
| 題材 | mandelbrot 16000×16000 |
ベンチマーク結果
best of 5 runs
| 実装 | Time | Instructions | L1-dcache-loads | vs C++ |
|---|---|---|---|---|
C++ (__m256d intrinsics) |
0.51s | 12.6G | 140M | 1.00x |
Rust (__m256d intrinsics) |
0.52s | 13.1G | 365M | 1.02x |
Rust (std::simd, nightly) |
0.52s | 12.8G | 237M | 1.02x |
Rust ([f64; 8] trait impl) |
0.71s | 20.5G | 956M | 1.39x |
Rust でも std::arch::x86_64 の intrinsics を使えば C++ と同等の性能が出る。問題は [f64; 8] に対する要素ごとの演算を trait impl で書いた場合に 1.39 倍遅くなること。
元のコード
Benchmarks Game に投稿されている Rust 実装 (#6) がベース。8ピクセルを [f64; 8] で同時処理する。
#[derive(Clone, Copy)] #[repr(align(32))] struct F64x8([f64; 8]); impl Mul for F64x8 { fn mul(self, rhs: F64x8) -> F64x8 { F64x8([ self.0[0] * rhs.0[0], self.0[1] * rhs.0[1], self.0[2] * rhs.0[2], self.0[3] * rhs.0[3], self.0[4] * rhs.0[4], self.0[5] * rhs.0[5], self.0[6] * rhs.0[6], self.0[7] * rhs.0[7], ]) } }
意図としては、8要素の並列演算を書けばコンパイラが <4 x double> × 2 にベクトル化してくれるだろうというもの。C++ 版は __m256d intrinsics で明示的に SIMD 命令を使っている。
根本原因: rustc の BackendRepr
rustc のソースを追うと原因が見える。
① 配列は無条件に BackendRepr::Memory になる
// compiler/rustc_abi/src/layout.rs fn array_like(...) -> LayoutData { // ... backend_repr: BackendRepr::Memory { sized: true } }
[f64; 4] でも [u8; 1000] でも、配列は全て Memory。#[repr(simd)] が付いた型は BackendRepr::SimdVector になり、LLVM の <4 x double> に直接マッピングされる。
② Memory 表現の配列は alloca + GEP + load/store でコード生成される
// compiler/rustc_codegen_ssa/src/mir/rvalue.rs:655 // "All arrays have BackendRepr::Memory"
③ インライン化後、SROA がスカラーに分解
self.0[0] * rhs.0[0] のような要素アクセスが個別の fmul double になる。配列だったという情報は消失する。
④ SLP Vectorizer が事後的に再ベクトル化を試みるが不完全
LLVM の SLP (Superword Level Parallelism) Vectorizer は、スカラー命令の中から並列実行可能なグループを見つけてベクトル化する。算術演算 (fmul, fadd, fsub) は <4 x double> にまとめてくれる。しかし以下は苦手
- 比較 + AND チェーン (
fcmp ogt× 8 +and i1× 7): 部分的にしかベクトル化されない - ビットマスク生成 (
fcmp ole→zext i1→shl(不均一定数) →or): SLP のパターン認識にマッチしない
C++ 版なら vcmplepd + vmovmskpd の2命令で済む処理が、SLP 経由だと大量のスカラー命令になる。
#[repr(simd)] を使えば?
#[repr(simd)] を付ければ BackendRepr::SimdVector になるが、SIMD 型に対する配列インデックスアクセス (self.0[i]) は MIR レベルで禁止されている (MCP#838)。simd_add 等の intrinsics が必要で元のコードスタイルは維持できない。
std::simd (nightly の portable_simd) なら safe に書けて intrinsics 同等の性能が出るが、安定化されていない。
アセンブリ分析
当初の仮説は「SLP が不完全だからスカラーコードが残って遅い」だった。しかしアセンブリを読むと状況はもう少し複雑だった。
内部ループは完璧にベクトル化されていた
mand8 関数の内部ループ (5イテレーション × 8回) のアセンブリ:
.LBB5_2: ; ホットループ vmulpd %ymm11, %ymm11, %ymm5 ; zr² (4 doubles 同時) vmulpd %ymm14, %ymm14, %ymm6 vmulpd %ymm12, %ymm12, %ymm7 vsubpd %ymm7, %ymm5, %ymm5 ; zr² - zi² vaddpd %ymm5, %ymm0, %ymm5 ; + cr ; ... 全て ymm レジスタ (256-bit) での演算 decq %rax jne .LBB5_2
全命令が vmulpd/vaddpd/vsubpd の ymm (256-bit = <4 x double>) 演算。SLP は内部ループの算術演算を完璧にベクトル化していた。
問題はループの外にあった
Mandelbrot の計算は 50 イテレーション。元のコードでは「5イテレーション × 8回 (早期脱出チェック付き)」のループの後、末尾の「5イテレーション × 2回」がループ外にアンロールされていた
for _ in 0..8 { do_5_iters!(); // ループ内: 完璧にベクトル化 if abs.all_gt(4.0) { return 0; } } do_5_iters!(); // ループ外: 問題のコード do_5_iters!(); // ループ外: 問題のコード
この末尾2回分が LLVM にアンロールされ、約350命令のスカラー/shuffle 混在コードになっていた
; ループ外の末尾コード (抜粋) vperm2f128 $49, %ymm14, %ymm11, %ymm3 ; shuffle vunpcklpd %xmm14, %xmm6, %xmm6 ; shuffle vextractf128 $1, %ymm8, %xmm7 ; shuffle vmulsd %xmm5, %xmm3, %xmm3 ; スカラー演算 vaddsd %xmm3, %xmm3, %xmm3 ; スカラー演算 vaddsubpd %xmm2, %xmm5, %xmm5 ; ... 約350命令続く
ループ構造がなくなると、SLP は10イテレーション分の巨大な演算 DAG を相手にすることになる。部分的にしかベクトル化できず、イテレーション間の値受け渡しで vperm2f128, vunpcklpd, vextractf128 等の shuffle が大量発生し、一部は vmulsd (スカラー) に退化していた。
perf stat の数字
| Rust (元) | C++ | |
|---|---|---|
| instructions | 20.5G | 12.6G |
| L1-dcache-loads | 956M | 140M |
命令数 1.6 倍、L1 ロード 6.8 倍。ループ外のスカラーコードとスタックへの spill が原因。
解決: 選択的 #[inline(never)]
内部ループが完璧にベクトル化されるなら、末尾もループの中に入れればいい。ただし単純にループ回数を増やすとループ内の条件分岐が増えて最適化を阻害する (実際に試して 0.80s に悪化した)。
最終的に効いたのは早期脱出チェックが不要な区間を #[inline(never)] 関数に分離するアプローチ
#[inline(never)] fn do_n_rounds( n: usize, zr: &mut F64x8, zi: &mut F64x8, cr: &F64x8, ci: &F64x8, abs: &mut F64x8, ) { for _ in 0..n { for _ in 0..ITERATIONS_WITHOUT_CHECK { do_iter(zr, zi, cr, ci, abs); } } } #[inline(never)] fn mand8(cr: &F64x8, ci: &F64x8, last_pixels: u8) -> u8 { // ... if last_pixels == 0 { // 早期脱出パス: インラインのまま (ループ内でチェック) for _ in 0..8 { do_5_iters!(); if abs.all_gt(4.0) { return 0; } } } else { // 全ピクセル集合内パス: 関数呼び出し do_n_rounds(8, &mut zr, &mut zi, cr, ci, &mut abs); } // 末尾2ラウンド: 関数呼び出し (アンロールさせない) do_n_rounds(2, &mut zr, &mut zi, cr, ci, &mut abs); abs.le_mask(4.0) }
do_n_rounds が #[inline(never)] なので
- LLVM はこの関数内でループ構造を維持する
- SLP がループ本体だけを最適化し、
<4 x double>の綺麗なパターンを維持 - 末尾の10イテレーションがループ外にアンロールされない
関数呼び出しのコスト (数命令) vs 排除できたスカラー/shuffle コード (約350命令)。後者が圧倒的に重い。
改善後の結果
| 実装 | Time | Instructions | L1-dcache-loads | vs C++ |
|---|---|---|---|---|
C++ (__m256d intrinsics) |
0.51s | 12.6G | 140M | 1.00x |
Rust (__m256d intrinsics) |
0.52s | 13.1G | 365M | 1.02x |
Rust (std::simd, nightly) |
0.52s | 12.8G | 237M | 1.02x |
| Rust (選択的 inline(never)) | 0.57s | 15.1G | 911M | 1.12x |
| Rust (元) | 0.71s | 20.5G | 956M | 1.39x |
命令数が 20.5G → 15.1G に 26% 減少。実行時間は 0.71s → 0.57s で 20% 改善。
試して効果がなかったもの
all_gt を f64::min ツリーリダクションに変更
let m01 = self.0[0].min(self.0[1]); let m23 = self.0[2].min(self.0[3]); // ... m_all > threshold
0.63s → 0.66s (悪化)。f64::min が fminnum intrinsic になり NaN ハンドリングのオーバーヘッドが入る。if a < b { a } else { b } に変えても 0.64s で改善せず。
F64x8 を F64x4 × 2 に分離
SLP が2グループを独立にベクトル化できるはず → 0.63s → 0.66s (悪化)。分離するとレジスタ圧力が増加する。
ループ統合 (条件付き早期脱出チェック)
末尾をメインループに統合して if i < 8 { check; } → 0.80s (大幅悪化)。条件分岐の追加がホットループ全体のコード生成に影響した。
残りの 12% の要因
改善後 (0.57s) と C++ (0.51s) にはまだ 12% のギャップがある。
原因は2つ
all_gt/le_maskの比較・マスク処理: C++ はvcmplepd+vmovmskpdの2命令。SLP 経由ではvcmpnltpd→vextractf128→vpor→vpackssdw→vtestpsと5命令以上になるdo_n_roundsの関数呼び出しオーバーヘッド: メモリ経由の引数渡し (L1-dcache-loads が 911M vs 140M)
1 は SLP の構造的限界。fcmp → and i1 チェーンや zext → shl(不均一定数) → or は SLP のリダクション認識にマッチしない。2 は #[inline(never)] の代償。どちらも [f64; N] + SLP の枠組みでは解消できない。
性能差の要因分析
| 要因 | レベル |
|---|---|
BackendRepr::Memory による LLVM IR のスカラー化 |
rustc の設計制約 |
| SLP による算術演算の再ベクトル化 | LLVM 最適化 (成功) |
| SLP による比較・マスク操作の再ベクトル化 | LLVM 最適化 (失敗) |
| ループ外アンロールによるコード品質劣化 | LLVM 最適化 (副作用) |
#[inline(never)] によるループ構造維持 |
ソースレベル対策 |
1.39x の性能差の内訳: ループ外アンロール問題が約 0.14s (全体の半分以上)、比較・マスクの SLP 限界が残りの約 0.06s。前者はソースレベルの #[inline(never)] で解消できたが、後者は [f64; N] + SLP の枠組みでは解消できない。intrinsics や std::simd を使えばこの制約を回避でき、C++ と同等の性能になる。