以下の内容はhttps://orisano.hatenablog.com/entry/2026/03/08/022408より取得しました。


Benchmarks Game mandelbrot: Rust の `[f64; 8]` が C++ の intrinsics より 1.4 倍遅いのはなぜか

前回の 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 olezext i1shl (不均一定数) → 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/vsubpdymm (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)] なので

  1. LLVM はこの関数内でループ構造を維持する
  2. SLP がループ本体だけを最適化し、<4 x double> の綺麗なパターンを維持
  3. 末尾の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_gtf64::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::minfminnum intrinsic になり NaN ハンドリングのオーバーヘッドが入る。if a < b { a } else { b } に変えても 0.64s で改善せず。

F64x8F64x4 × 2 に分離

SLP が2グループを独立にベクトル化できるはず → 0.63s → 0.66s (悪化)。分離するとレジスタ圧力が増加する。

ループ統合 (条件付き早期脱出チェック)

末尾をメインループに統合して if i < 8 { check; } → 0.80s (大幅悪化)。条件分岐の追加がホットループ全体のコード生成に影響した。

残りの 12% の要因

改善後 (0.57s) と C++ (0.51s) にはまだ 12% のギャップがある。

原因は2つ

  1. all_gt / le_mask の比較・マスク処理: C++ は vcmplepd + vmovmskpd の2命令。SLP 経由では vcmpnltpdvextractf128vporvpackssdwvtestps と5命令以上になる
  2. do_n_rounds の関数呼び出しオーバーヘッド: メモリ経由の引数渡し (L1-dcache-loads が 911M vs 140M)

1 は SLP の構造的限界。fcmpand i1 チェーンや zextshl(不均一定数) → 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++ と同等の性能になる。




以上の内容はhttps://orisano.hatenablog.com/entry/2026/03/08/022408より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14