Go におけるプラットフォーム非依存の SIMD

Go 1.27で可搬性のあるsimdパッケージが登場し、simd.Uint8sなどの型でAVX512、NEON、wasmのベクトル差異を隠蔽する。

日本語
コピー

David Chase、Junyang Shao 2026年9月24日 Go 1.26 と 1.27 では、単一命令複数データ(SIMD)演算のための実験的 API が導入されました。SIMD は多くの現代的な CPU が備えるネイティブ機能で、ソフトウェアはデータのベクトルに対して同一の演算を極めて高速に実行できます。たとえば 8 組の float64 値の加算を 1 命令でこなせます。暗号からデータ処理、AI まで、計算負荷の高い多くのタスクを大幅に高速化できます。実際、Go の Green Tea ガベージコレクタは SIMD を利用してメモリ内の生存オブジェクトのスキャンを高速化しています。 これらの新しい実験的 API が登場するまで、Go からこの機能にアクセスする唯一の方法は Go アセンブリを書くことでした。それだけの価値があるのは性能が本当に重要な計算カーネルに限られ、SIMD の恩恵を受けられたはずの多くのソフトウェアが CPU の能力を無駄に遊ばせていました。 Go 1.26 は amd64 向けに SIMD API を導入し、Go 1.27 は arm64(具体的には NEON)と wasm 向けにも API を追加しました。しかし SIMD API には根本的な難しさがあります。プラットフォーム間の差異が大きく、どの演算がサポートされるかだけでなく、ベクトルの表現方法まで異なるのです。固定サイズのベクトルを提供するプラットフォームもあれば(通常は 128 ビットから 512 ビット)、ベクトルサイズがビルド時には決まらず、プログラム起動時に問い合わせなければならないプラットフォームもあります。こうしたプラットフォームの能力を余すところなく扱うため、これらの API はアーキテクチャ依存の archsimd パッケージに置かれています。 しかし Go 1.27 はこれらのアーキテクチャ依存 API にとどまりません。実験的で完全にポータブル、プラットフォームにもサイズにも依存しない SIMD インターフェースも導入しました。おおむね C++ の Highway を基にしたものです。狙いは、SIMD をサポートするプラットフォームでは一度書けばアセンブリ並みの性能が出る「simd」コードを可能にし、SIMD を(まだ)サポートしないプラットフォームでは使えるエミュレーションを提供することです。simd パッケージは現在、amd64 の AVX、AVX2、AVX512、arm64 の NEON、そして wasm の SIMD 命令をサポートしています。

動機:SIMD アーキテクチャ間の差異

SIMD アーキテクチャは複数の次元で異なります。固定のベクトルサイズを 1 つだけ提供するものがあります(wasm、PowerPC、s390x の 128 ビット)。複数の固定ベクトルサイズを提供するものもあります(amd64 は 128、256、512、loong64 は 128 と 256)。Riscv64 は 128 から 65536 ビットの間でサイズ未指定のベクトルをサポートしますが、長さは 2 の冪に限られます。Arm64 は固定サイズ(128 ビット、NEON)と可変サイズ(128〜2048 ビット、2 の冪のみ、SVE)の両方をサポートします。あるアーキテクチャの特定のインスタンスがどのサイズを正確にサポートするかを知るには、機能チェックが必要です。amd64 でも AVX か、AVX2 か、AVX512 か。Arm64 でも NEON か、SVE か。SVE ならサイズはいくつか。どの SVE 変種か、SVE か、SVE2 か、SVE2.1 か。 SIMD アーキテクチャはベクトルマスクの扱い方も異なります。ベクトルでは、ベクトルをまたぐ if-then-else をマスクで実装できます。演算は通常どおり実行し、マスクが「真」の位置にだけ結果を代入(あるいはロード、ストア)するのです。マスクを提供しない SIMD 変種もあります。すべての演算が全要素に作用し、「マスク」はベクトルビットマスクとベクトルブール演算で行います(wasm、AVX、AVX2、NEON)。専用のマスクレジスタを持ち、ベクトル要素 1 つ分の演算を 1 ビットで制御するものもあります(AVX512 と RVV)。また SVE はベクトルの各バイトに 1 ビットを割り当てますが、マスク演算を実行するかどうかは各要素のマスクビットの最下位ビットで決まります。AVX2 もマスク付きロードとストアをサポートしますが、マスクには通常のベクトルを使い、演算を実行するかどうかは最上位ビットで決まります。

