プログラミング
誰もがSIMDを知るべきだ
Everyone Should Know SIMD (mitchellh.com)
要約
SIMD(Single Instruction, Multiple Data)は、CPUが一度に複数のデータに対して同じ命令を実行する並列処理技術です。一般的に複雑でニッチな最適化と見なされがちですが、筆者は、多くの開発者が日常的なプログラミングでもSIMDの恩恵を受けられると主張しています。この記事では、Zig言語を例に、SIMDの基本的な概念と、一般的な「N個の値を一度に処理する」コードの構造を、具体的な例を交えて解説しています。
全文翻訳
誰もがSIMDを知るべきだ
2026年7月22日
SIMDは複雑であるという評判があります。私は、それを学ぶには複雑すぎる、あるいは日常的なプログラミングには役立たない、ごく一部の高性能ソフトウェア向けの最適化だと見なす、非常に優秀なソフトウェアエンジニアにも何人も会ってきました。私はそれは間違っていると思います。SIMDは理解するのが簡単な場合があり1、一般的な「一度にN個の値を処理する」SIMDコードは、ほぼ常に同じ一般的な形状に従います。基本を学べば、SIMDを書くのはforループと同じくらい簡単です。そうでない場合は、今はスキップするのが良い兆候であることがほとんどです。すべての開発者は、少なくともその程度のSIMDを知っておくべきです。この記事ではZigを例に使用しますが、どのプログラミング言語にも適用できる一般的な記事です。SIMD命令のサポートはプログラミング言語によって異なり、将来的に多くのプログラミング言語がこれらの一般的な概念を公開してくれることを願っています!すべての投稿でこれをしなければならないのは嫌ですが、これもAIの助けなしに完全に手書きされたものであることを付け加えておきたいです。
目次
背景:SIMDとは?
一般的な形状
実際の例
ステップ1:定数のブロードキャスト
ステップ2:一度に1つのベクトルをループする
ステップ3:SIMD演算の実行
ステップ4:ベクトル結果の削減
ステップ5:スカラーテールでの完了
要約:一般的な形状
コンパイラはなぜこれをできないのか?
背景:SIMDとは?
すでにSIMDについて知っている場合は、このセクションをスキップしてください。
SIMDは、CPUが並列で複数の値に対して操作を実行できるようにします。たとえば、一度に1バイトずつ比較する代わりに、CPUは単一の命令で4、8、またはそれ以上のバイトを比較できます。
コードで次のようなループを見たことがある場合:
for (byte in bytes) { /* ... */ }
for (character in string) { /* ... */ }
for (value in array) { /* ... */ }
SIMDを使用する機会があります。SIMDはそれらを次のように変換します:
for (8バイトチャンク in bytes) { /* ... */ }
これは、並列性に対応する局所的な高速化につながります。4倍、8倍、あるいはそれ以上の速度でデータを処理します。これが効果を発揮するための唯一の本当の要件は、定期的に十分な量のバイトを処理する必要があることです。データがわずか数バイトまたは数十バイトしかないループに対してこれを行っても、それだけの価値はありません。しかし、数百、数千、数百万バイトを反復処理する場合、その効果は莫大になります。
これが基本です。simdutfやsimdjsonのようなプロジェクトはこれを極限まで推し進め、理解するのが難しいSIMD技術を使用しています。しかし、SIMDの恩恵を受けるために、そのようなアルゴリズムを書く必要はありません。一般的なケースは劇的に簡単です。
一般的な形状
一般的な「一度にN個の値を処理する」SIMDコードは、同じ5つのステップに従います。
1. 必要な定数をブロードキャストし、ベクトルアキュムレータを初期化します(もしあれば)。
2. 入力を一度に1つのベクトル幅のチャンクでループします。
3. すべてのレーンで並列に比較または算術演算を実行します。
4. 必要に応じてベクトル結果を削減または格納します。
5. 残りの要素をスカラーテールで処理します。
スカラーテールは、ベクトル化する前の通常のループですが、完全なベクトルに収まらない残りの部分のみを処理します。これをもっと行うにつれて、すべてのforループをこれらの5つのステップに自然に分解できるようになり、SIMDを書くことはスカラーループを書くのとほぼ同じくらい自然になります。
実際の例
Ghosttyからの実際の例を見てみましょう。スカラー実装、SIMD実装を見て、それを上記の一般的な形状にマッピングします。デコードされたコードポイントのスライスがあり、0xF以下の値(C0制御文字)を見るまで消費したいと考えています。2ターミナルはほとんどが印刷可能な文字なので、それらをまとめてバッチ処理しようとします。したがって、このループは次の印刷可能なランの終了を可能な限り迅速に見つけます。
スカラーループは1行です:
while (end < cps.len and cps[end] > 0xF) end += 1;
これは一度に1つのコードポイントを処理します。理解しやすいです。
以下は、CPU固有の組み込み関数3やコメントなしの汎用ベクトルバージョンです。後で詳しく説明します。
if (simd.lanes(u32)) |lanes| {
const V = @Vector(lanes, u32);
const threshold: V = @splat(0xF);
while (end + lanes <= cps.len) : (end += lanes) {
const values: V = cps[end..][0..lanes].*;
const greater_than_threshold = values > threshold;
if (@reduce(.And, greater_than_threshold)) continue;
const mask: std.meta.Int(.unsigned, lanes) = @bitCast(greater_than_threshold);
end += @ctz(~mask);
break;
}
}
while (end < cps.len and cps[end] > 0xF) end += 1;
コードはさらに12行です。これにより、ARM NEON(Apple Siliconを含む)でループのスループットが最大4倍、AVX2(ほとんどの最新のx86 CPU)で8倍、AVX-512(一部のIntel CPUおよびAMD Zen 4以降)で16倍向上する可能性があります。実際のリアルワールドのエンドツーエンドのスループットでは、AVX2 Intelデスクトップのターミナルプログラムから最終的なターミナル状態までの処理で、これは5倍程度の速度向上でした。SIMDコードの周りの他の要因により、理想的な速度向上のいくつかは常に失われますが…それでも5倍です!
さて、これらの12行が、概念に慣れていない人にとっては非常に奇妙に見えることがわかりました。そこで、ステップバイステップで説明し、以前に述べた形状に直接マッピングしましょう。
ステップ1:定数のブロードキャスト
最初の3行から始めましょう:
if (simd.lanes(u32)) |lanes| {
const V = @Vector(lanes, u32);
const threshold: V = @splat(0xF);
`simd.lanes(u32)`はGhosttyのヘルパーで、ターゲットCPUが一度に処理できるu32値の数を返します。これらの個々の値はレーンと呼ばれます。ARMでは4、AVX2では8、AVX-512では16を返します。ターゲットに目的のベクトルサイズがない場合、nullを返し、このコード全体をスキップしてSIMD作業を一切行いません。
`@Vector(lanes, u32)`はベクトル型を作成します。lanesが8の場合、VはCPUが並列で操作できる8つのu32値を含む単一の値になります。その他も同様です。
最後に、すべての値を0xFと比較する必要があります。ベクトル比較には両側にベクトルが必要なので、`@splat(0xF)`は0xFを各レーンにコピー(ブロードキャスト)します。結果は次のようなベクトルになります:
{ 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
これがステップ1です:ベクトル型を準備し、定数をブロードキャストします。一部のアルゴリズムはここでベクトルアキュムレータを初期化しますが、このアルゴリズムでは必要ありません。
ステップ2:一度に1つのベクトルをループする
次に、一度に1つの完全なベクトルをループします:
while (end + lanes <= cps.len) : (end += lanes) {
const values: V = cps[end..][0..lanes].*;
lanesが8の場合、少なくとも8つの値が残っている場合にのみループに入ります。ループ内では、それらの8つの値を`values`ベクトルにロードします。各ループの最後に、`end += lanes`は1つではなく8つずつ進みます。
完全なベクトルが必要であることは重要です。5つの値しか残っていない場合、8レーンのベクトルをロードすることはできません。これに対処するためのさまざまなトリックがありますが、簡単な方法として、後でステップ5で説明するスカラーテールで処理します。これがステップ2です:入力データを一度に1つのベクトル幅のチャンクでロードしてループします。ここでレーン数による高速化が見られます!
ステップ3:SIMD演算の実行
次に比較を実行します:
const greater_than_threshold = values > threshold;
`values`と`threshold`はどちらもベクトルなので、これはベクトル演算(文字通りのベクトルCPU命令)にマッピングされます。1つの`>`は、`values`の各レーンを`threshold`の対応するレーンと比較します。8つのレーンがある場合、これは`cps[end] > 0xF`のスカラー比較を8回実行することに相当しますが、1つのCPU命令で実行されます。4結果は、レーンごとに1つのブール値を持つ別のベクトルになります。概念的には、次のようになります:
values: { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
threshold: { 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
greater_than_threshold: { true, true, true, false, true, true, true, true }
これが実際のSIMD演算です。明示的な内部ループはありません。`>`演算子は、すべてのレーンに並列に適用されます。比較は単なる一例です。これは加算、乗算、最小値、最大値、またはその他の演算でも可能です。