2026年におけるRustでのSIMDの現状

恐れ知らずのSIMDメンテナーが書いた年次SIMDライブラリ調査。std::simd、wide、pulp、maceratorを比較し、x86命令セット互換の難題を詳解。

日本語
コピー

昨年からの進歩は大きい。その一端は私にもある。昨年の調査を読み終えたあと、当時いちばん将来性がありそうに見えた SIMD ライブラリにコードを送り始めた。それが縁で、今は Fearless SIMD のメンテナをしている。利益相反を避けるため、他のいくつかのライブラリ(std::simd、wide、pulp、macerator)の作者に本稿の草稿を読んでもらい、意見をもらった。とはいえ編集権は私にあり、誤りもすべて私の責任だ。今年の調査は前回より踏み込んでいる。シートベルトを締めて、最初から始めよう。

SIMD とは何か、なぜ使うのか

計算をするハードウェアは安い。だから今世紀に製造された CPU で演算能力に乏しいものはない。ところが命令デコードユニットはひとつしかなく、しかも高速化が難しい。そのせいで演算ハードウェアはほとんどの時間、遊んでいる。デコードというボトルネックを避けるには、複数の数値をまとめて CPU に渡し、同じ演算命令——たとえば加算——を一度に実行させればいい。これが SIMD という名前の由来だ。「single instruction, multiple data」、つまり単一命令・複数データ。2 つの数を足す代わりに、2 つの「ベクトル」を足す。所要時間は 1 回の加算とほとんど変わらない。近年の x86 チップならこのデータ幅は最大 512 ビットに達するので、理屈の上では f64 の数学演算は 8 倍、u8 なら 64 倍速くなる。実際に走らせてみると、もっと速いこともあれば、遅いこともある。

命令セット

歴史的に SIMD 命令は CPU アーキテクチャの設計が終わったあとで追加された。だからどのアーキテクチャでも SIMD は拡張であり、それぞれに独自のマーケティング名がついている。ARM では「NEON」で、64 ビット ARM CPU はすべて対応している。WebAssembly にはマーケティング部門がないので、そのまま「WebAssembly 128-bit packed SIMD extension」と呼ばれている。64 ビット x86 には「SSE2」が最初から載っていて、128 ビットベクトルの基本命令を提供する。だがその後、その上に大量の拡張が積み重ねられた。SSE 4.2 は演算を増やし、AVX と AVX2 は 256 ビットベクトルをもたらし、AVX-512 は 512 ビットベクトルとさらなる演算をもたらした。この「その後」という一言が問題を生む。

この CPU はあの命令に対応しているのか?

x86_64 CPU でプログラムを動かすとき、特定の SIMD 拡張に対応しているかどうかは自明ではない。だからデフォルトでは、コンパイラは SSE2 より先の命令を使えない。すべての x86_64 CPU で動くとは限らないからだ。この問題を避ける方法は 2 つある。バイナリを自社サーバーやパブリッククラウドでしか動かさないなら、それらは十分新しく、少なくとも十数年前に導入された AVX2 くらいは対応していると決め打ちしてよい。AVX2 非対応のマシンで動いたらクラッシュさせるか、おかしな挙動をさせるだけだ:

RUSTFLAGS='-C target-cpu=x86-64-v3' cargo build --release

だがバイナリを他人に配って動かしてもらうなら、この手は使えない。そこで関数マルチバージョニング(function multiversioning)の出番だ。同じ関数を SIMD 拡張ごとに複数コンパイルしておき、実行時に CPU がどの機能に対応しているかを調べて、対応する版を選ぶ。ありがたいことに、この問題を抱えるのは x86 だけだ。ARM は 64 ビット CPU で NEON を必須にし、その後も役に立つ SIMD 拡張は追加していない(後述)。WebAssembly では、SIMD ありとなしの 2 つのバイナリをコンパイルし、JavaScript でブラウザが SIMD に対応しているか調べる必要がある。

SIMD をどう使うか

SIMD を活かす方法は 3 つある:

#[cfg(all(any(target_arch = "x86", target_arch = "x86_64"),target_feature = "sse2"))]
_mm_add_epi32(__m128i , __m128i)
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
vaddq_u32(int32x4_t, int32x4_t)

それぞれ何をする必要があるのか、プログラミングモデルは今どうなっているのかを見ていこう。

自動ベクトル化

