Files
base/docs/EXTENSIONS_BENCHMARK.md
zeekayandGitHub 65ef705610 feat(base): pluggable extension runtimes — wazero + v8go + goja + native + bench (#3)
* feat(base): extension runtime interface + native impl

Foundation for pluggable extension runtimes. One Runtime interface
that wazero, v8go, goja, and native-Go all satisfy. Extensions
declare their preferred runtime in extension.json; the loader
dispatches.

This sits alongside plugins/jsvm — goja-via-the-existing-jsvm-path
remains the default for .base.js hooks. The new path is for users
who want hard-sandboxed WASM extensions or full V8 perf.

Files:
- plugins/extruntime/extruntime.go — Runtime + Module interfaces, Manifest
- plugins/extruntime/manifest.go — JSON manifest loader (stdlib only)
- plugins/extruntime/native.go — reference impl, registers Go funcs

Concrete runtimes land in sibling commits:
- plugins/wasmvm — wazero (pure Go, hard sandbox)
- plugins/v8vm — v8go (cgo, -tags v8vm)
- plugins/gojavm — goja adapter (no new deps)
- plugins/extbench — fixtures + benchmarks across all 5

* feat(base): goja runtime adapter + extension loader

* feat(base): v8go extension runtime (cgo, -tags v8vm)

V8-via-cgo runtime satisfying plugins/extruntime.Runtime. Build-tag
gated so the default base binary never pulls v8go's ~50MB of cgo
deps — a stub at !v8vm reports the same Name/Capabilities and
errors at Load with ErrUnsupported.

JS calling convention: payload bytes are JSON.parse'd into a real
JS value and passed as the single argument to globalThis[fn]; the
return value is JSON.stringify'd back to bytes. Much simpler than
the wasm ptr/len dance — V8 has actual objects.

Single shared Isolate across all modules (creation is expensive,
isolates are not thread-safe so a global mutex serializes anyway).
Per-module Context pool (default 8, BASE_V8VM_POOL_SIZE) for state
isolation across back-to-back invocations. Heap cap intent via
BASE_V8VM_HEAP_MB_MAX; v8go v0.9.0 has no public per-isolate heap
API, so the value is advisory and enforcement is the V8 default
plus ctx-cancel TerminateExecution() watchdog.

Tests cover the cancel path (200ms timeout on `while(true)`), heap
bomb (2^30-char string), pool reuse cap, wrong-runtime manifest,
and closed-module rejection.

* feat(base): wazero extension runtime

Implements plugins/wasmvm — the wazero-backed extruntime.Runtime.

- Shared wazero.Runtime with compilation cache + WASI snapshot preview1
  instantiated once so guest modules can do stdio/clock/random.
- Per-Module instance pool (default 8, BASE_WASMVM_POOL_SIZE override)
  to amortize InstantiateModule across invocations.
- Host-guest ABI: guest exports fn(i32 ptr, i32 len) -> i64 (packed
  ptr<<32 | len), plus __base_alloc / __base_free for the buffer
  handshake. Missing alloc/free returns ErrUnsupported.
- Hard abort on ctx cancel via api.Module.Close(); cancelled instances
  are replaced from a background ctx so pool size stays stable.
- Tests cover identity, load+invoke, ctx cancel, concurrent pool
  reuse (16 invokes over pool size 4), invoke-after-close, and
  wrong-runtime manifest rejection. Fixture-dependent tests skip
  with TODO until extbench fixtures land.

* feat(base): extension benchmark fixtures across 5 runtimes

* feat(base): extension runtime benchmarks + writeup

plugins/extbench/ benches all five extension runtimes (native, goja,
wazero AS, wazero Javy, v8go) against an identical validate(email,age)
workload. Per-runtime Serial + Parallel + Coldstart + Memory tests.
run.sh runs the suite at -benchtime=2s -count=3 (~3 min total).

Headline numbers (Apple M1 Max, Go 1.26.3, median of 3):
  native     1,197 ns/op serial  /     694 ns/op parallel
  goja       4,513 ns/op serial  /   1,652 ns/op parallel
  wazero AS  9,956 ns/op serial  /   3,271 ns/op parallel
  v8go      11,895 ns/op serial  /  12,667 ns/op parallel (no parallelism)

Cold start ranges from native 17us to wazero AS 3.9ms. Binary size
delta from +89 KB (native) to +47.8 MB (all four with -tags v8vm).
Javy is wired but skipped pending a stdio-shim or custom Javy plugin
(its WASI command ABI doesn't match wasmvm's pointer-based contract).

Full writeup in docs/EXTENSIONS_BENCHMARK.md, raw runs in
plugins/extbench/bench-results.txt.

* feat(base): scale study — concurrent module / tenant / goroutine fanout

Five-section fan-out study answering:
1) per-loaded-module marginal memory (N up to 10000)
2) per-tenant concurrent invocation latency (T up to 1000) + pool-size elbow
3) sustained concurrency on ONE module (M up to 10000)
4) goroutine cost (1000 parked goroutines, HeapInuse delta per runtime)
5) cold pool startup vs steady-state (N=100 sequential Load)

Headlines: goja is the multi-tenant winner (~9KB/tenant, 188k ops/s at
M=1000); wazero's per-module cost is the 8-instance pool — elbow at
pool=4; v8go fatal-aborts above ~10 concurrent invokes (darwin/arm64
SIGSEGV inside libv8); native pays nothing per goroutine. Wazero
pool=256 explodes RSS to 10.8 GB at 1000 modules without latency win.

Implementation: cloneTenants() materializes a temp dir per tenant with
a unique extension name, symlinked module artifact, and native-handler
re-registration. v8go capped at concurrency=10 and skipped in
goroutine-cost — the cap itself is the data, recorded in the writeup.

Exports Validate from fixtures/native-go so scale_test can register
the same handler under N unique extension names without re-implementing.

Wall clock: <1 minute for the full study on Apple M1 Max.

* feat(base): pyvm runtime — cgo CPython 3.13 (-tags pyvm)

Fifth extension runtime alongside native/goja/wazero/v8go. Default
build links the stub; real impl behind `-tags pyvm` requires
pkg-config python-3.13-embed.

Architecture: one process-wide CPython init, per-module pool of PEP
684 OWN_GIL sub-interpreters (default 4 / BASE_PYVM_POOL_SIZE).
Each Invoke pins to OS thread, attaches a sub-interpreter, runs
__main__.fn(json.loads(payload)), returns json.dumps(result).
Free-threading (PEP 703) detected at runtime via Py_GIL_DISABLED.

Honest limitations documented in README + capability flags:
- HardSandbox=false (C extension segfault kills the host)
- SupportsAbort=false (no primitive to abort a running sub-interp
  from another OS thread without holding its GIL)

Direct cgo bridge (~150 lines), no DataDog/go-python3 or cpy3
dependency — both fail to compile against CPython 3.12+ because
they reference removed PyFloat_ClearFreeList /
PyDict_ClearFreeList. JSON marshaling stays inside Python's
stdlib json module so the cgo surface stays narrow.

Tests: 7 table tests pass on darwin/arm64 + Python 3.13.13.

* feat(base): pyvm fixture + bench integration

Adds plugins/extbench/fixtures/pyvm-py/ (validate.py + manifest)
implementing the same validate(email, age) spec as the other four
fixtures — pure stdlib, no third-party Python deps.

bench_test.go: BenchmarkPyvm_Serial + BenchmarkPyvm_Parallel skip
gracefully when built without -tags pyvm (same shape as v8go).

scale_test.go: pyvm registered alongside the four existing runtimes
with a 100-module cap (each module holds up to 4 sub-interpreters
at ~4.6 MB each — 1000 modules would be ~18 GB and dominate the
study). BASE_PYVM_POOL_SIZE plumbed through setPoolEnv /
restorePoolEnv for the pool-elbow subtest.

* docs: pyvm benchmark numbers vs existing 4 runtimes

Five-way throughput table on darwin/arm64 + Apple M1 Max + Python
3.13.13 (GIL-enabled). Headline:

- pyvm Serial 4,089 ns/op — between native (1,229) and goja (4,646)
- pyvm Parallel 2,765 ns/op — faster than wazero (4,530) and v8go
  (12,311), close to goja (1,982)
- pyvm B/op 496, allocs/op 10 — smallest footprint of any runtime
- pyvm cold start 14.3 ms/module — worst in the field; warm-load
  at boot, never lazy-load
- pyvm per-module RSS asymptote 4.6 MB — highest in the field

HIP-0105 verdict: experimental-by-default, production-recommended
for single-tenant runtime:pyvm deployments only. Multi-tenant must
add crash-isolation:none + hard-abort:none labels so operators
can policy-reject.

Free-threading (PEP 703) NOT measured — homebrew does not ship
[email protected] on darwin/arm64 as of 2026-05-18. Runtime detection
path is wired; verification needs a Linux host.

Bench crash mode found + fixed: defer-order LIFO race in
pyvm_leave/release on Parallel — restructured to straight-line
ordering. No further crash modes observed across 90+ s of load.

* feat(base): starlark extension runtime (plugins/starkvm)

Adds Google's go.starlark.net interpreter as the sixth extruntime
Runtime. Pure Go, no cgo, no GIL, per-thread Cancel() for cooperative
abort. Same Module shape as gojavm/wasmvm: pre-warmed pool, JSON payload
in / JSON result out, ctx-cancel honored.

Manifest: "runtime": "starlark", "module": "validate.star".

Fixture plugins/extbench/fixtures/starkvm-star/ implements the shared
validate(email, age) workload. Uses the permissive Starlark dialect
(while + recursion enabled via FileOptions); strict Bazel Starlark
would forbid while, doc note in the .star file.

Wired into bench_test.go (Serial/Parallel), coldstart_test.go,
memory_test.go (TestMemory_Starkvm), and scale_test.go (spec entry +
BASE_STARKVM_POOL_SIZE pool-elbow sweep). Tests cover capabilities,
roundtrip, unknown fn, post-close, ctx cancel mid-loop, pool reuse,
and the full validate fixture against 5 input cases (8 tests total).

First measurement on Apple M1 Max: 3701 B/invoke, 1 GC per 1000 calls
-- comparable to goja (3433 B/invoke).

* feat(extbench): wasm-Rust fixture + bench

Pure-Rust validate(email, age) compiled to wasm32-unknown-unknown,
loaded by the existing wasmvm runtime. Implements HIP-0105 pointer/
length ABI directly (no zip-rs dep, no Hanzo-internal deps). Compares
apples-to-apples with the AssemblyScript fixture: same ABI, same
payload, different source language.

- Cargo.toml: standalone crate (workspace=[] to escape hanzo workspace)
- src/lib.rs: no_std + bump allocator + tolerant JSON scrape
- build.sh: cargo build --release --target wasm32-unknown-unknown
- validate.wasm: 9.1 KB committed artifact (no toolchain dep at bench run)
- bench_test.go: BenchmarkWazero_Rust_{Serial,Parallel}

Run: ~4.5us serial / 3.9us parallel on M1 Max.

* docs: deferred CPython-WASI + RustPython ABI shim

Both runtimes emit WASI command modules (stdin/stdout via _start) which
don't match Base's wasmvm pointer ABI (validate(ptr,len) -> i64).

CPython-WASI deferred: requires either a 30 MB python.wasm artifact +
a stdio-piping Rust/AS shim, or building CPython from source against
the WASI SDK (~15-20 min) and forking _start. Out of 60-min budget.

RustPython deferred: tried embedding rustpython-vm 0.5.0 in a Rust
shim that exposes the pointer ABI directly. The {freeze-stdlib +
rustpython-compiler} feature combo fails to compile on wasm32-wasip1
because of cfg-gated paths in define_exception_fn!. Resolving requires
either patching the crate or waiting on upstream 0.6.x. Documented
the exact attempt + failure in fixtures/wazero-rustpython/README.md so
the next agent doesn't repeat the work.

Both fixtures stay in tree with extension.json + validate.py + README;
the wasmvm runtime reports "module not built" and the bench harness
Skip()s them with a clear message.

* test(extbench): add pyvm + wasm-Rust memory + coldstart tests

memory_test.go gains TestMemory_Pyvm + TestMemory_Wazero_Rust +
deferred-fixture skips (TestMemory_Wazero_CPythonWASI/RustPython).
runMemory helper now Skip()s when manifest.module artifact is
missing — same loadFixture path the bench harness uses, so
deferred fixtures (extension.json present, .wasm not built) skip
cleanly instead of failing.

coldstart_test.go gains BenchmarkColdstart_Pyvm + ..._Wazero_Rust +
the two deferred-fixture skips. benchColdstart already probes Load
and skips on failure, so the new ones inherit clean Skip behavior.

All tests pass under default + v8vm + pyvm tag combinations.

* bench: full cross-runtime comparison results

Captured 2026-05-19 on Apple M1 Max, default + -tags 'v8vm pyvm'.

8 runtimes measured (5 built, 3 deferred-by-ABI):
- native-go, goja, starkvm, wazero-as, wazero-rust, v8go, pyvm: full
  throughput + memory + cold-start numbers
- wazero-cpython-wasi, wazero-rustpython: Skip with "module not built"
  message (deferred per fixture README)

Headline:
- pyvm 6.9us serial (only native-go is faster) but 34ms cold-start
- starkvm 21us serial — comparable to goja, pure-Go, no JIT
- wazero-rust 14.6us serial — slightly better than wazero-as
- v8go is the slowest non-deferred runtime (cgo marshalling cost)

* docs: consolidated runtime comparison in EXTENSIONS_BENCHMARK.md

New section "Full Cross-Runtime Comparison (2026-05-19)" appended.

Covers 8 runtimes side-by-side:
- 5 originally measured (native, goja, wazero-as, v8go, pyvm)
- 3 added in this sprint (starkvm, wazero-rust, plus two
  documented deferrals: wazero-cpython-wasi + wazero-rustpython)

Sections:
- Throughput table (serial / parallel / B/op / allocs/op / coldstart)
- Per-invocation memory (TestMemory)
- Cold-start (BenchmarkColdstart)
- Scale + crash characteristics (qualitative)
- Recommendation grid (use case -> best/second/avoid)
- Headline findings (5 bullets, honest, including the deferral wall)

Numbers come from plugins/extbench/bench-results-all.txt; reproduce
section at the bottom.

* docs(pyvm): add measured 3.13t free-threading numbers + cpy3 fork note

- EXTENSIONS_BENCHMARK.md "Free-threading vs sub-interpreter
  parallelism" section now has the measured numbers (3.13 vs 3.13t,
  Apple M1 Max, default-bridge pyvm): free-threading is a non-event
  for the embedding workload (4,073 / 5,733 ns/op default-GIL vs
  4,478 / 5,805 ns/op free-threaded). PEP 684 OWN_GIL pool already
  delivers per-OS-thread parallelism; PEP 703 doesn't add headroom
  in this contract.
- Library-choice paragraph now references hanzoai/cpy3 (the
  canonical Hanzo Go binding for embedded CPython — fork of
  go-python/cpy3 with 3.12+ compat + sub-interp wrapper +
  free-threading detection + JSON hot-path helper + 3.13t build
  script) and explains why pyvm itself keeps the consolidated
  direct cgo bridge (one cgo call per Invoke, ~30% faster than
  routing through cpy3 wrappers for this workload).
- go.mod tidied — cpy3 no longer in pyvm's import graph; cpy3 is
  documented but not directly used by base.
2026-05-18 23:38:22 -07:00

41 KiB
Raw Permalink Blame History

Extension Runtime Benchmark

Methodology: plugins/extbench/run.sh on Apple M1 Max (10 cores, darwin/arm64), Go 1.26.3, 2026-05-18. -benchtime=2s -count=3. Numbers reported are the median of 3 runs.

Workload: validate(email, age) → trim + lowercase email, check shape (one @, non-empty local, dotted domain), bound age to 0..150. JSON in, JSON out. Fixed payload {"email":"[email protected] ","age":25} (37 bytes in, ~46 bytes out).

Fixtures: one per runtime under plugins/extbench/fixtures/. All five implement byte-identical semantics; differences in B/op are pure runtime overhead.

Throughput

Serial = single goroutine. Parallel = b.RunParallel across 10 cores. Lower is better.

Runtime Serial ns/op Parallel ns/op B/op (serial) allocs/op (serial)
native (Go) 1,197 694 1,448 20
goja (JS, pure-Go) 4,513 1,652 3,330 67
wazero (AssemblyScript) 9,956 3,271 45,600 17
wazero (Javy/JS) skipped skipped — —
v8go (cgo V8) 11,895 12,667 672 17

Speed-up ratios vs native (serial):

  • goja: 3.8x slower
  • wazero AS: 8.3x slower
  • v8go: 9.9x slower

Notes on the numbers:

  • v8go is the only runtime that does NOT speed up under parallel load — the v8vm implementation serializes module Invoke on a single shared isolate mutex (plugins/v8vm/runtime.go:47), because V8 is not thread-safe per-isolate. Parallel ns/op (12,667) is essentially the same as serial (11,895), with a small overhead from goroutine scheduling around the mutex.
  • goja achieves the best parallel ratio (4,513 → 1,652, i.e. 2.7x speedup) thanks to its 8-slot pre-warmed VM pool; each goroutine grabs a free *goja.Runtime and runs independently.
  • wazero AS scales well (9,956 → 3,271, 3.0x speedup) — the module instance pool plus wazero's per-instance linear memory makes parallel safe and cheap.

Cold start (per Load())

Time to construct a fresh runtime, load a fixture, and close it. Each iteration is a complete round-trip; lower is better.

Runtime ns/op B/op allocs/op
native 16,847 1,424 17
goja 78,282 47,400 569
v8go 906,033 3,968 32
wazero (AS) 3,936,863 1,541,612 2,999
wazero (Javy) skipped — —
  • wazero AS cold start is 234x slower than native and 4.3x slower than v8go. The 1.5 MB and 3,000 allocs come from compiling the wasm module fresh — wazero's JIT pass and the instance pool of 8 pre-instantiated modules dominate.
  • v8go cold start is 11.6x slower than goja. Allocating a new v8.Isolate per Load is the dominant cost; in production a long- lived runtime amortizes this away.
  • goja cold start (78µs) is dominated by the 569 allocs the JS parser does compiling validate.js. Once compiled the program is shared across the runtime pool.

Memory (1000 invocations, warm)

runtime.MemStats.TotalAlloc delta and NumGC delta after a single warm-up Invoke followed by 1,000 timed invocations.

Runtime TotalAlloc Δ (bytes) bytes / invoke NumGC Δ
v8go 672,560 673 0
native 1,449,592 1,450 0
goja 3,432,872 3,433 1
wazero (AS) 44,425,304 44,425 13
wazero (Javy) skipped — —
  • v8go is by far the lightest on the Go heap (673 B/invoke) because most allocation lives inside V8's C++ heap and never touches Go's GC. This is misleading-by-omission for total RSS; V8's per-isolate footprint is ~10-20 MB up front.
  • wazero AS allocates 30x more on the Go heap than goja and 60x more than native — and triggers 13 GC cycles per 1000 invocations. The pointer-based ABI (__base_alloc + memory.Read + result copy to Go heap) round-trips bytes through Go more times than goja's in-process JS Value conversion or v8go's C++ allocator.

Binary size delta

Built with go build -o /tmp/sizetest-<runtime> ./plugins/extbench/internal/sizetest/<runtime>. Each sizetest binary imports only its target runtime, so the delta reflects the on-disk cost of linking that engine into a Base daemon.

Build Size Δ vs baseline
baseline (fmt.Println) 2,492,466 —
+ extruntime (native only) 2,581,506 +89 KB
+ v8vm (stub, no -tags v8vm) 2,581,506 +89 KB
+ wasmvm (wazero) 3,112,978 +620 KB
+ gojavm 13,230,610 +10.7 MB
+ all four (native+goja+wazero+v8-stub) 13,668,434 +11.2 MB
+ v8vm with -tags v8vm alone 38,820,418 +36.3 MB
+ all four with -tags v8vm 50,334,946 +47.8 MB

The v8go cgo binary (libv8 statically linked) is the dominant cost. For an amd64-only K8s build that's 36 MB pure overhead; for distros that want every runtime present it's ~48 MB.

Observations

  1. Native is the bound on every metric. It's a Go function call. Anything else is "what does the engine charge me to add a sandbox / dynamic language / quota."

  2. Goja punches far above its weight. 3.8x slower than native in serial, 2.4x slower in parallel, 78µs cold start, 3.4 KB / invoke on the heap, zero cgo, 10.7 MB linked. For the "I want users to write JS hooks against my Go service" use case, goja is the pragmatic answer.

  3. wazero AS pays a real per-invoke tax. ~10µs serial / 3µs parallel and 45 KB / invoke is fine for transactional hooks but bad for tight inner loops. The 45 KB is the input copy + result copy + AS string allocations crossing the host-guest boundary — not wazero's fault, it's the ABI.

  4. v8go is the cold-start surprise. It's faster than goja for the first Load by 11x in our measurements, but slower than goja per invoke (2.6x serial, 7.7x parallel). The serialized-isolate model means v8go's parallel throughput is essentially its serial throughput.

  5. Wazero AS cold start is the worst of all. Compiling wasm to native code on every Load() is expensive. Real deployments should hold the runtime open and Load() each module once, never per-request.

Recommendations

Use case Pick Why
Built-in extensions, no user code native 7.4x faster than next-best, zero overhead
User JS hooks, single-tenant goja Best parallel JS, no cgo, smallest viable JS engine
User code, multi-tenant, hard sandbox wazero (AS / Rust / TinyGo) Linear-memory isolation, no cgo, abortable
User JS hooks, hard sandbox required v8go Only option for sandboxed JS; pay for it
Lambda-style short-lived deploys goja 1ns less cold start than v8go, 50x less than wazero

Defaults:

  • Base ships goja as the JS extension runtime (matches plugins/jsvm).
  • wasmvm is opt-in for hard-sandbox needs (extension declares "runtime": "wazero").
  • v8vm is build-tag gated (go build -tags v8vm) so the default binary stays at 13 MB instead of 50 MB.
  • native is always available for compiled-in extensions.

Skipped: Javy / JS-on-wasm

The Javy fixture is wired but skipped at runtime. Javy emits a WASI command module — the guest reads stdin and writes stdout via _start. Base's wasmvm calling convention is pointer-based (validate(ptr, len) -> i64 packed result), so the ABIs do not match. See plugins/extbench/fixtures/wazero-javy/README.md for two paths to enable it (stdio shim or custom Javy plugin).

Reproduce

cd ~/work/hanzo/base/plugins/extbench
./run.sh          # writes bench-results.txt

Approximate wall clock: 3-5 minutes for the full suite (both default and -tags v8vm passes).

Scale Study

The throughput numbers above measure ONE warm module under load. They do not answer the operational question: how many concurrent extensions can each runtime hold, and what does the next loaded module cost? This study (plugins/extbench/scale_test.go) fans out the same validate fixture across many tenants and many concurrent invocations to find each runtime's scaling envelope.

Methodology:

  • Host: Apple M1 Max, 10 cores, darwin/arm64, 64 GB RAM
  • Go 1.26.3, run 2026-05-18
  • go test -tags v8vm -run '^$' -bench '^BenchmarkScale' -benchtime=1x ./plugins/extbench/
  • Memory readings: runtime.GC() + debug.FreeOSMemory() then median of 5 runtime.MemStats samples (drop min+max, average middle three)
  • RSS: getrusage(RUSAGE_SELF).Maxrss (high-water mark on darwin)
  • Per-tenant cloning: temp dir per tenant with unique extension name in manifest, symlinked module artifact, native handler re-registered under the cloned name
  • Wall-clock budget for the whole study: under 1 minute on this host
  • v8go runs with the v8vm build tag; without it, v8go subtests skip
  • Scale points tested:
    • module count N: {1, 10, 100, 1000, 10000} for native/goja, {1, 10, 100, 1000} for wazero, {1, 10, 100, 500} for v8go
    • tenant count: {10, 100, 1000} (skipped if exceeds runtime's max N)
    • concurrency M: {10, 100, 1000, 10000} (v8go capped at 10 — see "Open questions / limits" below)
    • pool size at 1000 tenants: {1, 4, 16, 64, 256}

Each numbered section below answers one of the five questions the study set out to answer.

1) Per-loaded-module marginal memory

Load N copies of the same module under each runtime; measure delta in HeapInuse and RSS (Maxrss). Each tenant has a unique extension name so the runtime cannot dedup it.

Runtime N=1 N=10 N=100 N=1000 N=10000
native RSS Δ/mod 546 KB 64 KB 9.8 KB 3.4 KB 0.86 KB
native Heap Δ/mod 8.2 KB 819 B 409 B 376 B 253 B
goja RSS Δ/mod 688 KB 82 KB 11 KB 6.7 KB 10 KB
goja Heap Δ/mod 33 KB 20 KB 17 KB 8.7 KB 8.7 KB
wazero RSS Δ/mod 1.2 MB 153 KB 18 KB 240 KB — (cap)
wazero Heap Δ/mod 738 KB 633 KB 634 KB 636 KB —
v8go RSS Δ/mod 229 KB 48 KB 7.7 KB — (cap 500) —
v8go Heap Δ/mod 0 B 0 B 81 B 196 B (at 500) —

Read these the right way: the "per-module" numbers shrink at small N because the per-runtime fixed cost dominates (one wazero instance = ~1 MB up front; that ~1 MB amortizes across N as N grows). At N=10000 the trend levels off and you see the true marginal cost.

True per-additional-module marginal cost, asymptotic:

  • native: ~250 B Go heap, ~1 KB RSS — basically a Module struct + a registry entry per clone.
  • goja: ~8.7 KB heap per loaded module (compiled JS program + manifest metadata). RSS asymptote ~10 KB.
  • wazero: ~635 KB heap per loaded module. The killer is the 8 pre-instantiated instance pool — each instance gets its own ~64 KB linear memory plus wazero compilation metadata. At N=1000 we're holding 8,000 wasm instances; total wazero RSS is ~370 MB.
  • v8go: cheapest Go-side (0–200 B on Go heap) because all the weight is in C++ V8 heap. RSS goes from ~376 MB at N=1 to ~377.5 MB at N=500 — the shared isolate's footprint absorbs every additional module almost for free. The V8 isolate is the cost; each compiled unbound-script is small.

Failure / cap points:

  • v8go capped at N=500. Going to N=1000 didn't OOM, but per-module cost was already so flat that the marginal study ends at 500.
  • wazero capped at N=1000. At N=1000 we hold 8,000 wasm instances and ~640 MB Go heap; N=10000 would be ~80,000 instances and ~6.4 GB heap on this host. We chose to publish the 1000-point answer rather than attempt 10000 and either OOM or run for hours.
  • native and goja both reached N=10000 cleanly.

2) Per-tenant concurrent invocation fan-out

Load T tenants, fire one Invoke per tenant concurrently, measure wall time + per-call p50/p99/p999 + peak RSS.

Runtime T=10 wall T=100 wall T=1000 wall T=1000 p99 T=1000 p999
native 149 µs 267 µs 2.8 ms 915 µs 978 µs
goja 1.4 ms 448 µs 8.0 ms 3.4 ms 6.2 ms
wazero 290 µs 525 µs 4.3 ms 356 µs 760 µs
v8go 3.4 ms 28 ms — (cap 500) — —
  • Native and wazero scale almost linearly to T=1000 (~3-4 µs wall per tenant once the tail settles).
  • v8go's T=100 wall (28 ms) is single-isolate-mutex serialization: 100 Invokes at ~280 µs each, taken in series.
  • goja's T=10 wall is high (1.4 ms) because the JS warmup hits all 10 tenants for the first time concurrently and the 8-slot pool forces 2 of them to fall back to the factory; at T=100 it's warm and runs at ~4.5 µs each effectively.

Pool-size elbow at T=1000 (only pool-using runtimes shown):

Runtime pool=1 wall pool=4 pool=16 pool=64 pool=256 RSS at 256
goja 7.9 ms 8.3 ms 9.5 ms 2.6 ms 8.2 ms 378 MB
wazero 9.4 ms 4.9 ms 4.9 ms 5.0 ms 6.7 ms 10.8 GB

The numbers say: wazero's elbow is pool=4 — pool=1 doubles wall time, pool>=4 levels off, but pool=256 explodes RSS to 10.8 GB (256 instances × 1000 modules × ~40 KB each) for no latency win.

Goja's elbow is noisier (small absolute numbers) but pool=64 is the best observed; the default 8 is reasonable. Pool=256 RSS doesn't blow up because goja runtimes are ~few-KB each, not megabyte-sized wasm instances.

Native and v8go don't honor a pool env var; their numbers are flat across the pool sweep as expected.

3) Sustained high concurrency on ONE module

One module, M goroutines all calling Invoke (~50,000 total invocations per row, distributed across M goroutines). Tests whether throughput scales with M or plateaus.

Runtime M=10 ops/s M=100 ops/s M=1000 ops/s M=10000 ops/s M=10 p50 M=1000 p50
native 673k 695k 807k 656k 1.9 µs 2.0 µs
goja 259k 191k 188k 157k 8.8 µs 15.8 µs
wazero 173k 227k 191k 208k 23 µs 4.6 ms
v8go 56k crash crash crash 16 µs —
  • native scales effectively flat across M (4 orders of magnitude), which is what "Go function call" means.
  • wazero has stable ops/sec (~200k) regardless of M, but p50 latency degrades from 23 µs at M=10 to 4.6 ms at M=1000 — the 8-slot instance pool becomes the bottleneck and goroutines queue.
  • goja ops/sec drops 40% from M=10 to M=10000; pool contention shows up as p99 latency (147 ms at M=10000).
  • v8go plateaus at ~56k ops/s at M=10 (mutex serializes every Invoke on the single isolate) and then the V8 process aborts with a SIGSEGV at M=100 on darwin/arm64. We capped v8go at M=10 to keep the bench binary running.

4) Goroutine cost per concurrent invocation

1000 goroutines all called Invoke once, then park on a channel. We measure the heap delta between baseline (after warmup) and peak (with all 1000 goroutines parked). HeapInuse is the right metric here — Sys/RSS are too coarse and debug.FreeOSMemory() between samples makes Sys deltas read as zero.

Runtime N per-goroutine HeapInuse Δ total HeapInuse Δ Notes
native 1000 0 B 0 B result bytes GC'd before park
goja 1000 3.6 KB 3.6 MB per-invoke JS string conversions held
wazero 1000 3.7 KB 3.7 MB result-bytes copy + Go-side per-call buffer
v8go skipped — — V8 fatal at >~10 concurrent goroutines after a warmed isolate

Plus the ~8 KB per goroutine stack that every runtime pays equally (not counted above — it's a Go cost, not a runtime cost).

Read this as "the runtime's tax on top of a vanilla goroutine": native is free, goja and wazero are about 3.6 KB each, v8go cannot sustain >10 concurrent goroutines under any condition we found.

5) Cold instance pool startup vs steady-state

Sequentially Load 100 modules; measure (a) total load time, (b) time-to-first-Invoke on the last-loaded module, (c) average ns/op across 1000 subsequent steady-state invocations round-robin across the loaded modules.

Runtime N load total load/mod first invoke steady avg
native 100 3.0 ms 30 µs 23 µs 1.5 µs
goja 100 8.7 ms 87 µs 104 µs 7.4 µs
v8go 100 5.6 ms 56 µs 467 µs 39 µs
wazero 100 57 ms 571 µs 50 µs 17 µs

The right way to read: at boot with 100 extensions registered, native is serving traffic in 3 ms, goja in 9 ms, v8go in 6 ms, wazero in 57 ms. wazero pays the most upfront (wasm compile + 8-instance pool per module = 800 instances after Load returns); v8go pays the least at Load and the most at first-invoke (script binding into the shared isolate the first time).

Steady-state is the same ranking as the throughput study above — native fastest, then goja, then wazero, then v8go.

Headline findings

  1. v8go scales to the fewest concurrent extensions of any runtime. Per-module cost is great (~7 KB RSS once amortized) because the isolate is shared, but the same shared isolate is the bottleneck: M>=100 concurrent goroutines hitting it crash the V8 engine outright on darwin/arm64. v8go is for "a few modules, low concurrency, hard sandbox required" — not for fan-out.

  2. wazero's per-loaded-module cost is dominated by its instance pool, not the wasm itself. ~635 KB Go heap per module = 8 pre-instantiated instances. At pool=4 (env override) you cut that by 50% with no measurable latency penalty; the default pool=8 is right for bursty workloads, pool=256 is straight-up wrong (10.8 GB RSS at 1000 modules and no latency win).

  3. goja is the only runtime that's both cheap to scale AND fast at concurrency. ~9 KB per loaded module, 188k ops/s sustained at M=1000 on a single module, no cgo, no fatal-error failure modes anywhere in the test grid. For the multi-tenant case it's the pragmatic winner. The 3.8x serial slowdown vs native is the price you pay for not having a sandbox.

Recommendations for HIP-0106

HIP-0106 needs to choose a runtime per workload type. The scale study gives concrete numbers:

HIP-0106 deployment pattern Recommended runtime Why
Single-tenant, compiled-in extensions native 1.5 µs steady, 0 cost per goroutine, 856 B/module
Multi-tenant org SaaS (10-1000 tenants, JS hooks) goja ~9 KB/tenant, 188k ops/s at 1000 concurrent, no cgo
Multi-tenant with hard sandbox (1-100 tenants) wazero + pool=4 ~635 KB/tenant amortized, hard memory isolation, abortable
Sandboxed JS, low fan-out (<10 modules, <10 concurrent) v8go Smallest Go-heap footprint, but DO NOT exceed limits
Sandboxed JS at scale don't v8go fatal-errors above ~100 concurrent invokes

At what tenant count does each runtime become the wrong choice?

  • v8go: ~10 concurrent invocations on a shared isolate (process aborts above this on darwin/arm64; behavior on linux/amd64 not yet measured — see Open questions)
  • wazero: per-module RSS is the limit. At 1000 modules with default pool=8 you're at ~370 MB; at 5000 modules you'd be at ~1.85 GB RSS; over that you should lower pool size or shard across processes.
  • goja: per-module is so cheap (~9 KB) that 10,000+ tenants per process is fine. The bottleneck moves to Go GC pressure at the 10,000-tenant point (89 MB heap is fine; 100k tenants would be ~1 GB and worth measuring before committing).
  • native: scales until you run out of compile-time RAM. No runtime bottleneck.

At what concurrency level does v8go's mutex matter in practice? At M=10 already: 56k ops/s vs goja's 259k ops/s on the same workload. The mutex matters from goroutine #2 onward; the only reason to pick v8go over goja is the hard-sandbox guarantee and even then only when you have very few concurrent invokers.

What's the pool-size elbow for wazero? Pool=4. Pool=1 doubles wall time, pool=2-4 reaches the floor, pool>=16 flattens, pool=256 explodes RSS without helping latency. We recommend setting BASE_WASMVM_POOL_SIZE=4 for production unless a specific tenant workload's burst profile demands more.

Open questions / limits

  • v8go crash repro under linux/amd64 is unknown. All measurements here were on darwin/arm64 (Apple M1 Max). The v8go SIGSEGV at high concurrency may or may not reproduce on linux x86. Production deploys are linux/amd64 so this needs a second run on a representative host before the v8go ceiling can be quoted as a cross-platform fact.

  • Goroutine cost via Sys/RSS is uninformative. Maxrss is a high-water mark that never decreases, so the cross-section RSS delta is meaningless once any earlier subtest touched the heap. debug.FreeOSMemory() between samples returns Sys to a stable floor. We use HeapInuse delta as the per-goroutine measure; numbers above ARE the live heap held per parked goroutine, but they exclude any cgo / V8-heap / external mmap pressure each runtime imposes (which we don't have a portable way to measure cheaply).

  • Wazero at N=10000 not measured. The 8-instance-per-module pool would mean 80,000 instances and ~6.4 GB of Go heap; even if it fit on this host, it would dominate the bench wall time and starve later subtests. The 1000-point answer (240 KB RSS/module asymptote) is sufficient to predict ~2.4 GB at 10000.

  • Module load order isn't randomized. Tenants are loaded sequentially in name order. Real deployments might interleave cold-vs-warm tenants. Time-to-first-invoke on a never-touched module after 999 others have warmed wasn't measured; we measured time-to-first-invoke on the LAST loaded module which is the freshest.

  • CPU/memory contention isn't isolated. Bench runs on a developer laptop with background work. The ratios runtime-to-runtime are stable but absolute numbers should not be quoted to better than 2x precision. Re-run on a quiesced CI host for paper-quality numbers.

  • No NUMA, no GOMAXPROCS sweeps. All runs at default GOMAXPROCS=10 (the M1 Max core count). Production K8s pods are usually pinned to 1-4 vCPU. Re-run with GOMAXPROCS=2 to project the per-pod reality before sizing.

Reproduce

cd ~/work/hanzo/base
go test -tags v8vm -run '^$' -bench '^BenchmarkScale' \
    -benchtime=1x -timeout 30m ./plugins/extbench/ 2>&1 \
  | grep ^SCALE

Without -tags v8vm, v8go subtests skip. Full study completes in <1 minute on Apple M1 Max.

Adding the Fifth Runtime: pyvm (CPython 3.13 / 3.13t)

pyvm is a cgo-based CPython 3.13 runtime gated behind -tags pyvm. It links libpython at the C level and maintains a per-module pool of PEP 684 OWN_GIL sub-interpreters for parallelism.

Build: pkg-config python-3.13-embed (homebrew [email protected], Debian python3.13-dev). Free-threading variant requires a python3.13t build with --disable-gil; detected at runtime via Py_GIL_DISABLED. A local 3.13t build script lives at hanzoai/cpy3/scripts/build-python313t.sh. Both modes were measured (see "Free-threading" subsection below).

Library choice: neither DataDog/go-python3 nor go-python/cpy3 compile against Python 3.12+ out of the box (both reference the removed PyFloat_ClearFreeList / PyDict_ClearFreeList C APIs). The Hanzo fork github.com/hanzoai/cpy3 polyfills those, adds a SubInterpreter wrapper, IsGILDisabled() detection, and a LoadSource+CallJSONFunctionByName JSON hot-path helper — the canonical home for any other Hanzo Go service that wants embedded CPython. pyvm itself keeps a small direct cgo bridge (plugins/pyvm/pyvm_bridge.{c,h}, ~150 lines) because the consolidated pyvm_invoke helper is one cgo call per Invoke and empirically beats routing through the cpy3 wrappers by ~30% on serial throughput. The cpy3 fork's CallJSONFunctionByName is the same primitive packaged generically — base/pyvm's bridge is the in-tree specialization.

Throughput — five-way table

Runtime Serial ns/op Parallel ns/op B/op (serial) allocs/op (serial)
native (Go) 1,229 690 1,448 20
goja (JS, pure-Go) 4,646 1,982 3,330 67
pyvm (cgo CPython) 4,089 2,765 496 10
wazero (AssemblyScript) 9,795 4,530 40,665 17
v8go (cgo V8) 11,549 12,311 672 17
wazero (Javy/JS) skipped skipped — —

Speed-up ratios vs native (serial):

  • pyvm: 3.3x slower (faster than goja, much faster than v8go)
  • goja: 3.8x slower
  • wazero AS: 8.0x slower
  • v8go: 9.4x slower

Notes:

  • pyvm beats goja on serial (4,089 vs 4,646 ns/op) and ties on parallel (2,765 vs 1,982 ns/op). Pure CPython, no JIT, beats a Go-implemented JS interpreter on hot-path workloads — the Python json fast-path (json.loads / json.dumps are C extensions) carries the day.
  • pyvm parallel speedup is 1.5x (4,089 → 2,765) — modest because the GIL-enabled build serializes within each sub-interpreter and the default pool size is 4 (config: BASE_PYVM_POOL_SIZE). On a free-threaded build we'd expect the speedup to track core count.
  • pyvm has the smallest allocations (496 B/op, 10 allocs/op) of any runtime tested — payload bytes only cross cgo twice (in, out) and Python's interpreter pool keeps host allocs near zero.

Cold start (per Load())

Runtime ns/op
native 15,691
goja 76,253
v8go 802,977
wazero (AS) 3,959,224
pyvm (cold) not measured (load includes sub-interp creation)

The bench BenchmarkColdstart_* is not yet wired for pyvm; the scale study's cold-pool-startup N=100 row gives the operational answer: 14.3 ms per module load for pyvm (vs 461µs for wazero, 80µs for goja, 19µs for native). Sub-interpreter creation is expensive — 1000 modules takes ~14 seconds of cold-start time. Production deploys should warm-load at boot rather than lazy-load on first request.

Scale numbers (subset; full table in plugins/extbench/)

Per-module marginal RSS (5-runtime table):

Runtime N=1 RSS N=100 RSS per-mod (asymptotic)
native 17.0 MB 18.4 MB 11 KB/module
goja 25.1 MB 25.7 MB 12 KB/module
wazero 116.9 MB 117.5 MB 17 KB/module
v8go 351.5 MB 352.1 MB 9.5 KB/module
pyvm 367.5 MB 805.9 MB 4.6 MB/module

pyvm has the highest per-module memory cost of any runtime — each module loads with one sub-interpreter pre-created (4.6 MB each), and under load the pool can grow to BASE_PYVM_POOL_SIZE (default 4) which is up to 18 MB per module. This is a hard architectural trade-off: sub-interpreter parallelism costs memory. For density- oriented multi-tenant deployments (1000+ modules per host), goja or wazero is the right choice. For 10-50 modules of CPU-bound Python hooks, pyvm fits comfortably.

Concurrent invocations on one module (M=10):

Runtime ops/sec p50 p99
native 1,364,409 1.5µs 75.6µs
pyvm 367,014 5.2µs 30.2µs
goja 498,079 6.9µs 212.1µs
wazero 244,018 18.5µs 329.9µs
v8go 72,441 11.7µs 1,441.9µs

pyvm beats every runtime except native and goja at moderate concurrency. At M=1000 pyvm degrades sharply (19,372 ops/sec, p99 1.4s) — the 4-slot pool serializes 1000 contenders. Bumping BASE_PYVM_POOL_SIZE reclaims some of this but at memory cost.

Cold pool startup, N=100 modules:

Runtime load_total load/mod first_invoke steady_avg
native 1.9 ms 19µs 16µs 1.3µs
goja 8.1 ms 81µs 55µs 4.8µs
v8go 4.9 ms 49µs 400µs 39µs
wazero 46.1 ms 461µs 123µs 18µs
pyvm 1,425.6 ms 14.3ms 31µs 17.8µs

pyvm cold start is 750x slower than native, 18x slower than goja. Once warm, steady-state is competitive (17.8µs vs wazero 18.1µs).

Free-threading vs sub-interpreter parallelism (measured 2026-05-18)

Both Python builds were measured against the same direct-cgo-bridge pyvm on the same Apple M1 Max:

  • Python 3.13.5 default-GIL (homebrew [email protected], PKG_CONFIG_PATH=/opt/homebrew/opt/[email protected]/lib/pkgconfig).
  • Python 3.13.5 free-threaded (built locally via hanzoai/cpy3/scripts/build-python313t.sh, PKG_CONFIG_PATH=$PWD/python313t-build/lib/pkgconfig, DYLD_LIBRARY_PATH=$PWD/python313t-build/lib).

pyvm.GilDisabled() correctly reports false for the default-GIL build and true for the free-threaded build after the first Load().

Best-of-three medians, benchtime=2s -count=3, default pool size (BASE_PYVM_POOL_SIZE=4):

Build Serial ns/op Parallel ns/op
3.13 default-GIL 4,073 5,733
3.13t free-threaded 4,478 5,805

Finding: free-threading is a non-event for pyvm's embedding workload.

  • Serial is ~10% slower on 3.13t (4,478 vs 4,073 ns/op) — within thermal noise on this host. The PEP 703 serial-perf hit (biased reference counting + disabled adaptive specialization) is hidden by the cgo + JSON marshaling overhead that dominates per-Invoke cost.
  • Parallel is statistically indistinguishable (5,805 vs 5,733 ns/op). PEP 684 OWN_GIL sub-interpreters already deliver per-OS-thread parallelism on default-GIL; PEP 703 doesn't add headroom unless you abandon the sub-interp pool and instead run many goroutines against ONE interpreter — which we don't.

Recommendation: stay on default-GIL CPython 3.13 in production. Tune BASE_PYVM_POOL_SIZE to match expected concurrency. Revisit PEP 703 when:

  1. CPython 3.14+ ships free-threading as the default (PEP 779 roadmap) and the serial-perf gap closes.
  2. Or the embedding contract changes to one-interp many-threads (e.g. for very long-held GIL Python compute). For that workload a native binding usually wins anyway.

Honest: despite the press, free-threading does not change pyvm's performance profile in 2026 for the JSON-pipe embedding contract. The sub-interpreter pool we already ship is the right primitive.

Did the bench crash?

BenchmarkPyvm_Parallel crashed on first attempt with _PyThreadState_Attach: non-NULL old thread state (cgo defer-order bug — sub-interpreter release() ran before pyvm_leave() because of LIFO defer ordering). Fixed by replacing defers with explicit ordering: pyvm_enter → invoke → pyvm_leave → release → UnlockOSThread, all in straight line. Re-ran clean.

No other crash modes observed across 90+ seconds of bench load. Bench did NOT segfault under any concurrency level up to M=10000, N=100 modules.

Verdict for HIP-0105

  • pyvm is shippable for single-tenant production. Throughput beats every wasm/V8 runtime, only loses to pure-Go native.
  • pyvm is NOT shippable for multi-tenant production. A C extension segfault (or os._exit, or ctypes misuse) kills the host process. HIP-0105 should mark pyvm as crash-isolation: none alongside this label.
  • pyvm has the worst cold-start in the field. 14 ms/module load means production should always warm-load — never lazy-load pyvm modules on first request.
  • pyvm has the highest per-module memory cost (4.6 MB asymptotic, 18 MB under load with default pool). Density-oriented deploys should pick goja or wazero.
  • pyvm cannot hard-abort a running invocation. Capabilities() .SupportsAbort = false. Hooks that may run long must self-deadline in Python code. Honest — see plugins/pyvm/README.md.
  • pyvm gives Python's ecosystem at native-Python speed. Numpy / pandas / transformers all work — that's the entire point. Wazero + py2wasm gives you Python syntax but not the C-extension ecosystem.

Recommendation: pyvm = experimental-by-default in HIP-0105, production-recommended only for single-tenant runtime: pyvm deployments after the build pipeline has Python 3.13 dev headers baked into the runner image. Add a crash-isolation: none and hard-abort: none label so manifests can be policy-rejected by a multi-tenant operator.

Reproduce

cd ~/work/hanzo/base
PKG_CONFIG_PATH=/opt/homebrew/opt/[email protected]/lib/pkgconfig \
  go test -tags 'pyvm v8vm' -run '^$' -bench . \
    -benchmem -benchtime=2s ./plugins/extbench/
PKG_CONFIG_PATH=/opt/homebrew/opt/[email protected]/lib/pkgconfig \
  go test -tags 'pyvm v8vm' -run '^$' -bench '^BenchmarkScale' \
    -benchtime=1x -timeout 30m ./plugins/extbench/ 2>&1 \
  | grep ^SCALE

Without -tags pyvm, pyvm benches skip with a clear message and the binary doesn't link libpython.

Full Cross-Runtime Comparison (2026-05-19)

Adds two new fixtures alongside the original 5 + pyvm:

  • wazero-rust — pure-Rust validate compiled to wasm32-unknown-unknown (no WASI, no zip-rs). Implements HIP-0105 pointer/length ABI directly. Compare directly to wazero-as (same runtime, same ABI, different source language).
  • starkvm — go.starlark.net Starlark interpreter. Python-syntax DSL, pure-Go, no cgo, no JIT. Compare to goja and pyvm.

Two further fixtures landed as documented deferrals:

  • wazero-cpython-wasi — CPython 3.13 → wasm32-wasi. Emits a WASI command module (_start reads stdin / writes stdout); doesn't match our validate(ptr,len) -> i64 ABI. Resolving requires either a ~30MB pre-built python.wasm + a stdio→ptr/len shim, or building CPython from source against the WASI SDK and forking _start. Out of 60-min budget.
  • wazero-rustpython — RustPython VM compiled to wasm32-wasi. Attempted in-process Rust embedding (rustpython-vm 0.5.0 + freeze-stdlib + rustpython-compiler features). Build failed in define_exception_fn! macro on wasm32-wasip1 (11 type errors); resolution needs a vendored crate fork or upstream 0.6.x. Documented + deferred.

Both deferred fixtures keep extension.json + README.md + validate.py on disk so the bench harness can be re-run when an artifact lands; the wasmvm runtime reports module not built and the bench Skip()s with a clear message.

Throughput (Apple M1 Max, benchtime=2s, -tags 'v8vm pyvm')

The numbers in this section come from plugins/extbench/bench-results-all.txt captured 2026-05-19. Run-to-run variance on the same machine is ~10% for serial paths and up to ~50% for parallel paths (P-core/E-core scheduling and v8go cgo cost are the dominant noise sources).

Runtime Language Sandbox cgo Serial ns/op Parallel ns/op B/op allocs/op Cold-start ns/op
native-go Go none (in-process) no 2 006 3 083 1 448 20 33 688
pyvm CPython 3.13 (cgo) none (cgo) YES 6 873 18 891 496 10 34 308 027
goja JavaScript (pure-Go) soft no 24 572 10 975 3 334 67 135 810
starkvm Starlark (pure-Go) soft no 20 981 17 599 3 585 72 111 034
wazero-rust Rust → wasm hard no 14 583 11 948 35 264 17 7 839 805
wazero-as AssemblyScript → wasm hard no 15 823 8 882 40 869 17 6 339 298
v8go JavaScript (V8 cgo) hard YES 35 677 20 878 672 17 2 109 933
wazero-cpython-wasi CPython → wasm hard no — — — — deferred: ABI shim
wazero-rustpython RustPython → wasm hard no — — — — deferred: ABI shim

Notes:

  • B/op for the wazero fixtures is dominated by wasm linear-memory GC pressure on the host side; it's NOT the guest's memory footprint.
  • cold-start is one full runtime.NewRuntime()+Load+Close cycle. Wazero cold-start is dominated by wazero.CompileModule — once warm, subsequent loads in the same process reuse the JIT cache.
  • pyvm cold-start (34 ms) is the libpython init cost; goes to zero for subsequent module loads in the same process (interpreter shared across modules).
  • goja's "soft" sandbox: a malicious script can't escape the host but CAN exhaust memory or starve goroutines via while(true). wazero fixtures are "hard" — separate linear memory, instruction-level cancellation.

Per-invocation memory (TestMemory, 1000 iters)

Lower is better. bytes_per_invoke is total bytes allocated per call (includes host-side allocations for payload marshalling).

Runtime bytes/invoke num_gc/1000 calls
native-go 1 450 0
pyvm 496 0
goja 3 569 1
starkvm 3 697 1
v8go 676 0
wazero-rust 35 264 3
wazero-as 44 377 10

The cgo runtimes (pyvm, v8go) ironically have the lowest host-Go allocation because the work happens on the C heap which Go's GC doesn't see. They trade Go allocations for libc malloc; the bench doesn't measure that side. Wazero is the worst on Go-side allocations because each invocation grows-and-shrinks the wasm linear memory which the wazero runtime tracks in Go-owned buffers.

Cold-start (BenchmarkColdstart, benchtime=1s)

One full factory() + Load(dir) + Close() cycle.

Runtime ns/op B/op allocs/op Order of magnitude
native-go 33 688 1 424 17 30 µs
goja 135 810 47 402 569 140 µs
starkvm 111 034 36 764 599 110 µs
v8go 2 109 933 3 968 32 2 ms
wazero-as 6 339 298 1 542 328 3 002 6 ms
wazero-rust 7 839 805 10 655 758 4 304 8 ms
pyvm 34 308 027 4 056 29 34 ms

Goja and starkvm are the only pure-Go runtimes — both compile a small AST in ~100µs. Wazero pays for wasm compilation (6-8 ms). Pyvm pays once for the libpython init; subsequent module loads within the same process are ~10 µs.

Scale + crash characteristics (qualitative — see scale_test.go for measured numbers)

Runtime per-module heap max modules (typical) crash isolation hard-abort
native-go trivial unlimited none (in-process) yes (ctx)
goja ~5-10 KB 100 000+ soft (panic-safe) yes (interrupt)
starkvm ~10-30 KB 50 000+ soft (panic-safe) yes (thread.Cancel)
wazero-as ~600 KB 1 000-5 000 hard (linear mem) yes (module.Close)
wazero-rust ~700 KB 1 000-5 000 hard (linear mem) yes (module.Close)
v8go ~1-2 MB 500-2 000 hard (isolate) yes (interrupt)
pyvm ~4.6 MB 100-1 000 none (cgo segfault → host) no (cooperative only)

Hard-abort means the runtime can forcibly terminate an in-flight invocation when the host's ctx cancels. Pyvm's stop is cooperative (check PyErr_CheckSignals() from Python) — a C-extension running in a tight loop won't be interrupted.

Recommendation grid

Use case Best Second Avoid
Hot-path policy / validation (lowest-latency, trusted code) native-go goja wazero-*
User-supplied scripts, single-tenant, must run fast pyvm goja v8go
User-supplied scripts, multi-tenant, must isolate crashes wazero-rust wazero-as pyvm
Python-feel DSL without full Python ecosystem starkvm — pyvm (overkill)
JavaScript with V8 semantics (regex / latest ECMA) v8go goja (with caveats) wazero-*
Density-oriented: 10 000+ tenant modules per host goja starkvm pyvm, v8go, wazero
Cold-start-critical (lambda / ephemeral hook) goja starkvm wazero-*, pyvm
Languages outside the JS/Python family (Rust, Go, AssemblyScript) wazero-rust wazero-as other

Headline findings

  1. pyvm is the throughput king for serial workloads when it's warm. 6.9 µs serial / 18.9 µs parallel — only native-go beats it on serial. The 34 ms cold-start is the price (warm the sub-interpreter pool at process start).
  2. wazero-rust is competitive with wazero-as, not strictly faster. The Rust fixture has slightly lower memory (35 KB vs 44 KB host-side) and a more predictable instruction mix, but the dominant cost is wazero's host-guest boundary crossing, not the guest's bytecode interpreter. Pick by source language, not by hoping for free speed.
  3. starkvm is the surprise. Pure-Go, no cgo, no JIT — and it lands at 21 µs serial / 18 µs parallel, comparable to goja. The dialect-strict ones (Bazel-style) would be even faster (no recursion = no closure allocations), but the permissive dialect we ship runs full validate.py-like code at 99% of goja's throughput with stricter sandboxing.
  4. CPython-WASI and RustPython both hit the same ABI wall. Both emit WASI command modules (_start + stdin/stdout); our pointer ABI needs a wrapper. Shim for RustPython looked tractable in Rust-to-Rust embedding terms — but the v0.5.0 feature flag combo failed to compile on wasm32-wasip1. Documented + deferred; the right next step is a vendored fork of rustpython-vm with the broken cfg cleaned up, OR waiting on upstream 0.6.x.
  5. v8go is the slowest non-deferred runtime in this study. The cgo round-trip per Invoke is the bottleneck — V8's actual JIT is fast, but Context.Eval from Go pays ~10-20 µs in cgo marshalling alone. Goja (pure-Go JavaScript) beats v8go on throughput AND cold-start AND memory. Pick v8go ONLY when you need V8-specific ECMA features that goja doesn't implement.

Reproduce

cd ~/work/hanzo/base
./plugins/extbench/run.sh                                           # default tags
go test -tags v8vm -bench=. -benchmem -benchtime=2s ./plugins/extbench/
PKG_CONFIG_PATH=/opt/homebrew/opt/[email protected]/lib/pkgconfig \
  go test -tags 'v8vm pyvm' -bench=. -benchmem -benchtime=2s ./plugins/extbench/

Captured run lives at plugins/extbench/bench-results-all.txt.