Goのブログが、SIMDを扱う実験的なAPIを大きく前に出してきました。SIMDは、CPUが複数の値をまとめて処理するための仕組みで、計算の重い処理をかなり速くできる一方、これまではGoから使うにはアセンブリを書くしかありませんでした。今回の発表で目を引くのは、単に高速化の道具を増やしただけではなく、CPUやOSの違いをできるだけ隠して「同じコードを別の環境で動かす」方向に踏み込んでいる点です。Go 1.26と1.27で何が変わったのかを追うと、Goが低レイヤー性能と移植性をどう両立しようとしているかが見えてきます。
元記事は、GoにおけるSIMDの扱いが大きく変わった、と説明しています。SIMDは Single Instruction Multiple Data の略で、1つの命令で複数のデータをまとめて処理するCPU機能です。たとえば float64 を8組まとめて足すような処理ができ、暗号、データ処理、AIのような計算集約的な処理を速くできます。GoのGreen Teaガベージコレクタも、メモリ上の生存オブジェクトを探す場面でSIMDを使っていると書かれています。
ただ、これまではGoからSIMDを使いたければGo assemblyを書くしかありませんでした。つまり、本当に重要な計算カーネルだけが対象になり、そこまでではないが効けば嬉しい、という処理はCPUの力を十分に使えなかったわけです。そこでGo 1.26では amd64 向けのSIMD API が入り、Go 1.27では arm64 の NEON と wasm にもAPIが広がりました。
一方で、SIMDはCPUごとの差がかなり激しいのが難点です。固定長のベクターしか持たないものもあれば、実行時に長さを調べないといけないものもある。マスクの扱いも違いますし、そもそも使える命令の種類もまちまちです。amd64 だけ見ても AVX、AVX2、AVX512 があり、arm64 でも NEON と SVE 系が混ざります。こうしたばらつきのため、従来の archsimd パッケージは「なるべく共通化」してはいても、書く側・試す側の負担は重いままでした。
そこで Go 1.27 は、archsimd とは別に、より移植性を重視した実験的な simd パッケージを導入しました。これは固定サイズのベクターを型に埋め込まず、各プラットフォームで共通に成り立つ操作だけを提供し、足りない部分は別のSIMD命令で補ったり、SIMDがない環境ではエミュレーションしたりします。Goの言い方では、書いたコードが常に動くことを重視しつつ、命令が合う環境ではアセンブリに近い性能を狙う設計です。利用するにはビルド時に GOEXPERIMENT=simd を指定します。
型の使い方も独特です。simd.Uint8s や simd.Float32s のように複数形の型名を使い、LoadFloat32s でスライスから読み込み、Store で書き戻します。記事には内積計算の例が載っていて、innerProduct では simd.Float32s を使いながら、ループで LoadFloat32s、MulAdd を呼び、端数は LoadFloat32sPart で処理しています。最後に sum(a) で要素を合計していますが、ここは現時点の制限として、Go 1.27 では全要素の合計を直接求める共通APIがまだないためで、次のリリースで ReduceSum が入る予定だとしています。
記事の後半は、対応する演算の一覧です。加算、減算、乗算、比較、マスク操作などが並び、すべての型で使えるものもあれば、整数だけ、浮動小数点だけ、あるいは特定の幅だけに限られるものもあります。要するに、GoはSIMDを「各CPUの癖をそのまま露出させる道具」ではなく、「よく使う共通部分をまず一枚の面にのせる道具」として再設計しようとしている、という話です。
私がまず面白いと思ったのは、このAPIが「高度な最適化」を誰でも使えるようにするというより、最適化の入口にある心理的な壁を下げようとしている点です。SIMDは速い。でも速い代わりに、CPUごとの違い、ベクター長の違い、マスクの違い、命令の有無の違いを全部意識しないといけない。そこが面倒だから、多くのソフトウェアは結局SIMDを使わないままでした。Goのsimdは、その面倒をかなり強引に隠そうとしている。これは実務ではかなり大きいと思います。
特に、GOEXPERIMENT=simd で有効化する実験機能にしたのは筋がいいです。いきなり安定APIにせず、使い方や足りない演算を見ながら広げていく。しかも最初の版では ReduceSum のような共通機能がまだない、と自分で限界も明言している。こういう出し方は、性能系のAPIとしてはかなり誠実です。速さを約束しすぎず、でも「どこでも動く」ことは約束する。その順番が逆ではないのがいい。
一方で、この設計はかなり割り切っています。SIMDは本来、CPUごとの違いこそが価値でもあります。AVX512のように広いベクターを扱える環境、SVEのように可変長を持つ環境、それぞれで強みが違う。普通なら「全部見せたい」と考えがちです。でもGoは逆に、共通部分だけをまず面に出し、それ以外はエミュレーションや別APIに回した。
これは保守的に見えて、実は攻めています。なぜなら、性能を削ってでも抽象化するのではなく、抽象化したうえで必要なら下層で埋める、という順序だからです。記事中にも「ソースコードの操作が下のハードウェアに合うときはアセンブリに近い効率、それ以外は可能な限りうまくエミュレーション」とあります。つまり、ベンチマークで勝つことより、長く使える書き方を優先している。これはGoらしいし、GoでSIMDをやる意味そのものを変える発想だと思います。
元記事は暗号、データ処理、AIを例に挙げていますが、私はむしろ、もっと地味な場面で効くと見ています。たとえばログ処理、圧縮前の前処理、画像や音声の一部、あるいはメモリ走査のような処理です。こうした領域は、個々の処理は派手ではないのに、積み重なるとCPU時間をかなり食います。そこにSIMDを少し入れられるだけで、全体の体感が変わることがある。
しかもGoは、手元の環境がAVX2かAVX512かを気にしながら配布する文化より、同じバイナリを広く配る文化に合っています。だからこそ「平台ごとの差をなるべく吸収するSIMD」は相性がいい。性能のためにコードを分岐させると運用が面倒になる、という現実を考えると、こういうAPIは意外と多くのプロダクトに効くはずです。高速な専用実装を持つチームだけの話ではない、というのが重要です。
期待は大きい一方で、成功の条件もはっきりしています。SIMDは、APIがあるだけでは広まりません。開発者が「どう書けばよいか」をすぐ理解でき、既存のスライス処理から自然に移行できて、しかも性能が納得できる必要があります。元記事が例として内積を出し、Load、MulAdd、Store というかなり素直な流れで見せているのは、その理解しやすさを意識しているからでしょう。
ただ、最初から全部を盛り込まない設計には、逆に言うと「どこまで一般化できるか」が常に問われます。ReduceSum が次で入る予定と明記されているのも、まだ穴がある証拠です。ここを埋めていけるかどうかで、単なる実験APIか、実用の道具かが分かれるはずです。Goはこれまでも、標準ライブラリやランタイムの強さで評価されてきましたが、今回のSIMDはその延長線上にあります。うまく育てば、低レイヤーの最適化をもっと普通のGoコードに近づける一歩になると思います。
参考: Platform-independent SIMD in Go - The Go Programming Language