Skip to content

Synthetic benchmark: all() handle mint/lift cost per fresh instance (wasmtime + deltic) - #82

Merged
lann merged 3 commits into
mainfrom
bench-handle-mint
Aug 11, 2026
Merged

Synthetic benchmark: all() handle mint/lift cost per fresh instance (wasmtime + deltic)#82
lann merged 3 commits into
mainfrom
bench-handle-mint

Conversation

@lann

@lann lann commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Measures the actual overhead of test-case handle minting at suite scale — the "all() per fresh instance" tax behind #22's instance-per-case numbers and #25's motivation — isolated from corpus preprocessing and generator work.

What's here

  • components/bench-suite: N trivially-passing cases via one #[case_row] (default 10000, host-set through the BENCH_CASES wasi env import). No corpus, no per-case data: registering, minting, and lifting handles is all it does. Non-default workspace member; not in any gate; not a lockfile citizen.
  • bench-mint bin (component-test-runner): wasmtime driver with the production Runner's exact engine config (pooling, CoW, epoch instrumentation, untyped Val calls), timing per fresh instance: instantiate / all#1 (registry build + mint + lift) / all#2 (mint + lift only — registry cached) / name[0] / run[0] / store drop.
  • js/runner-deltic/bench-mint.mjs: the same phases under the pinned deltic embedder on plain node.
  • findings.md 19–21: the numbers.

Results (medians, 17-core x86_64 Linux dev box)

wasmtime 47 (bench-mint --cases 100,1000,10000,30000 --instances 20):

cases instantiate all#1 all#2 name[0] run[0] drop ns/handle
100 12.8µs 45.9µs 9.5µs 1.4µs 1.6µs 5.1µs 95
1000 18.0µs 360µs 72.7µs 2.2µs 1.8µs 8.4µs 73
10000 21.4µs 3.19ms 791µs 3.1µs 2.2µs 82.6µs 79
30000 28.8µs 9.43ms 2.20ms 4.5µs 2.8µs 222µs 73

