GoがSIMDに対応した
Go言語がバージョン1.26でSIMDを使えるようになったというニュースを見た。
- Go 1.26連載:インデックス+SIMD | フューチャー技術ブログ(1月27日)
- Go SIMD part 1: Trying out Go with native SIMD support | Callista(2025年10月20日)
- simd/archsimd: architecture-specific SIMD intrinsics under a GOEXPERIMENT · Issue #73787 · golang/go
これで思ったのが、「ネイティブコンパイルする言語ではSIMDを扱えるのが当たり前になった」ということだ。これまでは「ネイティブコンパイルする言語でSIMDを扱えない言語」の代表格(私の中で)がGoだった。
C/C++は伝統的にアーキテクチャー依存な組み込み関数 (intrinsics) でSIMDを扱うことができる。RustやSwift、ZigのようなLLVMを前提とする言語もSIMDを扱うことができる。このうちZigはLLVMへの依存を不要にする方向のようだが、普通に考えてSIMDへのアクセスは維持するだろう。
関数型言語はどうか。Haskell (GHC) は結構前からLLVMバックエンドでSIMDが使えて、最近は自前のネイティブコード生成器でもSIMDが使えるように作業が進んでいる。OCaml界隈では、新興の亜種であるOxCamlがSIMDをサポートするようだ。Standard ML界隈では、MLKitの人がAVXに関心があるようだ(中の人がFutharkにも関わっていることが関係しているのかもしれない)。
もちろん、ネイティブコンパイルする言語でSIMDに未対応の言語(処理系)はまだまだある。しかし、GoがSIMDに対応したとなると、メジャーどころでは概ねカバーされたと言って良いのではないか。
SIMDは言語処理系のレベルで対応する必要がある
現代のコンピューターで扱える並列性は色々ある。GPUなどのアクセラレーターの活用、マルチコアを活用するスレッドあるいはプロセス並列、SIMD、それから命令レベル並列性だ。このうち、プログラミング言語のレベルでの対応が特に必要とされるのがSIMDではないか。
GPUの呼び出しは、書きやすさが不要ならOSのAPIを呼び出してプログラム(典型的には文字列)を送り込めれば良くて、CPU側で動くホスト言語の拡張は必要ない。プロセス並列も、OSのAPIを呼び出してプロセスの立ち上げと通信ができれば良い。スレッドは、メモリー順序づけやアトミック操作周りが厄介だが、基本的にはOSのAPIを呼び出してスレッドを立ち上げることができれば良い。命令レベル並列は、最悪ベタ書きすればなんとかなるだろう。
SIMDはどうか。SIMDを使いたい処理が定型的(例えば、行列乗算)であれば、その部分をCやアセンブリー言語で書いてFFIで呼び出すという手があるだろう。簡単な処理であれば、コンパイラーの自動ベクトル化に頼ることもできるかもしれない(これも「言語処理系のレベル」での対応だが)。しかし、SIMDを自由に使うには、コンパイラーを通じて専用命令を書き出す必要がある。SIMDベクトルを表す特殊な型も必要になる。SIMDベクトル型には演算子オーバーロードがあれば捗るだろう。なので、SIMDを扱うには言語にがっつり手を加えたい。
まあ、これは贔屓した見方かもしれない。スレッド並列をいい感じに書くための言語機能をもっと考えてもいいのかもしれない。
言語処理系にSIMDを実装する側の話は前に書いた:言語処理系にSIMD命令を実装することについて(主にx86向け)
SIMDの難しさ
断っておくが、私自身はSIMDを使う専門家というものではない。言語処理系のマニアとして、SIMDに関心があるという程度だ。
SIMDは、理念としては「複数組のデータに同じ操作を施す」というもので、これだけならそんなに難しくはない。C言語的な疑似コードで書けばこんな感じだ:
int32_t result[4], x[4], y[4]; // 実際には配列ではなく、1本ずつのレジスター
for (int i = 0; i < N; ++i) {
result[i] = f(x[i], y[i]); // fは何らかの処理
}
しかし、現実のSIMDにはこれだけでは済まない難しさがある。「SIMDの難しさ」がどういうものなのか、私にわかる範囲でいくつか挙げてみる。
Intel intrinsicsが難しい
C/C++でSIMDを扱う際の第一候補が「アーキテクチャー依存な組み込み関数を使う」ことだろう。x86ならIntelが定め、複数のコンパイラーで扱える組み込み関数(Intel intrinsics)だ。
Intel intrinsicsは、命名規則がお世辞にもわかりやすいとは言えない。例えば __m128i という型があるが、これはベクトル全体の幅はわかっても、要素の幅は何ビットなのか、要素の数はいくつなのかという情報が抜け落ちている。関数のサフィックスも _ps はpacked single-precisionで、_epi32 は要素が符号付き32ビット整数、みたいなことを覚えていく必要がある。
Intel intrinsicsのとっつきづらさが「SIMDが難しい」と思われる一つの要因ではないかと個人的には思っている。
例えばArmだと整数ベクトルの型は int32x4_t という感じなので、Intelよりはわかりやすい。
命令セット拡張が難しい
x86のような歴史の長いアーキテクチャーは、命令を継ぎ足して継ぎ足して進化してきた。なので、古いCPUも想定するプログラムでは、新しい命令を無条件に使うことができない。
だから、ある命令を使いたかったらそれがどの命令セット拡張に属するかを確認し、CPUが対応していなかったら代替処理を実行するかエラーを吐くかする必要がある。
この辺の話については以前に「CPUの機能を実行時に検出する」というシリーズ記事を書いた:
この辺の面倒くささは「命令セット拡張を把握するのが面倒くさい」「実行時に検出するのが面倒くさい」「target attributeを使いこなすのが難しい」に分割できるかもしれない。
まあ、新しい命令セット拡張を前提とできれば、古い世代のCPU向けの代替実装を用意しなくて良いので実装する側は楽になる。最近のx86ではAVX2を仮定しても困る人は少ないかもしれない。
命令セットが難しい
命令セットが持っていると(コンパイラーにとって)望ましい性質は、直交性だ。SIMDで言えば、「int32x4_t の乗算命令があれば、int64x2_t の乗算命令もあるし、int8x16_t や int16x8_t の乗算命令もある」という感じだ。類推ができてほしい。表が埋まっていて欲しい。
よくあるSIMD命令セットには、それがない。
x86やArm、WasmなどのSIMD命令セットがどんな命令を提供しているかを、以前記事にした:SIMD命令比較
そこに書いたように、x86のSSE2は int16x8_t の乗算命令しか(一発でできる命令を)提供していない。SSE4.1では int32x4_t の乗算命令も追加されたが、int64x2_t の乗算命令はAVX-512を待たなくてはならないし、int8x16_t の乗算命令は私の知る限りx86にはない。
Armを見ても、 int64x2_t の乗算命令やmin/maxは提供されていない。WebAssemblyは i8x16.mul がない。
命令がなければ他の命令を活用したり、スカラーにばらしてスカラーの操作をすれば良いのだが、その分コンパイラーまたはプログラマーの面倒が増える。
文字列操作が難しい
SIMDの重要な応用の一つが文字列操作だ。しかし、SIMDの理念が「複数組のデータに同じ操作を施す」なのに対し、文字列操作は必ずしもそれだけでは済まない。
例えば、strlenの実装にSIMDが活用できる。疑似コードで書けば次のようになる:
size_t strlen(const char *s)
{
uint8x16_t *p = (uint8x16_t *)((uintptr_t)s & (-16));
size_t delta = (const char *)p - s;
uint8x16_t a = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15};
uint8_t m = zip(a, load(p)).map((i,c) => i >= delta && c == '\0' ? i : 16).horizontal_min();
size_t len = m - delta;
while (m == 16) {
m = zip(a, load(p)).map((i,c) => c == '\0' ? i : 16).horizontal_min();
len += m;
++p;
}
return m;
}
要するに、16バイトごとに処理するということだ。これは範囲外アクセス上等の実装になっている(終端の0を超えて読む可能性がある)が、通常の環境ではページ境界を跨がなければ大丈夫、みたいなアレがあるらしい。
もっと高度な話として、UTF-8の操作やJSONの操作にSIMDを応用するみたいな話もあるが、どのようなテクニックが使われているのかまでは私は把握していない。
データの置き方が難しい(AoS vs SoA)
複素数の配列データは、メモリー上では実・虚・実・虚という風にデータが並ぶことが多いだろう。あるいは、画像であればRGBRGBRGBという風に並ぶだろう。こういう風に、C言語の構造体が並んだようなレイアウトをArray of Structures (AoS) と呼ぶ。
こういうデータをSIMDで扱いたい場合、複素数なら実部 float32x4_t、虚部 float32x4_t で表すのが「複数のデータに同じ操作を施す」という観点ではやりやすい。画像であれば uint8x16_t r, g, b という具合だ。
しかし、SIMD命令セットが通常提供するロード・ストア命令は「メモリー上の連続した要素たちの読み書き」なので、1つ飛ばし、2つ飛ばしというのはやりづらい。これが難しさだ。
SIMDで扱いやすいのは「配列からなる構造体」で、そういうレイアウトをStructure of Arrays (SoA) と呼ぶ。
Armのように、1〜4要素のAoSをロード・ストアする専用命令を持っているアーキテクチャーもある。
アラインメントの問題
アラインメントとは、メモリーのアドレスが4の倍数であるとか8の倍数であるとか16の倍数であるとか、そういう話だ。単精度浮動小数点数(C言語の float 型がこれのことが多い)であればアドレスは4バイト境界に揃っているのが通常だろう。SIMDで float の配列を処理するときはアドレスは4の倍数であることを仮定できる。
しかし、x86には、もっと大きなアラインメントを要求するロード・ストア命令がある。xmmレジスターなら16バイト、ymmなら32バイト、zmmなら64バイトという具合だ。もちろん、アラインメントを要求しないロード・ストア命令もある。
アラインメントが大きいと何がいいのかというと、私はその道の専門家ではないのであやふやな認識だが、メモリーにアクセスするときの効率とかそういうのが良いのだろう。
とにかく、アドレスが大きな数の倍数になっていると何らかの都合が良いのだ。
コンパイラーからするとこれはちょっとトリッキーで、SIMDベクトルをスタックに配置するときにアラインメントを考慮する必要がある。あるいは、ABIでスタックに置く引数の __m128 が16バイト境界に揃っていたり __m256 が32バイト境界に揃っていることを要求されたりするので、単に「高速化のためにあると嬉しい」ではなくてコンパイラーが必須で考慮すべき項目になっている。x86-64の呼び出し規約ではスタックポインターは16バイト境界に揃っていること(と定めている規約)が多いが、__m256 や __m512 を扱う際は自前でスタックポインターをアラインする必要がある。
関数型言語では末尾呼び出しが多用されるが、このスタックポインターをアラインするという行為が末尾呼び出しを阻害する、みたいな話もある。なので、関数型言語でSIMDを使う際は要注意だ。というか私が今まさに取り組んでいる問題なのだが。
アラインされたメモリー領域を簡単に作れるように、最近のC/C++には alignas (C11だと _Alignas)のような修飾子がある。あるいは、C11の aligned_alloc やC++17の operator new[](std::size_t, std::align_val_t) のような動的確保関数もある。
ただ、C11の aligned_alloc はWindowsのMicrosoftによるCランタイムでは使えない(提供されていない)という制約がある。C言語では aligned_alloc で確保した領域は通常の free 関数で解放できることを定めているが、Microsoftの実装ではそれは不可能なのだ。Microsoftは代わりに、_aligned_alloc と _aligned_free という独自の関数を提供している。MicrosoftとC標準化委員会の連携が取れていなさすぎる。
エイリアスの問題
コンパイラーによる自動ベクトル化は、いくつかの変換の組み合わせとして解釈できる。例えば、ベクトル化したいコードが次のようなものだとしよう:
// プログラム1
void f(size_t n, float s, float a[n], float b[n], float result[n])
{
for (size_t i = 0; i < n; ++i) {
float x = a[i];
float y = b[i];
result[i] = s * x + y;
}
}
コンパイラーはループを展開することができる。ここではループの4回分を展開したとしよう(プログラム2)。
// プログラム2
size_t i = 0;
for (; i + 4 <= n; i += 4) {
float x0 = a[i];
float y0 = b[i];
result[i] = s * x0 + y0;
float x1 = a[i + 1];
float y1 = b[i + 1];
result[i + 1] = s * x1 + y1;
float x2 = a[i + 2];
float y2 = b[i + 2];
result[i + 2] = s * x2 + y2;
float x3 = a[i + 3];
float y3 = b[i + 3];
result[i + 3] = s * x3 + y3;
}
for (; i < n; ++i) {
float x = a[i];
float y = b[i];
result[i] = s * x + y;
}
コンパイラーは命令の順序を適当に並べ替えられる、としよう。同じ種類の命令が並ぶようにする(プログラム3)。
// プログラム3
size_t i = 0;
for (; i + 4 <= n; i += 4) {
float x0 = a[i];
float x1 = a[i + 1];
float x2 = a[i + 2];
float x3 = a[i + 3];
float y0 = b[i];
float y1 = b[i + 1];
float y2 = b[i + 2];
float y3 = b[i + 3];
result[i] = s * x0 + y0;
result[i + 1] = s * x1 + y1;
result[i + 2] = s * x2 + y2;
result[i + 3] = s * x3 + y3;
}
for (; i < n; ++i) {
float x = a[i];
float y = b[i];
result[i] = s * x + y;
}
そうすると、これはSIMDに変換できる形だ。SIMDを使おう(プログラム4)。
// プログラム4
size_t i = 0;
for (; i + 4 <= n; i += 4) {
float32x4_t x = a[i..<i+4];
float32x4_t y = b[i..<i+4];
result[i..<i+4] = s * x + y;
}
for (; i < n; ++i) {
float x = a[i];
float y = b[i];
result[i] = s * x + y;
}
めでたしめでたし。……本当に?
プログラム2とプログラム3は本当に等価なのだろうか? result[i] への書き込みと、a[i+1] からの読み取りは入れ替えても良いのだろうか?2つの配列が重なっていると、入れ替えによって結果が変わるのではないだろうか?
実際の使用例で配列が重なることがないと分かっていても、コンパイラーはそのことを知らないので、自動ベクトル化の際にオーバーラップの検査を入れるか、あるいは自動ベクトル化を完全に諦めてしまう。
あなたが作ったプログラミング言語にLLVMバックエンドを用意したから自動ベクトル化がタダで手に入るぜ!と思っても、「配列の重なり」についてLLVMに十分な知識を与えなかったら、自動ベクトル化が全く起こらない、ということにもなり得る。
この「配列の重なり」、もっと一般にポインターの指す先が一致しうるかということは、エイリアス(別名)とも呼ばれる。
いくつかのプログラミング言語では、ポインターの指す先が重なることがないという情報をコンパイラーに伝えることができる。C言語のrestrictがそれで、前に記事を書いた:自動ベクトル化とC言語のrestrict
まあ、自動ベクトル化は低水準のプログラムから高水準な構造を復元する力技で、プログラミング言語がネイティブにSIMDに対応していれば最初からSIMDを活用したコードを書ける。なので、やり方によってはエイリアスのことはあまり考慮しなくても良いかもしれない。
コンパイル時定数を引数に取る組み込み関数
組み込み関数の一部は、引数がコンパイル時定数である必要がある。例えば、_mm_insert_epi16 の第3引数はコンパイル時定数である必要がある。これはアセンブリー言語・機械語のレベルで即値を要求されるからそうなっている。
そうすると、「コンパイル時定数を受け取る関数」の仕組みを用意している言語(C++やZig)以外では、そういう関数をライブラリーでラップするのが難しいということになる。
この辺の話は前に記事を書いた:コンパイル時定数しか受け付けない引数
単純に、高速なコードを書くのが難しい
単純に、高速なコードを書くのが難しいという話もある。キャッシュとかループ展開(アンロール)とか。SIMDを使う目的は処理の高速化なので、高速化のためにあらゆる知識を動員する必要があって難しい。
SIMDは面白い
SIMDは難しいが、その分言語処理系の方から見るとやり甲斐がある。複数のアーキテクチャーに対応した高水準な抽象化ライブラリーも、その言語の機能を駆使して作れると楽しいだろう。Haskellとか。
まあ、こんなことを言っていられるのは、私が趣味で言語処理系をやっているからかもしれない。