普通の Rust を書いて、コンパイラのヒューリスティクスに任せる! コンパイラが(おおむね)確実にベクトル化できる形でコードを書くことさえ気をつければ、かなりの効果が出る。ふつうは &[i32] ではなく &[i32].as_chunks() を回すという意味で、ベンチマークを取るかアセンブリを睨むかして、実際に効いていることを確かめる。詳細は Can You Trust a Compiler to Optimize Your Code? を参照。いちばん手間がかからず、ライブラリにも依存せず、コンパイラが対応する命令セットならどんなにマイナーでも自動で使ってくれる。欠点は信頼性に欠けること。関数が大きくなるほど、複雑になるほど、ベクトル化されない可能性は上がる。コンパイラのバージョンや周辺コードの変更で性能が大きく揺れることもある。浮動小数点型には特に注意が要る。

浮動小数点は厄介だ。 そこそこの精度で浮動小数点配列を合計するだけでも、話は驚くほど複雑になる。Taming Floating-Point Sums を参照。

以前は浮動小数点型に自動ベクトル化が効かなかった。結果の精度が変わってしまうからだ(たいていは良い方向に変わるが、コンパイラに観測可能な結果を変える権利はない)。Rust 1.98 でこれが変わり、algebraic_add() のような代数演算が安定化された。コンパイラが観測可能な結果を変えることを許すもので、危険度の低い -ffast-math といったところだ。とはいえ多くの場合、ベクトル化の対象になるには、これらを使うようにコードを書き換えなければならない。しかもマルチバージョニングをどうにかして手に入れる必要もある。ついでだから、それについても話そう……

multiversion crate

後で触れるオールインワンの SIMD crate もマルチバージョニングを提供しているが、自動ベクトル化にいちばん役立つ multiversion を手早く見ておく。使い方はとても簡単で、関数に #[multiversion(targets = "simd")] をつけるだけだ。だがこの手軽さは、文書化されていない落とし穴を隠している。#[multiversion] をつけた関数を呼ぶと、わずかにオーバーヘッドがかかる。ごくわずかで、十数命令にも満たない。だがアノテーションをつけた関数自体が短ければ、この分はかなり大きく見える。経験則はこうだ。関数にループがあれば #[multiversion] をつける。扱う値が少しだけなら #[inline(always)] をつける。ただし呼び出し側のどこかに #[multiversion] があることが条件だ。以下に挙げる他の crate にはこの落とし穴がなく、関数の大きさを気にする必要もない。代わりにボイラープレートが増える。multiversion は、事前定義された SIMD レベルを選ぶのではなく、必要な CPU 拡張を具体的に列挙できる唯一の crate だ。だから自分のコードがたまたま非常に新しい命令で恩恵を受けるなら、それを有効にできる。だが私の経験では、自動ベクトル化されたコードでそんなことはまず起きない。AVX-512 について言えば、multiversion が調べるのはそれが存在するかどうかで、実際に速いかどうかではない。これが実用上、性能を損なうことがある(後述)。ボイラープレートでこれを回避できる。すべての関数にこのコードを足すことになる:

#[multiversion::multiversion(targets(
 "x86_64+cmpxchg16b+popcnt+sse3+sse4.1+sse4.2+ssse3", // x86_64-v2
 "x86_64+avx+avx2+bmi1+bmi2+cmpxchg16b+f16c+fma+lzcnt+movbe+popcnt+sse3+sse4.1+sse4.2+ssse3+xsave", // x86_64-v3
 "x86_64+fxsr,adx,avx512bitalg,avx512bw,avx512cd,avx512dq,avx512f,avx512ifma,avx512vbmi,avx512vbmi2,avx512vl,avx512vnni,avx512vpopcntdq,bmi1,bmi2,cmpxchg16b,fma,gfni,lzcnt,movbe,pclmulqdq,popcnt,vpclmulqdq,xsave,xsavec,xsaveopt,xsaves", // Ice Lake and later
)]]

ポータブル SIMD 抽象

実用レベルの選択肢はすでにいくつかある。理想的な性質は次のとおり:

早見表:

命令セット対応状況:

  • macerator は LoongArch にも対応している。作者いわく「暇だったから」。

std::simd