変化の 3 つ目の源は演算そのものです。各アーキテクチャはベクトル要素を並べ替える独自のプリミティブ群を提供しており、入力が定数を要求するものもあれば、変数入力をサポートするものもあります。暗号関連の演算も SIMD アーキテクチャごとにサポート状況が異なります。基本的な算術演算でさえ差が出ることがあり、たとえば wasm には 64 ビット整数ベクトルの比較演算がありません。特定のアーキテクチャでベクトル長が決まっていても、命令がサポートされるかどうかはチェックすべき「機能」次第です。

Go のアーキテクチャ依存の archsimd パッケージは、設計上できる限りアーキテクチャ間で一貫させていますが、こうした癖の多くは残ります。そのためマルチプラットフォーム向けの SIMD コードを設計し、書き、テストするのは面倒です。archsimd パッケージ側でもう少し頑張って、異なるアーキテクチャの見た目を揃えることはできたでしょう。しかし効率を犠牲にせずにできることには限界があります。

概要

新しい simd パッケージは、固定長ベクトルを型システムから取り除き、すべての異なるプラットフォームの共通部分に属する演算だけをサポートすることで、こうした差異を隠します。共通部分の隙間は、他の SIMD 命令による効率的なエミュレーションで埋めます。目標は次のような演算群を提供することです。

  1. ベクトル化の恩恵を受ける多くのデータ処理アルゴリズム(ただし特定のベクトル長に縛られないもの)を支えるのに十分であること
  2. ソースコード中の演算が基盤ハードウェアと一致する場合、アセンブリ言語並みの効率が出ること
  3. それ以外の場合も可能な限りうまくエミュレーションすること
  4. そして読みやすく理解しやすいこと(最終的にこれらのコードを書くのが LLM であっても、いや、だからこそ)

SIMD 命令を持たないプラットフォームや、archsimd が対応していないプラットフォームでは、すべての操作がエミュレートされる。そのため、simd で書いたコードは常に動作する。

この実験的なパッケージを使うには、実験的な archsimd パッケージと同様、ビルド時に GOEXPERIMENT=simd を設定する必要がある。

simd のベクタ型は、simd.Uint8s や simd.Float32s のような、大文字・複数形のプリミティブ型にすぎない。ベクタはスライスからロードし、スライスへストアする。たとえば次のようになる。

// innerProduct returns the inner product of x and y.
func innerProduct(x, y []float32) float32 {
 var a simd.Float32s
 var i int
 for i = 0; i < len(x)-a.Len()+1; i += a.Len() {
 u := simd.LoadFloat32s(x[i : i+a.Len()])
 v := simd.LoadFloat32s(y[i : i+a.Len()])
 a = u.MulAdd(v, a)
 }
 if i < len(x) {
 u, _ := simd.LoadFloat32sPart(x[i:])
 v, _ := simd.LoadFloat32sPart(y[i:])
 a = u.MulAdd(v, a)
 }
 return sum(a)
}
// sum returns scalar sum of elements of x.
func sum(x simd.Float32s) float32 {
 s := make([]float32, x.Len())
 x.Store(s)
 var r float32
 for _, e := range s {
 r += e
 }
 return r
}

この例は、このパッケージの最初の実験的バージョンにおける制約も示している。ベクタ内のすべての要素を合計する汎用的な方法がないため、Go 1.27 の simd はこの操作に対応していない。ただし、ReduceSum が次のバージョンで登場し、その時点で sum を simd.ReduceSum にそのまま置き換えられるようになる。

