SIMDとベクトル化
この章でわかること:
- SIMD: 1つの命令で複数のデータを同時に計算する仕組み
- コンパイラによる自動ベクトル化と、それが効く条件
- 整数の合計は速いのに、浮動小数点の合計が12倍遅い理由
portable_simdによる明示的なSIMDの書き方- CPUのより新しいSIMD命令をコンパイラに許可する方法
1命令で複数のデータ — SIMD
Section titled “1命令で複数のデータ — SIMD”前章までのCPUは、1つの加算命令で1組の数を足していました。 しかし「配列の全要素に同じ計算をする」処理では、 同じ命令を延々と繰り返すことになります。 それなら、1つの命令で複数の要素をまとめて計算できるほうが効率的です。
この方式をSIMD(single instruction, multiple data)と呼びます。
SIMD命令は、通常より幅の広いベクトルレジスタ(vector register)を使います。
例えば128ビットのベクトルレジスタには、32ビット整数(i32)が4個入ります。
この「まとめて扱う1区画」をレーン(lane)と呼びます。
次の図がスカラ(1個ずつ)との違いです。
同じ4組の加算でも、SIMDなら1命令で済む
SIMD命令はCPUの世代とともに拡張されてきました。 おおまかには次の系統を知っていれば十分です。
- x86-64 — SSE2(128ビット、全x86-64 CPUが対応)、 AVX/AVX2(256ビット)、AVX-512(512ビット、対応CPU限定)
- ARM64 — NEON(128ビット、全ARM64 CPUが対応)、SVE(可変長、対応CPU限定)
コンパイラは既定では「そのアーキテクチャの全CPUが持つ命令」しか 使いません。x86-64ならSSE2、ARM64ならNEONが基準です。
自動ベクトル化 — コンパイラは勝手にSIMDを使う
Section titled “自動ベクトル化 — コンパイラは勝手にSIMDを使う”SIMDを使うのに、特別なコードを書く必要はないことも多いです。 コンパイラ(正確にはLLVM。6章参照)は、 ループを解析してSIMD命令に変換する自動ベクトル化 (auto-vectorization)を行います。
1章と同じ方法で、i32のスライスを合計する関数のアセンブリを見てみます。
pub fn sum(v: &[i32]) -> i32 { let mut s = 0i32; for &x in v { s = s.wrapping_add(x); } s}releaseビルドでは、ループの中心部はこうなります(x86-64、抜粋)。
.LBB0_5: movdqu xmm2, xmmword ptr [rdi + 4*rax] paddd xmm1, xmm2 movdqu xmm2, xmmword ptr [rdi + 4*rax + 16] paddd xmm0, xmm2 add rax, 8 cmp r8, rax jne .LBB0_5xmm0〜xmm2が128ビットのベクトルレジスタ、padddが
「i32を4レーン同時に加算」する命令です。しかもxmm0とxmm1の
2本のアキュムレータに分けて足しています。前章で手作業でやった
「依存の鎖を分ける」変形を、コンパイラは自動で行い、
1周あたり8要素を処理しているのです。
自動ベクトル化が効きやすいのは、おおよそ次の条件を満たすループです。
- 連続したメモリを順に読み書きしている(スライス、
Vecの走査)。 非連続アクセスを扱えるSIMD命令もありますが、連続が最も効率的です - 反復間に依存がない(前の周の結果を次の周が使わない)。 合計のような集約は形の上では依存がありますが、順序を変えても 結果が変わらない整数では、並べ替え可能な特例として扱われます
- 分岐がないか、前章のif変換で消せる形をしている
2章のSoAが効く理由もここにつながります。必要なデータが連続して 並んでいることは、キャッシュだけでなくSIMDの前提条件でもあります。
実験: 整数は速いのに浮動小数点が遅い
Section titled “実験: 整数は速いのに浮動小数点が遅い”ここで不思議な現象を見てもらいます。同じ書き方の合計ループを、
i32とf32で実行します。
use std::time::Instant;
fn main() { let n = 1_000_000; let passes = 20; let ints: Vec<i32> = (0..n).map(|i| (i % 100) as i32).collect(); let floats: Vec<f32> = (0..n).map(|i| (i % 100) as f32).collect();
// i32 の合計 let start = Instant::now(); let mut sum = 0i64; for _ in 0..passes { let mut s = 0i32; for &v in ints.iter() { s = s.wrapping_add(v); } sum += s as i64; } println!("i32 の合計: {:>9.3?} (sum={sum})", start.elapsed());
// f32 の合計(まったく同じ書き方) let start = Instant::now(); let mut sum = 0.0f64; for _ in 0..passes { let mut s = 0.0f32; for &v in floats.iter() { s += v; } sum += s as f64; } println!("f32 の合計: {:>9.3?} (sum={sum:.0})", start.elapsed());}筆者の実測(Playground)ではi32が約1.5ミリ秒、f32が約18.6ミリ秒。
12倍の差がつきました。i32は上で見たとおりSIMD化され、
f32は1個ずつ順に足すコードのままだからです。
なぜコンパイラはf32をベクトル化しないのでしょうか。
浮動小数点数の加算は、数学の実数と違って結合則が成り立ちません。
有限の桁で丸めながら計算するため、(a + b) + cとa + (b + c)の結果は
わずかに異なることがあります。SIMD化(や複数アキュムレータ化)は
足す順序を変える変形なので、結果のビットが変わりうるのです。
Rustコンパイラは「書かれたとおりの結果」を守る側に倒し、
順序を変える最適化を勝手には行いません。
実験の出力をよく見ると、i32版とf32版で合計値そのものが
違っています(983,644,560 対 990,000,000)。これはf32の逐次加算で
丸め誤差が蓄積した結果で、「順序が結果を変える」ことの実例です。
明示的SIMD — portable_simd
Section titled “明示的SIMD — portable_simd”ではf32の合計を速くしたければどうするか。
「順序を変えてよい」という判断をプログラマが引き受けて、
明示的にSIMDで書きます。
nightlyのRustにはportable SIMD(std::simd)という、
CPUの種類によらず書けるSIMD APIがあります。f32x8は
「f32を8レーン持つベクトル」型です。次のコードはnightlyの
Playgroundで実行されます。
#![feature(portable_simd)]use std::simd::f32x8;use std::simd::num::SimdFloat;use std::time::Instant;
fn main() { let n = 1_000_000; let passes = 20; let floats: Vec<f32> = (0..n).map(|i| (i % 100) as f32).collect();
// スカラ: 1個ずつ足す let start = Instant::now(); let mut total = 0.0f64; for _ in 0..passes { let mut s = 0.0f32; for &v in floats.iter() { s += v; } total += s as f64; } println!("スカラ: {:>9.3?} (sum={total:.0})", start.elapsed());
// SIMD: 8レーンで足し、最後にレーンをまとめる let start = Instant::now(); let mut total = 0.0f64; for _ in 0..passes { let mut acc = f32x8::splat(0.0); let mut chunks = floats.chunks_exact(8); for c in &mut chunks { acc += f32x8::from_slice(c); } let s: f32 = acc.reduce_sum() + chunks.remainder().iter().sum::<f32>(); total += s as f64; } println!("f32x8 : {:>9.3?} (sum={total:.0})", start.elapsed());}筆者の実測(Playground)ではスカラ約18.5ミリ秒に対してSIMD版は約2.4ミリ秒、 約8倍です。さらに出力を見ると、SIMD版の合計は990,000,000と 正確な値になっています。8本の部分和それぞれが小さく保たれるため、 丸め誤差もむしろ減ったのです。速さのために精度を捨てたのではなく、 「足す順序」の設計をプログラマが引き受けた、というのが正しい理解です。
コードの補足を2つ。splatは全レーンを同じ値で埋める関数、
from_sliceはスライスの先頭8要素をレーンに読み込む関数、
reduce_sumは8レーンを1つの合計にまとめる関数です。
そしてf32x8の「8レーン」は論理的な幅です。実際に何ビットの
命令になるかはコンパイル対象のCPUに合わせて決まり、
既定のx86-64(SSE2)では128ビット命令2つに分割されます。
安定版での明示的SIMD — std::arch
Section titled “安定版での明示的SIMD — std::arch”std::simdはまだnightly限定です(2026年時点)。安定版で明示的に
SIMDを書く場合は、std::archにあるCPU命令直結の関数
(イントリンシック、intrinsic)を使います。
#[cfg(target_arch = "x86_64")]fn sum_f32(v: &[f32]) -> f32 { if is_x86_feature_detected!("avx2") { // AVX2対応CPUでだけ、AVX2版(unsafe)を呼ぶ unsafe { sum_f32_avx2(v) } } else { v.iter().sum() }}イントリンシックは対象CPUを直接指定するため移植性がなく、
unsafeも必要です。unsafeになる理由は「対応していないCPUで
実行すると未定義動作になる」ためです。したがって、AVX2版の関数に
#[target_feature(enable = "avx2")]を付けたうえで、実行時にCPUの
対応を調べるis_x86_feature_detected!(実行時機能検出)で
守って呼ぶのが決まった形です。
本書ではこれ以上深入りしませんが、「安定版で最後の手段として存在する」
ことを知っていれば十分です。実務では、wideのようなクレートを使うか、
自動ベクトル化が効く形にループを整えるのが現実的な選択です。
より新しいSIMD命令をコンパイラに許す
Section titled “より新しいSIMD命令をコンパイラに許す”自動ベクトル化の性能は、コンパイラに「どの命令まで使ってよいか」を 伝えるだけでも変わります。既定のx86-64では128ビットのSSE2までですが、 次の指定でAVX2(256ビット)などを解禁できます。
# 実行するマシン自身が持つ全命令を許可(そのマシン専用バイナリになる)RUSTFLAGS="-C target-cpu=native" cargo build --release
# 特定の拡張だけを許可RUSTFLAGS="-C target-feature=+avx2" cargo build --release注意点は配布です。AVX2を許可してコンパイルしたコードを
AVX2のないCPUで実行してはいけません。不正命令で停止するならまだよいほうで、
未定義動作になりえます。手元で動かす計算プログラムならtarget-cpu=nativeが
手軽ですが、配布物では既定のまま、もしくは実行時機能検出で
切り替える設計にします。
- SIMDは1命令で複数レーンを計算します。x86-64はSSE2(128ビット)が基準で、 AVX2以降は明示的な許可が必要です
- コンパイラは「連続・依存なし・分岐なし」のループを自動ベクトル化します。 データを連続に置くこと(2章)は、SIMDの前提条件でもあります
- 浮動小数点の加算は結合則が成り立たないため、順序を変える ベクトル化はコンパイラが勝手には行いません。実測では12倍の差になりました
portable_simd(nightly)なら明示的なSIMDを移植性のある形で書けます。 部分和が小さく保たれ、精度が改善する場合もあります
ここまでの高速化はすべて1つのコアの中の話でした。 現代のCPUにはコアが数個〜数十個あります。 次章では、複数のコアを使う並列処理と、 コア間でメモリを共有することの落とし穴を見ていきます。