std::simd は SIMD の完全な解決策ではない。標準ライブラリに入れるしかない部品の寄せ集めに近く、残りはエコシステムの crate に丸投げされている。最大の欠点は nightly でしか使えず、API が今も不定期に破壊的変更を受けること。ある日コンパイラを更新したらコードが通らなくなり、修正に戻ることになる。それを受け入れられて、固定幅ベクタと、せいぜい multiversioning があれば十分というなら、かなり良い!std::simd が存在する意味は LLVM の上に直接乗ることにあり、LLVM が対応するあらゆるプラットフォームを狙える。大銀行しか使っていないような CPU や中国政府専用の変な CPU も含まれる。逆に言えば、std::simd が使える完全に一致する操作が LLVM 内部になければ代替手段はなく、実質 SIMD は一切使われない。これが起きる頻度は不安になるほど高い。sin() という表現は的確そのものだ。SIMD の皮をかぶったスカラー実装を出すのは最大の罪。そして reduce_sum() はどういうわけか性能と精度の両面で最悪のケースになる。浮動小数点の非自明な関数には手を出さないほうがいい。まともな三角関数に最も近いのは sleef crate——SLEEF の std::simd への部分移植で、ややバグがある。しかもこれがこの記事全体で手に入る最良の三角関数だ!std::simd は multiversioning に関して格別に柔軟。multiversion crate を使ってもいいし、この節の他のどの SIMD crate が提供する multiversioning を使ってもいい。他の crate はすべて自前の multiversioning しか使えない。Simd の API は非常にエレガントに見える。N を直接数学演算できれば快適に使えるが、それはできない。この機能は nightly でも不完全だ。それがなくても Simd でハードウェア幅のベクタを取れないわけではないが、見た目はずっと醜くなる。多くの場合 std::simd をそのまま使えて性能もそこそこだが、サードパーティ crate を寄せ集めて機能を足していくのは結局行き詰まる。たとえば sleef crate は multiversion crate と併用できず、sleef を fork して自分で組み合わせるしかない。サードパーティ拡張はそれぞれ勝手にやっているので組み合わせられない。必要なのは、すべての部品が協調して動くオールインワンのソリューションだ。それについて言えば……

fearless_simd

Fearless SIMD はオールインワンで、すべての部品が協調して動く。表のあの緑のチェックマークがずらりと並んだ列を見ればわかる!表にあるもの以外に、fearless_simd 固有の機能は:

主な欠点はボイラープレート。こう書けず

