偶然流れてきたポスト
これを見て気になったので、The Computer Language Benchmarks Game の fannkuch-redux ベンチマークにおいて、C (gcc #6)・C++ (g++ #6)・Rust (#6) の最速実装を手元環境で追試し、perf と objdump を使ってコンパイラが生成したコードの差異を命令レベルで分析した。
ソースコード
環境
| 項目 |
値 |
| 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 命令
.L20:
vpaddb xmm7, xmm8, xmm15
vpshufb xmm4, xmm0, xmm15
vmovd r9d, xmm4
add eax, 1
vpblendvb xmm7, xmm7, xmm3, xmm7
vmovdqa xmm15, xmm4
vpshufb xmm0, xmm0, xmm7
test r9b, r9b
jne .L20
特徴
- マスクテーブル不要:
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 がボトルネック
.L_flips:
movsx rax, dl
movzx edx, [rsp+rax+0x40]
shl rax, 4
vpshufb xmm1, xmm2, [rsp+rax+0xd0]
lea eax, [rcx+1]
vmovdqa xmm2, xmm1
vmovdqa [rsp+0x40], xmm1
movsx ecx, al
test dl, dl
jne .L_flips
.L_flips:
movsx rax, dl
add ecx, 1
movzx edx, [rsp+rax+0x40]
shl rax, 4
vpshufb xmm1, xmm2, [rsp+rax+0xd0]
vmovdqa xmm2, xmm1
vmovdqa [rsp+0x40], xmm1
test dl, dl
jne .L_flips
ボトルネック
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 命令
.LBB_flips:
mov [rsp+0x30], rbx
mov [rsp+0x38], r11
movzx r15d, sil
movzx esi, [rsp+r15+0x30]
shl r15d, 4
vmovq xmm14, rbx
vmovq xmm15, r11
vpunpcklqdq xmm14, xmm14, xmm15
vpshufb xmm14, xmm14, [r15+r14]
vpextrq r11, xmm14, 1
vmovq rbx, xmm14
inc r10d
test sil, sil
jne .LBB_flips
.LBB_flips:
vmovdqa [rsp+0x10], xmm14
movzx r10d, r13b
movzx r13d, [rsp+r10+0x10]
shl r10d, 4
vpshufb xmm14, xmm14, [r10+r14]
inc ebx
test r13b, r13b
jne .LBB_flips
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], xmm14 → movzx 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, xmm → vmovd %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 倍速いのか
L1-dcache-loads が 12 分の 1 (701M vs 8.4B–8.8B)
- C: 内側ループでメモリ操作ゼロ。マスクを動的計算、
perm[first] も vpshufb でレジスタ内完結
- C++/Rust: 毎フリップで XMM → スタック store + バイト load + マスクテーブル load
IPC が 2 倍 (1.77 vs 0.81–0.94)
- C: 全命令がレジスタ間演算で、依存チェーンが短い
- C++/Rust: store-to-load forwarding ストールでパイプラインが停止。1 命令に 38–39% のサイクルが集中
ループアンロール (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 版はそもそもこの操作を行わない設計)。