Performance
Every first-party figure on this site is a benchmark in the repository. Run them yourself:
(cd benchmarks/vs-wazero && go test -bench .) # Instantiate, HostCall, Compile, Executego test -bench . ./imports/wasip2/... ./internal/integration_test/bench/... ./internal/component/..../internal/component/... is where the component figures live — BenchmarkInstantiateHelloCached
in internal/component/instance is the 350.5 µs cached component instantiate. benchmarks/vs-wazero
is its own Go module, hence the subshell.
The head-to-head sweeps below — instantiate 9.1x, the cumulative geomean and B/op −22.9%,
the host-call pair and the compile −23% to −26% — were measured on an Apple M4 (arm64),
benchstat over interleaved A/B runs with upstream wazero as a control in the same runs. The
per-optimization figures that predate them — the interruptible-loop numbers, memory.grow, and the
host-call reflection baseline — are a core-pinned i9-12900HK (amd64). Method, the machine behind
each result, and the optimizations measured and rejected (including one that made arm64 8%
slower) are in
OPTIMIZATIONS.md.
Head to head with wazero
Section titled “Head to head with wazero”Measured against wazero in the same runs, on the same workloads.
| Path | wazy vs wazero | What & why |
|---|---|---|
| Instantiate | 9.1x | 1.724 µs vs 15.74 µs, on a 37 KB TinyGo module. |
Interruptible loops (WithCloseOnContextDone) |
9.8x vs wazero with the option on | The check is a register decrement and a predicted-not-taken branch, not a Go round-trip. Enabling it costs wazy +18% on fibonacci and wazero +1057% – each against its own build with the option off – which is where the 9.8x head-to-head comes from. A near-empty spin kernel is the worst case at +44%; a host-call-dense loop pays +2% against wazero’s +86%. Measured on a core with no uop cache, because code placement swamps smaller differences on Skylake-family parts. Against wazero@main. |
| Compiled execution | memory-heavy code leads | string_manipulation −18%, reverse_array −14%, base64 −12%, fibonacci a wash — the advantage tracks memory-access intensity, not arithmetic. |
| Host calls (Go ↔ Wasm) | a tie | 47.8 ns here, 48.3 ns there, on HostCall/gomodule/CallWithStack — the shared baseline both runtimes implement the same way. The win is structural, not per-call. |
| Cumulative | geomean −17.8%, B/op −22.9% | Across internal/integration_test/bench, versus the wazero fork point, with upstream wazero as a control in the same runs — its arms stayed flat. |
What the rewrite bought
Section titled “What the rewrite bought”These are wazy before and after its own optimization work — not a wazero comparison. They are why the absolute numbers on the front page look the way they do.
| Path | Before → after | |
|---|---|---|
| Host calls, reflection removed | 1086 ns / 6 allocs → 74.9 ns / 0 allocs | 14.5x |
memory.grow (in-capacity, amd64) |
2.441 µs → 109.0 ns, 0 allocs/op | 22x |
| Interpreter | a benchmark that allocated 1.35M times now allocates twice | ~30% |
| Memory per call | 11784 B / 3 allocs → 1551 B / 2 allocs | −87% |
| Compile, real modules | 5 KB Zig 753.3 µs → 556.4 µs; 10 KB Rust 1132.7 µs → 871.3 µs | −23% to −26% |
The host-call row is the one worth reading twice. wazy deleted reflection-based registration rather
than optimizing it, so the 14.5x is against a path that no longer exists here — wazero still ships
one. memory.grow is opt-in via WithMemoryCapacityReservePages; out-of-capacity, shared and
imported memories keep the safe Go path.
At scale: a 6.8 MB Rust module, measured by someone else
Section titled “At scale: a 6.8 MB Rust module, measured by someone else”The suites above are kernels. go-anydoc measured two
orders of magnitude up: a 6.8 MB wasm32-wasip1 command module (Rust 1.88, opt-level = 3,
wasm-opt -O3) run as a single instantiate — stdin in, stdout out, a long stretch of compute
between. Same .wasm, same input, same machine, identical output, CompileModule excluded from
every timing.
| Converting | wazero v1.12.0 | wazy v0.3.0 | |
|---|---|---|---|
| 1 KB docx, compiled | 0.64 ms | 0.13 ms | 5.0x |
| 5 MB docx body, compiled | 0.98 s | 0.19 s | 5.3x |
| 7.6 MB PDF, compiled | 1.63 s | 0.33 s | 5.0x |
| 1 KB docx, interpreted | 2.58 ms | 1.38 ms | 1.9x |
| 5 MB docx body, interpreted | 11.9 s | 6.8 s | 1.8x |
| 7.6 MB PDF, interpreted | 20.6 s | 12.1 s | 1.7x |
The compiled rows carry WithCloseOnContextDone(true), and most of that 5x is what the option
costs wazero rather than what its code generator produces. go-anydoc sets it unconditionally —
cancelling a context has to interrupt a conversion already running inside the guest — so every run
of its benchmark carries it, while the suites above never set it. Toggling only that call on the
5 MB body:
| Compiled, 5 MB docx body | wazero v1.12.0 | wazy v0.3.0 | |
|---|---|---|---|
| close-on-context-done on | 0.94 s | 0.19 s | 5.0x |
| off | 0.14 s | 0.11 s | 1.3x |
| what the option costs | 6.5x | 1.7x |
With it off the two compilers are ~30% apart. Against an interpreter the option is free on both, so
the 1.8x in the interpreted rows is the engines and nothing else.
go-pdfium
measured the same effect across 5,000 PDFs, at ~4.5x for wazero and ~1.4x for wazy. wazero’s
#2533 solves the same problem on their side; wazy’s
own check became native in #71, so the 1.7x above is
what v0.3.0 cost rather than what current main costs — there it is 1.04x–1.28x depending on the
core, against 1.01x–1.10x for wazero with its three in-flight PRs.
The 1 KB compiled row is the one that moved most. The first version of this report had wazy losing it at 0.6x, because instantiation dominates a document that small and two things made instantiation expensive: a funcref memo that scanned a list, which made taking a reference for each of the module’s 593 element-segment entries quadratic, and a linear memory allocated at exactly its initial page count, which made every instantiation of a guest whose allocator grows the heap reallocate and copy the whole memory. Both are fixed as of v0.3.0; see OPTIMIZATIONS.md.
Third-party measurement, not ours. Apple M5 Pro (18-core), 48 GB, macOS 26.5, Go 1.26.1,
CGO_ENABLED=0, anydoc.wasm 6,781,177 bytes (anydoc 0.2.3), min of 3 at -benchtime 3x. The
docx rows reproduce from a checkout of go-anydoc — go test -run '^$' -bench 'ConvertDOCX|CloseOnContextDone' -benchtime 3x -count 3,
since its harness generates its own inputs; the PDF rows need a file of your own, and the wazero
column is the same benchmarks with the three imports in its anydoc.go pointed at wazero.
Originally reported in issue #29 (since deleted).
Against wasmtime
Section titled “Against wasmtime”The head-to-head module also carries a three-way comparison against wasmtime — BenchmarkExecute3,
BenchmarkCompile3, BenchmarkExecute3Heavy, BenchmarkRelaxedSimd. Those arms link
wasmtime-go, so they need cgo: an irony worth stating on a page selling CGO_ENABLED=0.
Getting the most out of it
Section titled “Getting the most out of it”- Hoist
CompileModuleout of the request path, and add a compilation cache if the process is short-lived. - Use
CallWithStackin hot loops;Callallocates the result slice. - Reserve memory capacity with
WithMemoryCapacityReservePagesif the guest grows its memory repeatedly. - Know what
WithCloseOnContextDonecosts you. Real compute pays about +18%, a near-empty spin kernel +44%, and a host-call-dense loop +2%. There is no knob; leave it off unless you need to interrupt a stuck guest. - Do not pool instances. Instantiation is 1.7 µs and 3.3 KB; a fresh instance per request is both faster to reason about and safer than scrubbing a reused one.