SIMD の比較はマスク値を生成する。マスク値は対応するベクタ要素の幅に対応するため、Int8s の比較は Mask8s を生成し、以下同様に続く。マスク値はベクタの選択とフィルタリングに使える。

Go 1.27 時点で simd パッケージが対応する操作

以下の表で、V と U はベクタ型、M はマスク型、E はスカラ型、W は幅を表す。

パッケージレベルのロード / ブロードキャスト関数

関数Int8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
LoadV([]E) VYYYYYYYYYY
LoadVPart([]E) (V, int)YYYYYYYYYY
BroadcastV(E) VYYYYYYYYYY

ストア / 文字列操作

(x V).Method(...)Int8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
Store(s []E)YYYYYYYYYY
StorePart(s []E) intYYYYYYYYYY
String() stringYYYYYYYYYY

算術操作

(x V).Method(...) VInt8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
Abs() VYYYYY
Add(y V) VYYYYYYYYYY
AddSaturated(y V) VYYYY
Average(y V) VYY
Div(y V) VYY
IfElse(mask MaskWs, y V) VYYYYYYYYYY
Len() intYYYYYYYYYY
Masked(mask MaskWs) VYYYYYYYYYY
Max(y V) VYYYYYYYY
Min(y V) VYYYYYYYY
Mul(y V) VYYYYYYYY
MulAdd(y V, z V) VYY
Neg() VYYYYYY
Not() VYYYYYYYY
Or(y V) VYYYYYYYY
Sqrt() VYY
Sub(y V) VYYYYYYYYYY
SubSaturated(y V) VYYYY
Xor(y V) VYYYYYYYY

ブール演算とベクタマスク操作

(x V).Method(...) VInt8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
And(y V) VYYYYYYYY
AndNot(y V) VYYYYYYYY
CarrylessMultiplyEven(y V) VY
CarrylessMultiplyOdd(y V) VY
IfElse(mask MaskWs, y V) VYYYYYYYYYY
Masked(mask MaskWs) VYYYYYYYYYY
Not() VYYYYYYYY
Or(y V) VYYYYYYYY
Xor(y V) VYYYYYYYY

比較操作

(x V).Method(...) MInt8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
Equal(y V) MaskWsYYYYYYYYYY
Greater(y V) MaskWsYYYYYYYYY
GreaterEqual(y V) MaskWsYYYYYYYYY
Less(y V) MaskWsYYYYYYYYY
LessEqual(y V) MaskWsYYYYYYYYY
NotEqual(y V) MaskWsYYYYYYYYYY

変換操作

(x V).Method(...) UInt8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
ConvertToFloatW() FloatWsY
ConvertToIntW() IntWsYYYYY
ConvertToUintW() UintWsYYYY
ToMask() (to MaskWs)YYYY

マスク操作

(m M).Method(...) M)Mask8sMask16sMask32sMask64s
And(y M) MYYYY
Or(y V) VYYYY
String() stringYYYY
ToIntWs() (to IntWs)YYYY

シフトおよびローテート操作

(x V).Method() VInt8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
RotateAllLeft(dist uint64) VYYYYYY
RotateAllRight(dist uint64) VYYYYYY
ShiftAllLeft(dist uint64) VYYYYYY
ShiftAllRight(dist uint64) VYYYYY

ゼロオーバーヘッド再形成操作

(x V).Method(...) UInt8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
ToBits() UintWsYYYYYY
ReshapeToUint8s() Uint8sYYY
ReshapeToUint16s() Uint16sYYY
ReshapeToUint32s() Uint32sYYY
ReshapeToUint64s() Uint64sYYY
BitsToFloatW() FloatWsYY
BitsToIntW() IntWsYYYY

プラットフォーム固有コードとの相互変換

simd パッケージは、アプリケーションのあらゆる部分に対して制約が強すぎたり、必要な機能をシミュレートするにはまだ不十分だったりすることがある。そのため、simd パッケージはアーキテクチャ固有の SIMD との相互変換をサポートしている。simd パッケージの各ベクトル型には変換メソッド ToArch() があり、any を返す。この any は型アサーションによってプラットフォーム固有のアーキテクチャ型に変換できる。元に戻すには、simd.FromArch 関数のいずれかを使用する。ポータブルなコードにとって、これは各プラットフォーム向けのアーキテクチャ固有コード(シミュレーション実装を含む)を書く義務を伴う。

