SIMDとベクトル化
この章でわかること:
- SIMD: 1つの命令で複数のデータを同時に計算する仕組み
- コンパイラによる自動ベクトル化と、それが適用される条件
- 整数の合計は速いのに、浮動小数点の合計が12倍遅い理由
portable_simdによる明示的なSIMDの書き方- CPUのより新しいSIMD命令をコンパイラに許可する方法
SIMD: 1命令で複数のデータを計算する
前章までのCPUは、1つの加算命令で1組の数を足していました。 しかし「配列の全要素に同じ計算をする」処理では、 同じ命令を延々と繰り返すことになります。 それなら、1つの命令で複数の要素をまとめて計算できるほうが効率的です。
この方式をSIMD(single instruction, multiple data)と呼びます。
SIMD命令は、通常より幅の広いベクトルレジスタ(vector register)を使います。
例えば128ビットのベクトルレジスタには、32ビット整数(i32)が4個入ります。
この「まとめて扱う1区画」をレーン(lane)と呼びます。
次の図は、スカラ(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命令の生成
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_5
xmm0〜xmm2が128ビットのベクトルレジスタ、padddが
「i32を4レーン同時に加算」する命令です。しかもxmm0とxmm1の
2本のアキュムレータに分けて足しています。前章で手作業で行った
「依存の連鎖を分ける」変形を、コンパイラは自動で行い、
1周あたり8要素を処理しているのです。
自動ベクトル化が適用されやすいのは、おおよそ次の条件を満たすループです。
- 連続したメモリを順に読み書きするループです(スライス、
Vecの走査)。 非連続アクセスを扱えるSIMD命令もありますが、連続が最も効率的です - 反復間に依存がないループです(前の周の結果を次の周が使わない)。 合計のような集約は形の上では依存がありますが、順序を変えても 結果が変わらない整数では、並べ替え可能な特例として扱われます
- 分岐がないか、前章のif変換で消せる形をしているループです
2章のSoAが有利な理由もここにあります。必要なデータが連続して 並んでいることは、キャッシュだけでなくSIMDの前提条件でもあります。
実験: 整数は速いのに浮動小数点が遅い
同じ書き方の合計ループを、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の逐次加算で
丸め誤差が蓄積した結果で、「順序が結果を変える」ことの実例です。
fast-mathについて
C/C++コンパイラには、この制約を外して浮動小数点の並べ替えを
許可する-ffast-mathという指定があります。Rustの安定版には
相当する全体スイッチはなく、演算単位のfadd_fastなどが
nightlyに存在するのみです(2026年時点)。「精度と引き換えの高速化は、
プログラマが明示的に選ぶべき」というのがRustの設計方針です。
明示的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
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命令の使用をコンパイラに許可する
自動ベクトル化の性能は、コンパイラに「どの命令まで使ってよいか」を 伝えるだけでも変わります。既定の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にはコアが数個〜数十個あります。 次章では、複数のコアを使う並列処理と、 コア間でメモリを共有するときに生じる問題を扱います。