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
57 changes: 57 additions & 0 deletions bench/boundary/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,44 @@ smaller chunks need more of them to reach a measurable duration, larger
chunks need fewer. **polyengine drivers only**: jco's p3 stream support is
not under test here, so the jco lane is skipped for these rows.

## Compound element shapes ([#261](https://github.com/polymorph-components/polyengine/issues/261))

| export | what it measures |
| --- | --- |
| `lift-ops: async func(n: u32) -> list<op>` (guest returns `n` elements) | compound-element LIFT |
| `lower-ops: async func(ops: list<op>) -> u64` (guest folds, returns a checksum) | compound-element LOWER |

`op` is a 16-case variant over records — the width and payload mix
mirror #261's reported 17-case consumer schema (a DOM-mutation op
stream, records carrying `string`, `option<u16>`, `list<u8>`, and a
nested payload-free variant `node-update-kind`), because two of the
costs #261 identifies — `maxCaseAlignment` (`runtime/src/cabi/layout.ts`)
and the embedder facade's variant case resolution
(`runtime/src/embedder/values.ts` `toHost`) — are both O(case count) per
element. Every other shape in this instrument — `list<u8>`, `u32`,
`stream<u8>` — is a flat scalar and takes a bulk copy path (issues
#63/#67); before this lane, NOTHING in `bench/boundary` exercised the
per-element interpreted lift/lower loop that compound types (records,
variants, options, strings) actually walk. Both directions are measured
separately because `load.ts`'s per-element path and `store.ts`'s
per-element path are separate code with the same defect.

Reported as ns/element (`iters` reused as the element count `n`; `size`
is unused, passed through as "n/a" like `mode` is for the stream shapes)
— the unit that makes these numbers comparable to a whole variant-over-
records lift/lower rather than a byte or a call. Medians of 5 timed runs
after a warmup, same convention as the other tables. Element count
10000, calibrated (`sweep.mjs`, `ELEMENT_N`) so a timed run lands in the
tens-of-ms range. **polyengine drivers only**, same reason as the stream
shapes: jco is not under test here.

Methodology footnote: `lift-ops`'s guest caches its `Vec<Op>` in a
`thread_local!` keyed on `n` — the warmup call builds it, every timed
call clones the cached vector, measured in isolation at ~15 ns/element
on this box (a temporary export cloned without crossing the boundary).
`lower-ops`'s host array is built ONCE outside the timed loop, since
that lane measures lowering, not host array construction.

## Baseline (2026-08-11, linux-arm64 dev box, Node 24.18 / Deno 2.9.5, guest wit-bindgen 0.60; post-#63/#67 bulk list copies)

```
Expand Down Expand Up @@ -88,6 +126,20 @@ stream-pass 16384 6,665.9 MB/s 5,847 MB/s 7,
stream-pass 262144 13,716.5 MB/s 14,229.4 MB/s 22,863.2 MB/s
```

### Compound element shapes baseline (2026-09-03, linux-arm64 dev box, Node 24.18 / Deno 2.9.5, guest wit-bindgen 0.60) — pre-#261 optimization

```
compound-element lanes (ns/element; n=10000; jco lane skipped — see README.md):
shape polyengine-node-callback polyengine-node-jspi polyengine-deno-callback
lift-ops 3,804.4 3,909.3 3,184.6
lower-ops 3,482.3 3,646.8 3,285.3
```

This is the "before" baseline for #261, recorded before any optimization
of the per-element interpreted path lands. ~3.2-3.9 µs/element here vs.
#261's reported ~5 µs/element on a similar box — same order of
magnitude, within ~1.6x; the residual gap reads as box/config drift.

Two methodology footnotes for the stream rows:

- `stream-source` allocates and fills its whole payload inside the guest
Expand Down Expand Up @@ -127,6 +179,11 @@ What the baseline says:
guest's linear memory) at every chunk size, confirming the identity
transfer is doing what it claims. All three scale up sharply with
chunk size — per-rendezvous overhead amortizes over more bytes.
- **#261 compound elements**: the first instrument for the interpreted
per-element lift/lower path — every prior shape here is flat and
bulk-copies. `lift-ops`/`lower-ops` land at ~3.2-3.9 µs/element
pre-optimization; same sentinel role #54/#67 played for flat types —
these rows are what an optimization to the compound path should move.

The jco lane pins the family's own toolchain (the lann/jco all-fixes
transpile + preview2-shim release tarballs, the vendored
Expand Down
150 changes: 145 additions & 5 deletions bench/boundary/driver-polyengine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
// 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 | stream-sink | stream-source | stream-pass; mode:
// immediate | microtask (see host.mjs; ignored by the stream-* shapes,
// which have no host import — the host drives the stream endpoint
// directly). jspi selects jspi-mode suspension (needs
// --experimental-wasm-jspi under node).
// | send-sync | stream-sink | stream-source | stream-pass | lift-ops |
// lower-ops; mode: immediate | microtask (see host.mjs; ignored by the
// stream-*/*-ops shapes, which have no host import — the host drives the
// stream endpoint or the ops array directly). jspi selects jspi-mode
// suspension (needs --experimental-wasm-jspi under node).
//
// For the calls-per-second shapes (send/recv/send-sync), `iters` is the
// number of boundary crossings and `size` the payload size; emits
Expand All @@ -21,6 +21,15 @@
// `size` as the CHUNK SIZE (bytes) — total bytes moved per timed run is
// `iters * size`; emits { lane, shape, mode: "n/a", size, iters,
// totalBytes, medianMs, mbPerSec, kind: "stream" }.
//
// For the compound-element shapes (issue #261: lift-ops/lower-ops),
// `iters` is reused as the ELEMENT COUNT and `size` is unused (passed
// through as "n/a", same convention as mode for the stream shapes);
// emits { lane, shape, mode: "n/a", size: n, iters: n, medianMs,
// nsPerElement, kind: "element" }. ns/element (not calls/s or MB/s) is
// the unit that makes these numbers comparable to #261's reported
// figure — each element is a whole variant-over-records lift/lower, not
// a byte or a call.
import { makeHost } from "./host.mjs";

const isDeno = typeof Deno !== "undefined";
Expand Down Expand Up @@ -126,6 +135,137 @@ if (shape.startsWith("stream-")) {
mbPerSec: (totalBytes / (1024 * 1024)) / (median / 1000),
kind: "stream",
}));
} else if (shape === "lift-ops" || shape === "lower-ops") {
// Compound-element lanes (issue #261): `iters` is reused as the
// element count `n`; `size` is unused.
const n = iters;

// Host-side generator mirroring the guest's `build_ops` exactly (same
// 16-case cycle, same string/list shapes, same option-branch split —
// see wit/bench.wit for why the type is this wide/shaped) so both
// directions exercise the same distribution of cases/branches. The
// embedder facade's variant shape is `{ kind, value? }`
// (runtime/src/embedder/values.ts) and a record's `option<T>` field is
// an optional property, not a boxed `{some}`/`{none}` — see the
// `toHost` record case there. Record field labels are camelCased
// (`new-parent` -> `newParent`); a nested variant field (`updateKind`)
// is itself a `{ kind }` value, payload-free cases included.
function optionU16(i) {
return i % 3 === 0 ? undefined : i % 0xffff;
}
function smallBytes(i) {
const len = 2 + (i % 4);
return Uint8Array.from({ length: len }, (_, j) => (i + j) % 256);
}
function makeOps(count) {
const ops = [];
for (let i = 0; i < count; i++) {
switch (i % 16) {
case 0: {
const rec = { id: i, tag: `div${i}` };
const p = optionU16(i);
if (p !== undefined) rec.parent = p;
ops.push({ kind: "insert-element", value: rec });
break;
}
case 1:
ops.push({ kind: "remove-element", value: i });
break;
case 2:
ops.push({ kind: "set-attribute", value: { id: i, key: "class", value: `c${i}` } });
break;
case 3:
ops.push({ kind: "remove-attribute", value: { id: i, key: "data-x" } });
break;
case 4:
ops.push({ kind: "set-text", value: { id: i, text: `text${i}` } });
break;
case 5: {
const rec = { id: i, text: `t${i}` };
const p = optionU16(i);
if (p !== undefined) rec.parent = p;
ops.push({ kind: "insert-text", value: rec });
break;
}
case 6: {
const rec = { id: i, index: i % 64 };
const p = optionU16(i);
if (p !== undefined) rec.newParent = p;
ops.push({ kind: "move-node", value: rec });
break;
}
case 7:
ops.push({ kind: "clear-children", value: i });
break;
case 8:
ops.push({ kind: "set-class-list", value: { id: i, classes: smallBytes(i) } });
break;
case 9:
ops.push({ kind: "set-style", value: { id: i, style: smallBytes(i) } });
break;
case 10:
ops.push({ kind: "add-event-listener", value: { id: i, event: "click" } });
break;
case 11:
ops.push({ kind: "remove-event-listener", value: { id: i, event: "click" } });
break;
case 12:
ops.push({ kind: "focus", value: i });
break;
case 13:
ops.push({ kind: "blur" });
break;
case 14:
ops.push({ kind: "scroll-into-view" });
break;
default: {
const kinds = ["inserted", "updated", "removed", "moved"];
ops.push({
kind: "checkpoint",
value: { id: i, updateKind: { kind: kinds[i % 4] } },
});
break;
}
}
}
return ops;
}

async function runLiftOps() {
const ops = await inst.exports.liftOps(n);
if (ops.length !== n) throw new Error(`lift-ops: got ${ops.length} elements, expected ${n}`);
}

// Built ONCE outside the timed region: this lane measures LOWERING,
// not host array construction (the guest-construction footnote below
// applies only to lift-ops, where the guest builds its Vec inside the
// timed region — see README.md).
const hostOps = shape === "lower-ops" ? makeOps(n) : null;
let lastChecksum = null;
async function runLowerOps() {
const checksum = await inst.exports.lowerOps(hostOps);
if (lastChecksum === null) lastChecksum = checksum;
else if (checksum !== lastChecksum) {
throw new Error(`lower-ops: checksum ${checksum} != ${lastChecksum} across reps`);
}
}

const run = shape === "lift-ops" ? runLiftOps : runLowerOps;
await run(); // warmup
const times = [];
for (let r = 0; r < reps; r++) {
const t0 = performance.now();
await run();
times.push(performance.now() - t0);
}
times.sort((a, b) => a - b);
const median = times[Math.floor(times.length / 2)];
console.log(JSON.stringify({
lane, shape, mode: "n/a", size: n, iters: n,
medianMs: median,
nsPerElement: (median * 1e6) / n,
kind: "element",
}));
} else {
const fn = { send: inst.exports.send, recv: inst.exports.recv, "send-sync": inst.exports.sendSync }[shape];

Expand Down
Loading
Loading