自作言語 Mere の SIMD 対応 — 2026 年 9 月時点の実測値と考察

5 つのバックエンドを持つ小さな自作言語に SIMD を入れた 3 週間の記録。境界検査をループの前に寄せて clang にベクトル化させる、128 bit の型を 2 つ足す、自作 CPU にベクトル命令を足す。何が効いて何が効かなかったか、そしてそれを決める 4 つの性質。

meresimdperformancecompilersbenchmarksrisc-vwebassembly

Mere は私が自分のために書いている小さな言語だ。インタプリタと 4 つのコンパイル バックエンド(C、LLVM IR、WebAssembly テキスト、そしてアセンブラを介さず直接吐く RISC-V 機械語)を持ち、その RISC-V 出力を走らせるエミュレータも Mere で書いてある。 2026 年 9 月、この言語に一度も問われていなかった問いを立てた。SIMD はこういう言語に 何を買ってくれるのか、そしてどこで。この記事はその答えを数字で書いたもので、 プロジェクトを追っていない人に向けている。数字はすべて公開リポジトリ(merelang/merebenchmarks/)から再現でき、都合の悪い数字も都合の良い数字と同じ表に載せている。

最初の計測は 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_getvec_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 bu8x16_swizzle table idxu8x16_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 で必要とするもの。vsetivlivle8/vse8、整数と マスクの演算、vrgathervslideup/vslidedown(2 つで shift_in)、vredor、拡幅の vwredsumuf64x2 はこのバックエンドでは拒否する。機械に浮動小数のハードウェアが無く、 言語の 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 版だ。式の木は v1v7 で評価して根で 1 回だけ箱にし、 演算の operand としてしか使われない let 束縛の u8x16 は、最後の使用より前に呼び出しが走らない 限り v8v15 に住む。呼び出し先の自分のベクトルコードがそれを上書きするからだ。検証器の 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 回のベクトルロードだ。データで決まる添字、crc32table[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 が報われるのはベクトル化器がループを組めない所だけで、組める所では負ける。

← Back to Notes