自動ベクトル化とSIMDの活用
ループを書き換えるだけで数倍速くなる鍵がSIMD。ベクトル化の成立条件と失敗要因を押さえ、コンパイラ任せと組み込み関数を正しく使い分けられるようになる。
- 1.SIMDは1命令で複数要素を同時処理する仕組みで、自動ベクトル化はコンパイラがスカラループをSIMD命令に変換する最適化。成立にはデータ依存とアライメントの条件を満たす必要がある。
- 2.ループ運搬依存(前の反復の結果を次が使う)・関数呼び出し・複雑な制御フロー・ポインタエイリアスの疑いがあると、コンパイラは安全側に倒してベクトル化を諦める。
- 3.自動ベクトル化はコードを変えずに済むが当たり外れがある。確実に効かせたい・特殊命令を使いたい場合は組み込み関数で明示SIMDを書くが、移植性と保守コストとのトレードオフになる。
SIMDとは何か、なぜ速いのか
SIMD(Single Instruction, Multiple Data)は、1つの命令で複数のデータ要素を同時に処理するハードウェア機構です。通常のスカラ加算が float を1個ずつ足すのに対し、SIMDは幅の広いベクトルレジスタ(x86のAVX/AVX2なら256ビット=float 8個分)に複数要素を載せ、1命令で8組を一度に足します。理論上のスループットがレーン数倍になるのが速さの源泉です。
重要なのは、これがコア周波数を上げずに得られるデータ並列性だという点です。スレッド並列(アムダール則が支配する世界)がコア間でタスクを分けるのに対し、SIMDは1コア内で1命令あたりの仕事量を増やします。両者は直交し、併用できます。
自動ベクトル化とは、プログラマがスカラのループを普通に書いても、コンパイラがそれをSIMD命令へ自動変換する最適化のことです。ただしコンパイラは「結果が元のコードと完全に一致する」と証明できる場合だけ変換します。この証明が通るかどうかが、以降で扱う成立条件のすべてです。
ベクトル化の成立条件
コンパイラがループをベクトル化するには、複数反復を束ねても結果が変わらないことを保証する必要があります。鍵となるのが**ループ運搬依存(loop-carried dependence)**の有無です。
// ベクトル化可能: 各反復が独立
for (int i = 0; i < n; i++)
c[i] = a[i] + b[i];
// ベクトル化不可: a[i] が前の反復の a[i-1] に依存
for (int i = 1; i < n; i++)
a[i] = a[i-1] + b[i]; // 真の依存(read-after-write)
下の例では反復 i の入力が反復 i-1 の出力なので、8個まとめて並列に計算すると a[i-1] がまだ確定しておらず結果が壊れます。コンパイラの依存解析は、配列添字の式(多くは i の線形式)を調べ、異なる反復が同じメモリを書く・読むことがあるかを判定します。安全と証明できなければベクトル化しません。
もう一つの条件がアライメントです。多くのSIMDロード/ストアは、ベクトル幅の境界(256ビットなら32バイト)に揃ったアドレスで最も効率的に動きます。先頭がずれた配列に対しては、コンパイラは境界まで数要素をスカラで処理する「ピールループ(prologue)」と、末尾の端数を処理する「エピローグ」を生成し、中央の主要部だけをベクトル化します。アライメントが取れていれば余計な分岐とコピーが減り、より素直で速いコードになります。
sum += a[i] のような総和もループ運搬依存を持ちますが、加算が結合的なら順序を変えても(数学的には)同じ結果になります。コンパイラはこれをリダクションと認識し、複数の部分和を並列に貯めて最後に合算する形へ変換できます。ただし浮動小数点の加算は厳密には結合的でないため、-ffast-math 相当の許可がないとコンパイラは値が変わる変換を避けます。精度と速度のトレードオフがここに現れます。
自動ベクトル化が失敗する要因
依存とアライメント以外にも、コンパイラを安全側へ倒す要因は数多くあります。代表的なものを整理します。
| 失敗要因 | なぜ妨げになるか | 典型的な対処 |
|---|---|---|
| ポインタエイリアス | 別ポインタが同じ領域を指す可能性を排除できず依存を仮定する | restrict 修飾/別配列であると明示 |
| ループ運搬依存 | 前反復の結果を次反復が使い並列化で結果が変わる | アルゴリズムを依存のない形へ書き換える |
| 関数呼び出し | 副作用が不明な呼び出しは束ねられない | インライン化/純粋関数化 |
| 複雑な制御フロー | 反復ごとに異なる分岐は単純にはベクトル化できない | マスク化可能な単純な条件へ整理 |
| 可変・非定数の刻み幅 | 添字が i の線形式でないと依存解析が破綻する | 連続アクセス(stride 1)に正規化 |
最も厄介なのがポインタエイリアスです。C/C++では void f(float *a, float *b, int n) の a と b が同じ配列を指すかもしれず、コンパイラはその可能性を排除できません。重なりがあると反復をまとめた瞬間に結果が変わるため、安全のためベクトル化を諦めます。restrict(C)や __restrict(C++拡張)で「このポインタ経由でしかアクセスしない」と約束すると、依存の仮定が外れて変換が通ります。
// restrict により a/b/c は互いに重ならないと約束
void add(float * restrict c, const float * restrict a,
const float * restrict b, int n) {
for (int i = 0; i < n; i++)
c[i] = a[i] + b[i]; // ベクトル化される
}
制御フローについては、反復ごとの if をマスク(条件が真のレーンだけ書き戻すビットマスク)で表現できる単純な形なら、最近のコンパイラは予測実行的にベクトル化します。しかし break で抜ける可能性のあるループや、データ依存で回数が変わるループは束ねられません。なぜ失敗したかは、コンパイラの最適化レポート(GCCの -fopt-info-vec-missed、Clangの -Rpass-missed=loop-vectorize 等)で確認するのが鉄則です。これは最適化パスが下した判断を読み解く作業でもあります。
コンパイルが通っただけでは、そのループが本当にベクトル化されたかは分かりません。-O3 でも上記の要因で静かにスカラのままのことが多々あります。アセンブリに vaddps(AVXのパックド加算)のようなパックド命令が出ているか、または最適化レポートを必ず確認してください。思い込みで「速いはず」と進めると、後段のプロファイリングで初めて未ベクトル化に気づくことになります。
明示SIMD(組み込み関数)との使い分け
自動ベクトル化が当たらない、あるいは特殊命令(シャッフル、水平加算、FMA、ギャザー等)を使いたい場合は、**組み込み関数(intrinsics)**で明示的にSIMDを書きます。これはアセンブリを直接書かずに各SIMD命令へ1対1で対応する関数を呼ぶ方式で、レジスタ割り当てや命令スケジューリングはコンパイラに任せられます。
#include <immintrin.h>
// AVX で float を8個ずつ加算
void add8(float *c, const float *a, const float *b, int n) {
int i = 0;
for (; i + 8 <= n; i += 8) {
__m256 va = _mm256_loadu_ps(a + i);
__m256 vb = _mm256_loadu_ps(b + i);
_mm256_storeu_ps(c + i, _mm256_add_ps(va, vb));
}
for (; i < n; i++) c[i] = a[i] + b[i]; // 端数処理
}
明示SIMDは「確実にベクトル化される」「ハードウェアの全機能を引き出せる」反面、命令セットに密結合します。上のコードはAVX専用で、ARMのNEONやSVE、x86のSSE/AVX-512では書き直しが必要です。可読性も下がり、保守コストとバグの温床になりがちです。判断基準は次の通りです。
- まず自動ベクトル化を狙う。
restrictの付与、依存の除去、stride 1 化、ホットループの単純化で、コンパイラが変換できる形にコードを整える。これだけで多くのケースは十分速くなり、移植性も保てます。 - レポートで未ベクトル化と判明し、かつそのループが性能を支配している箇所に限って明示SIMDへ移す。局所的に、端数処理を伴う最小範囲で書くのが定石です。
- アーキテクチャ非依存性が要るなら、ラッパーライブラリ(
std::experimental::simd、Highway、xsimd 等)を挟み、命令セットごとの差異を吸収する。
試験・面接では「SIMD=データ並列(1命令で複数データ)、スレッド並列とは直交」「自動ベクトル化の阻害要因=ループ運搬依存・ポインタエイリアス・関数呼び出し・複雑な制御フロー」「restrict でエイリアスの仮定を外せる」「浮動小数点リダクションは結合則の都合で fast-math 許可が要る」の4点が頻出です。SoA配置(同種データの連続)がベクトル化に有利な点もメモリレイアウトと結びつけて押さえると応用が利きます。
まとめ
SIMDは1コア内のデータ並列性を引き出し、計算量を変えずにスループットをレーン数倍へ近づける機構です。自動ベクトル化はそれをコードを書き換えずに享受する最短路ですが、コンパイラは「結果が変わらないと証明できる」ループしか変換しません。ループ運搬依存・ポインタエイリアス・関数呼び出し・複雑な制御フローはその証明を阻み、静かにスカラのまま残します。だからこそ、まずは依存を断ち restrict を与えてアクセスを連続化し、最適化レポートで結果を確かめる。それでも届かない支配的ループだけを、組み込み関数で局所的に明示SIMD化する。移植性と保守性を秤にかけたこの順序が、上級者のベクトル化戦略の定石です。
プログラミング Article
自動ベクトル化とSIMDの活用を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
SIMD
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 6
導入後に効く点
ループ運搬依存(前の反復の結果を次が使う)・関数呼び出し・複雑な制御フロー・ポインタエイリアスの疑いがあると、コンパイラは安全側に倒してベクトル化を諦める。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 6
判断チェックリスト
- 自社の用途が「SIMD / ベクトル化」に近いか確認する。
- 強みである「SIMDは1命令で複数要素を同時処理する仕組みで、自動ベクトル化はコンパイラがスカラループをSIMD命令に変換する最適化。成立にはデータ依存とアライメントの条件を満たす必要がある。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。