Compile to WASM
Give rontolisp an output path ending in .wasm with -o, and it compiles the
source to a WebAssembly binary instead of interpreting it. As with the JVM
backend, the output extension selects the target, and the binary is emitted by
hand without a third-party assembler. Examples below assume
wasmtime 47+, which enables wasm-GC and
exception-handling by default:
echo '(print (+ 1 2))' > hello.lisp
rontolisp hello.lisp -o hello.wasm
wasmtime run hello.wasm
3
Choosing an Output
Two independent choices determine the shape of the output:
- Value model. By default, values live on the WebAssembly GC heap
(integers as
i31ref, boxed as a signed 64-bit struct past the fixnum range and as a limb-based big integer past that, floats boxed in a struct), which supports the full language but requires a wasm-GC capable runtime (wasmtime 14+, Node 22+, current browsers).--no-gcinstead lowers a pure-compute subset of the language onto unboxedi64/f64scalars and linear-memory strings — the result is a plain MVP module that runs on any WebAssembly engine and is orders of magnitude smaller. - Packaging. By default the output is a WASI Preview 1 core module.
--componentwraps it as a component: on the GC path a WASI 0.3 component with full I/O over the async canonical ABI, on the--no-gcpath a compact typed reactor component that runs with no host flags at all.--no-wasidrops the WASI imports, turning either packaging into a pure-compute library ("reactor"): a Preview 1 module a host instantiates with no import object, or — with--component— a reactor component that imports nothing and runs its top-level forms at instantiation.
Crossing the two axes gives the six shapes:
| Output shape | Flags | Language | Runs on | Details |
|---|---|---|---|---|
| WASI command module | (none) | full | wasm-GC engine with WASI Preview 1 (wasmtime run) | wasm-GC core module |
| Library (reactor) module | --no-wasi | full (pure-compute exports) | any wasm-GC engine, no imports needed (Node 22+, current browsers; --host-random adds one host import, --host-fetch two) | --no-wasi reactor mode |
| WASI 0.3 component | --component | full, plus component-only I/O (rontolisp:fetch, TCP sockets) | wasmtime 46+ or another component host with wasm-GC | WASI 0.3 component |
| Reactor component | --component --no-wasi | full (pure-compute exports) | any component host with wasm-GC, empty import object | Reactor components |
| Plain core module | --no-gc | numeric/string subset | any WebAssembly engine, even without wasm-GC or SIMD | Non-GC output |
| Compact typed component | --no-gc --component | numeric/string subset | any component host, zero flags | Compact component output |
Rule of thumb: pick the value model by what the code needs — the full
language means the GC heap; a numeric/string kernel that fits the subset gains
universal portability and a hundreds-of-bytes binary from --no-gc — then pick
the packaging by the host: a component host gets --component, a plain
engine or JavaScript embedder gets a core module.
Host Boundaries
Two complementary directives declare what crosses the module/host boundary:
rontolisp:wasm-export/rontolisp:wasm-importspell out the boundary by hand, in rontolisp's own type designators (:int,:float,:string,:s-expr, ...). The same directive compiles into four different host contracts depending on the output shape (raw core function, typed component-model export, ...). Under--no-wasi,--emit-js-gluewrites the JavaScript half of that boundary from the same declarations.- WIT contracts (
wit-export/wit-import) drive the boundary from a.witfile — one contract, checked on every backend, with per-backend implementations (typed component-model exports under--component, provider callbacks on the interpreter and the JVM). Also covers--emit-witand--scaffold-wit.
Running a Component in a Browser
jco transpile turns a component into plain JavaScript that runs in a page:
see the browser guide for what works today (a
--no-gc --component needs nothing at all, a wasm-GC --component loads and
computes but cannot yet print) and, at the end, a complete Node + browser
walkthrough for calling a --no-wasi / --no-gc reactor module by hand.
Cross-Cutting Flags
Optimize (Tree Shaking)
Compilation drops every function unreachable from the module's roots (its
exports and the _start/_initialize entry) and renumbers the survivors.
Unused WASI imports are removed too, so a pure-compute reactor module is a
handful of functions:
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
Pass --optimize=off and the module instead embeds the entire runtime
(printer, rational, string, reader and eval helpers, the WASI import slots, …)
regardless of what the program actually uses, because function indices are then
held fixed: the same fact module is ~155 KB rather than ~2.5 KB.
The shaking is behavior-preserving: it walks the call graph from
the actual call instructions, so anything reachable (including code an
embedded eval/load dispatches to) is kept. It applies on every output
shape, --component included. The
same levels also dead-code-eliminate the JVM output.
The dead functions take their baggage with them: the WASI imports only they used, the type definitions nothing left names, and the static string data no surviving code still addresses — a printed literal's module is a few hundred bytes rather than the whole runtime's string table.
Definitions that compile to byte-for-byte identical bodies — typically the
accessors a defstruct or define-condition generates — are emitted once, with
every call redirected to the shared body. Only the code is shared: each function
keeps its own identity, so (eq #'f #'g) stays NIL.
Naming the function outright helps here. (mapcar #'double xs), (reduce #'+ xs),
(sort xs #'<) and (funcall #'double x) compile to the same direct call
(double x) does, rather than making double a first-class value and calling it
through the runtime's per-arity dispatcher. Two things follow: the call itself is
cheaper, and a function nothing else reaches stops being reachable through that
dispatcher — which is often what keeps whole swathes of a library in the artifact.
Pass the same function as a computed value (a variable, a lambda, a designator
built at run time) and the dispatcher comes back, as it must.
That floor does not depend on how the program spells the write. A constant text
is rendered at compile time and emitted as bytes, so print, princ + terpri,
write-string, write-line and (format t "Hello, ~a!~%" "World") all leave the
runtime printer behind and land within a few dozen bytes of each other (under 600 B
as a core module, under 1.8 KB as a component). What is left of the static data is
only what the program itself writes: the printer's own fixed strings — NIL, the
list punctuation, the float specials, the character names — go with the printer.
Print a computed value and both come back, as they must.
A value the compiler can work out for itself is not a computed value, though. A
call to a pure built-in whose every argument is a literal — (* 6 7),
(length "Hello World!"), (concatenate 'string "Hello" " " "World!"),
(string-upcase "hi") — is evaluated at compile time and the call is deleted, on
every backend that compiles (the interpreter still evaluates it at run time, which
is the same answer). That happens before the printer fold above, so
(princ (* 6 7)) reaches the very same floor as (princ 42) and
(format t "~a~%" (length "abc")) the same as (format t "~a~%" 3). Redefine one
of those names — a defun, a defmethod, an flet — and your definition wins:
the compiler stops folding that name anywhere in the program. --dynamic turns
the whole thing off, since every name there resolves at run time.
A literal lookup table is folded the same way, and there the saving is the table
itself: (coerce '(0 #x77073096 …) '(vector (unsigned-byte 32))) and
(make-array n :element-type '(unsigned-byte 8) :initial-contents '(…)) become
the specialized vector they build, which the module carries as static data at the
element width instead of building a list of boxed integers at startup — around 4
bytes an element for a 32-bit table rather than 12. Each evaluation of the form
still yields a fresh, independently mutable vector, exactly as the call did. An
element that does not fit the declared width is not folded; the run-time builder
masks it, as it always has.
On the --component path the wrapper shrinks with the core, not just the core
itself. Which WASI 0.3 interfaces a component imports follows from what the program
can actually reach: (print "Hello World!") compiles to a component importing
wasi:cli/types and wasi:cli/stdout and nothing else — no wasi:filesystem, no
wasi:clocks, no wasi:random, and not even wasi:cli/stderr, since nothing in
that program can write to standard error — while a program that opens a file, reads
the clock and draws random bytes keeps them all, and one that calls
warn or writes to *error-output* gets
wasi:cli/stderr back — and so does one that uses a condition-handling form
(handler-case and friends), because the
report an uncaught condition prints before it traps goes there too. --emit-wit prints the world the component really has, so
the emitted .wit shrinks with it.
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
At --optimize=off a component always declares the full fixed WASI surface, which
is what makes the two builds comparable byte-for-byte across releases.
Tree shaking also decides how much of a loaded library it can reach. A
compiled program calls most functions directly, but a funcall needs a dispatch
table, and a function listed there counts as reachable whether or not anything
ever calls it that way. So a function is listed only when your program can
actually obtain it as a value — #'name, a quoted 'name designator, a
lambda — and everything else becomes ordinary dead code the shaker
removes. On a program that loads md5 and calls one function, that is the
difference between about 1.1 MB and 582 KB.
A program that holds a symbol builder — intern, find-symbol,
make-symbol, uiop:symbol-call — keeps the listing, and instead widens it: a
string or keyword constant the module carries can become a designator at run
time, so the compiler probes those spellings of each function's name as well.
That is what lets a Worker whose handler discovery is (find-symbol "RUN" pkg)
still shake out everything it calls only directly.
The listing is all-or-nothing, and what switches it off is a program that can
name a function out of data this compile never sees: any use of eval, read,
read-from-string or a runtime load — including one inside a library you
loaded. When the build does not shrink as much as you expected, ask the compiler
which operator it was:
rontolisp -Drontolisp.debug.dispatchgate=true app.lisp -o app.wasm
# => [dispatch-gate] every function stays dispatchable because of: EVAL
A ~/name/ directive in a format control string counts as well, because it
names its function at run time — but only a control string the compiler can see
brings it in, so a program that spells no such directive is unaffected (see
format).
--dynamic switches it off too, by design: late binding resolves any name at
run time.
One carve-out follows from that: a designator assembled at run time
out of computed pieces — (funcall (intern (concatenate 'string "gre" suffix))) — is no constant the compiler can read, so the call signals the
ordinary "undefined function" error. --dynamic is the way back, and
--optimize=off is not: the listing is not part of what the level switches, so
declining the optimizer does not bring such a name back.
For a much smaller module still, the same fact.lisp compiled with
--no-gc lowers fact to unboxed i32 and drops
the whole GC runtime that made the 2.5 KB (the condition hierarchy, cons cells,
the printer):
rontolisp fact.lisp --no-gc -o fact.wasm
wasmtime run --invoke fact fact.wasm 5 # => 120, from a ~108 byte module
The source is unchanged — wasm-export works identically on both value models
— and the resulting module also drops the wasm-GC engine requirement.
Independently of the level (and on every output mode, --component
included), compilation always tree-shakes the libraries it splices in: the
bundled Lisp-source ones (linalg:, vec:, JSON, URL, equalp/string<) and
every system loaded with
asdf:load-system / ql:quickload. A function,
variable or constant your program never mentions -- by name anywhere in the
source, including quoted symbols and string literals -- is not compiled into the
module. Your own code is never pruned, and neither is anything a load/require
splices in: only a library that came from a system is subject to it.
Classes, generic functions, methods, conditions and structures are pruned by
the same rule: a class nothing references leaves together with its methods,
and a method on a generic your program does call is still dropped when no
reachable code can create an instance of the class it specializes on. Methods
on the standard protocol names (initialize-instance, print-object,
close, ...) follow their class alone, since those calls are implicit.
The one consequence: a library function whose name is only assembled at runtime
from computed strings and called through eval/apply signals the usual
"undefined function" error. Compile with --no-prune (or --dynamic) to keep
every library definition in that case.
The flag takes an optional level. --optimize and --optimize=default are the
same thing — everything above, and what an absent flag already selects — and the
bare spelling keeps that meaning permanently; --optimize=size is that plus the
trades in the next section; --optimize=off is none of it, and emits what a
build before the flag was on by default emitted.
--optimize=off exists for two jobs, and neither of them is making a program
work: comparing a module against one built before a compiler change, and
bisecting a suspected tree-shaker bug by asking whether the unshaken module
behaves differently. A program whose functions are reached only through a name
the compiler cannot read needs --dynamic — the funcall dispatch listing above
is not part of what the level switches, so off does not bring such a name
back.
Optimizing for Size (--optimize=size)
Two wasm-GC emissions deliberately spend bytes to gain speed, and both are on at
--optimize=off and --optimize=default alike:
- an integer expression tree like
(logand (+ (ash x 7) i) #xFFFFFFFF)compiles twice — once as a single unboxedi64computation, and once through the generic helpers, as the fallback a float, a ratio or an overflow into bignum territory takes; - a
letbinding whose assignments are integer arithmetic gets an unboxedi64slot beside its ordinary boxed one.
--optimize=size declines both. Nothing the program computes changes — the
fast path only ever existed as an alternative to the fallback, which stays —
but the arithmetic now runs through the generic helpers, so the price is real,
and how much you pay depends on how integer-heavy the program is:
| program | --optimize=default | --optimize=size | run time |
|---|---|---|---|
| ironclad SHA-256/HMAC/PBKDF2, 4096 rounds | 2,078,195 B | 1,562,816 B (-24.8%) | 1.4 s -> 5.2 s (3.8x) |
a vec:-kernel neural-net training loop | 271,233 B | 214,169 B (-21.0%) | 1.07 s -> 1.26 s (+18%) |
a float MLP training loop (no 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, best of three runs.) The size win barely varies; the run-time price does, because only integer arithmetic fuses — a float kernel pays it on its loop indices alone, while a crypto round pays it on everything.
So reach for it when the module has to travel — an edge deploy, a browser download, a registry with a size limit — unless the program's hot loop is integer arithmetic (hashing, crypto, bit twiddling), where the same win costs several times the run time.
The level is accepted on every backend, so a build script need not know which
one it targets, but only wasm-GC (Preview 1 and --component) has anything to
trade: the JVM and --no-gc outputs are
byte-for-byte what --optimize=default produces.
SIMD Acceleration (--simd)
--simd is the one acceleration switch shared by every backend: it lowers the
vectorizable vec: and linalg: kernels to
real vector instructions. On WASM it is orthogonal to the value model:
- wasm-GC +
--simdlowers the kernels to native fixed-width SIMD (f64x2/f32x4) over GC-managed lane-group arrays — packed float arrays stay ordinary GC objects, and memory behaves exactly as without the flag. Composes with--componentand every--optimizelevel; run as usual withwasmtime run(wasmtime enables the SIMD proposal by default). --no-gc+--simdlowers the same kernels tov128over the packed linear-memory blocks. Without--simd,--no-gcemits plain scalar loops instead — a v128-free MVP module that also runs on a runtime lacking the SIMD proposal.
The full story — which kernels vectorize, precision rules for single-float
reductions, measured effects, and the linalg interception — lives in the
SIMD acceleration guide.