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


Benchmarks Game fannkuch-redux: C / C++ / Rust の性能差はなぜ?

偶然流れてきたポスト

これを見て気になったので、The Computer Language Benchmarks Game の fannkuch-redux ベンチマークにおいて、C (gcc #6)・C++ (g++ #6)・Rust (#6) の最速実装を手元環境で追試し、perfobjdump を使ってコンパイラが生成したコードの差異を命令レベルで分析した。

ソースコード

環境

項目
CPU Intel Core i7-8650U (Kaby Lake-R, 4C/8T, 1.90GHz base)
OS Debian, Linux 6.1.0-26-amd64
ISA Features SSE4.1, SSE4.2, AVX, AVX2
gcc / g++ 12.2.0 (Debian 12.2.0-14), 15.2.0 (Debian 15.2.0-14)
rustc 1.84.0 (9fc6b4312 2025-01-07), 1.94.0 (4a4ef493e 2026-03-02)

gcc 12.2 / rustc 1.84.0 は手元環境にたまたま入っていたもの。

コンパイルオプションは Benchmarks Game の公式設定に準拠

C:    gcc -pipe -O3 -fomit-frame-pointer -march=ivybridge -pthread
C++:  g++ -pipe -O3 -fomit-frame-pointer -march=ivybridge -std=c++17 -fopenmp
Rust: rustc -C opt-level=3 -C target-cpu=ivybridge -C codegen-units=1

Rust は Benchmarks Game 公式では rayon クレートを使用。本追試でも同様に cargo build --release + RUSTFLAGS="-C target-cpu=ivybridge" で再現した。

ベンチマーク結果

n=12 で 3 回実行した best を採用(time コマンド使用)。

実装 コンパイラ Wall time User CPU スレッド数 CPU時間比
C gcc 12.2 1.75s 7.06s 4 (pthread) 1.00x
C gcc 15.2 1.82s 7.26s 4 (pthread) 1.03x
Rust rustc 1.94.0 2.07s 15.89s 8 (rayon) 2.25x
C++ g++ 12.2 2.08s 15.93s 8 (OpenMP) 2.26x
C++ g++ 15.2 2.18s 16.46s 8 (OpenMP) 2.33x
Rust rustc 1.84.0 2.34s 17.77s 8 (rayon) 2.52x

C は 4 スレッドで 8 スレッドの C++ / Rust より速い。シングルスレッド性能に 2 倍以上の差がある。

perf stat: HW カウンタ比較

生データ: perf_stat.txt (gist)

メトリクス C gcc-12 C gcc-15 C++ g++-12 C++ g++-15 Rust 1.84 Rust 1.94
cycles 25.6B 26.8B 55.7B 57.6B 62.1B 55.6B
instructions 45.3B 44.7B 52.5B 50.2B 68.7B 45.3B
IPC 1.77 1.67 0.94 0.87 1.11 0.81
L1-dcache-loads 701M 482M 8,450M 8,457M 8,840M 8,833M
branches 4,714M 4,705M 6,667M 6,228M 6,417M 6,704M
branch-misses 462M (9.8%) 460M (9.8%) 596M (8.9%) 637M (10.2%) 608M (9.5%) 591M (8.8%)

注目すべき数値

  • IPC: C は 1.77、C++/Rust は 0.81〜0.94。同じ命令数でも C の方が 2 倍速くパイプラインを回せている
  • L1-dcache-loads: C は 701M、C++/Rust は 8.4B〜8.8B で 12 倍の差。C の内側ループにはメモリアクセスが存在しない
  • instructions: Rust 1.94 は 45.3B で C の 45.3B とほぼ同数。にもかかわらずサイクル数は 2.17 倍。命令あたりのレイテンシが問題。一方 Rust 1.84 は 68.7B と 52% 多い

アセンブリ

3 つの実装は同じ基本アルゴリズムを使っているが、ホットループの実装方針が根本的に異なる。

objdump 生データ: objdump_*.txt (gist)

C (gcc #6): 9 命令

;; gcc 12.2 / gcc 15.2 で同一構造
.L20:                                       ;        perf annotate
  vpaddb    xmm7,  xmm8, xmm15             ;  5.4%  動的に反転マスクを計算
  vpshufb   xmm4,  xmm0, xmm15             ;  2.3%  next = perm[perm[0]]
  vmovd     r9d,   xmm4                     ;        first を GPR に取得
  add       eax,   1                        ;  5.7%  flips++
  vpblendvb xmm7,  xmm7, xmm3, xmm7       ; 10.8%  マスク完成(identity とブレンド)
  vmovdqa   xmm15, xmm4                     ;        v3 更新
  vpshufb   xmm0,  xmm0, xmm7              ;        ★ 配列反転実行
  test      r9b,   r9b                      ;        first == 0?
  jne       .L20                            ;  4.2%

特徴

  • マスクテーブル不要: vpaddb + vpblendvb で反転マスクを動的計算。{0,-1,-2,...,-15}first を加算し、符号ビットで vpblendvb することで {first, first-1, ..., 0, identity...} を生成
  • _mm_cvtsi128_si32 で先頭要素を GPR に取得: レジスタ間転送 (vmovd) のみ、スタック経由なし
  • _mm_shuffle_epi8 でポインタ追跡: perm[first]vpshufb で XMM 内完結
  • 2 回アンロール: X(+=) / X(-=) マクロで偶数/奇数の checksum を交互処理、外側ループの分岐を半減

C++ (g++ #6): 9〜10 命令、store-to-load forwarding がボトルネック

;; g++ 12.2: 10命令
.L_flips:                                   ;        perf annotate
  movsx     rax,   dl                       ;  3.0%
  movzx     edx,   [rsp+rax+0x40]           ; 39.1%  ★ next = current[first]
  shl       rax,   4                        ;
  vpshufb   xmm1,  xmm2, [rsp+rax+0xd0]    ; 11.5%  reverse(マスクテーブル参照)
  lea       eax,   [rcx+1]                  ;  3.0%  flips++
  vmovdqa   xmm2,  xmm1                     ;
  vmovdqa   [rsp+0x40], xmm1                ;  4.0%  ★ current をスタックに書き戻し
  movsx     ecx,   al                       ;        (g++ 12 のみ、15 では消滅)
  test      dl,    dl                       ;
  jne       .L_flips                        ;  2.8%
;; g++ 15.2: 9命令(movsx ecx, al が消え add ecx, 1 に変更)
.L_flips:                                   ;        perf annotate
  movsx     rax,   dl                       ;  3.1%
  add       ecx,   1                        ;        flips++
  movzx     edx,   [rsp+rax+0x40]           ; 38.0%  ★ next = current[first]
  shl       rax,   4                        ;
  vpshufb   xmm1,  xmm2, [rsp+rax+0xd0]    ; 11.2%  reverse
  vmovdqa   xmm2,  xmm1                     ;  3.0%
  vmovdqa   [rsp+0x40], xmm1                ;  3.7%  ★ store back
  test      dl,    dl                       ;
  jne       .L_flips                        ;  2.4%

ボトルネック

  • reinterpret_cast<char(&)[16]>(current)[first] → コンパイラが XMM レジスタから可変インデックスのバイトを取得できず、vmovdqa で XMM → スタックに store → movzx でバイト load。XMM (16B) store の直後に 1B load するため、store-to-load forwarding がサイズ不一致で完全転送できず、ストールが発生。全サイクルの 38〜39% がこの 1 命令に集中
  • masks_reverse テーブルがスタック上: Masks 構造体(512B)がブロックごとにスタックに構築され、内側ループで毎回 16B load

Rust (#6): rustc 1.84.0 では 14 命令、1.94.0 では 8 命令

;; rustc 1.84.0 (LLVM 19.1.5) — 14 命令
.LBB_flips:                                 ;        perf annotate
  mov       [rsp+0x30], rbx                ; 13.3%  ★ GPR → stack (low 8B)
  mov       [rsp+0x38], r11                ;  2.3%  ★ GPR → stack (high 8B)
  movzx     r15d,  sil                     ;        zero-extend first
  movzx     esi,   [rsp+r15+0x30]          ; 20.1%  ★ next = perm[first] (STALL)
  shl       r15d,  4                       ;        index * 16
  vmovq     xmm14, rbx                    ;        GPR → XMM (low)
  vmovq     xmm15, r11                    ;        GPR → XMM (high)
  vpunpcklqdq xmm14, xmm14, xmm15         ;  6.4%  128-bit に結合
  vpshufb   xmm14, xmm14, [r15+r14]       ; 10.8%  反転実行
  vpextrq   r11,   xmm14, 1               ; 11.7%  ★ XMM → GPR (high)
  vmovq     rbx,   xmm14                  ;        XMM → GPR (low)
  inc       r10d                           ;        flips++
  test      sil,   sil                     ;        next == 0?
  jne       .LBB_flips                     ;
;; rustc 1.94.0 (LLVM 21.1.8) — 8 命令
.LBB_flips:                                 ;        perf annotate
  vmovdqa   [rsp+0x10], xmm14              ;  6.3%  ★ current をスタックに store
  movzx     r10d,  r13b                     ;        zero-extend first
  movzx     r13d,  [rsp+r10+0x10]           ; 36.7%  ★ next = perm[first] (STALL)
  shl       r10d,  4                        ;        index * 16
  vpshufb   xmm14, xmm14, [r10+r14]        ; 11.7%  ★ 反転実行(in-place)
  inc       ebx                             ;  3.2%  flips++
  test      r13b,  r13b                     ;        next == 0?
  jne       .LBB_flips                      ;  3.0%

rustc 1.84.0 の問題点

  • u128 を GPR ペア (rbx, r11) で保持: 毎イテレーション vmovq ×2 + vpunpcklqdq で XMM に組み立て、vpshufb で反転後、vpextrq + vmovq で GPR に戻す往復転送が発生
  • store-forwarding stall も発生するが、GPR↔XMM 転送コストに埋もれてサイクル比率は 20.1% に留まる

rustc 1.94.0 での改善

  • u128 を XMM レジスタ (xmm14) で保持: GPR↔XMM 往復転送が消滅し 14 → 8 命令に
  • store-forwarding 問題は C++ と同じ: vmovdqa [rsp+0x10], xmm14movzx r13d, [rsp+r10+0x10] で 36.7% のサイクルを消費
  • vpshufb xmm14, xmm14, [mem]: in-place 更新でレジスタコピー不要(C++ は vmovdqa xmm2, xmm1 が必要)

パーミュテーション増分の差異

ホットループにはフリップだけでなく、次のパーミュテーションへの遷移も含まれる。

実装 手法 特徴
C vpmovmskb + bsf で繰り上がり位置を一発検出 → 1 回の vpshufb ベクトル化。スカラーループなし
C++ count[i]int64_t 配列でスカラーインクリメント ループ。大半は 1 回で break
Rust 15 段のカスケードを完全アンロール 分岐予測に依存。命令数は少ないが IPC は低い

C 版はカウンタを XMM レジスタに保持し、vpmovmskb でオーバーフロー位置をビットマスクとして抽出、bsf で最下位ビットを見つけて 1 回の pshufb で完了する。

コンパイラバージョン間の差異

gcc 12.2 → gcc 15.2 (C)

内側フリップループは同一構造(9 命令、メモリアクセスゼロ)。IPC が 1.77 → 1.67 にわずかに低下。

変化点 詳細
レジスタ割り当て vmovd %edx, xmmvmovd %r9d, xmm など。本質的な差ではない
L1-dcache-loads 701M → 482M に減少。初期化コードが改善
IPC 低下 ループ外の命令スケジューリングが微妙に変化。結果 +3% の性能低下

g++ 12.2 → g++ 15.2 (C++)

g++ 12.2: 10 命令(movsx ecx, al が余分)
g++ 15.2:  9 命令(add ecx, 1 に変更、1 命令削減)
変化点 詳細
内側ループ 10 → 9 命令に微改善。flips カウンタの符号拡張が不要に
branch-misses 596M → 637M に増加 (+7%)。ループ外の分岐構造が変化
総合性能 命令削減と分岐ミス増が相殺し、ほぼ同等(+3% 悪化)

store-forwarding のボトルネック (38〜39%) は両バージョンで変化なし。

rustc 1.84.0 → rustc 1.94.0 (Rust)

github.com

rust-lang/rust#142915 で DestinationPropagation MIR 最適化パスがデフォルト有効化されたこと(2025-09-17 マージ、rustc 1.92.0 で stable 化)。

stable では rustc 1.91.0 が OLD、rustc 1.92.0 が NEW。

rustc 1.91.0 (stable):                                     OLD (14命令)
nightly-2025-09-17 (a9d0a6f15, LLVM 21.1.1):               OLD (14命令)  ← 最後の OLD
nightly-2025-09-18 (4645a7988, LLVM 21.1.1):               NEW (8命令)   ← 最初の NEW
rustc 1.92.0 (stable):                                     NEW (8命令)

両 nightly とも同じ LLVM 21.1.1 を使用しており、LLVM のバージョン変更は原因ではない。DestinationPropagation が不要なコピーを排除し MIR を単純化した結果、LLVM のレジスタアロケータが u128 を GPR ペアではなく XMM レジスタに保持するようになった。

メトリクス rustc 1.84.0 rustc 1.94.0 変化
内側ループ命令数 14 8 -43%
総 instructions 68.7B 45.3B -34%
cycles 62.1B 55.6B -10%
IPC 1.11 0.81 -27%
User CPU 17.77s 15.89s -11%
;; rustc 1.84.0(14命令)— GPR↔XMM 往復転送
mov     [rsp+0x30], rbx           ; GPR → stack (low)
mov     [rsp+0x38], r11           ; GPR → stack (high)
movzx   r15d, sil
movzx   esi, [rsp+r15+0x30]      ; stack → byte
shl     r15d, 4
vmovq   xmm13, rbx               ; GPR → XMM (low)
vmovq   xmm15, r11               ; GPR → XMM (high)
vpunpcklqdq xmm13, xmm13, xmm15  ; combine
vpshufb xmm13, xmm13, [r15+r14]  ; reverse
vpextrq r11, xmm13, 1            ; XMM → GPR (high)
vmovq   rbx, xmm13               ; XMM → GPR (low)
inc     r10d
test    sil, sil
jne     .loop

;; rustc 1.94.0(8命令)— XMM 保持
vmovdqa [rsp+0x10], xmm14        ; XMM → stack
movzx   r10d, r13b
movzx   r13d, [rsp+r10+0x10]     ; stack → byte
shl     r10d, 4
vpshufb xmm14, xmm14, [r10+r14]  ; reverse (in-place)
inc     ebx
test    r13b, r13b
jne     .loop

rustc の MIR 最適化により不要なコピーが排除され、LLVM が u128 の値を GPR ペアではなく XMM レジスタで保持するようになったことで

  • vmovq ×2 + vpunpcklqdq(GPR→XMM 組み立て: 3 命令)が消滅
  • vpextrq + vmovq(XMM→GPR 分解: 2 命令)が消滅
  • stack への store も mov ×2 → vmovdqa ×1 に統合

結果として C++ とほぼ同等の性能 (User CPU: 15.9s vs 16.0s) を達成した。

perf annotate: ホットスポット分布

生データ: perf_annotate_*.txt (gist)

各実装でサイクルの過半数を消費する命令

C (gcc 12.2)

 8.33%  vpblendvb xmm9, xmm3, xmm9, xmm9   ← 動的マスク生成
 8.23%  vpblendvb xmm14, xmm3, xmm14, xmm14  ← 2つ目のアンロール版
 6.41%  add       $1, %eax                    ← flips++
 6.30%  jne       .L20                        ← ループ末尾

メモリアクセスに起因するストールがゼロ。全サイクルが演算に使われている。

C++ (g++ 12.2)

39.09%  movzbl 0x40(%rsp,%rax,1), %edx     ← ★ store-to-load forwarding stall
11.48%  vpshufb 0xd0(%rsp,%rax,1), %xmm2   ← マスクテーブル load
13.63%  movsbq  %cl, %rdx                  ← checksum 計算

1 命令で全サイクルの 39%。直前の vmovdqa で 16B store した直後に 1B load するサイズ不一致の store-to-load forwarding。

Rust (rustc 1.84.0)

20.10%  movzbl 0x30(%rsp,%r15,1), %esi     ← ★ store-to-load forwarding stall
13.27%  mov    %rbx, 0x30(%rsp)            ← GPR → stack (low)
11.73%  vpextrq $0x1, %xmm14, %r11        ← ★ XMM → GPR (high)
10.75%  vpshufb (%r15,%r14,1), %xmm14      ← reverse
 6.36%  vpunpcklqdq %xmm15, %xmm14        ← GPR → XMM 結合
 2.31%  mov    %r11, 0x38(%rsp)            ← GPR → stack (high)

store-forwarding stall に加え、GPR↔XMM 間の往復転送がサイクルを消費している。vpextrq (11.73%) と mov ×2 + vpunpcklqdq (計 21.94%) が毎イテレーション発生。

Rust (rustc 1.94.0)

36.74%  movzbl 0x10(%rsp,%r10,1), %r13d    ← ★ store-to-load forwarding stall
11.72%  vpshufb (%r10,%r14,1), %xmm14      ← reverse
 6.27%  vmovdqa %xmm14, 0x10(%rsp)         ← store

GPR↔XMM 転送が消滅し、ボトルネックが store-forwarding に一本化された。

性能差の説明

なぜ C は 2.25 倍速いのか

  1. L1-dcache-loads が 12 分の 1 (701M vs 8.4B–8.8B)

    • C: 内側ループでメモリ操作ゼロ。マスクを動的計算、perm[first]vpshufb でレジスタ内完結
    • C++/Rust: 毎フリップで XMM → スタック store + バイト load + マスクテーブル load
  2. IPC が 2 倍 (1.77 vs 0.81–0.94)

    • C: 全命令がレジスタ間演算で、依存チェーンが短い
    • C++/Rust: store-to-load forwarding ストールでパイプラインが停止。1 命令に 38–39% のサイクルが集中
  3. ループアンロール (2x)

    • C: X(+=) / X(-=) で 2 パーミュテーション / イテレーション。外側ループのオーバーヘッドが半減
    • C++/Rust: 1 パーミュテーション / イテレーション

なぜ C++ と Rust は同等なのか (rustc 1.94.0 以降)

rustc 1.92.0 で DestinationPropagation MIR 最適化パスがデフォルト有効化され、rustc が LLVM に渡す IR が改善された結果、内側ループ構造が C++ とほぼ同一に

  • 両者とも 8–9 命令
  • 両者とも store-to-load forwarding が最大ボトルネック (37–39%)
  • 両者ともマスクテーブル参照方式

まとめ

差分の要因 レベル
動的マスク生成 vs テーブル参照 アルゴリズム設計
store-to-load forwarding コンパイラ / 言語制約
ベクトル化カウンタ vs スカラーループ アルゴリズム設計
ループ 2x アンロール ソースレベル最適化
GPR↔XMM 往復転送 (旧 Rust) rustc の MIR 最適化不足
gcc 12→15 / g++ 12→15 のレジスタ割り当て変化 コンパイラバージョン

C vs C++/Rust の 2.25x の差はコンパイラの最適化品質ではなく、ソースコードレベルでのアルゴリズム設計の差。C 版の作者 (Ilya Kurdyukov) は C++ #6 を「参考にした」と記述しているが、ホットパスを完全に再設計し、メモリアクセスをゼロにしている。この差はコンパイラバージョンを上げても埋まらない。

一方、旧 Rust の C++ に対する性能差は rustc の MIR 最適化不足が原因で、LLVM に渡す IR に不要なコピーが残っていたために LLVM が u128 を GPR ペアで保持してしまっていた。rustc 1.92.0 で DestinationPropagation がデフォルト有効化されたことで解消された。x86 には「XMM レジスタから可変インデックスで 1 バイトを取得する命令」が存在しないため、store-to-load forwarding は C++/Rust のどちらでも避けられない(C 版はそもそもこの操作を行わない設計)。




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

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