Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ dist/
*.tmp
# ports/webcrypto conformance runner output (conformance/run.ts)
ports/webcrypto/conformance/results-*.jsonl

# bench/boundary: local artifacts (the recipe rebuilds them)
bench/boundary/deltic-embedder.local.mjs
bench/boundary/generated/
bench/boundary/node_modules/
bench/boundary/guest/target/
80 changes: 80 additions & 0 deletions bench/boundary/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# bench/boundary — the host-boundary microbench

Calls-per-second across the host import boundary, per ABI shape — the
instrument behind [#17](https://github.com/lann/deltic/issues/17)'s
jco-vs-deltic baseline, [#54](https://github.com/lann/deltic/issues/54)'s
lift-throughput finding, and [#8](https://github.com/lann/deltic/issues/8)'s
cost ledger. The design goal is attribution, not realism: the guest is a
tight loop over echo-shaped imports whose host bodies are trivial, so
what the clock sees is lift/lower + dispatch + (for async shapes) the
suspension machinery — and both stacks run on **the same engine** (plain
`node` runs the deltic callback ABI with no flag; the jco lane and
deltic's jspi mode share `--experimental-wasm-jspi`), so V8/GC/JIT
variables cancel.

```sh
just bench-boundary # deltic lanes: node callback+jspi, deno
just bench-boundary with-jco # + the incumbent jco lane (npm ci + transpile on first use)
```

The deltic lanes measure the CURRENT TREE: the recipe builds the local
embedder bundle (`tools/release-bundle/build.ts`) and the local
translator shim. Numbers are box-relative — compare lanes within one
run, or the same lane across commits on one box, never absolute values
across machines.

## Shapes

| export | import it loops | boundary shape |
| --- | --- | --- |
| `send` | `ping: async func(list<u8>) -> u32` | UDP-send-shaped: payload guest→host |
| `recv` | `fetch: async func(u32) -> list<u8>` | UDP-receive-shaped: payload host→guest |
| `send-sync` | `ping-sync: func(list<u8>) -> u32` | the sync-lowered control |

Host settlement `mode`: `immediate` (a plain return value — the fast
path) and `microtask` (an async host fn, i.e. an already-resolved
promise — the wakeup-shaped path a real receive takes). Payload sizes 0
(pure call overhead) and 1200 B (QUIC-ish MTU). Medians of 5 timed
export calls after a warmup call; each export call runs `iters`
boundary crossings.

## Baseline (2026-08-10, linux-arm64 dev box, Node 24.18 / Deno 2.9.5, guest wit-bindgen 0.60)

```
shape mode size deltic-node-callback deltic-node-jspi deltic-deno-callback jco-node-jspi
send immediate 0 1,079,807/s 1,016,348/s 1,168,392/s 336/s
send immediate 1200 680,210/s 637,144/s 722,588/s 336/s
send microtask 0 541,613/s 250,349/s 526,868/s 338/s
send microtask 1200 396,974/s 223,360/s 399,989/s 339/s
recv immediate 0 1,182,824/s 1,111,585/s 1,381,300/s 345/s
recv immediate 1200 18,546/s 18,213/s 21,706/s 375/s
recv microtask 0 550,893/s 300,230/s 604,611/s 338/s
recv microtask 1200 18,160/s 17,364/s 20,366/s 490/s
send-sync immediate 0 1,604,379/s 1,057,590/s 1,440,209/s 338,332/s
send-sync immediate 1200 759,075/s 591,627/s 922,990/s 300,576/s
send-sync microtask 0 1,545,879/s 1,234,868/s 1,359,083/s 337,477/s
send-sync microtask 1200 629,432/s 623,742/s 826,725/s 310,692/s
```

What the baseline says:

- **Async import round-trips**: deltic's callback ABI sustains 0.4–1.2 M
crossings/s; jco's async path costs ~3 ms per call flat
(timer-quantized — its sync path is healthy at ~300 k/s, so the cost
is the async task loop, the same machinery behind lann/jco#11 and
polymorph-iroh's 5 ms polling workaround). For the UDP direct path
(#4) this is the difference between "boundary is free" and "boundary
is the bottleneck".
- **#54**: host→guest `list<u8>` lift runs ~45 ns/byte (~22 MB/s
ceiling, flat across sizes — a per-element copy), while guest→host
lower is bulk (~6.4 GB/s at 16 KiB). The `recv @ 1200` rows are the
regression sentinel for the fix.
- **jspi vs callback** (same runtime, same engine): parity on
immediate-settled paths, ~2× behind on deferred (microtask) paths —
the suspend/resume cost, recorded for #8.

The jco lane pins the family's own toolchain (the lann/jco all-fixes
transpile + preview2-shim release tarballs, the vendored
`jco-transpile.mjs` wrapper, and polymorph-test's `bindImports` for the
WASI spellings — the exact stack the consumer repos' jco legs run). It
exists as the incumbent baseline and retires with the jco era.
54 changes: 54 additions & 0 deletions bench/boundary/driver-deltic.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// The deltic lane of the boundary microbench: runs under BOTH plain
// `node` (callback ABI, no engine flag) and `deno run -A`.
//
// node driver-deltic.mjs <bundle.mjs> <translator.wasm> <shape> <mode> \
// <iters> <size> [reps] [jspi]
//
// The bundle is the embedder surface — the LOCAL tree's
// (tools/release-bundle/build.ts output) for tracking this repo, or a
// pinned release asset for cross-version comparison. shape: send | recv
// | send-sync; mode: immediate | microtask (see host.mjs); jspi selects
// jspi-mode suspension (needs --experimental-wasm-jspi under node).
// Emits one JSON line: { lane, shape, mode, size, iters, medianMs,
// callsPerSec }.
import { makeHost } from "./host.mjs";

const isDeno = typeof Deno !== "undefined";
const readFile = isDeno
? (p) => Deno.readFile(p)
: async (p) => new Uint8Array(await (await import("node:fs/promises")).readFile(p));
const argv = isDeno ? Deno.args : process.argv.slice(2);
const [bundlePath, translatorPath, shape, mode, itersS, sizeS, repsS, jspiFlag] = argv;
const iters = Number(itersS), size = Number(sizeS), reps = Number(repsS ?? 5);

const cwd = isDeno ? Deno.cwd() : process.cwd();
const deltic = await import(new URL(bundlePath, `file://${cwd}/`).href);
const translator = await deltic.Translator.create(await readFile(translatorPath));
const componentBytes = await readFile(
new URL("./guest/target/wasm32-wasip2/release/boundary_bench_guest.wasm", import.meta.url).pathname,
);
const { plan, adapters } = translator.translate(componentBytes);

const imports = {
...deltic.wasiShims(),
"bench:boundary/host@0.1.0": makeHost(mode),
};
const inst = await deltic.instantiate(
{ plan, componentBytes, adapters },
imports,
jspiFlag === "jspi" ? { jspi: true } : { jspi: false },
);
const fn = { send: inst.exports.send, recv: inst.exports.recv, "send-sync": inst.exports.sendSync }[shape];

await fn(Math.min(iters, 1000), size); // warmup
const times = [];
for (let r = 0; r < reps; r++) {
const t0 = performance.now();
await fn(iters, size);
times.push(performance.now() - t0);
}
times.sort((a, b) => a - b);
const median = times[Math.floor(times.length / 2)];
const engine = isDeno ? "deno" : "node";
const lane = `deltic-${engine}-${jspiFlag === "jspi" ? "jspi" : "callback"}`;
console.log(JSON.stringify({ lane, shape, mode, size, iters, medianMs: median, callsPerSec: Math.round(iters / (median / 1000)) }));
39 changes: 39 additions & 0 deletions bench/boundary/driver-jco.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// The jco lane of the boundary microbench (the incumbent baseline; this
// lane retires with the jco era). Needs the npm tree (`npm ci` here) and
// a transpile (`node jco-transpile.mjs transpile ...` — the recipe does
// both):
//
// node --experimental-wasm-jspi driver-jco.mjs <shape> <mode> <iters> <size> [reps]
//
// Emits the same JSON line shape as driver-deltic.mjs.
import { readFile } from "node:fs/promises";
import { instantiate } from "./generated/bench.js";
import { bindImports } from "@polymorph/component-test-js/imports";
import { cli, clocks, io, random, filesystem } from "@bytecodealliance/preview2-shim";
import { makeHost } from "./host.mjs";

const [shape, mode, itersS, sizeS, repsS] = process.argv.slice(2);
const iters = Number(itersS), size = Number(sizeS), reps = Number(repsS ?? 5);

const modules = new Map();
for (const name of ["bench.core.wasm", "bench.core2.wasm", "bench.core3.wasm"]) {
modules.set(name, await WebAssembly.compile(await readFile(new URL(`./generated/${name}`, import.meta.url))));
}
const imports = bindImports({
wasi: { cli, clocks, io, random, filesystem },
env: [],
sut: { "bench:boundary/host": makeHost(mode) },
});
const inst = await instantiate((n) => modules.get(n), imports);
const fn = { send: inst.send, recv: inst.recv, "send-sync": inst.sendSync }[shape];

await fn(Math.min(iters, 1000), size); // warmup
const times = [];
for (let r = 0; r < reps; r++) {
const t0 = performance.now();
await fn(iters, size);
times.push(performance.now() - t0);
}
times.sort((a, b) => a - b);
const median = times[Math.floor(times.length / 2)];
console.log(JSON.stringify({ lane: "jco-node-jspi", shape, mode, size, iters, medianMs: median, callsPerSec: Math.round(iters / (median / 1000)) }));
Loading
Loading