WASM へのコンパイル
rontolisp に -o で .wasm で終わる出力パスを与えると、ソースを解釈実行する代わりに WebAssembly バイナリへコンパイルします。JVM バックエンドと同様、出力の拡張子がターゲットを選択し、バイナリはサードパーティのアセンブラなしで直接出力されます。以下の例は wasmtime 47+ を前提としており、wasm-GC と例外処理はデフォルトで有効です:
echo '(print (+ 1 2))' > hello.lisp
rontolisp hello.lisp -o hello.wasm
wasmtime run hello.wasm
3
出力の選び方
出力の形状は、互いに独立な 2 つの選択で決まります:
- 値モデル。 デフォルトでは値は WebAssembly の GC ヒープ上に置かれ(整数は
i31refで、fixnum 範囲を超えると符号付き 64 ビット構造体に、それも超えるとリム表現の多倍長整数にボックス化。浮動小数点数は構造体にボックス化)、言語全機能をサポートしますが、wasm-GC 対応ランタイム(wasmtime 14+、Node 22+、現行ブラウザ)が必要です。--no-gcは代わりに言語の純粋計算サブセットをアンボックスなi64/f64スカラーとリニアメモリ文字列へローワリングします — 結果は任意の WebAssembly エンジンで動く素の MVP モジュールで、サイズも桁違いに小さくなります。 - パッケージング。 デフォルトの出力は WASI Preview 1 コアモジュールです。
--componentはそれをコンポーネントとしてラップします: GC パスでは非同期正準 ABI 上でフル I/O を備えた WASI 0.3 コンポーネント、--no-gcパスではホスト側フラグを一切必要としないコンパクトな型付きリアクターコンポーネントです。--no-wasiはどちらのパッケージングからも WASI インポートを取り除き、純粋計算ライブラリ(「リアクター」)にします: ホストがインポートオブジェクトなしでインスタンス化できる Preview 1 モジュール、あるいは--componentと組み合わせると、何もインポートせずトップレベルフォームをインスタンス化時に実行するリアクターコンポーネントです。
2 つの軸を掛け合わせると 6 つの形状になります:
| 出力形状 | フラグ | 言語 | 動作環境 | 詳細 |
|---|---|---|---|---|
| WASI コマンドモジュール | (なし) | 全機能 | WASI Preview 1 対応の wasm-GC エンジン(wasmtime run) | wasm-GC コアモジュール |
| ライブラリ(リアクター)モジュール | --no-wasi | 全機能(純粋計算エクスポート) | インポート不要の任意の wasm-GC エンジン(Node 22+、現行ブラウザ。--host-random を付けるとホストインポートが 1 つ、--host-fetch では 2 つ増える) | --no-wasi リアクターモード |
| WASI 0.3 コンポーネント | --component | 全機能 + コンポーネント限定 I/O(rontolisp:fetch、TCP ソケット) | wasmtime 46+ または wasm-GC 対応の別のコンポーネントホスト | WASI 0.3 コンポーネント |
| リアクターコンポーネント | --component --no-wasi | 全機能(純粋計算エクスポート) | wasm-GC 対応の任意のコンポーネントホスト、空のインポートオブジェクト | リアクターコンポーネント |
| 素のコアモジュール | --no-gc | 数値/文字列サブセット | wasm-GC も SIMD もない環境を含む任意の WebAssembly エンジン | 非 GC 出力 |
| コンパクトな型付きコンポーネント | --no-gc --component | 数値/文字列サブセット | 任意のコンポーネントホスト、フラグゼロ | コンパクトなコンポーネント出力 |
大まかな指針: 値モデルはコードの要件で選びます。言語全機能が必要なら GC ヒープ、サブセットに収まる数値/文字列カーネルなら --no-gc(どこでも動く移植性と数百バイトのバイナリが得られます)。パッケージングはホストで選びます。コンポーネントホストなら --component、素のエンジンや JavaScript 埋め込みならコアモジュールです。
ホスト境界
モジュールとホストの境界を渡るものは、2 つの補完的なディレクティブで宣言します:
rontolisp:wasm-export/rontolisp:wasm-importは境界を、rontolisp 自身の型指定子(:int、:float、:string、:s-expr、...)で手書きで綴ります。同じディレクティブは出力形状に応じて 4 つの異なるホスト契約にコンパイルされます(生のコア関数、型付きコンポーネントモデルエクスポートなど)。--no-wasiでは--emit-js-glueが同じ宣言からその境界の JavaScript 側を書き出します。- WIT 契約(
wit-export/wit-import) は.witファイルから境界を駆動します — 1 つの契約がすべてのバックエンドで検査され、実装はバックエンドごとに異なります(--componentでは型付きコンポーネントモデルエクスポート、インタプリタと JVM ではプロバイダコールバック)。--emit-witと--scaffold-witも同ガイドで扱います。
ブラウザでコンポーネントを実行する
jco transpile はコンポーネントを、ページで動くただの JavaScript に変換します。今日動くもの(--no-gc --component は何も要らず、wasm-GC の --component はロードして計算はできますがまだ印字はできない)、そして末尾の --no-wasi / --no-gc リアクターモジュールを手書きで Node とブラウザから呼び出す完全なウォークスルーは、ブラウザガイドを参照してください。
横断的なフラグ
最適化(ツリーシェイキング)
コンパイルは、モジュールのルート(そのエクスポートと _start/_initialize エントリ)から到達不能なすべての関数を落とし、生き残りを再番号付けします。未使用の WASI インポートも除去されるため、純粋計算のリアクターモジュールは一握りの関数だけになります:
echo "(defun fact (n) (if (<= n 1) 1 (* n (fact (- n 1)))))
(rontolisp:wasm-export 'fact :params '(:int) :returns :int)" > fact.lisp
rontolisp fact.lisp --no-wasi -o fact.wasm
wasmtime run --invoke fact fact.wasm 5 # => 120, from a ~2.5 KB module
--optimize=off を渡すと、モジュールは代わりに、関数インデックスがそのとき固定に保たれるために、プログラムが実際に使うものとは無関係にランタイム全体(プリンター、有理数、文字列、リーダー、eval ヘルパー、WASI インポートスロットなど)を埋め込みます: 同じ fact のモジュールは約 2.5 KB ではなく約 155 KB になります。このシェイキングは動作を保存します: 実際の call 命令から呼び出しグラフを辿るため、到達可能なもの(組み込みの eval/load がディスパッチするコードを含む)はすべて保持されます。--component を含むすべての出力形状で有効です。同じレベルは JVM 出力のデッドコード除去も行います。
落ちた関数は道連れも持っていきます: それらだけが使っていた WASI インポート、もはやどこからも名指されない型定義、そして生き残ったコードがどこからも参照しない静的な文字列データです — リテラルを 1 つ表示するだけのモジュールは、ランタイム全体の文字列表ではなく数百バイトになります。
バイト単位で同一の本体にコンパイルされる定義 — 典型的には defstruct や define-condition が生成するアクセサ — は 1 つだけ出力され、すべての呼び出しが共有本体に付け替えられます。共有されるのはコードだけです: 各関数の同一性は保たれるため、(eq #'f #'g) は NIL のままです。
関数を名前で直接書くと有利です。(mapcar #'double xs)、(reduce #'+ xs)、(sort xs #'<)、(funcall #'double x) は、double を第一級の値にしてランタイムのアリティ別ディスパッチャ経由で呼ぶのではなく、(double x) と同じ直接呼び出しにコンパイルされます。結果は 2 つです: 呼び出し自体が安くなり、他から到達されない関数がそのディスパッチャ経由でも到達不能になります — ライブラリの広い範囲を成果物に残していたのは、たいていこの経路です。同じ関数を計算された値として渡せば(変数、lambda、実行時に組み立てた指示子)、当然ディスパッチャが戻ってきます。
この下限は出力の書き方に依存しません。定数のテキストはコンパイル時にレンダリングされてバイト列として埋め込まれるため、print、princ + terpri、write-string、write-line、(format t "Hello, ~a!~%" "World") のいずれもランタイムのプリンタを置き去りにし、互いに数十バイトの差に収まります(コアモジュールで 600 B 未満、コンポーネントで 1.8 KB 未満)。静的データに残るのはプログラム自身が書き出すものだけです: プリンタ固有の固定文字列 — NIL、リストの区切り文字、浮動小数点数の特殊値、文字名 — はプリンタと一緒に落ちます。計算結果を表示すれば、当然どちらも戻ってきます。
ただし、コンパイラ自身が値を求められるものは「計算結果」ではありません。すべての引数がリテラルである純粋な組み込み関数の呼び出し — (* 6 7)、(length "Hello World!")、(concatenate 'string "Hello" " " "World!")、(string-upcase "hi") — はコンパイル時に評価され、呼び出しそのものが消えます(コンパイルするすべてのバックエンドで。インタプリタは従来どおり実行時に評価しますが、答えは同じです)。これは上記のプリンタの畳み込みより前に起きるため、(princ (* 6 7)) は (princ 42) とまったく同じ下限に届き、(format t "~a~%" (length "abc")) は (format t "~a~%" 3) と同じになります。これらの名前を再定義すれば — defun、defmethod、flet — あなたの定義が勝ちます: コンパイラはその名前をプログラム中のどこでも畳み込まなくなります。--dynamic では、すべての名前が実行時に解決されるため、この機能全体が無効になります。
リテラルのルックアップテーブルも同じ仕組みで畳み込まれ、こちらはテーブルそのものが節約になります: (coerce '(0 #x77073096 …) '(vector (unsigned-byte 32))) や (make-array n :element-type '(unsigned-byte 8) :initial-contents '(…)) は、それが構築する特殊化ベクタになり、モジュールは起動時にボックス化された整数のリストを組み立てる代わりに、要素幅そのままの静的データとして持ちます — 32 ビットのテーブルなら 1 要素あたり約 12 バイトではなく約 4 バイトです。フォームを評価するたびに新しい独立した可変ベクタが得られる点は、呼び出しのときとまったく同じです。宣言された幅に収まらない要素は畳み込まれず、従来どおり実行時の構築側がマスクします。
--component では、コアだけでなくラッパーもプログラムに合わせて縮みます。コンポーネントがどの WASI 0.3 インターフェースをインポートするかは、プログラムが実際に到達できる範囲から決まります: (print "Hello World!") は wasi:cli/types と wasi:cli/stdout だけをインポートするコンポーネントになり — wasi:filesystem も wasi:clocks も wasi:random もなく、そのプログラムには標準エラーへ書く手段がないので wasi:cli/stderr すらありません — 一方でファイルを開き、時計を読み、乱数を引くプログラムはそれらをすべて保持し、warn を呼ぶか *error-output* へ書くプログラムでは wasi:cli/stderr が戻ります。コンディション処理フォーム(handler-case など)を使うプログラムでも同様です。捕捉されなかったコンディションがトラップする前に出す報告も、そこへ書かれるからです。--emit-wit はコンポーネントが実際に持つワールドを出力するので、生成される .wit も一緒に縮みます。
echo '(print "Hello World!")' > hello.lisp
rontolisp hello.lisp --component -o hello.wasm # ~1.7 KB
rontolisp hello.lisp --component --optimize=off -o hello-full.wasm # ~165 KB
--optimize=off のコンポーネントは常に固定の WASI 表面をすべて宣言します。これがリリースをまたいで 2 つのビルドをバイト単位で比較可能にしています。
ツリーシェイキングは、読み込んだライブラリにどこまで手が届くかも左右します。コンパイル済みプログラムはほとんどの関数を直接呼びますが、funcall にはディスパッチ表が必要で、そこに載った関数は実際にその経路で呼ばれるかどうかに関わらず到達可能扱いになります。そこで、表に載るのはプログラムが実際に値として取得しうる関数だけ — #'name、クォートされた 'name の指定子、lambda — で、それ以外は普通のデッドコードとなりシェイカーが除去します。md5 を読み込んで関数を 1 つ呼ぶだけのプログラムでは、これが約 1.1 MB と 582 KB の差になります。
シンボルビルダー — intern、find-symbol、make-symbol、uiop:symbol-call — を持つプログラムでは、絞り込みは有効なままで、代わりに範囲が広がります: モジュールが持つ文字列やキーワードの定数は実行時に指定子になりうるため、コンパイラは各関数名のそれらの綴りも調べます。ハンドラの発見を (find-symbol "RUN" pkg) で行う Worker が、直接しか呼ばないものをそれでも削ぎ落とせるのはこのためです。
この絞り込みは全部か無かで、無効になるのは、今回のコンパイルが決して目にしないデータからプログラムが関数を名指しできる場合です。該当するのは eval、read、read-from-string、実行時の load のいずれかの使用で、読み込んだライブラリの中にあるものも含みます。ビルドが期待ほど縮まないときは、どの演算子が原因かをコンパイラに尋ねてください:
rontolisp -Drontolisp.debug.dispatchgate=true app.lisp -o app.wasm
# => [dispatch-gate] every function stays dispatchable because of: EVAL
format の制御文字列に含まれる ~/name/ ディレクティブも該当します。実行時に関数を名前で指定するためです。ただし持ち込まれるのはコンパイラが見つけられる制御文字列だけなので、そのディレクティブを綴らないプログラムには影響しません(formatを参照)。
--dynamic でも同様に無効になります。遅延束縛は実行時に任意の名前を解決するためです。
そこから 1 つの例外が生じます: 実行時に計算された断片から組み立てた指定子 — (funcall (intern (concatenate 'string "gre" suffix))) — はコンパイラが読める定数ではないため、その呼び出しは通常の「undefined function」エラーを通知します。戻る手段は --dynamic であって --optimize=off ではありません: この絞り込みはレベルが切り替える対象に含まれないため、最適化を断ってもそうした名前が戻ってくることはありません。
さらに小さく仕上げたい場合は、同じ fact.lisp を --no-gc でコンパイルすると fact は unboxed な i32 にローワリングされ、2.5 KB を占めていた GC ランタイム一式(条件クラス階層、cons セル、プリンタ)が丸ごと落ちます:
rontolisp fact.lisp --no-gc -o fact.wasm
wasmtime run --invoke fact fact.wasm 5 # => 120, from a ~108 byte module
ソースは変更不要で(wasm-export は値モデルを問わず同じ挙動)、生成モジュールは wasm-GC エンジンの要求も落とします。
レベルとは独立に(--component を含むすべての出力モードで)、コンパイルは常にスプライスしたライブラリをツリーシェイキングします。対象は同梱の Lisp ソースライブラリ(linalg:、vec:、JSON、URL、equalp/string<)と、asdf:load-system / ql:quickload で読み込んだシステムです。プログラムがソース中でその名前に一切言及しない(クォートされたシンボルや文字列リテラルの中も含む)関数・変数・定数はモジュールに含まれません。あなた自身のコードが刈られることはなく、load/require でスプライスされたファイルも刈られません。対象になるのはシステム由来のライブラリだけです。
クラス・総称関数・メソッド・コンディション・構造体も同じ規則で刈り込まれます。どこからも参照されないクラスはそのメソッドごと除かれ、プログラムが呼ぶ総称関数のメソッドであっても、特化先クラスのインスタンスを到達可能なコードが作れないものは除かれます。標準プロトコル名(initialize-instance、print-object、close など)のメソッドは呼び出しが暗黙なので、特化先クラスの生死だけに従います。
その帰結として、実行時に計算した文字列から名前を組み立てて eval/apply 経由で呼び出すライブラリ関数は、通常の「undefined function」エラーを通知します。その場合は --no-prune(または --dynamic)を付けてコンパイルすると、すべてのライブラリ定義が保持されます。
このフラグは省略可能なレベルを取ります。--optimize と --optimize=default は同じもの — ここまでに書いたすべてであり、フラグを書かなかったときに既に選ばれているもの — で、素の綴りのこの意味は恒久的に変わりません。--optimize=size はそれに次節のトレードを加えたもので、--optimize=off はそのどれでもなく、このフラグがデフォルトで有効になる前のビルドが出力していたものを出力します。
--optimize=off の用途は 2 つで、そのどちらもプログラムを動かすことではありません: コンパイラの変更前にビルドしたモジュールとの比較と、シェイクしていないモジュールの挙動が違うかどうかを問うことによるツリーシェイカーのバグの二分探索です。コンパイラが読めない名前を通してしか到達できない関数を持つプログラムに必要なのは --dynamic です — 上記の funcall ディスパッチの絞り込みはレベルが切り替える対象に含まれないため、off でそうした名前が戻ってくることはありません。
サイズ最適化(--optimize=size)
wasm-GC のコード生成には、速度と引き換えにバイト数を費やしている箇所が 2 つあり、どちらも --optimize=off でも --optimize=default でも有効です:
(logand (+ (ash x 7) i) #xFFFFFFFF)のような整数式ツリーは2 回コンパイルされます — 1 回は unboxed なi64の単一計算として、もう 1 回は汎用ヘルパ経由で、浮動小数点数・有理数・bignum 領域への桁あふれが辿るフォールバックとして。- 代入が整数演算である
let束縛には、通常の boxed なスロットに加えて unboxed なi64スロットが与えられます。
--optimize=size はこの 2 つを断ります。プログラムの計算結果は何も変わりません — 高速経路は残り続けるフォールバックの代替として存在していただけです — が、演算は汎用ヘルパ経由になるため代償は実在し、その大きさはプログラムがどれだけ整数中心かで決まります:
| プログラム | --optimize=default | --optimize=size | 実行時間 |
|---|---|---|---|
| ironclad SHA-256/HMAC/PBKDF2、4096 ラウンド | 2,078,195 B | 1,562,816 B (-24.8%) | 1.4 s -> 5.2 s (3.8 倍) |
vec: カーネルを使うニューラルネットの学習ループ | 271,233 B | 214,169 B (-21.0%) | 1.07 s -> 1.26 s (+18%) |
浮動小数点 MLP の学習ループ(vec: なし) | 159,747 B | 125,496 B (-21.4%) | 5.6 s -> 6.1 s (+9%) |
cl-postgres の hello world (--component) | 8,024,998 B | 6,384,099 B (-20.4%) | — |
(wasmtime 47、3 回実行の最良値。)サイズの削減幅はほとんど変わりませんが、実行時間の代償は大きく変わります。融合されるのは整数演算だけだからです — 浮動小数点カーネルはループのインデックス計算でしか代償を払いませんが、暗号のラウンド計算はすべてで払います。
したがって、モジュールを運ぶ必要があるとき — エッジへのデプロイ、ブラウザへのダウンロード、サイズ上限のあるレジストリ — に使ってください。ただしプログラムのホットループが整数演算(ハッシュ、暗号、ビット演算)である場合は別で、そこでは同じ削減が実行時間の数倍という代償を伴います。
このレベルはすべてのバックエンドで受け付けられるので、ビルドスクリプトが対象を知る必要はありません。ただしトレードする対象があるのは wasm-GC(Preview 1 と --component)だけで、JVM と --no-gc の出力は --optimize=default が生成するものとバイト単位で同一です。
SIMD アクセラレーション(--simd)
--simd はすべてのバックエンドに共通する唯一のアクセラレーションスイッチです: ベクトル化可能な vec: および linalg: カーネルを本物のベクトル命令へローワリングします。WASM では値モデルと直交します:
- wasm-GC +
--simdはカーネルを GC 管理のレーングループ配列上のネイティブ固定幅 SIMD(f64x2/f32x4)へローワリングします — パック float 配列は通常の GC オブジェクトのままで、メモリはフラグなしの場合とまったく同じに振る舞います。--componentおよびすべての--optimizeレベルと組み合わせられ、通常どおりwasmtime runで実行します(wasmtime は SIMD プロポーザルをデフォルトで有効にしています)。 --no-gc+--simdは同じカーネルをパックされたリニアメモリブロック上のv128へローワリングします。--simdなしの--no-gcは代わりに素のスカラーループを出力します — SIMD プロポーザルのないランタイムでも動く v128 フリーの MVP モジュールです。
全体像 — どのカーネルがベクトル化されるか、単精度リダクションの精度規則、測定された効果、linalg のインターセプト — は SIMD アクセラレーションガイドにあります。