fn my_func(a: A, b: B) {

こう書かねばならない

#[simd]
fn my_func(simd: S, a: A, b: B) {

実に冗長。AVX-512 は新しい CPU でしか使われず、そこでは性能を落とさない(ハードウェアの節を参照)。multiversion を手動で設定してそう振る舞わせることはできるが、デフォルトではなく、大量のボイラープレートが要る(前述のとおり)。他の SIMD 抽象 crate は AVX-512 の有無を確認するだけだ。最近 v1.0 がリリースされ、セキュリティポリシーなども付いてきた。最大の穴は三角関数。SLEEF のようなものを fearless_simd の仕組みに移植した実装はまだない。

wide

wide の利点は多い。プラットフォーム対応が良く、実装されている操作も多く、すでに v1.0。三角関数すらあるが、その精度は明示的に未規定とされている。最大の欠点は multiversioning と根本的に相容れないこと。x86 を狙わないか、常に -C target-cpu= で既知のハードウェア向けにビルドするなら問題ないが、そうでなければ性能は大きく落ちる。唯一の回避策は cargo multivers だが、これは長時間動くプログラムにしか効かない。そうでなければ起動オーバーヘッドが SIMD の性能利得をはるかに上回る。もう一つの欠点は、要素型に対してもベクタ幅に対しても、いかなる形のジェネリクスもサポートしないこと。ただしマクロで回避できる。関数をジェネリックに書く代わりに macro_rules! で包み、T::from_slice ではなく $type::from_slice と書く。ボイラープレートは少し増えるが、ジェネリクス制約のボイラープレートが省けるので、得るものと失うものがある。実際にやったが、それほど悪くない。paste crate などを持ち込めばなおさらだ。f32 や f64 など少数の型しか狙わないなら、ジェネリクスがあるライブラリでもマクロ方式を選びたくなるかもしれない。「ジェネリック」なサイズの配列も得られるからだ。本当のジェネリック配列サイズは nightly 限定で不完全な機能だ。だが generic-array crate で回避することもできるし、最大可能 SIMD サイズの配列をひとつ作ってしまう手もある。

pulp

pulp は faer 線形代数ライブラリを支えるために作られた。それが優先順位を決めている。実装されている操作はほとんどが数学演算で(swizzle はない)、API もネイティブ幅のベクタを中心に設計されている。固定幅ベクタは技術的には可能だが、ドキュメントが皆無で、使うのはかなり面倒。調べている途中でいくつか修正を投げた。健全性のバグも一つ含まれる。要素型に対するジェネリクスはネイティブにはサポートしないが、wide で述べたマクロの技法はここでも使える。そのマルチバージョン化は私が見た中で最も冗長。ボイラープレートを減らすマクロはあるが、それでも他の選択肢に比べればかなり冗長だ。

macerator

macerator は pulp の近縁で、設計思想も似ている。burn の CPU バックエンドを支えるために作られた。pulp と比べると、要素型に対するジェネリックコードのサポートが増え、intrinsic への安全なアクセスとドキュメントの大部分が削られている。固定幅ベクタには一切手を付けていない。それに加えて SSE4.2 がデフォルトで有効になり、LoongArch 向けの最適化されたコードパスも追加された。std::simd を除けば、f16 データに対して部分的に可搬な操作を提供する唯一のライブラリだが、対応する操作のリストはごく限られている。AVX-512 で使うには nightly コンパイラが必要で、NEON は stable でそのまま動く。使っていてかなりぎこちない。標準ライブラリの f16 が nightly でしか使えず、この crate はそれなしで何とかやっているからだ。crates.io では、burn 以外にこれを使っているものはない。

その他

特定のプロジェクト一つだけのために書いた SIMD crate(jxl_simd、pathfinder_simd など)は対象外とする。一般ユーザー向けではないからだ。主に AI に書かせた crate(magetypes、simdeez、thermite など)も含めない。本番環境で使う気になれないからで、特に後ろ二つはバグが多くて不安になる。

intrinsic への安全なアクセス

可搬 SIMD はいいものだが、特定の命令セットにしかない具体的な命令が欲しくなることもある。そうなったら intrinsic を直接使うしかない。Rust v1.87+ ではプラットフォーム固有の intrinsic を安全に呼び出せる:

#[target_feature(enable = "avx2")]
fn add_avx2(a: __m256, b: __m256) -> __m256 {
 _mm256_add_ps(a, b) // this is an avx2 intrinsic
}

注意すべき点が二つある:

どちらの問題にも、すでに定番の解決策がある:

このリストにあるものはすべて、この二つのアプローチを何らかの形で実装したものだ。

archmage

archmage は CPU 特性トークンを提供し、safe_unaligned_simd crate で安全な load/store をラップする。中核は #[arcane] プロシージャルマクロだ。あらかじめ定義された SIMD レベルの集合から選べる:SSE2/SSE4.2/AVX2 といったおなじみのものに加え、性質の異なる二段階の AVX-512——初期実装は遅く、Ice Lake 以降でようやく実用的になる——どちらでも好きな方を選べる。ARM 側はベースラインの NEON にいくつかの拡張レベルが加わる。32 ビット x86 は非対応(その場合は常にスカラフォールバックになる)だが、2026 年にもなってこれは大した話ではない。

fearless_simd

fearless_simd は kernel! マクロを通じて、ほぼ archmage と同じ道具を提供する。これは宣言的マクロで、プロシージャルマクロではない。ビルド時間はその分短くなるが、archmage のようにジェネリック関数や const ジェネリック関数に注釈を付けられるわけではない。そもそも intrinsics とジェネリクスは相性が悪いので、通常は問題にならない。safe_unaligned_simd は同梱していない。安全なロードは可搬 SIMD 抽象でこなせるからだ。それでもどうしてもロードを _mm256_loadu_epi64() と書きたいなら自分で持ち込める。たいていは、すでにそう書かれたコードを移植するためだ。SIMD レベルは可搬 SIMD 抽象と一致している。だからどんなに詳しくても、あの遅い初期の AVX-512 を自分から選ぶことはできないし、aes や bf16 のような NEON の非ベースライン拡張にも触れられない。利点は、可搬 SIMD と intrinsics を気軽に混ぜられることだ。アルゴリズムの大部分は可搬 SIMD で書き、本当に必要な箇所だけ少しの intrinsics で済ませられる。

pulp

pulp はこの分野で意外なほど強い。どの SIMD レベルにも属さない intrinsics、たとえば aes 特性の _mm_aesenc_si128 を使いたいとき、自分で SIMD 特性トークンを作らずに安全に使う最良の(そして唯一の)方法だ。残念ながらそのやり方はドキュメントに書かれていない。ドキュメントをめくると、Avx512ifma のように各種 CPU 特性の名前を冠した構造体が並び、コンストラクタがあり、その CPU 特性に対応する intrinsics がぶら下がっている。ならば構築してそのまま関数を呼べばいいと思うだろう?違う。それでも動きはするが、性能は目も当てられない。必要な CPU 特性を保証しない関数から、追加の CPU 特性を要する intrinsic を呼んでいるからだ(CPU 特性のチェックは失敗しうることを忘れないように)。つまりその intrinsic は単独の関数に切り出す必要がある。結果、命令を一つ実行するために関数呼び出しのオーバーヘッド——命令数個分——を払うことになる。「命令数個」は一つよりずっと多い。関数呼び出しのオーバーヘッドが大半を占め、性能は当然ながら急落する。正しいやり方は、必要な特性をすべて有効にした context を作り、その context から intrinsics をまとめて呼ぶことだ。こんなふうに:

pulp::simd_type! {
 pub struct Ifma {
 pub ifma: "avx512ifma",
 }
}

if let Some(isa) = Ifma::try_new() {
 isa.vectorize(
 #[inline(always)]
 || {
 // Put the entire hot loop here.
 // isa.ifma._mm512_madd52lo_epu64(...)
 },
 );
}

実際に動く、より完全な例を探しているならここを見てほしい。

念のため付け加えると、fearless_simd リポジトリにも同様の機能が独立した単体 crate として提案されたことがある。実装は完成していたが、実際にメンテナンスを引き受ける人が現れず、取り込まれないままになっている。pulp に何か不満があるなら、試してみて、自分が引き受けてもいいと思えるか確かめてみるといい。

intrinsics の現状

std::simdと自動ベクトル化を除けば、SIMD コードはすべて SIMD intrinsics の上に成り立っている。intrinsics 自体もかなり単純明快なはずのものだ。特定の CPU 命令にきれいに対応するのが intrinsics の役割だからだ。これほど単純で、これほど重要なのだから、ちゃんと動いていると思うだろう。動いていない。Rust でも、C++ でも、C でも。 「この命令をそのまま出せ」という要求とコンパイラの最適化は根本的に相容れない。SIMD intrinsics を純粋なブラックボックスとして扱わせれば、他の場所で効率を犠牲にすることになる。たとえば ARM で私が踏んだ実際のバグ:u32x4::from([1,2,3,4])が遅い。これは要するに定数のロードであり、u32x4のメモリレイアウトはu324 つ分の配列と完全に同じだから、極めて安価——ロード 1 回だけで済むはずだった。ところが、土台となる ARM のロード intrinsic vld1_u32_x4がコンパイラ内でブラックボックス操作として実装されていたため、LLVM から見えるのはスタック上の値に対するブラックボックス操作でしかなかった。生成されたアセンブリは、まず定数をレジスタにロードし、それをスタックに置き、ブラックボックスのvld1_u32_x4でレジスタに戻す。修正は、vld1_u32_x4のブラックボックス実装をやめ、通常のロードの互換ラッパーにしてコンパイラが普通に最適化できるようにするだけだった。調査と修正実装に協力してくれた Rust stdarch メンテナの Folkert de Vries に感謝する。では、すべての intrinsics を通常のコンパイラ操作のラッパーにすればいいのか?そううまくはいかない。あのvld1_u32_x4は本物のブラックボックスではない。ハードウェア操作であり、ただ誰もそれ用の最適化パスを書いていないだけだ。そして、ある奇妙な操作をコンパイラが理解できる基本ブロックで表現しようとすると(あるいは、少しずつ異なる複数のハードウェア命令の共通挙動を抽象化したいだけでも)、その操作は複数の基本ブロックから成ることになる。これは Rust にとって特に魅力的だ。LLVM 以外のバックエンドを支えるのが楽になるからだ。もっとも Clang も同じ方向に進み続けている。だが、いくつかのブロックを組み合わせて目的の操作を作るには、オプティマイザがそれらを 1 命令に組み直す必要がある。無関係な最適化パターンがこれを簡単に壊す——ブロックを並べ替えたり、パターンマッチを断ち切ったりする。だからこれらの最適化は、単純なテストケースでは動いても、実コードでは効かなくなる。つまり SIMD intrinsics は終わりのない綱引きに閉じ込められている:片方では意図した命令へ lowering し、もう片方では意図したコンパイラ最適化に付き合わなければならない。こうした根本的な制約とは別に、コンパイラの命令選択のバグもある。LLVM が定数インデックス付きの 512 ビットベクトル shuffle を見て「ああ、これなら最適化できるぞ!」と言い出し、その「最適化」が SSE4.2 時代の操作で、私が要求した shuffle 命令をそのまま実行するよりずっと遅い、というのに出くわした。(報告後、これは LLVM 23 で修正された。)ほかにも、効率的にエンコードすることだけを目的とした intrinsic を、より効率の悪いエンコードに lowering する例もある。私が自分で見つけたこうしたコンパイラバグの一覧は実際に手元にある。Rust 固有のものは報告後に修正されたが、Rust・C++・C すべてに影響する LLVM のバグはまだ山ほど残っている。ちなみに——いや、これは LLVM だけの問題ではない。GCC にも同種の不具合があり、MSVC はこの点で主要なオープンソースコンパイラ 2 つより明らかに見劣りする。おまけの豆知識: Intel は一部の AVX-512 命令を検索可能な Intrinsics Guide に入れ忘れた。そのためほとんどのコンパイラが実装しておらず、今では高水準言語からこれらの命令を使えない。私は rustc にこれらをコントリビュートしたが、stable ではまだリリースしていない。

インラインアセンブリ

これでコンパイラを迂回し、intrinsics の問題をすべて回避できると思うかもしれない。ある程度はその通りだが、代償としてインラインアセンブリブロックが完全なブラックボックスになる。最適化の問題がすべて舞い戻るだけでなく、インラインアセンブリブロックへの出入り自体にもコストがかかる。中にまとまった関数を書いてコンパイラ最適化を犠牲にする覚悟があるなら成立するが、命令 1、2 個を使いたいだけなら割に合わない。

コンパイラ機能のウィッシュリスト

手短に、重要な順に:

標準ライブラリ側では、intrinsics の変更を検証する crater 的な仕組みがほしい。そして std::simd が stable で使えるようになってほしい。そうすればエコシステムの crate はコードの大半を削れて、しかもあらゆるマイナーなプラットフォームに対応できるようになる。

結論

Rust エコシステムの SIMD サポートは大きく成熟した。改善の余地が残る箇所もある(特に三角関数)が、コンパイラ機能とライブラリエコシステムの近年の進歩のおかげで、メモリ安全性が必須でない場合でも Rust は SIMD コードにとって魅力的だ。

延長戦:ハードウェアの現状

5 つの異なる命令セット向けにポータブルな SIMD コードを書いた後だけに、言いたいことは山ほどある。

x86

これはもう本当にめちゃくちゃで、しかも悪いのは全部 Intel だ。AVX2 は最も普及している命令セットであり、同時に私が使った中で最も厄介な命令セットでもある。単純明快な SIMD 操作を実装しようとするたび、半分くらいのケースでは AVX-512 と NEON がネイティブで対応しているのに、AVX2 だけは複雑で遅いエミュレーションが必要になる。そもそもまともな 256 ビット ISA ですらなく、128 ビットの実行ユニットを 2 つ無理やりくっつけただけの代物だという点が、さらに事態を悪くしている。だが、まだ悪い話がある。AVX2 を積んだ CPU は 2013 年に出荷されていたのに、AVX2 を積まない最後の Intel CPU はなんと 2021 年発売だった! つまり AVX2 の存在を前提にすることはできない。運が良ければ……ノートをめくる……2008 年の SSE4.2 で我慢してくれ! だから 2026 年になっても、Firefox のハードウェア調査では x86 CPU の 15% が AVX2 非対応のままだ。そいつらのために SSE4.2 のコードパスを書いて保守しろというわけだ。楽しんでくれ! 今年は何年だよ?!Intel は 2015 年に AVX-512 を出して AVX2 の馬鹿げた点をいくつも直した、そして……どの CPU にも載せなかった? 基本的にサーバーにしか登場せず、それ以外の全員は AVX2 か、ひどいと SSE4.2 に閉じ込められたままだ。その結果 Intel はまったく異なる 3 つの SIMD 拡張を同時に抱え込むことになった! だが待て、もっと悪い話がある! AVX-512 を初期サポートした Intel CPU で AVX-512 命令を実行すると、1 コアしか使っていなくてもすべてのコアの周波数が下がる。どこかで AVX-512 の負荷が動けば、チップ全体の残りの性能が引きずられる! 皮肉なことに、AVX-512 が載るのはコア数の多いハイエンド CPU ばかりで、そういう CPU ほどこの巻き添えの影響を特に強く受ける! こういう CPU なら AVX-512 を全コアで長時間回し続ければ多少は得があると思うかもしれないが、そうなると今度はメモリがボトルネックになり、CPU が何をしていようと関係なくなる。つまりこれらの CPU では、AVX-512 は実質的に性能向上をもたらさず、しばしば足を引っ張り、メモリに触れない人工的なマイクロベンチマークでしか見栄えがしない。実に惜しい。それ以外の点では AVX-512 は AVX2 からの大躍進なのだ。512 ビット幅の話は置いておくとしても、まともな操作セットを返してくれるだけで十分なのに! このクロック低下は 2019 年の Ice Lake アーキテクチャまで直らなかった。完全にではないが、AVX-512 が概ね割に合う程度にはなった。だから fearless_simd は Ice Lake 以降でしか AVX-512 をサポートせず、AMD もサポートする——AMD にはクロック低下の問題がそもそもない。AMD は Zen 4 で、Intel がこれをどれだけ盛大にしくじったかを示してみせた。Zen 4 にはハードウェアレベルの 512 ビット演算すらなく、ほとんどの 512 ビット演算を 256 ビット実行ユニットにマッピングする。それでもベンチマークでは Intel のネイティブ 512 ビットハードウェアを打ち負かした。Zen 5 でネイティブ 512 ビットハードウェアが載ると、勝負は完全についた。Intel が最近苦戦しているのも無理はない。とはいえ AMD に非がないわけではない。scatter/gather 命令が使えないのは彼らのせいだ。Zen ではこれらの命令にハードウェアのネイティブ実装がなく、小さなロードを大量に発行するより遅い。Intel も初期の AVX2 CPU では scatter/gather をスカラーロードより遅くしていたが、彼らは(ある程度)直した。AMD は試みすらしなかった。LLVM はコードを自動ベクトル化するとき、AVX-512 向けに scatter/gather 命令を生成することがあり、これで Intel では性能が 1.75 倍、AMD では 4 倍低下する。だから LLVM が何を狙っているのか私にもよくわからない。この問題を引き起こすには -C target-cpu= を渡す必要があったと記憶しているが、大規模にテストしたわけではない。Steam のハードウェア調査では 23.9% のシステムが AVX-512 をサポートしている。この数字はハイエンド/ゲーミング寄りだ。たとえば Firefox の GPU 調査で SSE4.2 しかない 15% のシステムは、ここでは 2% しかない。調査は AVX-512 のオプション機能別にも集計していて、そこから 23.95%(avx512vnni)から 23.90%(ベースライン avx512f)を引くと -0.05% のシステムが異常に遅い AVX-512 を持っていると推測できる。この割合がなぜ負になるのか、君にも理由は想像がつくだろうし、私にもつく。つまり少なくともデスクトップでは、あの欠けた AVX-512 は存在しないので、気にしなくていい。したがって Ice Lake を AVX-512 の要件にしても失うものはなく、有用な命令がいくつか手に入る。とはいえベースラインの AVX-512 でもそれほど悪くはない。Skylake サーバーの普及率について公開データはないが、この種のサーバーは AVX-512 を積みつつ性能を落とす。Ice Lake を AVX-512 の要件にすれば Skylake は AVX2 に戻るので、この性能低下は避けられるはずだ。Intel はあとほんの少しでまたしくじるところだった。彼らは AVX-512 を AVX10 で置き換えようとしたが、AVX10 とは AVX-512 そのもので、ベクトル幅が 256 ビットか 512 ビットのどちらかで、どちらなのかはわからない、というだけのものだ。幸い AMD の明らかに優れた設計——512 ビットベクトルをそのまま 256 ビットハードウェアにマッピングする——がこの惨事を回避させ、Intel をまともなプログラミングモデルへと引き戻した。ふう。ありがとう、AMD。今年、AVX-512 はついに AVX 10.2 への改名を経て、間もなく登場する Intel CPU で必須になる。これこそ我々がずっと求めていたものだ。うん、改名のことじゃない。あと、ゼロに非常に近い浮動小数点数を扱う演算は性能が急落する。AMD はこうした演算で約 2 倍遅く、Intel では 30 倍の減速に見舞われる。なぜか SIMD について恐ろしい事実を知るたび、元凶はいつも Intel だ。

ARM

AVX2 が間違えた点を、64 ビット NEON はすべて正しくやっている。ベクタ幅は 128 ビットしかないので SSE4.2 相当に見えるが、実際には AVX2 に近い。NEON のレジスタ空間は AVX2 と同じ大きさで、実のところボトルネックはたいていそこにある。しかも 128 ビットの実行ユニットを 2 本並べて明示的に扱う必要がない。高性能な ARM コアは命令レベル並列性によって 128 ビット演算を透過的に並列実行し、電力制約の厳しい安価なコアは 1 本ずつ実行すればいい。さらに NEON には 128 ビットを超える命令がちょうどよい数だけ入っていて、よくある操作がそのまま動く。私が試した SIMD base64 デコードでは、同じアルゴリズムが NEON 上で AVX2 より 1.5〜2 倍速かった(ただし AVX-512 よりは 2 倍遅い)。そしてこのすべてが、いくつもの異なるモデルではなく、単一の単純なプログラミングモデルの上に成り立っている。64 ビット ARM チップでは必須なので、multiversioning も要らない。Aarch64 NEON に唯一文句をつけるなら、同じチップに実行特性のまったく異なる 2 種類のコア(「性能コア」と「効率コア」)が載っていることだ。性能コアで速い命令列は効率コアで遅く、その逆も同じ。だから対象の CPU がはっきり分かっていても、最適な実装を一つに絞ることはできない。すべてはトレードオフだ。しかも市場に出回る ARM CPU の多様さを考えれば、話はさらに悪くなる。幸い NEON はハードウェア命令として直接実装される操作が十分多く、性能もそこそこなので、これが完全な悪夢にはならずに済んでいる。一方 SVE はほぼ役に立たない。SVE2 は現在 ARM CPU で必須だが、ハイエンドのサーバーチップでも 128 ビット幅で実装されているので、手続きが増えた扱いにくい NEON でしかない。技術的には、クラウドサーバー向け ARM チップに 256 ビット SVE を実装した世代があった。だがそれは SVE2 ではなく SVE であり、そもそもクラウドなら AVX-512 をそのまま使えばいい。256 ビット SVE2 ハードウェアが普及してその真価が見えるまで、あと 10 年ほど待つ必要があるかもしれないが、今は手を出す価値はない。ARM には AVX-512 に対抗するものがない。AVX-512 はレジスタ空間が 4 倍あり、512 ビットの実行ユニットを 4 本備えて一度に 2048 ビットを処理する。だが ARM の狙う市場と、優れた AVX-512 も最終的にはメモリ帯域で頭打ちになることを考えれば、ARM にそんなものが必要だとは思わない。AVX-512 の主な利点は対応できる操作の幅がはるかに広いことで、NEON にはすでにそれがある。

RISC-V

RISC-V ベクタ命令(RVV)は取るに足らない。ベクタをサポートする RISC-V ハードウェア自体が取るに足らないからだ。RISC-V は安価なマイクロコントローラ分野で快進撃を続けているが、2026 年時点でベクタ対応の RISC-V ハードウェアはコストパフォーマンスがひどい。Tenstorrent が 2028 年ごろに実際にテープアウトすれば状況は変わるかもしれないが、2026 年なら気にする必要はまったくない。まともなハードウェアがあったとしても、仕様の書き方のせいで一部の重要な命令をコンパイラが使えない。これだけで性能はばかげた水準まで落ち込む。マイナーなコンパイラフラグをいじらない限りは。

その他

残りの SIMD 対応アーキテクチャはどれもニッチすぎて、金を払ってでもやれと言われない限り検討に値しない。POWER と s390x は IBM や銀行が金を出しており、LoongArch は中国政府が金を出している。std::simd は今も 64 ビット SPARC で CI を回しているが、それをサポートするために金を払う者はいない。

出典: shnatsel.github.io← ホームへ戻る