以下は、現時点では存在せず Go 1.28 で追加されるべきメソッド/関数を対象とした完全な例である。アルゴリズムが Int8s.OnesCount() を必要としているとする(Go 1.27 の simd にはこれがない)。プラットフォームごとにアルゴリズム全体を書き直すのではなく、欠けている操作だけを実装すればよい。

まず amd64 を見る。AVX と AVX2 の命令には欠けているが、AVX512 にはある:

//go:build goexperiment.simd && amd64
package simd_test
import (
 "simd"
 "simd/archsimd"
)
var popcnt4x16 = [16]int8{0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4}
var popcnt4x32 = [32]int8{
 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4,
 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4,
}
// OnesCount returns the number of one bits for each element.
func OnesCount(v simd.Int8s) simd.Int8s {
 switch x := v.ToArch().(type) {
 case archsimd.Int8x16:
 lut := archsimd.LoadInt8x16Array(&popcnt4x16)
 mask0f := archsimd.BroadcastInt8x16(0x0f)
 lo := x.And(mask0f)
 hi := x.ToBits().ReshapeToUint16s().ShiftAllRight(4).
 ReshapeToUint8s().BitsToInt8().And(mask0f)
 return simd.Int8sFromArch(lut.PermuteOrZero(lo).
 Add(lut.PermuteOrZero(hi)))
 case archsimd.Int8x32:
 lut := archsimd.LoadInt8x32Array(&popcnt4x32)
 mask0f := archsimd.BroadcastInt8x32(0x0f)
 lo := x.And(mask0f)
 hi := x.ToBits().ReshapeToUint16s().ShiftAllRight(4).
 ReshapeToUint8s().BitsToInt8().And(mask0f)
 return simd.Int8sFromArch(lut.PermuteOrZeroGrouped(lo).
 Add(lut.PermuteOrZeroGrouped(hi)))
 case archsimd.Int8x64:
 return simd.Int8sFromArch(x.OnesCount())
 default:
 // GODEBUG=simd=0 emulation
 return OnesCountEmulated(v)
 }
}

インターフェース変換と型 switch は一見すると非効率に見えるが、simd のコンパイラ側の実装がコードを特化し、最適化によって型 switch を消し去ってくれる。

NEON と Wasm はどちらも Int8s.OnesCount() をサポートしているので、実装はずっと単純になる。とはいえ Int8s.ToArch と Int8sFromArch は依然として使っている。

//go:build goexperiment.simd && (wasm || arm64)
package simd_test
import (
 "simd"
 "simd/archsimd"
)
// OnesCount returns the number of one bits for each element.
func OnesCount(v simd.Int8s) simd.Int8s {
 // TODO when SVE is added, this won't work
 switch x := v.ToArch().(type) {
 case archsimd.Int8x16:
 return simd.Int8sFromArch(x.OnesCount())
 default:
 // GODEBUG=simd=0 emulation
 return OnesCountEmulated(v)
 }
}

ハードウェア SIMD を持たない環境もあることを忘れないでほしい。

//go:build goexperiment.simd && !(amd64 || wasm || arm64)

package simd_test
import (
 "simd"
)
// OnesCount returns the number of one bits for each element.
func OnesCount(v simd.Int8s) simd.Int8s {
 return OnesCountEmulated(v)
}

最後に、この演習を仕上げるために、すべての実装が共有するフォールバックとして、独立したシミュレーション関数をもう一つ書いておく。

