RustではじめるCPUとGPU

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個ずつ計算する方式)との違いを示します。

スカラ: 加算命令を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命令の生成

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の前提条件でもあります。

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

同じ書き方の合計ループを、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());
}

筆者の実測(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の逐次加算で 丸め誤差が蓄積した結果で、「順序が結果を変える」ことの実例です。

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で実行されます。

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());
}

筆者の実測(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にはコアが数個〜数十個あります。 次章では、複数のコアを使う並列処理と、 コア間でメモリを共有するときに生じる問題を扱います。