自作言語 Mere の SIMD 対応 — 2026 年 9 月時点の実測値と考察
5 つのバックエンドを持つ小さな自作言語に SIMD を入れた 3 週間の記録。境界検査をループの前に寄せて clang にベクトル化させる、128 bit の型を 2 つ足す、自作 CPU にベクトル命令を足す。何が効いて何が効かなかったか、そしてそれを決める 4 つの性質。
Mere は私が自分のために書いている小さな言語だ。インタプリタと 4 つのコンパイル
バックエンド(C、LLVM IR、WebAssembly テキスト、そしてアセンブラを介さず直接吐く
RISC-V 機械語)を持ち、その RISC-V 出力を走らせるエミュレータも Mere で書いてある。
2026 年 9 月、この言語に一度も問われていなかった問いを立てた。SIMD はこういう言語に
何を買ってくれるのか、そしてどこで。この記事はその答えを数字で書いたもので、
プロジェクトを追っていない人に向けている。数字はすべて公開リポジトリ(merelang/mere の
benchmarks/)から再現でき、都合の悪い数字も都合の良い数字と同じ表に載せている。
最初の計測は SIMD の話ではなかった
分かりやすいベンチマークは axpy だ。double のベクトル 2 本に対して
c[i] = c[i] + alpha * a[i]、200 万要素、100 pass。Mere は C にコンパイルして clang に渡し、
clang は -O2 で自動ベクトル化するので、素朴な期待値は手書き C との同着だった。
実測は 2.8 倍遅かった。
実際に走った複製のアセンブリを読んだ。関数名で探した方は呼ばれていないラッパーで、
main にインライン展開された複製が本物だった。そこにベクトル命令は 1 つも無かった。
clang の診断が理由を言っていた。「ループに早期脱出が 3 つある」「配列の範囲を同定できない」。
脱出とは Mere の境界検査だった。vec_get と vec_set は添字を検査し、失敗すると戻らない
呼び出しでループを抜ける。1 反復に 3 つ。しかもその呼び出しの後では、それが長さやデータを
変えていないと証明できないので、ベクトルの長さとデータポインタを毎回リロードする。
次に、吐かれた C を手で直した。検査をループの外へ出し、データポインタを局所変数に取る。
それだけで clang はベクトル化し、時間は手書き C と一致した。再帰を素の for に書き直すだけでは
何も変わらなかった。つまり 2.8 倍のほとんどは境界検査の「形」で、SIMD そのものの寄与は
そのうち 1.3〜1.4 倍分だった。ここで作業の順番が決まった。まず形を直す、全バックエンドで。
明示的な lane はその後で判断する。
経路 1: 範囲をループの前で 1 回だけ検査する
境界検査の versioning(v0.1.420)は AST に対するパスなので、どのバックエンドの前でも走る。 Mere のループの書き方、
let rec axpy = fn (i: int) ->
if i == n then ()
else
let _ = vec_set c i (f_add (vec_get c i) (f_mul alpha (vec_get a i))) in
axpy (i + 1);
に対して、アクセスを検査なしの双子に替えた兄弟 axpy__rvfast を足し、呼び出し点 axpy 0 を
if 0 >= 0 && 0 <= n && n - 1 + 1 <= vec_len c && n - 1 + 1 <= vec_len a
then axpy__rvfast 0 else axpy 0
に書き換える。guard が添字の範囲全体を一度に検査する。ループ本体に脱出は残らず、clang は
ベクトル化し、インタプリタを含む全バックエンドが要素ごとの検査を省く。条件は想像どおりの
もので、第一引数が添字の末尾再帰関数、リテラルの歩幅、添字と不変な上限の比較による exit、
添字が添字そのものか添字について単調(両端点で範囲が押さえられる)、容器がループ外で束縛
されていること、本体が長さを変えられず、変えうるものを呼ばないこと。書き換えるのは step 側
だけだ。exit 側の検査は残す。そこでのアクセスは v[n]、末尾の 1 つ先を読むからで、これは
v0.1.420 で出荷した健全性の穴で、exit 側の要素を読むプログラムが C では 46 を印字し
インタプリタでは失敗する、という形で v0.1.427 に見つけた。このパスのゲートは今、全プログラムを
パス ON と OFF、4 バックエンドで走らせて同じ出力を要求する。
事前には分からなかった設計点が 2 つ。最初の版は dispatcher 関数だった。axpy 自身が
if guard then fast else slow になる。これは LLVM と Wasm のラムダ持ち上げを壊した。dispatcher
は自分では捕獲変数を使わないので、fast 側へそれを渡す持ち上げができない(推移的な捕獲)。
呼び出し点で分岐し、元の関数を slow としてそのまま残せば、新しい捕獲は要らない。もう 1 つは、
top-level のループが自分の名前で自分の plan を隠してしまい、main が fast を一度も呼ばず
時間が動かなかったバグで、これも main のアセンブリ(走る複製)で呼び先が slow だと分かった。
結果。python3 benchmarks/run.py <row> --reps 3、macOS/arm64、Apple clang 21、C 行は同じ clang の
-O2。
| 行 | Mere 前 | Mere 後 | 手書き C |
|---|---|---|---|
axpy |
C の 2.8 倍 | 83 ms、pass 部分は両者 50 ms で同着 | 73 ms |
bytecount(バッファ内の 1 バイトを数える) |
23 ms | 21 ms | 21 ms |
matmul 512 |
144 ms | 144 ms | 132 ms |
crc32 |
43 ms | 43 ms | 45 ms |
axpy に残る 10 ms は、C 行が malloc した配列へ書く所を Mere は vec_push でベクトルを
組み立てている分だ。matmul が動かないのは内側が浮動小数の縮約で、fast-math 無しでは clang が
加算の順序を守るから。crc32 が動かないのはループがデータで添字を決めるテーブル参照だから。
どちらも後で述べる 4 つの性質で説明がつく。ここで言いたいのは、検査を外へ寄せるのは無料で
どこでも効くが、それが何を買うかはループ次第、ということだ。
経路 2: lane を自分で書くための 128 bit 型 2 つ
自動の経路は WebAssembly に何もしない。WAT は書いたまま組み立てて走らせ、後ろに最適化器が
無い。clang が見通せないループにも何もしない。そこで Mere に値の型が 2 つ増えた
(v0.1.422〜425)。f64x2 は double 2 つ、u8x16 はバイト 16 個。普通の値で、演算はすべて
builtin だ。f64x2_add a b、u8x16_swizzle table idx、u8x16_shift_in prev cur k。全部で
24 個あり、速くしたいプログラムを 1 本書いて、それが必要とするものを列挙して決めた。
そのプログラムは Keiser と Lemire の流儀の UTF-8 検証器だ。16 バイトを 1 step で、現在と直前の
バイトの nibble で 3 つの 16 要素テーブルを引いて and し、lane ごとの誤りマスクを作る。続きバイトの
欠落は飽和減算で拾う。これは自動ベクトル化器が組めない典型で、スカラの検証器は状態機械であり、
状態機械は鎖だからだ。表現はバックエンドごとに、C は clang の vector_size(16)、LLVM IR は
<16 x i8>、Wasm は v128、RISC-V は V 拡張(後述)。移植不能な演算は swizzle だけで、x86 の
pshufb、arm64 の tbl、Wasm の i8x16.swizzle、RISC-V の vrgather を、Mere は Wasm の意味
(15 を超える index は 0)に固定した。
| 行 | スカラ Mere | lane つき Mere | スカラ C |
|---|---|---|---|
utf8valid(4 MiB、20 pass) |
72 ms | 29 ms | 54 ms |
axpy_simd(明示 f64x2) |
自動ベクトル化の axpy、0.08 s |
0.12 s | 0.07 s |
検証器が正当化だ。スカラ Mere の 2.5 倍、スカラ C の 1.9 倍、約 4 GB/s。C 行はわざとスカラで、
intrinsic を使わない C プログラマが書くものにしてある。だから比較は「同じ言語で lane あり対なし」
と「lane つき Mere 対 lane なし C」になる。axpy_simd は負けで、わざと表に載せている。人が
1 step 2 lane で書く所を、clang は自動ベクトル化したループを 8 lane 相当に展開する。だから明示の
f64x2 は何も書かないより遅い。そのベンチマークの manifest にはそう書いてある。
Wasm にはもう 1 つ言うことがあり、それは時間ではなくメモリの話だ。関数呼び出しを越えるか
データ構造に入る SIMD 値は 16 バイトの箱としてヒープに置かれ、Wasm バックエンドはメモリを回収
しない。最初の版は中間値をすべて箱にしていて、長い検証は入力 64 KiB でメモリを使い切った。
式の中では unboxed に、v128 の局所変数に置くようにして(v0.1.429)、これは 256 KiB に、
axpy_simd は 20,000 要素から 50,000 要素に動いた。だが u8x16 を次の反復へ持ち越すループは
今も反復ごとに確保する。step を region ブロックで包んでも救えない。持ち越す値は region から
コピーして出され、そのコピーが確保そのものだからだ。これは SIMD ではなくメモリモデルの限界で、
そう書き残してある。
経路 3: 言語が自分で書いた CPU の上のベクトル命令
Mere の 5 つ目のバックエンドは RV32IM / RV64IM の機械語を吐き、それが走る機械は Mere で書いた
エミュレータ memu だ。どちらにもベクトル拡張は無かった。ここに SIMD を入れるとは両側に RVV 1.0
を足すことで、最初の決定は「正しい」の定義だった。-cpu rv32,v=true,vlen=128 の QEMU を外部の
基準にした。命令サブセットの Python モデル、memu、QEMU を同じ 15 本の directed プログラムと
200 本の fuzz プログラムで走らせ、全レジスタの一致を要求した。最初は一致しなかった。fuzzer は、
memu が仕様では予約(reserved)の「vd が source と重なる vrgather」を平然と計算しているのを
見つけた。今は QEMU と同じく trap する。QEMU はまた、プログラムが mstatus.VS を立てるまで
ベクトル命令を一切実行せず、memu にはその状態が無かった。基準を読むまで、それはハングに見える
タイムアウトだった。
サブセットは UTF-8 検証器が VLEN=128、e8 で必要とするもの。vsetivli、vle8/vse8、整数と
マスクの演算、vrgather、vslideup/vslidedown(2 つで shift_in)、vredor、拡幅の
vwredsumu。f64x2 はこのバックエンドでは拒否する。機械に浮動小数のハードウェアが無く、
言語の float ライブラリはソフトウェアだからだ。
| Mere 製 RV32 コア上の検証器(64 KiB、20 pass) | 時間 |
|---|---|
| スカラ | 2.75 s |
| RVV | 1.85 s |
| RVV、値を箱でなくベクトルレジスタに置く(v0.1.430) | 1.6〜1.7 s |
最後の行は Wasm の unboxed 化の RISC-V 版だ。式の木は v1〜v7 で評価して根で 1 回だけ箱にし、
演算の operand としてしか使われない let 束縛の u8x16 は、最後の使用より前に呼び出しが走らない
限り v8〜v15 に住む。呼び出し先の自分のベクトルコードがそれを上書きするからだ。検証器の
listing で箱への store は 43 から 16 になった。残る 16 は再帰呼び出しで次の反復へ渡す値だ。
エミュレータは解釈実行なので、この 1 割は命令数の差である。
lane が報われるかを決めるもの
測った 7 行は、すべてループの 4 つの性質から出てくる。
反復のあいだの依存。 SIMD は同じ演算を独立な要素に同時に行う。c[i] = c[i] + alpha * a[i]
は i について独立だ。状態機械 state[i+1] = f(state[i], byte[i]) は鎖で、鎖を lane に割る
ベクトル化器は無い。明示版の検証器は状態機械を lane に載せたのではない。別のアルゴリズムで、
各バイトを直前のバイトとの組み合わせから小さなテーブルで分類するので、各 lane は隣にしか
依存しない。u8x16_shift_in は前ブロックの末尾バイトを今のブロックへ持ち込むためにある。
「ベクトル化できる」とは「依存を局所化できた」ということで、その形を見つけるのはコンパイラで
なくプログラマの仕事だ。
添字の作り方。 連続した添字は 1 回のベクトルロードだ。データで決まる添字、crc32 の
table[b] は gather で、たいていのハードウェアでは lane ごとのロードになる。crc32 が動かなかった
理由がこれだ。swizzle は検証器のテーブルを成立させる例外で、16 エントリ以内のテーブルなら
レジスタ内の gather を 1 命令でやる。
演算の種類。 整数と bit 演算は結合的で、コンパイラは lane をまたいで順序を変えられる。
浮動小数の加算は結合的でないので、fast-math が無ければ縮約は直列のままだ。それが matmul。
分岐はマスクと select に直せるときだけベクトル化され、データ依存の早期脱出はできない。
コンパイラ自身の境界検査がその脱出 3 つだった、というのが経路 1 で寄せるまでの話だ。
lane 数と計算密度。 得られる倍率の上限は lane 数で、バイトなら 16、double なら 2。
メモリ律速のループは先に帯域の天井に当たる。axpy は乗加算 1 回に 2 ロード 1 ストアで、SIMD の
寄与は 2.8 倍の差のうち 1.3〜1.4 倍分だった。検証器はバイトあたり十数回の参照と bit 演算をするので、
16 lane が 2.5 倍になった。axpy_simd が負けるのは、人の 2 lane がコンパイラの展開した 8 lane に
出会うからだ。
| 効く | 効かない |
|---|---|
| 反復が独立、または依存を隣の数バイトに局所化できる | 前の反復の結果が次の反復の入力 |
| 連続した添字、16 エントリ以内のテーブル | 大きなテーブルへのデータ依存の添字 |
| 整数・bit 演算、マスクで書ける分岐 | 順序固定の浮動小数縮約、データ依存の早期脱出 |
| byte lane、バイトあたりの演算が多い | 2 lane、メモリ律速、ループ内の確保や呼び出し |
そこから出る実務の規則。要素ごとの整数ループは普通に書き、自動の経路に C と同着させる。
浮動小数の要素ごとのループも同じ経路で足りる。浮動小数の縮約はどちらの経路でも動かない。
u8x16 に手を伸ばすのは、バイト処理のループが遅く、かつ直前バイトへの依存を隣接バイトの
組み合わせのテーブル参照に書き直せるとき。UTF-8 検証、JSON の構造走査、base64。手書きの
f64x2 が報われるのは、自動ベクトル化器がループをまったく見られない所だけだ。
計器が私にしたこと
3 週間のうちコンパイルより計測に費やした時間の方が長く、その罠は数字より読者の役に立つ。
- 正しい「名前」の関数のベクトル命令を数えたら、呼ばれていないラッパーを数えていた。走る複製は
mainにインライン展開された方だ。mainから呼び出しを追う。 - clang のフラグを何個か入れたシェル変数を zsh が単語分割せず、clang は
-Rpass=regex ...という 1 個のフラグを受け取って診断を 0 行印字した。それは「ベクトル化されていない」と読めた。 - zsh の multios で
2>&1 >file | headはコンパイラの出力をheadに流し、headが先に閉じて コンパイラを SIGPIPE で殺した。途中で切れた C は 1 時間コード生成のバグに見えた。 - POSIX シェルは
VAR=x shell_functionの代入を関数の後も保持するので、ゲートの「パス OFF」設定が 以後の全 emit に効き、ゲートは「何も plan されていない」を緑で報告した。 - Rosetta は misaligned な SSE ロードで fault しない。alignment を書かない
store <16 x i8>は 16 を 仮定し、ヒープは 8 バイト境界の箱を渡す。本物の x86 ではそれはmovapsでクラッシュだ。arm64 で 通り、Apple silicon 上の x86-64 Linux コンテナで通り、CI の runner でだけ落ちた。修正は emit の 最後の 1 行、メモリを通るベクトル load/store すべてにalign 8を付けること、そして裸のものが 残っていないというテスト。 - このどれよりも前に、CI は 3 日間赤で、誰も理由を見ていなかった。吐いた C が
uintptr_tを使い、 arm64 では<arm_neon.h>が<stdint.h>を連れてくるが、x86-64 のスカラ分岐は連れてこない。 C をコンパイルする全ゲートが Linux で落ち、ビルド行列は吐いた C をコンパイルしないので緑だった。 赤い CI は最初の失敗の下流にあるゲートを全部隠す。上の Linux 限定のバグ 2 つは緑に戻した後に しか見つからず、versioning パスの二乗の fixpoint も、スケーリングのゲートが虚空に向けて 報告し続けていたものだった。
残っていること
Wasm と RISC-V の明示 SIMD を縛っているのは命令セットでなくメモリモデルだ。回収が無い限り、 反復をまたいで持ち越す値は反復ごとの確保になる。LLVM バックエンドにはファイルをバイト列として 読む lowering が無く、UTF-8 の行はそこでは走らない。RISC-V のベクトルサブセットは e8、LMUL=1 だけ で、言語の中にまだ広い要素幅を求めるものが無いからだ。最初の利用者のいない機能は汎用のふりを した一般化になるので、それを必要とするプログラムが現れるまで開けておく。
数字が支える一文の要約。境界検査を外へ寄せれば自動ベクトル化は無料で、効く所ではすべて勝つ。 明示的な lane が報われるのはベクトル化器がループを組めない所だけで、組める所では負ける。