(rontolisp) docs

WASM へのコンパイル

rontolisp-o.wasm で終わる出力パスを与えると、ソースを解釈実行する代わりに WebAssembly バイナリへコンパイルします。JVM バックエンドと同様、出力の拡張子がターゲットを選択し、バイナリはサードパーティのアセンブラなしで直接出力されます:

echo '(print (+ 1 2))' > hello.lisp
rontolisp hello.lisp -o hello.wasm
wasmtime run -W gc hello.wasm
3

WASM バックエンドは、コンパイルフラグの組み合わせでいくつかの異なる出力形状を生成できます。まず次のセクションで全体像をつかみ、自分のホストと用途に合う形状を選んでください。あとはその形状のセクションだけを読めば十分です — 各セクションは自己完結しています。

出力の選び方

出力の形状は、互いに独立な 2 つの選択で決まります:

  • 値モデル。 デフォルトでは値は WebAssembly の GC ヒープ上に置かれ(整数は i31ref、浮動小数点数は構造体にボックス化)、言語全機能をサポートしますが、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 パスではホスト側フラグを一切必要としないコンパクトな型付きリアクターコンポーネントです。Preview 1 の GC パスでは、代わりに --no-wasi で WASI インポートを取り除き、ホストがインポートオブジェクトなしでインスタンス化できる純粋計算ライブラリ(「リアクター」)にできます。

2 つの軸を掛け合わせると 5 つの形状になります:

出力形状フラグ言語動作環境選ぶ理由
WASI コマンドモジュール(なし)全機能WASI Preview 1 対応の wasm-GC エンジン(wasmtime run -W gc)プログラム全体をコマンドラインから実行する
ライブラリ(リアクター)モジュール--no-wasi全機能(純粋計算エクスポート)インポート不要の任意の wasm-GC エンジン(Node 22+、現行ブラウザ)JavaScript から Lisp 関数を呼び出す
WASI 0.3 コンポーネント--component全機能 + コンポーネント限定 I/O(rontolisp:fetch、TCP ソケット)wasmtime 46+(後述のフラグ)または wasm-GC 対応のコンポーネントホスト型付きコンポーネントエクスポート + 本物の I/O
素のコアモジュール--no-gc数値/文字列サブセットwasm-GC も SIMD もない環境を含む任意の WebAssembly エンジン極小・依存ゼロの計算カーネル
コンパクトな型付きコンポーネント--no-gc --component数値/文字列サブセット任意のコンポーネントホスト、フラグゼロ極小の型付きコンポーネント

大まかな指針: 値モデルはコードの要件で選びます。言語全機能が必要なら GC ヒープ、サブセットに収まる数値/文字列カーネルなら --no-gc(どこでも動く移植性と数百バイトのバイナリが得られます)。パッケージングはホストで選びます。コンポーネントホストなら --component、素のエンジンや JavaScript 埋め込みならコアモジュールです。

さらに 2 つのフラグは形状と直交しており、末尾の横断的なフラグで扱います:

  • --optimize はモジュールをツリーシェイキングします(GC の --component パスでは no-op)。
  • --simd は数値ベクトルカーネルをネイティブ v128 命令で高速化します(どちらの値モデルでも有効)。

Lisp 関数のエクスポート

デフォルトでは、コンパイルされたモジュールはエントリポイント(_start)しか公開しません。個々の Lisp 関数をホスト(wasmtime --invoke、JavaScript、または別のモジュール)から直接呼び出せるようにするには、rontolisp:wasm-export ディレクティブでマークし、パラメータと結果の WASM 境界型を宣言します:

rontolisp fact.lisp -o fact.wasm
wasmtime run --invoke fact -W gc fact.wasm 5
120

ディレクティブ自体はどの出力形状でも同じです。形状ごとに変わるのはエクスポートのホスト契約です — コアモジュール形状では生のコア関数、--component では型付きコンポーネントモデルエクスポートになります。インタプリタと JVM バックエンドではこのディレクティブは no-op(指定されたシンボルを返すだけ)なので、同じソースがすべてのバックエンドで動作します。

型指定子とその境界表現は次のとおりです:

DesignatorWASM boundaryNotes
:inti3231-bit signed range (the internal i31ref)
:longi64--no-gc only; full 64-bit signed range, matching the non-GC backend's internal i64
:floatf64
:booli320 is nil, any non-zero value is t
:string(ptr, len)UTF-8 bytes in linear memory; a component-model string under --component
:s-expr(ptr, len)s-expression text (any value except a function); GC value model only

副作用を目的とする関数は、:returns を省略する(または nil'():void を与える)ことで void の結果を宣言できます。ラッパーは Lisp の戻り値を破棄し、WASM の結果を持ちません。同様に、:params の省略・nil'() は引数なしを意味します。

:as はエクスポート名を変更します — ホスト向け API に Lisp シンボルとして自然でない名前(例えば JavaScript 向けの camelCase)を付けたいときに便利です:

すべての形状に共通する制限:

  • エクスポートできるのはトップレベルの defun のみで、宣言したパラメータ数はそのアリティと一致しなければならず、関数値を受け取ったり返したりする関数は対象外です。
  • エクスポート名はデフォルトで素の Lisp 名(fact)になり、:as で変更できます。引数の書き方はホストに依存します(wasmtime --invoke fact module.wasm 5instance.exports.fact(5) など)。

エクスポートモード早見表

同じディレクティブは、--no-gc / --component フラグに応じて 4 つの異なるホスト契約にコンパイルされます:

GC core module (default / --no-wasi)GC --component--no-gc core module--no-gc --component
ホスト要件wasm-GC エンジン(wasmtime -W gc、Node 22+、現行ブラウザ)wasmtime 46+(-W gc=y -W component-model-more-async-builtins=y)または wasm-GC + JSPI 対応のコンポーネントホスト任意の WebAssembly エンジン任意のコンポーネントモデルホスト、フラグ不要
エクスポートの形生のコア関数型付きコンポーネントモデルエクスポート(WAVE --invoke、jco)生のコア関数型付きコンポーネントモデルエクスポート(WAVE --invoke、jco)
スカラー:int/:float/:bool/void:int/:float/:bool/void+ :long(i64)+ :long(s64)
:string手動の (ptr,len) + __ronto_allocコンポーネントモデル string(正準 ABI)手動の (ptr,len) + __ronto_allocコンポーネントモデル string(正準 ABI)
:s-expr手動の (ptr,len)コンポーネントモデル string(印字テキスト)非対応非対応
関数本体で使える機能言語全機能言語全機能非 GC サブセット非 GC サブセット
エクスポート内の I/O動作する(実 WASI インポート。--no-wasi ではトラップ)同期エクスポートではトラップ。:async t を宣言するprint のみ(単一の fd_write インポート)print のみ(組み込み WASI 0.2 stdio ブリッジ)
プログラムのトップレベル_start として実行wasi:cli/run として共存defun + ディレクティブのみdefun + ディレクティブのみ
呼び出しごとの文字列メモリホスト管理(__ronto_alloc)正準 post-return が解放ホスト管理(__ronto_alloc + アリーナ API。スカラー戻り値では自動)正準 post-return が解放
典型的なサイズ約 100 KB(--optimize で約 2 KB)約 110 KB数十バイト〜数 KB数百バイト〜数 KB

このページの残りは各形状の詳細です: エクスポートの呼び出し方、その中で動くもの、そして各ホストが提供すべきものです。

デフォルト出力: wasm-GC コアモジュール

デフォルトの出力 — -o file.wasm 以外のフラグなし — は、wasm-GC 値モデル上の WASI Preview 1 コアモジュールです:

  • wasm-GC — 整数は i31ref として表現されます。浮動小数点数は float_struct { f64 } にボックス化されます。スタック上のすべての値は (ref eq) として型付けされます。これが言語全機能(cons セル、シンボル、クロージャ、ハッシュテーブル、eval など)を支えるものであり、モジュールが wasmtime 14+(-W gc)、Node 22+、現行ブラウザといった wasm-GC 対応ランタイムを必要とする理由です。
  • WASI Preview 1 — モジュールは 8 つの wasi_snapshot_preview1 関数(標準出力の fd_writerandom_get、クロック、環境変数など)をインポートし、_start エントリポイントを公開するため、wasmtime run はプログラムのトップレベルをコマンドのように実行します。

エクスポートされた関数は生のコア関数です: スカラー(:int/:float/:bool)は素の数値として境界を渡るため、wasmtime --invokeinstance.exports.fact(5) が直接使えます。メモリ経由の :string:s-expr はモジュールのエクスポートする memory を通じて (ptr, len) ペアを渡し、ホストが引数バイト列を書き込むための __ronto_alloc(size) バンプアロケータも併せてエクスポートされます — このプロトコルはメモリを読み書きできるホスト(JavaScript であって wasmtime --invoke ではない)を必要とし、付録で端から端まで解説します。モジュールのインスタンス化には依然として 8 つの WASI インポートを満たす必要があります。wasmtime run は自動で提供し、ブラウザホストは純粋計算関数に対して no-op スタブを供給できます。あるいは --no-wasi で丸ごと取り除けます。

この値モデルに関する 2 つの動作上の注意:

  • パラメータ数の上限。 関数(defun または lambda)は最大 7 つのパラメータしか取れません(インタプリタと JVM バックエンドにこの制限はありません)。上限を超えた固定アリティの defun は自動的にバンドルされます: コンパイラは最初の 6 パラメータを残し、残りをリストに詰め、すべての直接呼び出しサイトを一致するように書き換えます — そのため幅広いライブラリシグネチャもそのままコンパイルされます。そのような関数の値を #'name/symbol-function で取るのはコンパイルエラーです(バンドルされた形を知っているのは直接呼び出しだけです)。また、上限を超えた lambda や可変長関数は依然としてエラーになります — その場合は自分で引数をリストにまとめてください。可変長関数の rest リストは 1 パラメータと数えられるため、&rest 関数は最大 6 つの必須パラメータを宣言でき、直接呼び出しサイトでは任意個の引数を受け取れます。
  • 浮動小数点数の印字の形。 WASM ではあらゆる大きさの浮動小数点数が印字できます: 整数部は 2⁶³ まで正確で、それを超える値は近似的な指数形式(1.0E19)にフォールバックし、Infinity-InfinityNaN は他のバックエンドと同様にその語で印字されます。形の違いが 1 つ残っています: 10⁷ から 2⁶³ までは、インタプリタと JVM が指数表記(1.5E12)を使うのに対し、WASM はすべての桁を印字します(1500000000000.0)。rontolisp:json-stringify もこの形の違いを引き継ぎます。

ホスト関数のインポート

rontolisp:wasm-importwasm-export の逆方向です: ホストが提供する関数を宣言し、指定した名前でトップレベルの defun とまったく同じように Lisp から呼び出せるようにします — #'namefuncallmapcareval も含めてです。:from はインポートモジュール名(デフォルト "env")、:as はその中のフィールド名(デフォルト: Lisp 名)を指定し、型指定子は上記と同じ表です:

wasmtime では、それらをエクスポートする別のモジュールをプリロードしてインポートを満たします — ここではホストモジュール自体も Lisp で書かれており、:as エイリアス add でその関数をエクスポートしています:

$ cat host.lisp
(defun host-add (a b) (+ a b))
(rontolisp:wasm-export 'host-add :as "add" :params '(:int :int) :returns :int)
$ rontolisp host.lisp -o host.wasm --no-wasi
$ rontolisp main.lisp -o main.wasm --no-wasi
$ wasmtime run -W gc --preload host=host.wasm --invoke add10 main.wasm 32
42

ブラウザ(または Node)ではインポートオブジェクトがそのままモジュール表になります — :from 名ごとに 1 キー、:as 名ごとに 1 プロパティです。これは WASM バックエンドが提供しないあらゆるものへの脱出ハッチでもあります。例えば三角関数の組み込みはないので、JavaScript のものを借りられます:

const imports = { math: { sin: Math.sin, cos: Math.cos } };
const { instance } = await WebAssembly.instantiate(bytes, imports);

WebGL トライアングルの例はこのパターンの hello world です: 10 個のインポート関数、エクスポートなしで、色付きの三角形をすべて Lisp から描画します。WebGL キューブの例は 3D を加えます: 透視投影と回転の行列を毎フレーム Lisp で計算します。WebGL ギャラクシーの例は同じ発想を完全なブラウザプログラムに育てたものです: WebGL パイプライン全体が Lisp から駆動されます — GLSL シェーダは Lisp ソース内にあり、Lisp が 34 個のインポートされたホスト関数を通じてコンパイル・リンク・バッファ確保とすべてのドローコールを発行し、JavaScript はハンドルテーブル上の 1 行バインディングだけを提供します。

スカラー型以外の境界の詳細:

  • :string/:s-expr引数は、モジュールのエクスポートする memory 内への (ptr, len) ペアとしてホストに届きます(:s-expr 引数は先に読み取り可能なテキストへ印字されます)。
  • :string結果はホストがリニアメモリに書き込む必要があります — エクスポートされた __ronto_alloc でバッファを確保し、(ptr, len) ペア(JavaScript では 2 要素配列)を返します。
  • :s-expr結果は組み込みリーダーで解析されるため、ホストはリスト構造全体をテキストとして渡し返せます。

制限:

  • デフォルト(wasm-GC)の Preview 1 出力専用です: --component--no-gc はこのディレクティブをエラーで拒否します。
  • インタプリタと JVM バックエンドでは、このディレクティブは呼び出すとエラーを通知するスタブを定義します。共有ソースはどこでもロードできますが、実際にインポートを呼び出すには WASM ホストが必要です。
  • インポートされた関数にも他の関数と同じ 7 パラメータのアリティ上限があります。
  • モジュールのインスタンス化には宣言したすべてのインポートの提供が必要です: wasmtime run はインポートモジュール名ごとに --preload <module>=<file>.wasm を必要とし、JavaScript ホストはインポートオブジェクトを渡します。

No-WASI(リアクター)モード

--no-wasi を追加すると、WASI 関数を一切インポートしない Preview 1 モジュールが出力され、ホストはインポートオブジェクトなしでインスタンス化できます — エクスポートされた Lisp 関数だけを表面とする「リアクター」/ライブラリモジュールです:

rontolisp fact.lisp --no-wasi -o fact.wasm
wasmtime run --invoke fact -W gc fact.wasm 5      # => 120

リアクターは JavaScript からも同様に簡単に駆動できます: インポートオブジェクトがないため、ホスト側は「インスタンス化してからエクスポートを呼び出す」だけです(WebAssembly.instantiate(bytes).then(({ instance }) => instance.exports.fact(5)))。コピー&ペーストして実行できる完全な Node + ブラウザの例は、このページ末尾の付録にあります。

8 つの WASI インポートスロットは内部のトラップスタブで埋められるため、すべての関数インデックスは固定のままです(他のコード生成に変更はありません)。このモードは純粋計算のエクスポート専用です: あらゆる I/O(print/read/open/getenv/時刻/random、印字するトップレベルフォームを含む)はスタブに当たってトラップします。Preview 1 専用です — --no-wasi--component のもとでは無視されます。

モジュールはリアクター(WASI コマンドではない)なので、トップレベルの初期化子は _start ではなく _initialize としてエクスポートされます。ホストはインスタンス化後に一度 _initialize を呼んでトップレベルフォーム(エクスポートされた関数が読む defvar/defparameter/setq のグローバル)を実行すべきです。トップレベル状態を持たない純粋計算リアクターは省略できます。

WASI 0.3 コンポーネント(--component)

--component を追加すると、Preview 1 コアモジュールの代わりに WASI 0.3(Preview 3)コンポーネントが出力されます。コンポーネントは wasi:cli/stdout@0.3.0 を通じて印字します:

rontolisp hello.lisp --component -o hello.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y hello.wasm
3

WASI 0.3 ではすべてのバイト I/O が組み込みのコンポーネントモデル型 stream<u8> / future<T> と非同期正準 ABI を流れます。rontolisp は同じ Preview 1 コアモジュールを無変更のまま保ち — 依然として 8 つの wasi_snapshot_preview1 関数をインポートします — アダプタコアモジュールがそれらを WASI 0.3(wasi:cliwasi:filesystemwasi:clockswasi:random)の上に stream.new/stream.read/stream.writefuture.read を使って実装します。コンポーネントの wasi:cli/run@0.3.0 エクスポート(async func)はスタックフルな非同期エクスポートとしてリフトされるため、同期的な stream/future 組み込みは協調的にブロックし、アダプタは直線的なコードのままです。非同期正準 ABI とスタックフルリフトは wasmtime 46+ でデフォルト有効です。同期的な stream/future 組み込みだけがまだフィーチャーゲートされており、それが -W component-model-more-async-builtins=y の理由です(wasm-GC コアのための -W gc=y も併せて)。

wasmtime の起動方法が出力の種類を選ぶわけではありませんwasmtime run は wasmtime のデフォルトサブコマンドで、コアモジュールかコンポーネントかを自動検出するため、wasmtime run -W gc は Preview 1 の hello.wasm も同様に実行します。Preview 1 コアモジュールと WASI 0.3 コンポーネントのどちらが生成されるかを決めるのは、コンパイル時の --component フラグだけです。(実際上の違いはコンポーネント専用ランタイムで現れます。そこではコンポーネントは動きますが Preview 1 コアモジュールは動きません。)

コンポーネント内で動くもの、そして各機能が実行時に必要とするもの:

  • print/標準出力、標準入力(read、0 引数の read-linewasi:cli/stdin@0.3.0 経由)、ファイル I/O(openclosewrite-line、ストリーム read-lineloadwith-open-file)はすべて動作します。ファイルアクセスには --dir が必要です(パスは最初にプリオープンされたディレクトリに対して解決されます):
cat > fileio.lisp <<'EOF'
(with-open-file (out "greeting.txt" :direction :output)
  (write-line "hello" out))
(with-open-file (in "greeting.txt")
  (print (read-line in)))
EOF
rontolisp fileio.lisp --component -o fileio.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y --dir . fileio.wasm
# "hello"
  • randomwasi:random@0.3.0 から本物のエントロピーを引きます(Preview 1 はホストの random_get を使います)。そのため (random N) は実行ごとに異なります。get-universal-time / get-internal-real-time / get-internal-run-timewasi:clocks@0.3.0(system-clock/monotonic-clock)を読み、getenvwasi:cli/environment@0.3.0 を読みます。
  • 送信 HTTP(rontolisp:fetchrontolisp:await / rontolisp:then / rontolisp:promisep のプロミス操作)はコンポーネントモードで動作し、真の非同期性も含みます: fetch はリクエストを送って(処理中の wasi:http レスポンスハンドルをラップした)プロミスを即座に返すため、await が各リクエストをブロックする前に複数のリクエストを重ねられます。プロミス操作自体はどのモードでもコンパイルできます。コンポーネント専用なのは fetch だけです。これはハイブリッドです: ベースの I/O は WASI 0.3 のまま、fetch 自体は wasi:http@0.2 + wasi:io@0.2 をインポートします(非同期の wasi:http@0.3 はまだ上流に存在しません)。fetch コンポーネントは非同期フラグに加えて -S http=y で実行してください。fetch を使わないコンポーネントは wasi:http をインポートしないため、-S http は不要です。
  • TCP ソケット(rontolisp:tcp-connect / tcp-listen / tcp-accept / tcp-local-port)はコンポーネントモードで wasi:sockets@0.3.0 の上で動作します(ネイティブに WASI 0.3 — 0.2 ハイブリッドではありません)。ソケットは双方向ストリームハンドルなので、read-line / write-line / read-byte / write-byte / close が直接使えます。ソケットコンポーネントは非同期フラグに加えて -S tcp=y -S inherit-network=y で実行してください。これらがなくてもコンポーネントは起動しますが、すべてのソケット操作が失敗して nil を返します。ホストは IPv4 リテラルでなければならず(ホスト名解決はまだありません)、rontolisp:fetch と tcp 関数はまだ 1 つのコンポーネントで組み合わせられません。
  • それ以外の点では、コンパイルされた Lisp はサポートされる機能について Preview 1 出力と同一に振る舞います。受信 HTTP のサービング(rontolisp:http-handler)もコンポーネントにコンパイルされますが、別種のコンポーネント(wasi:http/incoming-handler)で、wasmtime serve のもとで動きます — HTTP ハンドラーガイドを参照してください。

コンポーネントモデル関数エクスポート(wasm-export)

--component のもとでは、rontolisp:wasm-export型付きコンポーネントモデルエクスポートになり、正準 ABI を通じて WAVE 構文(wasmtime run --invoke 'name(args)'、experimental 警告なし)で呼び出せます — しかも wasi:cli/run のコマンドエントリと共存するため、同じコンポーネントは引き続きコマンドとしても実行できます:

rontolisp sumsq.lisp --component -o sumsq.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y --invoke 'sumsquared(2, 3)' sumsq.wasm
# 25    (the export's return value, rendered by wasmtime)
wasmtime run -W gc=y -W component-model-more-async-builtins=y sumsq.wasm
# 400    (the ordinary run entry executes the top-level program)

2 つのコマンドは異なるものを表示します: --invoke は名前付きエクスポートだけを呼び出し、トップレベルのプログラム(wasi:cli/run エントリ)は実行されません — 25 は wasmtime がエクスポートの戻り値を WAVE 構文でレンダリングしたものであり、print の出力ではありません。素の run は代わりにトップレベルのプログラムを実行するため、400(print (sumsquared 10 10)) の出力です。

型付きシグネチャ(:ints32:floatf64:boolbool:stringstring:s-expr → 印字された S 式テキストを運ぶ string:returns 省略 → 結果なし)は任意のコンポーネントホストから見え、:as はコア側と同様にコンポーネントエクスポートの名前を変更します。

:string 境界は本物のコンポーネントモデル string として越えます — どちら側にも手動のポインタ処理はありません。ホストは引数のバイト列をリニアメモリへローワリングし、結果を正準 ABI を通じて読み出します。その後モジュールは呼び出しごとの確保を解放する(正準 post-return 関数がバンプアロケータをポップする)ため、常駐インスタンスは繰り返し呼び出しでもフラットに保たれます:

rontolisp greet.lisp --component -o greet.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y --invoke 'greet("世界")' greet.wasm
# "Hello, 世界"

デフォルトではエクスポートは同期的にリフトされ、純粋計算でなければなりません: その中の I/O(printreadrontolisp:fetch、ファイルアクセス)は実行時に "cannot block a synchronous task" でトラップします。:async t でエクスポートを非同期と宣言すると、代わりに非同期関数型に対してリフトされ — run エントリと同じスタックフル非同期の形です — その中の I/O が動作します。wasmtime --invoke は非同期エクスポートもまったく同じ方法で呼び出します:

rontolisp status.lisp --component -o status.wasm
wasmtime run -W gc=y -W component-model-more-async-builtins=y -S http=y \
  --invoke 'fetch-status("https://httpbin.org/status/204")' status.wasm
# "fetching"
# 204

コンポーネントの WIT レベルの契約では、:async t エクスポートは async func です(例えば jco は Promise を返す関数として型付けし、同期エクスポートは普通の関数のままです)。同期と非同期のエクスポートは 1 つのコンポーネント内で自由に混在でき、:async:string/:s-expr を含むすべての境界型と組み合わせられ、:async エクスポートのないプログラムはバイト単位で同一の出力を生成します。

コンポーネントエクスポートの現在の制限:

  • 同期(デフォルト)エクスポートは純粋計算専用です: その中の I/O は実行時に "cannot block a synchronous task" でトラップします。エクスポートが印字・fetch・その他の I/O を行うときは :async t にオプトインし、純粋計算のエクスポートは同期のままにしてください。
  • :async が意味を持つのはここだけです: Preview 1 / --no-wasi のコアエクスポートは無視し(そこではホストが直接 I/O を提供します)、--no-gc --component は拒否します(コンパクトなリアクターコンポーネントには非同期アダプタがありません)。
  • jco(1.25.2)は :async t エクスポートをトランスパイルして非同期として型付けしますが、まだ呼び出せません — 生成されるドライバはコールバック方式の非同期タスクを前提としており、スタックフル非同期エクスポートは上流で未実装です(トランスパイルされた run を呼べないのと同系統のギャップです)。非同期エクスポートの検証済みパスは wasmtime run --invoke です。同期エクスポートはどちらでも動作します。
  • エクスポート名は lower-kebab-case のコンポーネントモデル名(sum-squared)でなければなりません。その文法から外れる Lisp 名については、コンパイラが :as での改名を求めます。
  • エクスポートの呼び出しはプログラムのトップレベルを先に実行しないため、defvar/defparameter のグローバルを読むエクスポートは未初期化の値を見ることになります(これは Preview 1 の --invoke の動作と一致します)。

純粋計算のエクスポートキットには、コンパクトな --no-gc --component が同じ型付きエクスポート(加えて :longs64、ただし :s-expr なし)を、wasmtime のフラグを一切必要としない数百バイトのコンポーネントとして出力します。

非 GC 出力(--no-gc)

上記の GC 値モデルの出力は — 最適化されたリアクターであっても — すべての値が GC ヒープ型(i31ref、float 構造体、(ref eq))であるため、依然として wasm-GC 対応ランタイムを必要とします。--no-gc を追加すると、代わりに素の MVP モジュールが出力されます: rec グループなし、struct/array/i31 型なし、eqref なし、インポートなしです(素のリニアメモリはプログラムが文字列を使うときのみ追加され — 後述 — 単一の fd_write インポートは印字するときのみ追加されます)。印字しないモジュールはインポートオブジェクトなしでインスタンス化でき、-W gc なしで任意の MVP クラスのランタイムで動作します:

rontolisp fact.lisp --no-gc --optimize -o fact.wasm
wasmtime run --invoke fact fact.wasm 5      # => 120, no -W gc needed

これは、各値をアンボックスな wasm スカラーへ直接ローワリングし、文字列には小さなリニアメモリ表現を加えることで達成されます — そのため対象サブセットは言語の制限であって、別の言語ではありません。プログラムの形も制限されます: トップレベルには defunrontolisp:wasm-export ディレクティブのみを置けます(純粋計算リアクターであり、_start はありません)。境界指定子は :int:long:float:bool:string(および :void/省略)です。:s-expr非対応です — このバックエンドが意図的に省いている cons/リーダー/プリンターのランタイムを必要とするためです。

数値ベクトルカーネル(vec: パッケージ)も --no-gc で動作し、デフォルトでは素のスカラーループへローワリングされます — そのためベクトルプログラムも上記の「任意の MVP ランタイムで動く」性質を保ちます。--simd を追加すると、それらのカーネルはネイティブの WebAssembly SIMD(v128)へローワリングされ、SIMD プロポーザル対応のランタイム(wasmtime ではデフォルト有効)が必要になります。

対象となるサブセット

関数が対象となるのは、その推移的な呼び出しグラフ全体が次のサブセットに収まる場合だけです:

  • 数値とブール: 算術(+ - * / mod rem 1+ 1- abs min max sqrt)、整数ビット演算(logand logior logxor lognot ash)、比較と述語(= < <= > >= not zerop plusp minusp evenp oddp);
  • 制御と束縛: if/when/unless/cond/progn/let/let*、再帰、他の対象関数の呼び出し;
  • 反復とローカルな変更: dotimes/do/do* とその基盤の while/setq/return。let/do 束縛変数は自由に再代入できます。loop は非 cons 化節(数値 forsum/count/maximize/minimizerepeat/while/until/do/return)に限り対象です — collect/append/nconcfor ... in/on の節はリストを確保するため対象外です;
  • 浮動小数点/整数変換: float truncate floor ceiling round;
  • 文字列と文字: 文字列リテラル、文字リテラル、(concatenate 'string ...)lengthsubseqstring=charchar-code/code-charchar=、および(整数・浮動小数点数・文字列の)princ-to-string。独立した文字型はありません: 文字はそのコードポイントで表現されるため、移植性のあるイディオム (char= (char s i) #\x)(char-code (char s i)) は他のバックエンドとまったく同じように振る舞い、素の (char s i):int 境界を越えるとコードが見えます;
  • 印字: printprincterpri(省略可能なストリーム引数なし) — 後述を参照;
  • メモリ回収: rontolisp:with-arena

それ以外のヒープオブジェクトを確保するもの(cons/リスト、シンボル、ベクタ、ハッシュテーブル、eval/apply、I/O、dolist/リスト反復、自由変数やグローバルへの代入、&optional/&rest/&key などのラムダリストキーワード — rest リストは cons です)は関数を対象外にします。黙って誤コンパイルするのではなく、問題の操作を名指しするコンパイルエラーになるため、境界は明示的なままです。

数値モデル

各値の wasm 型は静的型推論で選ばれます: 整数は i64、浮動小数点数は f64 を使います。型はエクスポートの境界指定子を種として呼び出しグラフ上の不動点で推論され、整数と浮動小数点数が出会う場所(例えば (* 3.14 n))では整数が f64 へ昇格します。i64 を使うことで整数演算は 2^63 まで正確です — GC バックエンドの i31 fixnum よりも、全 f64 ローワリング(2^53 までしか正確でない)よりもはるかに広く、例えば a*a - (a-1)*(a+1) は中間値が 2^53 を超えても正確に 1 のままです。

推論は自動的に拡幅もします: let/do 束縛変数は初期化子と代入されるすべての値のジョインを取るため、浮動小数点数と足し合わされる整数アキュムレータは f64 になります:

--no-gc のもとでは、これは acc(および戻り値)を f64 と推論し、ループカウンタ ii64 のままです。

有理数型はないため、完全な Common Lisp とも GC バックエンドとも異なる点が 2 つあります: / は浮動小数点除算であり(1/3 の比はありません)、ブール文脈で偽になるのは値がちょうどゼロのときです(Common Lisp は nil だけを偽として扱います)。境界指定子はホスト幅のままです — :int/:bool は(GC バックエンドと同様)32 ビットの i32 として渡るため、32 ビット範囲外の戻り値はラップします。広い i64 範囲は内部計算にのみ適用されます。パラメータや結果が 32 ビット範囲を超えうるときは :long を宣言してください — wrap/extend なしの i64 として境界を渡ります(:long--no-gc 専用で、整数が i31ref である GC バックエンドは拒否します)。このモードが対象とする数値カーネル(階乗、数学/金融関数、バリデータ)については、結果はインタプリタおよび GC バックエンドと一致します。

文字列

文字列はリニアメモリ内の [length][bytes] ヘッダを指す i32 ポインタで、(concatenate 'string ...) は新しいバッファをバンプ確保します — そのため文字列の組み立てはただのアキュムレータループです:

スライスと検査も同じ表現の上で動きます: length はヘッダを読み、subseq はスライスを新しいバッファへコピーし、string= は内容をバイト単位で比較し、char はバイトをインデックスし、princ-to-string は整数を文字列化します — 蓄積だけでなく、ルーティング/パースのカーネルにも十分です:

文字列を使用するモジュールは(拡張可能な)リニアメモリを持ち、その memory__ronto_alloc(size) バンプアロケータを関数とともにエクスポートします。:string パラメータはホストがメモリに書き込む (ptr, len) ペアとして渡され、:string の結果も同じ方法で返されます — そのため文字列を返すエクスポートは、wasmtime --invoke だけではなく、エクスポートされたメモリを読み書きできるホスト(JavaScript、小さな Node スクリプト、ブラウザのプレイグラウンド)を必要とします。付録で JS 側を詳しく説明し、--no-gc --component はこの手動プロトコルを丸ごと不要にします。

これが ASCII アートのマンデルブロレンダラーを wasm-GC なしで動かせる理由です: examples/console/mandelbrot-nogc.lisp は浮動小数点の脱出時間ループを保ちながら、描画したグリッドを印字する代わりに 1 つの文字列として返します:

$ rontolisp examples/console/mandelbrot-nogc.lisp --no-gc --optimize -o mandelbrot.wasm
$ node -e '(async () => {
  const ex = (await WebAssembly.instantiate(
    require("fs").readFileSync("mandelbrot.wasm"), {})).instance.exports;
  const [p, n] = ex.mandelbrot(-2.5, 1.0, -1.2, 1.2, 70, 30, 30);
  process.stdout.write(Buffer.from(new Uint8Array(ex.memory.buffer, p, n)).toString());
})()'

印字(print / princ / terpri)

エクスポートされた関数は印字できます: print(読み取り可能な形 + 末尾の改行。文字列は引用符付きで出力)、princ(表示形、改行なし)、terpri(改行)が対象サブセット内で動作し、出力はインタプリタとバイト単位で一致します:

$ cat show.lisp
(defun show (n)
  (print n)
  (print (* 1.5 n))
  (print "done"))
(rontolisp:wasm-export 'show :params '(:int) :returns :void)
$ rontolisp show.lisp --no-gc -o show.wasm
$ wasmtime run --invoke show show.wasm 4
4
6.0
"done"

浮動小数点数は GC バックエンドと同じ桁抽出プリンターを通って印字されます。IEEE のエッジ(NaNInfinity/-Infinity-0.0。2^63 以上の大きさは WASM バックエンドの E 表記の形を使います)も含みます。数値の print はそのテキストを一時的な文字列に描画して即座に回収するため、印字ループでヒープは成長しません。

知っておくべきことが 2 つあります:

  • 印字するモジュールは 1 つのインポートを持ちます。 print/princ/terpri は単一の wasi_snapshot_preview1.fd_write インポートを通じて書き込みます — これはプログラムが印字するときにのみ追加されるため、印字しないモジュールはインポートゼロと正確なバイト列を保ちます。WASI Preview 1 ホストなら fd_write は自動で提供されますが(wasmtime run、Node 組み込みの node:wasi モジュール)、印字するモジュールはマンデルブロのスニペットのように空の {} インポートオブジェクトではインスタンス化できなくなります — 生の JavaScript 埋め込みは { wasi_snapshot_preview1: { fd_write } } を供給する(または node:wasi を使う)必要があります。
  • ブールはリテラルでのみ名前で印字されます。 この値モデルには実行時ブール型がありません: (print t) / (print nil)t / nil を印字しますが、(print (> a b)) のような計算されたブールはその 0/1 整数を印字します。省略可能なストリーム引数とパック float 配列の印字はコンパイルエラーです。

メモリの回収(アリーナ API)

__ronto_alloc は決して解放しないバンプアロケータなので、常駐ホスト — 1 つのインスタンスを生かしたままループで呼び出し、毎回新しい入力バッファを確保するホスト — のリニアメモリは際限なく成長します。2 つの機構がそれを平坦に保ちます:

  • スカラー戻り値では自動。 エクスポートが非メモリのスカラー(:int/:long/:float/:bool/:void)を返す場合、そのラッパーはエントリでヒープトップをスナップショットし、出口で復元します。そのためその呼び出しが確保したすべて(:string 引数の内部コピーと、あらゆる concatenate/subseq/princ-to-string のスクラッチ)は戻り時に回収されます。ホスト側ですることはありません。
  • ホスト自身のバッファには手動。 ホストは入力バッファを呼び出しのに確保するため、それはラッパーの自動リセットマークより下にあり、生きたまま残されます。それも回収するために、文字列を使用するモジュールは同じヒープポインタ上の対の関数もエクスポートします:
exportsignaturemeaning
__ronto_alloc_mark() -> i32snapshot the current bump-heap top
__ronto_alloc_reset(i32 mark) -> ()restore the top to a saved mark

入力を確保するにスナップショットを取り、結果を読み出したに復元すれば、常駐インスタンスは何回呼び出されても完全に平坦なままです:

node -e '(async () => {
  const ex = (await WebAssembly.instantiate(
    require("fs").readFileSync("count_vowels.wasm"), {})).instance.exports;
  const enc = new TextEncoder();
  const countVowels = (s) => {
    const b = enc.encode(s);
    const mark = ex.__ronto_alloc_mark();        // snapshot BEFORE allocating input
    const ptr = ex.__ronto_alloc(b.length);
    new Uint8Array(ex.memory.buffer, ptr, b.length).set(b);
    const n = ex.count_vowels(ptr, b.length);    // scalar result read out here
    ex.__ronto_alloc_reset(mark);                // pop the input + wrapper scratch
    return n;
  };
  const before = ex.memory.buffer.byteLength;
  for (let i = 0; i < 100000; i++) countVowels("Hello, World! " + i);
  console.log(before, "->", ex.memory.buffer.byteLength);   // 65536 -> 65536 (flat)
})()'

