WASM Non-GC Output (--no-gc)
Every GC-value-model output — even an optimized reactor — needs a wasm-GC
capable runtime, because every value is a GC heap type (i31ref, the
float struct, (ref eq)). Add --no-gc to emit a plain MVP module
instead: no rec group, no struct/array/i31 type, no eqref and no
import (a plain linear memory is added only when the program uses strings —
see below — and the single fd_write import only when it
prints). A print-free module instantiates
with no import object and runs on any MVP-class runtime with no -W gc:
rontolisp fact.lisp --no-gc -o fact.wasm
wasmtime run --invoke fact fact.wasm 5 # => 120, ~108 bytes, no -W gc needed
It achieves this by lowering each value directly onto an unboxed wasm
scalar, plus a small linear-memory representation for strings — so the
eligible subset is a restriction of the language, not a different one. The
program shape is also restricted: the top level may contain only
defuns and rontolisp:wasm-export directives (a pure-compute reactor —
there is no _start), and the boundary designators are :int, :long,
:float, :bool, :string (and :void/omitted); :s-expr is not
supported — it would need the cons/reader/printer runtime this backend
deliberately omits.
Numeric vector kernels (the vec: package) work
under --no-gc too, lowered to plain scalar loops by default — so a vector
program keeps the "runs on any MVP runtime" property above. Add
--simd to lower those
kernels to native WebAssembly SIMD (v128) instead, which then needs a
runtime with the SIMD proposal (on by default in wasmtime).
Eligible subset
A function is eligible only if its entire transitive call graph stays inside this subset:
- numbers and booleans: arithmetic (
+ - * / mod rem 1+ 1- abs min max sqrt), the integer bitwise operators (logand logior logxor lognot ash), comparison and predicates (= < <= > >= not zerop plusp minusp evenp oddp); - control and binding:
if/when/unless/cond/progn/let/let*, recursion and calls to other eligible functions; - iteration and local mutation:
dotimes/do/do*and the underlyingwhile/setq/return, with a let/do-bound variable freely reassigned;loopis eligible only for its non-consing clauses (numericfor,sum/count/maximize/minimize,repeat/while/until/do/return) — itscollect/append/nconcandfor ... in/onclauses allocate lists and are not; - float/int conversions:
float truncate floor ceiling round; - strings and characters: string literals, character literals,
(concatenate 'string ...),length,subseq,string=,char,char-code/code-char,char=andprinc-to-string(of integers, floats and strings). There is no separate character type: a character is represented by its code point, so the portable idioms(char= (char s i) #\x)and(char-code (char s i))behave exactly like the other backends, while a bare(char s i)crossing an:intboundary shows the code; - printing:
print,princandterpri(without the optional stream argument) — see below; - memory reclamation:
rontolisp:with-arena.
Anything else that would allocate a heap object (cons/list, symbols,
vectors, hash tables, eval/apply, I/O, dolist/list iteration, a free
variable or assignment to a global, a lambda-list keyword such as
&optional/&rest/&key — the rest list is a cons) makes the function
ineligible. Rather than miscompile silently, that is a compile error
naming the offending operation, so the boundary stays explicit.
Numeric model
Each value's wasm type is chosen by static type inference: integers use
i64, floats use f64. Types are inferred with a fixpoint over the call
graph seeded by the export boundary designators, and where an integer and a
float meet (e.g. (* 3.14 n)) the integer is promoted to f64. Using i64
makes integer arithmetic exact to 2^63 (the GC backend goes further still,
promoting to big integers of any magnitude) — far wider than what an all-f64 lowering
(exact only to 2^53) could offer; for example a*a - (a-1)*(a+1) stays
exactly 1 even when the intermediates exceed 2^53.
Inference also widens automatically: a let/do-bound variable takes the
join of its initializer and every value assigned to it, so an integer
accumulator summed with floats becomes an f64:
Under --no-gc this infers acc (and the return value) as f64 while the
loop counter i stays i64.
There is no rational type, so two things differ from full Common Lisp and
from the GC backend: / is floating-point division (no 1/3 ratios), and a
value is false in a boolean context exactly when it is zero (Common Lisp
treats only nil as false). The boundary designators stay host-width —
:int/:bool cross as a 32-bit i32 (as in the GC backend), so a returned
value outside the 32-bit range wraps; the wide i64 range applies only to
the internal computation. When a parameter or result can exceed the 32-bit
range, declare it :long — it crosses the boundary as i64 with no
wrap/extend. For the numeric kernels this mode targets
(factorials, math/finance functions, validators) the results match the
interpreter and the GC backend.
Strings
A string is an i32 pointer to a [length][bytes] header in linear memory,
and (concatenate 'string ...) bump-allocates a fresh buffer — so building
up a string is just an accumulator loop:
Slicing and inspection work on the same representation: length reads the
header, subseq copies a slice into a fresh buffer, string= compares
content byte-wise, char indexes a byte, and princ-to-string renders an
integer — enough for routing/parsing kernels, not just accumulation:
A module that uses strings gains a (growable) linear memory, and exports
that memory plus a __ronto_alloc(size) bump allocator alongside your
functions. A :string parameter arrives as a (ptr, len) pair the host
writes into memory, and a :string result is returned the same way — so a
string-valued export needs a host that can read/write the exported memory
(JavaScript, a small Node script, the browser playground) rather than just
wasmtime --invoke. The browser guide
walks through the JS side, and
--no-gc --component
removes the manual protocol entirely.
This is what lets the ASCII-art Mandelbrot renderer run with no wasm-GC:
examples/console/mandelbrot-nogc.lisp
keeps the floating-point escape-time loop but returns the rendered grid as
one string instead of printing it:
$ rontolisp examples/console/mandelbrot-nogc.lisp --no-gc -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());
})()'
The export's type is not written in that Lisp file at all: a checked-in
world (mandelbrot_component.wit)
declares export mandelbrot: func(x0: f64, ..., max-iter: s32) -> string;,
and rontolisp:wit-export
says the program implements it. One directive serves both builds: the core
module above is byte-identical to the hand-written wasm-export it
replaced, and the same source compiles as a component where
wasmtime run --invoke 'mandelbrot(-2.5, 1.0, -1.2, 1.2, 70, 30, 30)'
returns the string with no host memory code and no runtime flags.
Printing (print / princ / terpri)
An exported function can print: print (readable form plus a trailing
newline, so strings come out quoted), princ (display form, no newline)
and terpri (a newline) work inside the eligible subset, with output
byte-identical to the interpreter:
$ 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"
Floats print through the same digit-extraction printer as the GC backend,
including the IEEE edges (NaN, Infinity/-Infinity, -0.0; a magnitude
≥ 2^63 uses the WASM backends' E-notation shape). Each print of a
number renders its text into a transient string that is reclaimed
immediately, so a print loop does not grow the heap.
Two things to know:
- A printing module has one import.
print/princ/terpriwrite through a singlewasi_snapshot_preview1.fd_writeimport — added only when the program prints, so a print-free module keeps zero imports and its exact bytes. Any WASI Preview 1 host providesfd_writefor free (wasmtime run, Node's built-innode:wasimodule), but a printing module no longer instantiates with an empty{}import object the way the Mandelbrot snippet does — a raw JavaScript embedder must supply{ wasi_snapshot_preview1: { fd_write } }(or usenode:wasi). Add--no-wasito trade the output for the imports: thefd_writeimport becomes a built-in sink (printed bytes are discarded, nothing traps), the module keeps zero imports, and — under--component— a printing program takes the print-free component shape again (one core module, no imports, sync exports, callable through jco on plain Node). - Booleans print by literal only. The value model has no runtime
boolean type:
(print t)/(print nil)printt/nil, but a computed boolean such as(print (> a b))prints its0/1integer. The optional stream argument and printing a packed float array are compile errors.
Reclaiming memory (the arena API)
__ronto_alloc is a bump allocator that never frees, so a resident host
— one that keeps a single instance alive and calls it in a loop, allocating
a fresh input buffer each time — grows its linear memory without bound. Two
mechanisms keep it flat:
- Automatic, for scalar returns. When an export returns a non-memory
scalar (
:int/:long/:float/:bool/:void), its wrapper snapshots the heap top on entry and restores it on exit, so everything the call allocates (the internal copy of a:stringargument, plus anyconcatenate/subseq/princ-to-stringscratch) is reclaimed on return. Nothing to do host-side. - Manual, for the host's own buffer. The host allocates its input buffer before the call, so it sits below the wrapper's auto-reset mark and is left live. To reclaim it too, the string-using module also exports a matched pair over the same heap pointer:
| export | signature | meaning |
|---|---|---|
__ronto_alloc_mark | () -> i32 | snapshot the current bump-heap top |
__ronto_alloc_reset | (i32 mark) -> () | restore the top to a saved mark |
Snapshot before allocating the input, restore after reading the result, and a resident instance stays perfectly flat no matter how many times it is called:
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)
})()'
The arena is a manual stack, not a garbage collector, so two rules apply:
- Only reset to a mark taken before everything still live — popping to a mark taken after data you still need frees that data.
- A
:string-returning export does not auto-reset (its result is a live heap pointer). Read the returned bytes out of memory before calling__ronto_alloc_reset— resetting first frees the string and the next allocation overwrites it.
The count-vowels example
walks through this recipe with both a Node and an
Endive (Java) host.
The wasm-GC backend exports the same
__ronto_alloc_mark/__ronto_alloc_reset pair with the same host recipe
(see the wasm-GC arena API),
but only there does the host's buffer need reclaiming — the engine
handles everything the Lisp side allocates. The automatic scalar-return
reset is --no-gc-only: it is sound because nothing a --no-gc call
allocates can outlive it (no cons, closures, hash tables or global setq
in the subset).
Reclaiming from Lisp (rontolisp:with-arena)
Both mechanisms above fire at the export boundary — nothing is freed
within one call. A loop that allocates each iteration
(concatenate 'string builds a fresh buffer, vec:zeros/vec:ones a
fresh vector) therefore grows the heap for the duration of the call.
rontolisp:with-arena names
that reclamation boundary in the source: it snapshots the bump heap
pointer, runs its body, and pops everything the body allocated — keeping
only the body's own value (a string or packed float array result is copied
down to the snapshot point):
With the arena, a hundred thousand iterations stay within the initial
linear memory; without it, the same loop grows by one vector per
iteration. The escape contract is the same as __ronto_alloc_reset's:
nothing allocated inside the body may be reachable after it, except the
body's own value. On the interpreter, the JVM backend and the default
(wasm-GC) output, with-arena is observationally a plain progn — a real
garbage collector already reclaims — so the same source runs on every
backend.
Compact Component Output (--no-gc --component)
Add --component to wrap the same MVP core module as a WASM component
whose exports become typed component-model exports, callable through the
canonical ABI with WAVE syntax. A print-free core module has zero imports,
so the wrap needs no WASI adapter, no shared-memory module and no wasm-GC
— the whole component stays in the hundreds of bytes for a small program
and runs with no wasmtime flags at all:
rontolisp fact.lisp --no-gc --component -o fact.wasm
wasmtime run --invoke 'fact(5)' fact.wasm
# 120
The typed WIT signature carries each designator under its own WIT name
(:s32 → s32, :u32 → u32, … up to :s64/:u64, which only this
backend can lift; :int and :long are the legacy aliases of :s32 and
:s64), plus :float → f64, :bool → bool, :string → string, and
an omitted :returns → no result. The component also transpiles with jco
(jco transpile, where the 64-bit types surface as JavaScript BigInt) and
runs on any component-model host, with no wasm-GC support required.
:long is valid here, unlike the GC component path — use it when a value
can exceed the 32-bit range, matching the backend's internal i64
arithmetic:
rontolisp cube.lisp --no-gc --component -o cube.wasm
wasmtime run --invoke 'cube(2000000)' cube.wasm
# 8000000000000000000
A :string boundary crosses as a real component-model string — no manual
pointer handling on either side. The host lowers the argument bytes into
the module's own memory and reads the result back out through the
canonical ABI, and the module frees every per-call allocation afterwards
(a canonical post-return function pops the bump allocator to its base),
so a resident instance stays flat across repeated calls:
rontolisp greet.lisp --no-gc --component -o greet.wasm
wasmtime run --invoke 'greet("world")' greet.wasm
# "Hello, world"
Printing works here too: a program that
prints gets a built-in print micro-adapter — three tiny fixed core
modules that implement the core's single fd_write import over WASI 0.3
(wasi:cli/stdout's write-via-stream plus the async stream/future
built-ins), wired in only when the program prints. WASI 0.3 has no
synchronous write, so the exports of a printing program become async
lifts (the WIT world shows them as async func) — which is why the
component still runs with zero flags: everything it uses is base
component-model async, on by default in wasmtime 46+ (the wasmtime floor
for a printing component; a print-free one has no imports at all and
runs on older hosts too). The print output is byte-identical to the
interpreter — with the earlier show.lisp:
rontolisp show.lisp --no-gc --component -o show.wasm
wasmtime run --invoke 'show(4)' show.wasm
# 4
# 6.0
# "done"
# ()
Trade-offs against the plain --no-gc output, and current limits:
- A component needs a component-model-capable host; the raw core module
runs on any WebAssembly engine through the plain embedding API. Both
outputs stay available — pick per host, and note the component is not
the default for
--no-gc. (Without--component, a:stringcrosses as the manual(ptr,len)core ABI instead.) - The component is a pure reactor: there is no
wasi:cli/runentry (nothing runs at the top level). Printing inside an export works through the micro-adapter above; every other I/O stays outside the--no-gcsubset as usual.:async tis rejected — a printing program's exports are lifted async automatically, and there is nothing else an export could suspend on. - The export name must be a lower-kebab-case component-model name; for a
Lisp name outside that grammar the compiler asks you to rename it with
:as. - Tree shaking composes: the core module is shaken before the wrap.
--emit-witcomposes too, and writes a tiny import-free world of just the typed exports (plus thewasi:cli/stdout@0.3.0import — andasync funcexport signatures — when the program prints).