コンテンツにスキップ

SIMDとベクトル化

この章でわかること:

  • SIMD: 1つの命令で複数のデータを同時に計算する仕組み
  • コンパイラによる自動ベクトル化と、それが効く条件
  • 整数の合計は速いのに、浮動小数点の合計が12倍遅い理由
  • portable_simdによる明示的なSIMDの書き方
  • CPUのより新しいSIMD命令をコンパイラに許可する方法

前章までのCPUは、1つの加算命令で1組の数を足していました。 しかし「配列の全要素に同じ計算をする」処理では、 同じ命令を延々と繰り返すことになります。 それなら、1つの命令で複数の要素をまとめて計算できるほうが効率的です。

この方式をSIMD(single instruction, multiple data)と呼びます。 SIMD命令は、通常より幅の広いベクトルレジスタ(vector register)を使います。 例えば128ビットのベクトルレジスタには、32ビット整数(i32)が4個入ります。 この「まとめて扱う1区画」をレーン(lane)と呼びます。 次の図がスカラ(1個ずつ)との違いです。

スカラ: 加算命令を4回実行a0+b0a1+b1a2+b2a3+b3SIMD: ベクトル加算命令を1回実行(4レーン同時)a0a1a2a3+b0b1b2b31つのベクトルレジスタ(128ビット)に i32 が4レーン入る

同じ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_5

xmm0xmm2が128ビットのベクトルレジスタ、padddが 「i32を4レーン同時に加算」する命令です。しかもxmm0xmm12本のアキュムレータに分けて足しています。前章で手作業でやった 「依存の鎖を分ける」変形を、コンパイラは自動で行い、 1周あたり8要素を処理しているのです。

自動ベクトル化が効きやすいのは、おおよそ次の条件を満たすループです。

  • 連続したメモリを順に読み書きしている(スライス、Vecの走査)。 非連続アクセスを扱えるSIMD命令もありますが、連続が最も効率的です
  • 反復間に依存がない(前の周の結果を次の周が使わない)。 合計のような集約は形の上では依存がありますが、順序を変えても 結果が変わらない整数では、並べ替え可能な特例として扱われます
  • 分岐がないか、前章のif変換で消せる形をしている

2章のSoAが効く理由もここにつながります。必要なデータが連続して 並んでいることは、キャッシュだけでなくSIMDの前提条件でもあります。

実験: 整数は速いのに浮動小数点が遅い

Section titled “実験: 整数は速いのに浮動小数点が遅い”

ここで不思議な現象を見てもらいます。同じ書き方の合計ループを、 i32f32で実行します。

同じ書き方の合計。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());
}
stable / releasePlaygroundで開く ↗

筆者の実測(Playground)ではi32が約1.5ミリ秒、f32が約18.6ミリ秒。 12倍の差がつきました。i32は上で見たとおりSIMD化され、 f32は1個ずつ順に足すコードのままだからです。

なぜコンパイラはf32をベクトル化しないのでしょうか。

浮動小数点数の加算は、数学の実数と違って結合則が成り立ちません。 有限の桁で丸めながら計算するため、(a + b) + ca + (b + c)の結果は わずかに異なることがあります。SIMD化(や複数アキュムレータ化)は 足す順序を変える変形なので、結果のビットが変わりうるのです。 Rustコンパイラは「書かれたとおりの結果」を守る側に倒し、 順序を変える最適化を勝手には行いません。

実験の出力をよく見ると、i32版とf32版で合計値そのものが 違っています(983,644,560 対 990,000,000)。これはf32の逐次加算で 丸め誤差が蓄積した結果で、「順序が結果を変える」ことの実例です。

ではf32の合計を速くしたければどうするか。 「順序を変えてよい」という判断をプログラマが引き受けて、 明示的にSIMDで書きます。

nightlyのRustにはportable SIMD(std::simd)という、 CPUの種類によらず書けるSIMD APIがあります。f32x8は 「f32を8レーン持つベクトル」型です。次のコードはnightlyの Playgroundで実行されます。

f32の合計を8レーンで(nightly)
#![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());
}
nightly / releasePlaygroundで開く ↗

筆者の実測(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ビット)などを解禁できます。

Terminal window
# 実行するマシン自身が持つ全命令を許可(そのマシン専用バイナリになる)
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にはコアが数個〜数十個あります。 次章では、複数のコアを使う並列処理と、 コア間でメモリを共有することの落とし穴を見ていきます。