アリーナは手動のスタックであってガベージコレクタではないため、2 つのルールがあります:

  • まだ生きているすべてのものよりに取ったマークにだけリセットしてください — まだ必要なデータのに取ったマークへポップすると、そのデータが解放されます。
  • :string返すエクスポートは自動リセットしません(その結果は生きたヒープポインタです)。__ronto_alloc_reset を呼ぶ前に、返されたバイト列をメモリから読み出してください — 先にリセットすると文字列が解放され、次の確保がそれを上書きします。

count-vowels の例は、Node と Endive(Java)の両ホストでこのレシピを一通り示します。

Lisp からの回収(rontolisp:with-arena)

上記の 2 つの機構はどちらもエクスポート境界で発火します — 1 回の呼び出しのでは何も解放されません。反復ごとに確保するループ(concatenate 'string は新しいバッファを、vec:zeros/vec:ones は新しいベクトルを作ります)は、したがって呼び出しの間ヒープを成長させます。rontolisp:with-arena はその回収境界をソース内で指名します: バンプヒープポインタをスナップショットし、本体を実行し、本体が確保したすべてをポップします — 本体自身の値だけを残して(文字列またはパック float 配列の結果はスナップショット位置へコピーダウンされます):

アリーナがあれば 10 万回の反復も初期リニアメモリ内に収まります。なければ同じループは反復ごとにベクトル 1 つ分成長します。エスケープ契約は __ronto_alloc_reset と同じです: 本体内で確保されたものは、本体自身の値を除き、本体の後から到達可能であってはなりません。 インタプリタ、JVM バックエンド、デフォルト(wasm-GC)出力では、with-arena は観測上は素の progn です — 本物のガベージコレクタがすでに回収します — そのため同じソースがすべてのバックエンドで動作します。

コンパクトなコンポーネント出力(--no-gc --component)

--component を追加すると、同じ MVP コアモジュールが WASM コンポーネントとしてラップされ、エクスポートは正準 ABI を通じて WAVE 構文で呼び出せる型付きコンポーネントモデルエクスポートになります。印字しないコアモジュールはインポートゼロなので、ラップに WASI アダプタも共有メモリモジュールも wasm-GC も不要です — 小さなプログラムならコンポーネント全体が数百バイトに収まり、wasmtime のフラグを一切必要とせず動作します:

rontolisp fact.lisp --no-gc --component -o fact.wasm
wasmtime run --invoke 'fact(5)' fact.wasm
# 120

型付き WIT シグネチャは :ints32:longs64:floatf64:boolbool:stringstring に対応し、:returns 省略は結果なしです。コンポーネントは jco でもトランスパイルでき(jco transpile:long は JavaScript の BigInt として現れます)、wasm-GC サポートなしで任意のコンポーネントモデルホスト上で動作します。

GC コンポーネントパスと違い、ここでは :long が有効です — 値が 32 ビット範囲を超えうるときに使ってください。バックエンド内部の i64 演算とそのまま一致します:

rontolisp cube.lisp --no-gc --component -o cube.wasm
wasmtime run --invoke 'cube(2000000)' cube.wasm
# 8000000000000000000

:string 境界は本物のコンポーネントモデル string として越えます — どちら側にも手動のポインタ処理はありません。ホストは引数のバイト列をモジュール自身のメモリへローワリングし、結果を正準 ABI を通じて読み出します。その後モジュールは呼び出しごとの確保をすべて解放する(正準 post-return 関数がバンプアロケータをベースまでポップする)ため、常駐インスタンスは繰り返し呼び出しでもフラットに保たれます:

rontolisp greet.lisp --no-gc --component -o greet.wasm
wasmtime run --invoke 'greet("world")' greet.wasm
# "Hello, world"

印字もここで動作します: 印字するプログラムには組み込みの print マイクロアダプタ — コアの単一の fd_write インポートを WASI 0.2 stdio(wasi:cli/stdout と、wasi:io/streams同期的な blocking-write-and-flush)の上に実装する 3 つの小さな固定コアモジュール — が、プログラムが印字するときだけ配線されます。エクスポートは通常の同期リフトのまま、フラグゼロという性質も維持され(ホストは 0.2 stdio をデフォルトで提供します)、印字出力はインタプリタとバイト単位で一致します — 先の show.lisp を使うと:

rontolisp show.lisp --no-gc --component -o show.wasm
wasmtime run --invoke 'show(4)' show.wasm
# 4
# 6.0
# "done"
# ()

素の --no-gc 出力とのトレードオフ、および現在の制限:

  • コンポーネントはコンポーネントモデル対応のホストを必要とします。生のコアモジュールは素の埋め込み API を通じて任意の WebAssembly エンジンで動きます。両方の出力が使えます — ホストごとに選んでください。コンポーネントは --no-gc のデフォルトではありません。(--component なしでは、:string は代わりに手動の (ptr,len) コア ABI で境界を渡ります。)
  • コンポーネントは純粋なリアクターです: wasi:cli/run エントリはありません(トップレベルでは何も実行されません)。エクスポート内の印字は上記のマイクロアダプタで動作します。それ以外の I/O は通常どおり --no-gc サブセットの外です。:async t は拒否されます(サスペンドするための非同期アダプタが存在しません)。
  • エクスポート名は lower-kebab-case のコンポーネントモデル名でなければなりません。その文法から外れる Lisp 名については、コンパイラが :as での改名を求めます。
  • --optimize は組み合わせられます: コアモジュールはラップの前にツリーシェイキングされます。

横断的なフラグ

最適化(ツリーシェイキング)

デフォルトでは、コンパイルされたモジュールは、関数インデックスが固定に保たれているために、プログラムが実際に使うものとは無関係にランタイム全体(プリンター、有理数、文字列、リーダー、eval ヘルパー、WASI インポートスロットなど)を埋め込みます。--optimize を追加すると、モジュールのルート(そのエクスポートと _start/_initialize エントリ)から到達不能なすべての関数を落とし、生き残りを再番号付けします。未使用の WASI インポートも除去されるため、純粋計算のリアクターモジュールは一握りの関数まで縮みます:

rontolisp fact.lisp --no-wasi --optimize -o fact.wasm
wasmtime run --invoke fact -W gc fact.wasm 5      # => 120, from a ~2 KB module

この fact の例では、モジュールは約 100 KB から 2 KB 未満まで縮みます。--optimize はオプトインで、動作を保存します: 実際の call 命令から呼び出しグラフを辿るため、到達可能なもの(組み込みの eval/load がディスパッチするコードを含む)はすべて保持されます。GC の --component パスでは no-op です(WASI 0.3 アダプタがコアの固定インポート/インデックスレイアウトに依存しているため、コンポーネントは無変更で出力されます)。--no-gc --component では有効です — コアモジュールはラップの前にシェイクされます。同じフラグは JVM 出力のデッドコード除去も行います。

--optimize とは独立に(--component を含むすべての出力モードで)、コンパイルは常に同梱の Lisp ソースライブラリ(linalg:vec:、JSON、URL、equalp/string<)をツリーシェイキングします。プログラムがソース中でその名前に一切言及しない(クォートされたシンボルや文字列リテラルの中も含む)ライブラリ関数はモジュールに含まれません。その帰結として、実行時に計算した文字列から名前を組み立てて eval/apply 経由で呼び出すライブラリ関数は、通常の「undefined function」エラーを通知します。その場合は --no-prune(または --dynamic)を付けてコンパイルすると、すべてのライブラリ定義が保持されます。

SIMD アクセラレーション(--simd)

--simd はすべてのバックエンドに共通する唯一のアクセラレーションスイッチです: ベクトル化可能な vec: および linalg: カーネルを本物のベクトル命令へローワリングします。WASM では値モデルと直交します:

  • wasm-GC + --simd はカーネルを GC 管理のレーングループ配列上のネイティブ固定幅 SIMD(f64x2/f32x4)へローワリングします — パック float 配列は通常の GC オブジェクトのままで、メモリはフラグなしの場合とまったく同じに振る舞います。--component および --optimize と組み合わせられ、通常どおり wasmtime run -W gc で実行します(wasmtime は SIMD プロポーザルをデフォルトで有効にしています)。
  • --no-gc + --simd は同じカーネルをパックされたリニアメモリブロック上の v128 へローワリングします。--simd なしの --no-gc は代わりに素のスカラーループを出力します — SIMD プロポーザルのないランタイムでも動く v128 フリーの MVP モジュールです。

全体像 — どのカーネルがベクトル化されるか、単精度リダクションの精度規則、測定された効果、linalg のインターセプト — は SIMD アクセラレーションガイドにあります。

付録: JavaScript からのモジュール呼び出し

リアクターモジュール(--no-wasi または --no-gc)は何もインポートしないため、ホスト側は丸ごと「インスタンス化してからエクスポートを呼び出す」だけです — そして Node とブラウザで同じコードです。端から端まで、コピー&ペーストで動く完全な例を示します。3 つのエクスポートからなる小さなキットから始めます:

--no-gc(任意のエンジンで動く)と --optimize(エクスポートから到達不能なものをすべて落とす — ここではモジュール全体が約 200 バイト)でコンパイルします:

rontolisp mathkit.lisp --no-gc --optimize -o mathkit.wasm

Node 18+ では、これを run.mjs として保存して node run.mjs を実行します:

import { readFile } from 'node:fs/promises';

// Node reads the .wasm from disk. In a browser, use the streaming fetch shown below.
const bytes = await readFile(new URL('./mathkit.wasm', import.meta.url));
const { instance } = await WebAssembly.instantiate(bytes);   // no import object

const ex = instance.exports;
console.log(ex.fact(10));                         // 3628800
console.log(ex.area(2));                          // 12.566370614359172
console.log(Boolean(ex['in-range'](5, 0, 10)));   // true   (:bool crosses as 0 / 1)
console.log(Boolean(ex['in-range'](42, 0, 10)));  // false
3628800
12.566370614359172
true
false

ブラウザで違うのはバイト列の読み込み方だけです — instantiateStreamingfetch を直接受け取ります — ページ全体は次のとおりです:

<!doctype html>
<script type="module">
  const { instance } = await WebAssembly.instantiateStreaming(fetch('./mathkit.wasm'));
  const ex = instance.exports;
  document.body.textContent = `fact(10) = ${ex.fact(10)}, area(2) = ${ex.area(2)}`;
</script>

知っておく価値のある境界の詳細:

  • in-range のようなハイフン付きの Lisp 名は有効な JavaScript 識別子ではないため、ブラケットアクセスで参照します: ex['in-range'](...)
  • :int/:float は素の JS 数値として届きます。:booli32(0/1)として渡るため、本物の JS ブールが欲しければ Boolean(...) で包んでください。
  • --no-gc モジュールは任意の WebAssembly エンジンで動きます。GC の --no-wasi モジュールは wasm-GC 対応のエンジン(Node 22+、現行ブラウザ)を必要とします。上記の JavaScript はどちらでもバイト単位で同一です — コンパイルフラグを差し替えるだけで、他には何も変わりません。

文字列の受け渡し(:string)

上記のスカラーの例は、:int/:float/:bool が素の数値として境界を渡るため、メモリを必要としません。:string は代わりにモジュールのエクスポートする memory を通じて (ptr, len) ペアを渡します: ホストは(エクスポートされた __ronto_alloc(size) バンプアロケータで確保したオフセットに)引数のバイト列をメモリへ書き込み、(ptr, len) を渡し、エクスポートが返す (ptr, len) をデコードします。

:string--no-gc のもとで動作するため、関数が非 GC の文字列サブセット(上記の対象サブセットを参照)に収まっている限り、モジュールは依然として任意のエンジンで動作します。プロトコルを示すには挨拶文ビルダーで十分です:

rontolisp greetkit.lisp --no-gc --optimize -o greetkit.wasm
import { readFile } from 'node:fs/promises';

const bytes = await readFile(new URL('./greetkit.wasm', import.meta.url));
const { instance } = await WebAssembly.instantiate(bytes);   // no import object
const ex = instance.exports;
const enc = new TextEncoder(), dec = new TextDecoder();

// Copy a JS string into linear memory; return its (ptr, len).
function write(str) {
  const b = enc.encode(str);
  const ptr = ex.__ronto_alloc(b.length);
  new Uint8Array(ex.memory.buffer, ptr, b.length).set(b);
  return [ptr, b.length];
}
// Decode a (ptr, len) result. Re-read ex.memory.buffer AFTER the call: a call may grow
// memory, which detaches the previous ArrayBuffer.
const read = (ptr, len) => dec.decode(new Uint8Array(ex.memory.buffer, ptr, len));

console.log(read(...ex.greet(...write('rontolisp'))));     // Hello, rontolisp!
Hello, rontolisp!

--no-gc --component では、同じ :string エクスポートが型付きコンポーネントモデル string として境界を越えるようになり、上記のホスト側グルーコードはすべて不要になります(正準 ABI がコピーを行い、post-return 関数がヒープを平坦に保ちます)。

より高機能な文字列関数(string-upcasesubseqstring= など)は非 GC サブセットの外です。それらを使うということは、代わりに wasm-GC バックエンド(--no-wasi)向けにコンパイルするということです — 境界プロトコルは同一で、エンジンが wasm-GC 対応である必要があるだけです。下の :s-expr の例がそのパスを示します。

リストの受け渡し(:s-expr)

:s-expr任意の Lisp 値を S 式テキストとして運びます: モジュールは入力を組み込みリーダーで解析し、結果を印字して返します。同じ (ptr, len) / __ronto_alloc プロトコルの上でです。そのリーダー/プリンター/cons の機構は wasm-GC 専用なので、:s-expr(および上記のより高機能な文字列関数)には --no-wasi と wasm-GC 対応エンジン(Node 22+、現行ブラウザ)が必要です:

rontolisp textkit.lisp --no-wasi --optimize -o textkit.wasm
// Same instantiate + write/read helper as above (textkit.wasm needs a wasm-GC engine).
console.log(read(...ex.shout(...write('hello'))));         // HELLO
console.log(read(...ex.rev(...write('("a" "b" "c")'))));   // ("c" "b" "a")
HELLO
("c" "b" "a")

ブラウザでは読み込みの行だけが変わります(WebAssembly.instantiateStreaming(fetch(...)))。write/read/memory/__ronto_alloc のロジックは同一です。多値の (ptr, len) を返す関数は JS では 2 要素配列として現れます。read(...ex.shout(...)) としているのはそのためです。