//go:build goexperiment.simd
package simd_test
import (
 "simd"
)
// OnesCountEmulated returns the number of one bits for each element.
func OnesCountEmulated(v simd.Int8s) simd.Int8s {
 a := [2]uint64{}
 v.ToBits().ReshapeToUint64s().Store(a[:])
 a0, a1 := a[0], a[1]
 m1 := uint64(0x5555555555555555)
 m2 := uint64(0x3333333333333333)
 m4 := uint64(0x0f0f0f0f0f0f0f0f)
 a0 = (a0 & m1) + ((a0 >> 1) & m1)
 a1 = (a1 & m1) + ((a1 >> 1) & m1)
 a0 = (a0 & m2) + ((a0 >> 2) & m2)
 a1 = (a1 & m2) + ((a1 >> 2) & m2)
 a0 = (a0 & m4) + ((a0 >> 4) & m4)
 a1 = (a1 & m4) + ((a1 >> 4) & m4)
 a[0], a[1] = a0, a1
 return simd.LoadUint64s(a[:]).ReshapeToUint8s().BitsToInt8()
}

API の共通部分とメソッドのシミュレーション

simd パッケージが提供する操作は、ほとんどのアーキテクチャで許容できる性能で動く必要がある。まず、すべてのプラットフォームでサポートされる操作は、simd でも難なくサポートできる。こうした操作には通常、ロード、ストア、算術、比較が含まれる(ただし比較のすべてではない)。

異なるアーキテクチャの SIMD メソッドの素朴な共通部分を取っても、依然としてかなりの穴が残る。 その穴は、アーキテクチャ固有の archsimd API にシミュレーションを追加して埋める。 これらの API にはすでに多くの単純なシミュレーションが含まれていて、Go プログラマの負担を軽くしている。 符号付き整数と符号なし整数の加算は同じ命令を使うが、 Go が int と uint の両方に対して + 演算子をサポートしているのと同じように、 archsimd パッケージも Int8x16.Add(Int8x16) と Uint8x16.Add(Uint8x16) の両方を提供する。 コンパイル後は同じ命令になるというのに。 現代のプログラミング言語は、浮動小数点の符号反転や絶対値をビット演算でどう実装するかプログラマに知っていろと要求したりしない。 だから archsimd は必要な箇所でそれを実装している。見方によっては「シミュレーション」と呼んでもいい。

数命令で済むシミュレーションもある。たとえば、すべてのベクトル要素に同じシフト量しか使えないアーキテクチャもあれば、要素ごとに異なるシフト量を許すアーキテクチャもある。simd でスカラーシフトをサポートするには、ベクトルシフトでスカラーシフトをシミュレートする。一部の符号なし比較が欠けているアーキテクチャでは、符号付き比較と定数との XOR 2 回で済ませる。

欠けている命令がすべてこんなに単純とは限らない。carryless multiply 命令は暗号と CRC チェックサムで重要だが、常にサポートされているわけではない。これを simd API から外してしまうと、重要なアルゴリズムが使えなくなる。そこでシミュレーション実装を用意している。主な用途の一つが暗号なので、実行時間は入力に依存しない。

別のケースでは、「pairwise add」(「horizontal add」とも呼ばれる)のようなプリミティブ命令を実装せず、次のバージョンで simd パッケージに pairwise add が普通担っている高レベルの操作、つまり総和リダクションを提供する。これはユーザーをベクトル長依存から遠ざけるのにも役立つ。ハードウェアが pairwise add 命令を提供していても、リダクションのステップ数はベクトル長に依存するままだからだ。

すべてのプラットフォームをサポートするという制約——今後 1 年ほどで archsimd に現れると予想されるプラットフォームも含めて——は、simd にどのメソッドを追加するかについて保守的な姿勢を取らせる。Riscv64、ppc64、s390x、loong64 にはそれぞれ独自の SIMD 拡張がある。

GODEBUG 設定

一部のハードウェアサポートがあるプラットフォームでは、GODEBUG で挙動を変えられるので、simd を使うコードをさまざまなハードウェア構成でテストしやすくなる。プログラムを実行する前に GODEBUG 環境変数を設定すればよい。