(Cross-checked against the production path: COMPONENT_TEST_PROFILE=1 ct-runner … --only bench/mint/c00000 reports all#1=3.19ms / all#2=0.93ms on the same artifact.)

deltic pre-83fff30 / Node 24 (--instances 10; translator init ~16ms + translate ~25ms one-off):

cases instantiate all#1 all#2 name[0] run[0] ns/handle
100 842µs 218µs 64.1µs 32.3µs 30.5µs 641
1000 658µs 738µs 351µs 27.5µs 20.7µs 351
10000 643µs 5.23ms 3.38ms 26.1µs 21.7µs 338
30000 916µs 22.0ms 11.0ms 63.3µs 37.5µs 368

What the numbers force

  1. wasmtime: the guest registry build, not the handle lift, is the larger half of the per-instance all() tax (~3:1 at 10k: 2.4ms build vs 0.79ms lift, both linear). A direct-access suite interface (get(name)/take(indexes), the Runner execution-policy guidance #22-thread sketch) caps at ~25% of the win unless the SDK also makes the registry lazy/static — SDK static-table work and Pre-initialized suites (build-time wizer snapshot of the case table) #25 come first, or stacked.
  2. deltic: same shape, bigger constants — 30× instantiation, 4.3× lift, 10× per boundary call. Per-fresh-instance topologies on the JS legs stay tolerable only while per-instance work is O(cases served), not O(suite).
  3. The JS legs' real quadratic is harness.mjs's freshCases relocation: a linear name() scan ≈ N/2 × 25µs ≈ 130ms per case at 10k — ~20× the all() it sits on top of. all() order is contractually deterministic across instances, so positional relocation (+ one name() verify) is sound and erases it. Not changed here; needs its own issue/PR.

Verification

  • just check green (fmt, clippy, host tests, WIT).
  • ct-runner … --enumerate on the artifact: 10000 names, grammar-valid, prefix record covers them (drift check passes).
  • Both drivers rerun post-fmt with stable numbers.

…cale

Synthetic benchmark isolating the per-fresh-instance cost of the
tests.all() protocol from corpus/generator work, quantifying the
instance-per-case overhead discussed in #22/#25: components/bench-suite
registers N trivial cases (BENCH_CASES env, default 10000), and two
phase-timing drivers measure instantiate / all#1 (registry build +
mint + lift) / all#2 (mint + lift, registry cached) / name / run /
teardown per fresh instance — bench-mint (wasmtime, byte-identical
engine config and untyped-Val calls as the production Runner) and
js/runner-deltic/bench-mint.mjs (pinned deltic embedder, plain node).

Findings (docs/findings.md 19-21), medians on one 17-core dev box:

- wasmtime 47: all() at 10k cases = 3.2ms per instance, splitting
  ~3:1 into guest registry build (~240ns/case) vs mint+lift
  (75-80ns/handle, linear through 30k); instantiate 21us, store drop
  83us. So the guest-side registry build, not the handle lift, is
  the larger half of #22's measured K=1 tax — SDK static-table work
  and #25 outrank a direct-access interface until stacked.
- deltic pre-83fff30/Node 24: same shape, bigger constants —
  instantiate ~650us (30x), lift ~340-370ns/handle (4.3x), boundary
  calls ~25us (10x).
- harness.mjs freshCases relocation (linear name() scan) is the
  dominant JS-leg cost at scale: ~N/2 x 25us =~ 130ms/case at 10k,
  ~20x the all() it sits on top of; positional relocation is sound
  (all() order is contractually deterministic) and erases it.

The bench artifacts stay out of every gate: bench-suite is a
non-default workspace member like the other fixtures, not built by
just build, and not a lockfile citizen.
…ld vanishes

The mint benchmark's follow-up (issue #25): with the registry-build
half measured as ~3x the lift at 10k cases, try wizening the suite so
fresh instances are born with the case table built.

It works, but only by driving wasmtime-wizer 47 as a library
(Wizer::run_component takes a caller-supplied instantiate closure, so
our linker satisfies test-context — a host resource init never calls —
plus full WASI). The CLI path is blocked three ways, recorded as
finding 22: the invoke grammar rejects versioned interface qualifiers,
unknown-import stubbing cannot synthesize resource types, and composed
bundles hit 'nested components with modules not currently supported'.
The init entry is therefore a bare-named wizer-initialize export:
bench-suite's new wizer-init feature adds it as a second inline-WIT
world, merged by wasm-component-ld. keep_init_func(false) — the
default — emits an invalid component (dangling core-instance export
reference); the driver keeps it. Custom sections survive the rewrite:
scheduling and drift checks work on the wizened artifact.

Measured (findings 23-24), 10k-case suite, medians:

- wasmtime: all#1 3.15ms -> 663us (= all#2: born initialized);
  instantiate unchanged at ~19us (CoW absorbs the 122KB -> 1.29MB
  snapshot); store drop 80us -> 12us. End-to-end K=1 full-isolation
  run: 30.8s -> 7.1s sequential, 1.14s at jobs=8 — per-case isolation
  on a wizened suite now undercuts #22's shared-instance numbers.
- deltic: net ~1.5x only — all#1 6.9ms -> 2.9ms but instantiate
  0.78ms -> 2.17ms (no CoW; the active data segment is copied per
  instantiation). K>1 remains the JS-leg lever.

Also corrects finding 21's scan-cost constant: hot-loop name() is
~3.4us (a cold single call measures ~26us), so the freshCases scan
averaged ~17ms/case at 10k (33ms worst, measured), not 130ms; fixed
by positional relocation in PR #83 (33.4ms -> 0.0ms measured).

New surfaces, all out of the gates: bench-suite feature wizer-init
(default build unchanged — a pure suite world), runner feature wizer
with the required-features bin wizer-preinit (optional dep
wasmtime-wizer, absent from default builds; clippy clean under the
feature).
@lann

lann commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Scope addition (second commit): the wizer experiment the mint numbers motivated. Component-level pre-init works via wasmtime-wizer 47 as a library (wizer-preinit bin, feature-gated; the CLI is blocked three ways — findings 22). Measured on the 10k suite: all#1 3.15ms → 663µs (= all#2, born initialized), instantiate unchanged under CoW, K=1 full-isolation run 30.8s → 7.1s sequential / 1.14s at jobs=8. Deltic nets only ~1.5× (no CoW: the 1.29MB snapshot is memcpy'd per instantiation). Also corrects finding 21's scan constant (hot name() ≈ 3.4µs → ~17ms/case at 10k, not 130ms) — the fix itself is PR #83.

…e extra export

Correction prompted by reading the actual grammars (wasm-wave's
FuncNameToken lexer, wit-parser's ItemName): the invoke syntax
supports versioned interfaces and always has — the version goes LAST
(pkg:ns/iface.func@1.2.3), resolving the dot ambiguity; the
export-name-order form I tried is rejected by design. 'The invoke
grammar cannot name versioned exports' in the previous commit was
wrong; the unhelpful 'invalid token' error obscured a syntax error.

Consequences, all verified:

- wizer-preinit now inits via polymorph:test/tests.all@0.1.0() — the
  parenthesized wave-call form (the bare item-name path requires
  [] -> []). Works on any UNMODIFIED suite artifact; the minted
  handles are per-call state and cost nothing measurable in the
  snapshot (1,286,599 vs 1,289,515 bytes with a dedicated no-op init
  export). Same numbers: all#1 700us = all#2 648us at 10k.
- bench-suite's wizer-init feature (second inline-WIT world, optional
  wit-bindgen dep, force_registry_init hook) is deleted — dead weight.
- The CLI's real remaining blockers, re-verified with correct syntax:
  resource imports cannot be stubbed (suite worlds), nested components
  (bundles), and wizer defaults WASI off (-S cli needed for env-reading
  inits). keep_init_func(false)'s dangling-export rewrite is a known
  open bug (bytecodealliance/wasmtime#13168) — moot for the all()-as-
  init route, where keeping is semantically mandatory anyway.
- One genuine small gap remains upstream: the wave func-name lexer's
  semver subpattern is bare X.Y.Z, so prerelease-versioned interfaces
  (@0.3.0-rc-...) cannot be named in call form.

Finding 22 rewritten accordingly.
@lann

lann commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Third commit corrects the second: the invoke grammar was never broken — version-last syntax (polymorph:test/tests.all@0.1.0()) works, so the init function is now the contract's own all() and the wizer-init feature (extra world export, optional wit-bindgen dep) is deleted. wizer-preinit wizens unmodified suite artifacts. Finding 22 rewritten; remaining real CLI blockers: resource-import stubbing, nested components, WASI-off default. The strip bug is known upstream (bytecodealliance/wasmtime#13168).

@lann
lann merged commit 4eae35d into main Aug 11, 2026
3 checks passed
@lann
lann deleted the bench-handle-mint branch August 11, 2026 03:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant