Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -606,9 +606,9 @@ How `json-as` stacks up against other JSON libraries on a ~5 KiB GitHub-repo pay

### Runtime Comparison

How fast the **same** `json-as` classic bench deserializes the minified payloads across six WebAssembly runtimes — including [WARP](https://github.com/wasm-ecosystem/wasm-compiler), a single-pass compiler built for embedded targets, alongside the optimizing JITs (Wasmtime, WAVM), the pure-Go wazero, and JS engines (V8, Bun).
How fast the **same** `json-as` classic bench deserializes the minified payloads across six WebAssembly runtimes — including [WAGO](https://github.com/wago-org/wago), a pure-Go, no-cgo single-pass JIT, alongside the optimizing JITs (Wasmtime, WAVM), the pure-Go wazero, and JS engines (V8, Bun).

Each runtime runs the real `bench()` lib (warm up, time the loop, report MB/s itself) on a `NAIVE`-mode build with a shared feature set (no SIMD / bulk-memory / non-trapping float-to-int) so the executed code is equivalent. The WASI runtimes read the payload over WASI; V8/Bun via an `env`-ABI host. WARP has no WASI and — by design ("no recursions") — can't re-enter the module from a host import, so it runs through a small custom C++ host that links `performance.now`/`console.log`/`writeFile` with the payload embedded. The timed run is split into small frames with a full GC between them (the bench lib's `BENCH_FRAMES`, applied to every runtime so the measurement is identical); this excludes stop-the-world GC pauses and keeps WARP — an embedded, single-shot-oriented compiler — inside its stable envelope. Even so, WARP's single-pass codegen lands within a few percent of the optimizing JITs.
Each runtime runs the real `bench()` lib (warm up, time the loop, report MB/s itself) on a `NAIVE`-mode build with a shared feature set (no SIMD / bulk-memory / non-trapping float-to-int) so the executed code is equivalent. The WASI runtimes read the payload over WASI; V8/Bun use an `env`-ABI host. WAGO runs through a small Go host built against its public API with the documented `wago_guardpage` mode; the payload is embedded so the comparison does not require a filesystem plugin, while `performance.now`/`console.log`/`writeFile` still keep measurement and result reporting inside the real guest benchmark.

<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/runtimes-deserialize.svg" alt="Deserialization throughput across WebAssembly runtimes">

Expand All @@ -618,10 +618,10 @@ Each runtime runs the real `bench()` lib (warm up, time the loop, report MB/s it
<img src="https://raw.githubusercontent.com/JairusSW/json-as/refs/heads/docs/charts/v1.5.0/runtimes-serialize.svg" alt="Serialization throughput across WebAssembly runtimes">
</details>

Reproduce locally (the JS engines and standalone runtimes are auto-detected; point `WARP_SRC` at a [wasm-ecosystem/wasm-compiler](https://github.com/wasm-ecosystem/wasm-compiler) checkout with its libs built — see the script header for the cmake flags):
Reproduce locally (the JS engines and standalone runtimes are auto-detected; point `WAGO_SRC` at a [wago-org/wago](https://github.com/wago-org/wago) checkout so the small benchmark host can be built):

```bash
WARP_SRC=/path/to/wasm-compiler npm run bench:runtimes
WAGO_SRC=/path/to/wago npm run bench:runtimes
npm run charts:runtimes
```

Expand Down
8 changes: 4 additions & 4 deletions assembly/__benches__/lib/bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,10 @@ export function bench(
// Optional framed measurement: split the timed run into BENCH_FRAMES frames
// and run a full __collect() (untimed) between them. Each frame's allocation
// churn stays bounded and the heap is reset between frames, which keeps
// runtimes that destabilize under one long single-shot allocation loop (e.g.
// WARP) inside their safe envelope while still timing the same total ops. The
// between-frame GC pauses are excluded from `elapsed`; the incremental GC that
// runs *within* each frame is still timed, exactly as in the unframed loop.
// memory-constrained or single-shot runtimes inside their safe envelope while
// still timing the same total ops. The between-frame GC pauses are excluded
// from `elapsed`; the incremental GC that runs *within* each frame is still
// timed, exactly as in the unframed loop.
// Defaults to a single frame (identical to the original behavior).
// @ts-expect-error: BENCH_FRAMES may be undefined.
const frames: u64 = isDefined(BENCH_FRAMES) ? u64(BENCH_FRAMES) : 1;
Expand Down
116 changes: 116 additions & 0 deletions bench/runners/wago_host.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Command wago_host runs a generated json-as runtime benchmark on WAGO.
//
// The benchmark keeps timing inside WebAssembly. This host only supplies the
// small env ABI used by the AssemblyScript bench library and forwards result
// records to stdout for scripts/run-bench.runtimes.sh.
package main

import (
"encoding/binary"
"fmt"
"os"
"time"
"unicode/utf16"

wago "github.com/wago-org/wago"
)

var epoch = time.Now()

func liftString(m wago.HostModule, ptr uint32) string {
mem := m.Memory()
if ptr == 0 || ptr < 4 || uint64(ptr) > uint64(len(mem)) {
return ""
}

byteLen := binary.LittleEndian.Uint32(mem[ptr-4 : ptr])
end := uint64(ptr) + uint64(byteLen)
if byteLen%2 != 0 || end > uint64(len(mem)) {
return ""
}

units := make([]uint16, byteLen/2)
for i := range units {
off := int(ptr) + i*2
units[i] = binary.LittleEndian.Uint16(mem[off : off+2])
}
return string(utf16.Decode(units))
}

func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "wago_host: "+format+"\n", args...)
os.Exit(1)
}

func trace(message string) {
if os.Getenv("WAGO_HOST_TRACE") != "" {
fmt.Fprintln(os.Stderr, "wago_host:", message)
}
}

func main() {
if len(os.Args) != 2 {
fail("usage: wago_host <module.wasm>")
}

wasm, err := os.ReadFile(os.Args[1])
if err != nil {
fail("read module: %v", err)
}
if !wago.GuardPageSupported() {
fail("guard-page bounds are unavailable; build with -tags wago_guardpage")
}

trace("compiling module")
config := wago.NewRuntimeConfig().WithBoundsChecks(wago.BoundsChecksSignalsBased)
compiled, err := wago.Compile(config, wasm)
if err != nil {
fail("compile module: %v", err)
}
defer compiled.Close()
trace("module compiled")

imports := wago.Imports{
"env.performance.now": wago.HostFunc(func(_ wago.HostModule, _ []uint64, results []uint64) {
results[0] = wago.F64(float64(time.Since(epoch).Nanoseconds()) / 1e6)
}),
"env.Date.now": wago.HostFunc(func(_ wago.HostModule, _ []uint64, results []uint64) {
results[0] = wago.F64(float64(time.Now().UnixNano()) / 1e6)
}),
"env.console.log": wago.HostFunc(func(m wago.HostModule, params, _ []uint64) {
fmt.Println(liftString(m, uint32(params[0])))
}),
"env.writeFile": wago.HostFunc(func(m wago.HostModule, params, _ []uint64) {
name := liftString(m, uint32(params[0]))
data := liftString(m, uint32(params[1]))
fmt.Printf("__AS_BENCH_JSON__%s\t%s\n", name, data)
}),
"env.abort": wago.HostFunc(func(m wago.HostModule, params, _ []uint64) {
fmt.Fprintf(
os.Stderr,
"abort: %s in %s:%d:%d\n",
liftString(m, uint32(params[0])),
liftString(m, uint32(params[1])),
uint32(params[2]),
uint32(params[3]),
)
panic(wago.HostExit{Code: 1})
}),
}

trace("instantiating module")
instance, err := wago.Instantiate(compiled, wago.InstantiateOptions{Imports: imports})
if err != nil {
fail("instantiate module: %v", err)
}
defer instance.Close()
trace("module instantiated")

// The module is built with --exportStart so initialization and the full
// benchmark run through WAGO's normal exported-function invocation path.
trace("running benchmark")
if _, err := instance.Invoke("start"); err != nil {
fail("run benchmark: %v", err)
}
trace("benchmark finished")
}
143 changes: 0 additions & 143 deletions bench/runners/warp_host.cpp

This file was deleted.

10 changes: 5 additions & 5 deletions scripts/build-chart-runtimes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Cross-runtime throughput chart for the classic payloads. Compares how fast the
// SAME json-as NAIVE-mode bench deserializes the minified payloads under six
// WebAssembly runtimes (WARP / wasmtime / wavm / wazero / v8 / bun). Every bar is
// WebAssembly runtimes (WAGO / wasmtime / wavm / wazero / v8 / bun). Every bar is
// the runtime's own bench()-reported MB/s - see scripts/run-bench.runtimes.sh.
//
// Populate the logs first:
// WARP_SRC=/path/to/wasm-compiler bash scripts/run-bench.runtimes.sh
// WAGO_SRC=/path/to/wago bash scripts/run-bench.runtimes.sh
// Then:
// bun scripts/build-chart-runtimes.ts
import fs from "node:fs";
Expand All @@ -16,11 +16,11 @@ import {
} from "./lib/bench-utils";
import { rgba, BASE } from "./lib/palette";

// One distinct hue per runtime; WARP (the subject) gets the hero blue.
// One distinct hue per runtime; WAGO (the subject) gets the hero blue.
const RUNTIMES: { key: string; label: string; bg: string; border: string }[] = [
{
key: "warp",
label: "WARP",
key: "wago",
label: "WAGO",
bg: rgba("pacificBlue", 0.9),
border: BASE.pacificBlue,
},
Expand Down
4 changes: 2 additions & 2 deletions scripts/build-charts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ JSON_CHART_RUNTIME="$CHART_RUNTIME" bun ./scripts/build-library-deserialize.ts
bun ./scripts/build-lazy.ts
# Classic-dataset mode comparison (NAIVE/SWAR/SIMD + lazy, no JS baseline)
bun ./scripts/build-chart-classic.ts
# Cross-runtime comparison (WARP/wasmtime/wasmer/wavm/v8/bun). Opt-in: only built
# Cross-runtime comparison (WAGO/wasmtime/wavm/wazero/v8/bun). Opt-in: only built
# when scripts/run-bench.runtimes.sh has produced logs (it needs external
# runtimes + a WARP vb_bench build), so the default chart build never fails on it.
# runtimes), so the default chart build never fails on it.
if compgen -G "./build/logs/runtimes/*/*.deserialize.json" >/dev/null 2>&1; then
bun ./scripts/build-chart-runtimes.ts
fi
Expand Down
42 changes: 42 additions & 0 deletions scripts/gen-wago-bench.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Generates the WAGO variant of a classic bench. The WAGO host intentionally
// exposes only timing, logging, result writing, and abort imports, so payloads
// are embedded instead of requiring a filesystem plugin. The generated module
// still uses the real bench()/dumpToFile calls and measures the same JSON work.
//
// Usage: node scripts/gen-wago-bench.mjs <name> <out.ts>
import fs from "node:fs";
import path from "node:path";

const [, , name, outPath] = process.argv;
if (!outPath) {
console.error("usage: gen-wago-bench.mjs <name> <out.ts>");
process.exit(1);
}

let src = fs.readFileSync(
path.resolve(`assembly/__benches__/classic/${name}.bench.ts`),
"utf8",
);

// Drop test-only assertions, which are not part of the measured workload.
src = src
.split("\n")
.filter((line) => !/from\s+["'].*__tests__\/lib["']/.test(line))
.filter((line) => !/^\s*expect\(/.test(line))
.join("\n");

// Replace every readFile("<path>") call with the file's escaped contents.
src = src.replace(/readFile\(\s*"([^"]+)"\s*,?\s*\)/g, (_match, filePath) =>
JSON.stringify(fs.readFileSync(path.resolve(filePath), "utf8")),
);

// The other bindings from the bench helper import remain in use.
src = src.replace(/^\s*readFile,\s*$/m, "");

const header = `// AUTO-GENERATED by scripts/gen-wago-bench.mjs - do not edit.
// WAGO build of the "${name}" classic bench: identical schema and bench calls,
// with its payload embedded. Results are emitted through the WAGO host.
`;

fs.writeFileSync(path.resolve(outPath), header + src);
console.log(`> ${outPath}`);
Loading