Go 1.27 では、SIMD サポートのレベルはおおむねベクトル長で分かれている。

  • GODEBUG=simd=0 は、ハードウェアサポートが利用可能でも SIMD 操作をシミュレーションで行う。
  • GODEBUG=simd=128 は、128 ビットベクトルとその機能を使う。利用できない場合は即座に panic する。
  • GODEBUG=simd=256 は、可能なら 256 ビットベクトルとその機能を使う。
  • GODEBUG=simd=512 は、可能なら 512 ビットベクトルとその機能を使う。
  • GODEBUG=simd=+128 は、一部の機能がサポートされていなくても 128 ビットベクトルとその機能を使う。サポートされていない命令を使えばコードは panic するが、使わなければそのまま動くこともある。Raspberry Pi がその例で、NEON はサポートするが PMULL(carryless multiply)を欠いている。
  • GODEBUG=simd=+256 は、一部の機能がサポートされていなくても 256 ビットベクトルとその機能を使う。サポートされていない命令を使えばコードは panic するが、使わなければそのまま動くこともある。Apple Silicon 上の amd64 エミュレーションがその例で、AVX2 はサポートするが VPCLMULQDQ(これも carryless multiply)を欠いている。
  • GODEBUG=simd=+512 は、一部の機能がサポートされていなくても 512 ビットベクトルを使う。

実装の詳細

simd を使うコードをデバッグしていて、スタックトレースをちらりと見ただけでも、奇妙な型やメソッドが増えていることに気づくはずだ。理由は、simd がパッケージであると同時に内部実装パッケージであり、さらにコンパイラフロントエンドの一部として AST 書き換えでもあるからだ。

AST 書き換えは、simd 型に言及する関数、変数、型に対して特化コピーを複数生成し、その中の simd 型を simd/internal/bridge のサイズ特化型への参照に置き換える。各ブリッジ型は archsimd 型として定義されるが、メソッドセットは制限される。特化された関数、変数、型には @simdNNN という形の接尾辞が付く。ここで NNN はベクトル長(128、256、512)か、シミュレーション実装を表す 0 のいずれかだ。内部的に simd に言及するがシグネチャには含まない関数は、ラッパー関数に変換される。プログラム起動時に検出した SIMD レベルで分岐し、対応する特化版を呼び出す。特化関数は他の特化関数を直接呼び出すので、ディスパッチのオーバーヘッドはない(インライン化されることもある)。この書き換え戦略を選んだのは、コードの重複と SIMD 性能の間のトレードオフだ。オーバーヘッドは、SIMD 計算の内部でディスパッチが起きないよう必要な高さまで引き上げてあり、それ以上ではない。ある計算の中で SIMD ディスパッチが「低すぎる」と感じられたら、simd 型に無駄に言及するだけで、次の例のように一段上に持ち上げられる。

func BenchmarkVpsumdSIMD(b *testing.B) {
 // mention "simd" so the benchmark loop calls specialized vpsumd3 directly
 var _ simd.Uint64s
 var w, x, y, z uint64 = ... // magic constants omitted.
 var lo, hi uint64
 for b.Loop() {
 // vpsumd3 does simd stuff, but lacks a simd signature,
 // so that it can be compared with non-SIMD emulations.
 lo, hi = vpsumd3(w, x, y, z)
 }
 sinkLo, sinkHi = lo, hi
}

今後の予定

archsimd についてより詳しく説明するブログ記事を近いうちに公開する予定です。

Go 1.28 では archsimd に SVE サポートを追加する計画で、simd にも同じサポートを入れたいと考えています。さらに重要なのは、simd パッケージがすでに対応している操作に加えて、より多くの SIMD 操作(OnesCount、マスク操作、リダクション操作、ベクトル並べ替え操作など)を追加したいという点です。Go 1.28 には少数の「機能バリアント」も含める予定です。これにより、ハードウェアによるベクトル実装を持ちながら一、二の操作が欠けているだけのプラットフォーム(Raspberry Pi など)が、完全なエミュレーションまで一気に後退してしまうのを避けられます。

前の記事:サイズ特化したメモリ割り当て

ブログ一覧

出典: The Go Blog← ホームへ戻る