diff --git a/bench/boundary/README.md b/bench/boundary/README.md index a870714..4522291 100644 --- a/bench/boundary/README.md +++ b/bench/boundary/README.md @@ -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` (guest returns `n` elements) | compound-element LIFT | +| `lower-ops: async func(ops: list) -> 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`, `list`, 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`, `u32`, +`stream` — 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` 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) ``` @@ -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 @@ -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 diff --git a/bench/boundary/driver-polyengine.mjs b/bench/boundary/driver-polyengine.mjs index d7c3438..e370876 100644 --- a/bench/boundary/driver-polyengine.mjs +++ b/bench/boundary/driver-polyengine.mjs @@ -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 @@ -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"; @@ -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` 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]; diff --git a/bench/boundary/guest/src/lib.rs b/bench/boundary/guest/src/lib.rs index 3c6b8db..0137571 100644 --- a/bench/boundary/guest/src/lib.rs +++ b/bench/boundary/guest/src/lib.rs @@ -6,10 +6,20 @@ mod bindings { path: "wit", world: "bench", generate_all, + // Needed by the compound-element lanes' guest-side cache (issue + // #261): `lift_ops` clones a thread_local-cached `Vec` rather + // than rebuilding it every call (see `cached_ops` below). + additional_derives: [Clone], }); } use bindings::bench::boundary::host; +use bindings::{ + NodeUpdateKind, Op, OpAddEventListener, OpCheckpoint, OpInsertElement, OpInsertText, + OpMoveNode, OpRemoveAttribute, OpRemoveEventListener, OpSetAttribute, OpSetClassList, + OpSetStyle, OpSetText, +}; +use std::cell::RefCell; use wit_bindgen::rt::async_support::{spawn_local, StreamReader}; struct Component; @@ -63,6 +73,175 @@ impl bindings::Guest for Component { async fn stream_pass(s: StreamReader) -> StreamReader { s } + + // Compound-element lanes (issue #261): a 16-case variant-over-records + // element (`Op`, see wit/bench.wit for why this width/shape), the + // only non-flat payload in the instrument — every other shape above + // is a scalar (`list`, `u32`, `stream`) and takes a bulk copy + // path (issues #63/#67). These measure the per-element interpreted + // lift/lower loop instead. + + async fn lift_ops(n: u32) -> Vec { + // Guest-side construction is cached (`cached_ops`, keyed on `n`): + // the warmup call builds it, every timed call clones it. See the + // README footnote for the measured clone-residue cost this still + // leaves inside the timed region. + cached_ops(n) + } + + async fn lower_ops(ops: Vec) -> u64 { + let mut acc = 0u64; + for op in &ops { + acc = acc.wrapping_add(fold_op(op)); + } + acc + } +} + +/// Deterministically cycles through all 16 `Op` cases so each is +/// exercised in proportion (`i % 16`), with both `option` branches +/// and varying (small — this is not a payload-size lane) `list` +/// lengths. +fn build_ops(n: u32) -> Vec { + (0..n) + .map(|i| match i % 16 { + 0 => Op::InsertElement(OpInsertElement { + id: i, + tag: format!("div{i}"), + parent: option_u16(i), + }), + 1 => Op::RemoveElement(i), + 2 => Op::SetAttribute(OpSetAttribute { + id: i, + key: "class".to_string(), + value: format!("c{i}"), + }), + 3 => Op::RemoveAttribute(OpRemoveAttribute { + id: i, + key: "data-x".to_string(), + }), + 4 => Op::SetText(OpSetText { + id: i, + text: format!("text{i}"), + }), + 5 => Op::InsertText(OpInsertText { + id: i, + text: format!("t{i}"), + parent: option_u16(i), + }), + 6 => Op::MoveNode(OpMoveNode { + id: i, + new_parent: option_u16(i), + index: (i % 64) as u16, + }), + 7 => Op::ClearChildren(i), + 8 => Op::SetClassList(OpSetClassList { + id: i, + classes: small_bytes(i), + }), + 9 => Op::SetStyle(OpSetStyle { + id: i, + style: small_bytes(i), + }), + 10 => Op::AddEventListener(OpAddEventListener { + id: i, + event: "click".to_string(), + }), + 11 => Op::RemoveEventListener(OpRemoveEventListener { + id: i, + event: "click".to_string(), + }), + 12 => Op::Focus(i), + 13 => Op::Blur, + 14 => Op::ScrollIntoView, + _ => Op::Checkpoint(OpCheckpoint { + id: i, + update_kind: match i % 4 { + 0 => NodeUpdateKind::Inserted, + 1 => NodeUpdateKind::Updated, + 2 => NodeUpdateKind::Removed, + _ => NodeUpdateKind::Moved, + }, + }), + }) + .collect() +} + +/// Exercises both `option` branches. +fn option_u16(i: u32) -> Option { + if i % 3 == 0 { + None + } else { + Some((i % 0xffff) as u16) + } +} + +/// A short `list` (2-5 bytes; not a payload-size lane). +fn small_bytes(i: u32) -> Vec { + let len = 2 + (i % 4) as usize; + (0..len).map(|j| ((i + j as u32) % 256) as u8).collect() +} + +thread_local! { + /// Guest-side cache for `lift_ops` (issue #261, revision 2): the + /// warmup call builds `Vec` for a given `n`; every timed call + /// clones the cached vector instead of rebuilding it, so the timed + /// region no longer pays a `format!`-per-element guest allocation. + /// The clone itself (Strings and `list`s are deep-copied) still + /// allocates inside the timed region — see the README footnote for + /// its measured cost. + static OPS_CACHE: RefCell)>> = const { RefCell::new(None) }; +} + +fn cached_ops(n: u32) -> Vec { + OPS_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + let needs_build = !matches!(&*cache, Some((cached_n, _)) if *cached_n == n); + if needs_build { + *cache = Some((n, build_ops(n))); + } + cache.as_ref().unwrap().1.clone() + }) +} + +/// Cheap, non-allocating fold touching every field so nothing is +/// dead-code-eliminated (used by `lower_ops`). +fn fold_op(op: &Op) -> u64 { + match op { + Op::InsertElement(o) => fold_fields(o.id, o.tag.len() as u64, o.parent.unwrap_or(0) as u64, 0), + Op::RemoveElement(id) => fold_fields(*id, 0, 0, 1), + Op::SetAttribute(o) => fold_fields(o.id, o.key.len() as u64, o.value.len() as u64, 2), + Op::RemoveAttribute(o) => fold_fields(o.id, o.key.len() as u64, 0, 3), + Op::SetText(o) => fold_fields(o.id, o.text.len() as u64, 0, 4), + Op::InsertText(o) => fold_fields(o.id, o.text.len() as u64, o.parent.unwrap_or(0) as u64, 5), + Op::MoveNode(o) => fold_fields(o.id, o.new_parent.unwrap_or(0) as u64, o.index as u64, 6), + Op::ClearChildren(id) => fold_fields(*id, 0, 0, 7), + Op::SetClassList(o) => fold_fields(o.id, o.classes.len() as u64, 0, 8), + Op::SetStyle(o) => fold_fields(o.id, o.style.len() as u64, 0, 9), + Op::AddEventListener(o) => fold_fields(o.id, o.event.len() as u64, 0, 10), + Op::RemoveEventListener(o) => fold_fields(o.id, o.event.len() as u64, 0, 11), + Op::Focus(id) => fold_fields(*id, 0, 0, 12), + Op::Blur => fold_fields(0, 0, 0, 13), + Op::ScrollIntoView => fold_fields(0, 0, 0, 14), + Op::Checkpoint(o) => fold_fields( + o.id, + match o.update_kind { + NodeUpdateKind::Inserted => 0, + NodeUpdateKind::Updated => 1, + NodeUpdateKind::Removed => 2, + NodeUpdateKind::Moved => 3, + }, + 0, + 15, + ), + } +} + +fn fold_fields(id: u32, a: u64, b: u64, case_const: u64) -> u64 { + (id as u64) + .wrapping_add(a) + .wrapping_add(b) + .wrapping_add(case_const) } bindings::export!(Component with_types_in bindings); diff --git a/bench/boundary/guest/wit/bench.wit b/bench/boundary/guest/wit/bench.wit index d8db10d..d9b9a3d 100644 --- a/bench/boundary/guest/wit/bench.wit +++ b/bench/boundary/guest/wit/bench.wit @@ -13,6 +13,105 @@ interface host { world bench { import host; + + /// A compound element type (issue #261): a 16-case variant over + /// records, mirroring the width and payload mix of the consumer + /// schema #261 reports (a DOM-mutation op stream: 17 cases, record + /// payloads carrying `string`, `option`, `list`, and a + /// nested variant), because two of the costs #261 identifies are + /// both O(case count) per element: `maxCaseAlignment` + /// (runtime/src/cabi/layout.ts) recurses through every case's fields + /// on every lift, and the embedder facade's variant case resolution + /// (runtime/src/embedder/values.ts `toHost`) is a linear scan over + /// cases. + record op-insert-element { + id: u32, + tag: string, + parent: option, + } + + record op-set-attribute { + id: u32, + key: string, + value: string, + } + + record op-remove-attribute { + id: u32, + key: string, + } + + record op-set-text { + id: u32, + text: string, + } + + record op-insert-text { + id: u32, + text: string, + parent: option, + } + + record op-move-node { + id: u32, + new-parent: option, + index: u16, + } + + record op-set-class-list { + id: u32, + classes: list, + } + + record op-set-style { + id: u32, + style: list, + } + + record op-add-event-listener { + id: u32, + event: string, + } + + record op-remove-event-listener { + id: u32, + event: string, + } + + /// The nested-variant payload (#261's reported schema has one): a + /// small payload-free variant inside a record field, forcing the + /// per-element path to recurse one level deeper. + variant node-update-kind { + inserted, + updated, + removed, + moved, + } + + record op-checkpoint { + id: u32, + update-kind: node-update-kind, + } + + variant op { + insert-element(op-insert-element), + remove-element(u32), + set-attribute(op-set-attribute), + remove-attribute(op-remove-attribute), + set-text(op-set-text), + insert-text(op-insert-text), + move-node(op-move-node), + clear-children(u32), + set-class-list(op-set-class-list), + set-style(op-set-style), + add-event-listener(op-add-event-listener), + remove-event-listener(op-remove-event-listener), + focus(u32), + blur, + scroll-into-view, + checkpoint(op-checkpoint), + } + /// Each export loops its import `iters` times with a `size`-byte /// payload and folds the results (defeating dead-code elimination); /// the host times the whole export call. @@ -37,4 +136,11 @@ world bench { /// the payload rendezvous is /// host<->host with no guest memory touched. export stream-pass: async func(s: stream) -> stream; + + /// Compound-element LIFT (issue #261): the guest returns `n` + /// elements, the host lifts them. + export lift-ops: async func(n: u32) -> list; + /// Compound-element LOWER (issue #261): the host passes `n` + /// elements, the guest folds them and returns a checksum. + export lower-ops: async func(ops: list) -> u64; } diff --git a/bench/boundary/sweep.mjs b/bench/boundary/sweep.mjs index e4ccf58..af8c37b 100644 --- a/bench/boundary/sweep.mjs +++ b/bench/boundary/sweep.mjs @@ -57,6 +57,23 @@ for (const shape of STREAM_SHAPES) { } } +// Compound-element lanes (issue #261): no bulk-copy path exists for these +// (a variant over records — the interpreted per-element lift/lower loop is +// exactly what's under test), so unlike the flat scalar shapes above, +// there's no size dimension — just an element count `n`, calibrated +// (10000) so a timed run lands in the tens-of-ms range on this box. +// polyengine drivers only, same reason as the stream shapes: jco is not +// under test here. +const ELEMENT_SHAPES = ["lift-ops", "lower-ops"]; +const ELEMENT_N = 10000; +const elementRows = []; + +for (const shape of ELEMENT_SHAPES) { + elementRows.push(run("node", ["driver-polyengine.mjs", bundle, translator, shape, "n/a", String(ELEMENT_N), "0", "5"])); + elementRows.push(run("node", ["--experimental-wasm-jspi", "driver-polyengine.mjs", bundle, translator, shape, "n/a", String(ELEMENT_N), "0", "5", "jspi"])); + elementRows.push(run("deno", ["run", "-A", "driver-polyengine.mjs", bundle, translator, shape, "n/a", String(ELEMENT_N), "0", "5"])); +} + const lanes = [...new Set(rows.map((r) => r.lane))]; const pad = (s, n) => String(s).padEnd(n); console.log(pad("shape", 10) + pad("mode", 11) + pad("size", 6) + lanes.map((l) => String(l).padStart(22)).join("")); @@ -85,3 +102,15 @@ for (const shape of STREAM_SHAPES) { console.log(pad(shape, 14) + pad(size, 10) + cells.join("")); } } + +console.log(); +console.log(`compound-element lanes (ns/element; n=${ELEMENT_N}; jco lane skipped — see README.md):`); +const elementLanes = [...new Set(elementRows.map((r) => r.lane))]; +console.log(pad("shape", 12) + elementLanes.map((l) => String(l).padStart(26)).join("")); +for (const shape of ELEMENT_SHAPES) { + const cells = elementLanes.map((lane) => { + const r = elementRows.find((x) => x.lane === lane && x.shape === shape); + return (r ? r.nsPerElement.toLocaleString("en-US", { maximumFractionDigits: 1 }) : "-").padStart(26); + }); + console.log(pad(shape, 12) + cells.join("")); +}