自作言語 Mere の WebAssembly 対応 — 2026 年 9 月時点の現在地
ある言語の Wasm バックエンドの状況報告。能力を WASI インタフェースに落とし、実ネットワークプログラムを 2 つのホストで component として走らせ、WASI のものではなかった並行の壁に突き当たり、誰も測っていなかった playground が静かに 3 倍になっていた。全体で 1,064,718 バイトから 616,025 バイトへ。
Mere は私が自分のために書いている小さな言語だ。インタプリタと 4 つのコンパイル バックエンド(C、LLVM IR、WebAssembly テキスト、RISC-V 機械語)を持ち、Wasm の バックエンドは約 11,400 行ある。この記事はそのバックエンドの状況報告だ。2026 年 9 月時点で何ができて何ができないか、そして誰も見張っていなかった数字が 3 倍に なっていたと気づいた 1 日について。
実測の記事である。以下の数字はすべてこのマシンか CI から出たもので、都合の悪い 数字も都合の良い数字と同じ表に載せてある。
設計上の細部をひとつ先に書いておく。これが後の全部を形づくるからだ。この
バックエンドが吐くのは WebAssembly テキストであって、バイナリではない。
wabt の wat2wasm が組み立てる。つまり出力はどの段階でも読めて、diff が取れて、
grep できる。以下の測定のいくつかが可能だったのはそのおかげで、成果物そのものに
問いを立てることができた。
能力が WASI インタフェースになった
最初のアークは Component Model だった。Mere には小さい言語が育てがちな形でホスト
能力が生えていた。print が 1 行書き、args がコマンドラインを返し、read_file
がファイルを読む。それぞれが env というモジュールからの ambient な import で、
これはまさに component 化できない形だった。component は必要なものを world で宣言
するが、env.* は何も宣言していない。
そこで各能力を WASI インタフェースの上に落とした。
| 能力 | 落とし先 |
|---|---|
print |
fd_write |
args |
args_get |
env_var |
environ_get |
time |
clock_time_get |
| stdin | fd_read |
read_file / write_file |
path_open + fd_read / fd_write |
| TCP / UDP / DNS | wasi:sockets(p2、resource 型) |
ファイルシステムは予想より安く済んだ。preview 1 アダプタの path_open 系がそれを
運んでくれるので、preview 2 の resource と stream のモデルにファイルのために触る
必要がなかった。ソケットは逆だった。preview 1 ではソケットを作ることすらできない
ので、wasi:sockets に直接向かうしかない。resource ハンドル、pollable.block、
そして canonical ABI による record と variant の平坦化(i32 が 12 個並ぶ)が
まとめてやってくる。
結果として、テストケースではなく実際のプログラムが component として動く。
Mere で書いた HTTP クライアントが実ソケットでページを取得する。Mere で書いた DNS
リゾルバが 8.8.8.8 に UDP クエリを送って A レコードを読む。どちらも 1 つの
アーティファクトが 2 つのホストで動く。wasmtime ネイティブと、Node 上の
jco だ。この 2 ホスト性は意図して作ったもので、見た目より価値がある。1 つの
ランタイムでしか動かない component は、仕様に対してではなく実装に対してしか
テストされていない。
バージョンは wasi:*@0.2.3 に、1 箇所で pin してある。以前は 32 個の import
それぞれに書かれていて、ビルドスクリプトにさらに 8 箇所あった。1 つの規則が 40
通りに綴られている状態で、それを動かすリリースは 40 箇所すべてで正しくなければ
ならなかった。ビルドスクリプトはもう繰り返さない。コンパイラが今吐いた
モジュールから版を読み戻すので、embed する world が import の使っていない版を
名乗ることはできない。
WASI のものではなかった壁
1 つだけ届かなかった dogfood がある。Redis プロトコルの KV サーバだ。接続ごとに スレッドを spawn し、channel で store スレッドと話す。communicating によって共有 する形で、ハンドラは map に一切触れない。
Component Model は単一スレッドだ。canonical ABI は共有メモリのスレッドを想定して
いないし、wasi-threads はそれと非互換な core module の機能である。だから私は穴
を記録し、選択肢を書き出し、再訪条件を置いた。component-model async が WASI 0.3
で成熟したら、と。
WASI 0.3.0 は 2026 年 6 月 11 日に出た。確認しに戻って、自分が書いたことの両方の 半分が、逆向きに古くなっているのを見つけた。
ツールの半分は満たされていた。wasmtime 46 は -S p3=y を持っている。しかし
WASI 0.3 は threading を明示的にスコープ外としている。 0.3 が入れたのは
async func / stream<T> / future<T> — ホストが 1 つのイベントループを回す
並行であって、並列ではない。私の再訪条件は、0.3 がどれだけ成熟しても
満たされ得なかった。
サーバのソースを読み直して残りが決まった。接続ごとに 1 spawn、channel、store に 触れないハンドラ。これは I/O バウンドの CSP であって、並列を必要としていない。 形としては協調スケジューリングに完璧に合う。
だが WASI 0.3 が定めるのはコンポーネント境界の async ABI であって、ゲストが 計算の途中で中断する手段ではない。direct style の言語から吐いたコアモジュールは、 依然として自前の状態機械かスタック切替を要る。それは当時すでに選択肢に書いてあった もの(green-threads ランタイム、最も難しいと注記していた)で、0.3 の出荷でその 仕事は 1 行も減っていない。
壁は最初からアダプタではなかった。私は保留を外部イベントの名前に紐づけ、その イベントは別の問いへの答えを持って到着した。正直な書き方はこうだ。これは自分の側 の継続機構を要する。着手するかどうかは判断であって、待ちではない。
誰もバイトを測っていなかった
ドキュメントサイトには playground がある。15 本のデモが .wasm にコンパイルされ、
ブラウザに配られている。誰も測っていなかった。
見に行って、チェックアウト済みのビルドディレクトリを見つけ、そこから数字を読んだ。 悪くないように見えた。それから、そのディレクトリが 2 か月古いことに気づいた。 gitignore されていて、一度も建て直されていなかった。ソースから建て直すと、絵が まるごと変わった。
| 7 月のビルド(古い) | 実際 | |
|---|---|---|
| ファイル数 | 10 | 15 |
| 合計 | 632 KB | 1,064,718 B |
hello.wasm |
5.2 KB | 15.0 KB |
hello.wasm は 2 か月で 3 倍になっていて、誰も何も言わなかった。 ビルド成果物
のサイズは、他のどの検査も見ていない量だからだ。そして古いツリーを読んだことが、
私が最初に「十分小さい」と結論した理由でもある。間違った成果物の測定は、測定と
まったく同じ顔をする。
文脈として: Web Almanac の 2025 年のクロールでは、実世界の .wasm の中央値は 14 KB
だ。1 行印字するプログラムが、Web 全体の中央値を超えていた。
なのでゲートを作った。本物のサイトを一時ディレクトリに建て(チェックアウト済みの ものは決して見ない)、各モジュールを floor と ceiling の両方を持つバンドと比べる。 ceiling は誰もが予想する回帰の側だ。floor はより重要な半分で、中身が消えて stub に なったデモは、ceiling だけの検査が永久に「合格」と呼び続ける類の失敗である。
最適化器が取れなかったもの
wasm-opt -Oz はどこでも走っていなかった。入れたら playground から 34.1% が落ちた。
小さいデモで約 -48%、セルフホストしたコンパイラのビルドで -27% 〜 -33%。結果は
すべて validate を通り、ヘッドレスで走らせられるデモはインタプリタとまったく同じ
答えを返す。
より面白いのは取れなかった方の数字で、1 つの列がそれを教えてくれた。 elem の数が 1 ファイルも動かなかった。 65 は 65 のまま、627 は 627 のまま。 関数の数は 3 分の 1 落ちているのに。
Mere のクロージャ呼び出しは全部が関数テーブルを通る。だから elem セグメントの各
エントリは、最適化器が消してはいけない root だ。call_indirect がそこを狙いうる。
hello.wasm は -Oz を通しても 152 関数のうち 101 を保ち、そのうち 65 が
テーブルのエントリだった。
それがいくらの価値かを知るために、テーブルを手でモジュールから抜いた。上限を測る ためだけに作った壊れた成果物だ。そして 2 通りに切り分けた。
hello.wasm、-Oz 後 |
バイト | 関数 |
|---|---|---|
| 出荷時のまま、65 roots | 7,915 | 101 |
| top-level fn アダプタ 34 を抜く | 7,267 | 58 |
| prelude のラムダ 31 を抜く | 4,596 | 54 |
| 両方抜く | 2,299 | 7 |
34 個のアダプタを落とすと 43 関数と 648 バイトが消える。代わりに 31 個のラムダを 落とすと 4 関数と 3,319 バイトが消える。安く見えた半分は本当に安く、2 つは互いを 生かし合うので超加法的で、そして 1 行印字するプログラムが本当に要るのは 7 関数 だった。
これで仕事の枠が変わった。「テーブルを絞る」ではなかった。エントリは静的には到達
可能で、そのモジュールには call_indirect が 51 箇所ある。「プログラムが到達
できない prelude 関数をそもそも emit しない」だった。関数本体を emit することが、
そのラムダをテーブルに入れる行為だからだ。
プログラムが呼ばない prelude
そこでバックエンドは AST 上で到達可能性を計算し、main body から seed して、到達した
ものだけを emit するようになった。hello.wasm は 1,946 バイト・8 関数になり、
テーブルのエントリは 1 つになった。
2 つの制約がこれを形づくった。刈るのは prelude だけだ。ユーザが書いた関数は、walk が
使用を見つけられなくても常に root であり続ける。爆風を自分で書いていないコードの中
に収めるためだ。そして walk には catch-all の腕がない。将来構文ノードが増えたら、
その下の名前を黙って落とすのではなくビルドが止まる。多めに見積もれば誰も呼ばない関数
が 1 つ残るだけだが、少なく見積もれば「emit されなかった関数への呼び出し」が出て、
wat2wasm がそれを名指しで拒否する。
そして本当の穴が 1 つ、まさにそうやって名乗り出た。単相化された特殊化は
<base>__<型タグ> という名前を持つが、その名前はソースのどこにも出てこない。
呼び出し側は list_map と言い、emit 側は
list_map__list_top_decl__closure_top_decl_top_decl__list_top_decl と書く。基底に
到達したら、その全インスタンスに到達しなければならない。セルフホストの bootstrap
テストが 1 回の実行でそれを捕まえた。
2 つ目の穴は私の変更より古く、私の変更が露出させただけだった。call_indirect は
何も登録されていなくてもテーブルを必要とするのに、テーブルの宣言は 1 つの構文の
名前を持つフラグが立ったときだけ行われていた。高階のベクトルヘルパ、それを必要と
すると観測された唯一のケースだ。それが成り立っていたのは、他の何かがあらゆる
プログラムでテーブルを非空に保っていたからにすぎない。まさにこのアダプタたちだ。
刈り取りがその偶然を取り去り、差分コーパスの bytes→vector ブリッジが
table variable out of range: 0 (max 0) で組み立てられなくなった。
その最初の修正は、より静かな形で間違っていた。「emit されたテキストに
call_indirect があるか」で判定したのだが、テーブル節は main 関数の本体やいくつかの
ランタイム節が文字列として存在するより前に作られる。走査は部分集合を読んで全体
について答えていたことになる。今はテーブルを無条件に宣言している。一度も通らない
モジュールで約 20 バイト、そして間違いようがない。
着地点:
| before | after | |
|---|---|---|
| playground 合計 | 1,064,718 B | 616,025 B |
| 床(最小モジュール) | 15,329 B | 1,662 B |
hello.wasm |
15,403 B / 152 関数 | 1,946 B / 8 関数 |
刈り取り率はプログラムから予想されるとおりに動く。hello は top-level 関数 34 個の
うち 1 個、ワードカウンタは 35 個のうち 2 個、2048 の実装は 59 個のうち 26 個、
セルフホストのコンパイラは 319 個のうち 287 個を保つ。
被検体を見ていなかった 20 本のアサーション
刈り取りは 20 本のユニットテストを壊した。そしてその全部が、すでに間違っていた。
emit された Wasm に対する部分文字列のアサーション、つまり「このプログラムを
コンパイルすると i32.store offset=4 が出る」という主張で、値表現が 64 bit に
広がる前のメモリレイアウトを述べていた。長いあいだ偽だったのだ。通っていたのは、
その文字列が被検体のプログラムが使っていないランタイムヘルパ関数の中に現れて
いたからだ。刈り取りがヘルパを取り去り、偶然の一致も一緒に消えた。
なので 1 本ずつ直すのをやめて、同じ形のアサーション 97 本を全部掃いた。さらに 13 本
が偽だった。置換はすべてプログラム自身の main 関数から読み取り、自明なプログラム
0 に含まれないことを確認した。それは元のアサーションが一度もやっていなかった
確認であり、レイアウト変更を生き延びてしまった理由でもある。
掃きはもう 1 つ見つけた。97 本のうち 41 本は、プログラム 0 にも当たる。
偽ではなく、ただ空虚だ。ランタイムがそれらの名指す opcode を全部含んでいるので、
ほとんど何に対しても通ってしまう。それらは手を付けず、記録した。別の仕事であり、
掃いたから直ったふりをするのは、それ自体が偽の緑になる。
計器の側にも対になる話がある。サイズゲートを毒見するために hello.wasm を別の
妥当なモジュールにすり替えたとき、サイズ検査は緑と言った。 すり替え先がバンドの
中に着地したからだ。捕まえたのは、出荷モジュールをインタプリタと突き合わせて走らせる
振る舞い検査の方だった。大きさしか知らないゲートは、それが別物だと教えてくれない。
計器が私にしたこと
その日の失敗のうち 3 つは私のもので、そして韻を踏んでいる。
ゲートは着地した瞬間から 3 コミット連続で CI が赤だった。 サイトのビルドは
dune exec で始まり、CI ではビルドツールはパッケージマネージャの環境の中にしか
パスが通っていない。デプロイのワークフローは最初からそのスクリプトを正しく呼んで
いた。私は CI のステップを隣のゲートの形をコピーして書いたのだが、隣はコンパイラ
のバイナリを直接叩くのでそれを必要としない。手本が間違っていた。ゲート自身の報告が
それを悪化させた。「サイトのビルドが失敗した」という 1 行が、たった 1 行の
「コマンドが見つからない」を覆っていたので、そこからの最初の推測も外れた。
最適化器のバージョンが、測定の一部だと分かった。 このリポジトリが他のすべてを pin するやり方でツールチェーンを pin したが、binaryen だけはディストリビューション に任せた。Ubuntu 24.04 が配るのは version 108 で、これはこれらのモジュールを一切 読めない。フラグなしでは tail call を拒否し、フラグありでは mutable な export された global を拒否する。バンドはバイト数なのだから、それを生んだ最適化器は pin の中に 属する。version 132 は Linux x86-64 でも macOS arm64 でも、記録されたバイトを正確に 出す。
そして私は、書いたばかりのゲートは回したのに、同じ量をずっと見張っていた方は 回さなかった。 別の budget 検査が、example サーバの Wasm サイズを同じ種類の floor つきで長らく測ってきていた。刈り取りは 3 本ともその下に押し込んだ。私が見なかった ものを CI が捕まえた。ある数字のために計器を 1 つ作ったことで、すでにその数字に 向けられていた計器を探すのをやめていたのだ。
新しいゲートを CI と同じイメージで 1 回走らせたことが、出荷前に 4 つ目を捕まえた。
コンテナの Node 18 は opcode 0x12 を拒否し、振る舞い検査はそれを「最適化された
モジュールがインタプリタと違う答えを返す」と報告した。間違ったツールを名指す失敗は、
失敗が無いより悪い。今は前提を先に問い、CI はバージョンではなく能力そのものを表明
させている。
残っているもの
Mere は WasmGC を使っていない。 線形メモリの上に自前の bump アロケータと自前の クロージャ表現を積んで配っている。2026 年のエコシステムにおける管理言語のサイズの 見出しは、まさに逆の動きだ。メモリ管理をホストに渡し、コレクタを同梱するのをやめる。 報告されている削減は本物である。それが、自分で書いた CPU も同時にターゲットにして いる言語にとって正しい取引かどうかは未解決の問いで、私はまだ測っていない。
41 本の空虚なアサーション。 偽ではなく弱い。1 本ずつ判別する入力が要る。
並行。今は正しく整理されている。コンパイラ側の継続または協調スケジューリングの 機構であって、待ちではなく判断だ。
wasm-opt が component を処理できない。 これは上流の未解決事項だ。preview 2
以降は component が既定の成果物なので、最も効く最適化パスが、実際に出荷するものを
素通りしている。
Wasm 3.0 の機能群のうち、このバックエンドが使っているのは tail call だけだ。GC も、 例外処理も、64 bit メモリも、アトミックも使っていない。それが現在地の公平な要約 だろう。component 化された能力層を持つ線形メモリのコンパイラ、以前の 4 分の 1 の コストになったブラウザ playground、そして形の分かった壁が 1 つ。