偶然流れてきたポスト
この記事の内容に対する話ではないのだけど、一般的にRustはC/C++より若干パフォーマンスが劣るとされるのは何でだろう?LLVMとGCCの性能差?https://t.co/CRKrVKA8PT
— きさく (@namachan10777) 2026年3月7日
これを見て気になったので、The Computer Language Benchmarks Game の fannkuch-redux ベンチマークにおいて、C (gcc #6)・C++ (g++ #6)・Rust (#6) の最速実装を手元環境で追試し、perf と objdump を使ってコンパイラが生成したコードの差異を命令レベルで分析した。
ソースコード
- C (gcc #6): fannkuchredux-gcc-6 — Contributed by Ilya Kurdyukov
- C++ (g++ #6): fannkuchredux-gpp-6 — Contributed by Andrei Simion (with patch from Vincent Yu)
- Rust (#6): fannkuchredux-rust-6 — Contributed by Henry Jayakusuma
環境
| 項目 | 値 |
|---|---|
| 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], 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)
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: ホットスポット分布
各実装でサイクルの過半数を消費する命令
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
- C: 内側ループでメモリ操作ゼロ。マスクを動的計算、
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:
なぜ 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 版はそもそもこの操作を行わない設計)。