You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
cabi: compound element types recompute layout per element and per field — ~5 µs/element for a stream<variant-of-records>; 2.6x from memoizing four pure functions #261
Found while evaluating an explicit-WIT-schema alternative to a hand-rolled byte protocol in a consumer (polyengine-dioxus, which streams DOM mutations guest→host). The schema is a variant operation with 17 cases over records — several carrying string, option<u16>, list<u8>, and a nested variant — delivered as stream<operation>.
A stream<operation> element lifts at ~5.0 µs. The same batch as stream<u8> decodes at ~76 ns/op. Linear in element count across a 100→4000-row sweep (5225 / 4920 / 4310 / 4266 ns per element), so this is a per-element constant factor, not a size effect.
Most of it is layout recomputation the runtime already has enough information to cache. Memoizing four functions by type identity — 71 lines, no contract change, no emission machinery — takes the same workload from 4981 → 1909 ns/element (2.6x) in one sitting, and end-to-end in the consumer's benchmark from 5.07x → 1.74x slower than the byte channel on its heaviest operation.
Root cause
loadListFromValidRange (runtime/src/cabi/load.ts:127) hoists elemSize out of the loop, but everything below it recomputes from scratch, per element and per field:
loadVariant (load.ts:169) calls maxCaseAlignment(cases) (layout.ts:80) on every element. That walks all 17 cases, and alignment on each case recurses through its record's fields into nested variants and options. One full type-tree walk per element.
loadRecord (load.ts:153) calls both alignment(field.type) and elemSize(field.type)per field, per element — each an independent recursive walk of that field's subtree.
despecialize (types.ts:267) allocates on every call for option/tuple/enum/result — a fresh {kind:"variant", cases:[…]} object. It sits at the top of load, alignment and elemSize, so every option<u16> field allocates several times per element just to be measured.
discriminantType (types.ts:306) allocates a fresh {kind:"u8"} per call, and it is called from inside loadVariant, alignmentVariant and elemSizeVariant.
None of this is data-dependent. alignment/elemSize are pure functions of (ValType, PtrType); the descriptor's type objects are stable for the lifetime of a plan.
Second contributor, in the embedder adapter rather than the CABI: toHost (runtime/src/embedder/values.ts) calls camelCase(f.label) — a split/map/join, allocating — per field per element (:193, :202), and resolves the variant case by linear scan over all 17 (:212, :427). (Its checkNoCollisions is correctly memoised per type and is not a per-element cost.)
Measurements
Box: linux-arm64 dev box, Deno 2.9.5, guest wit-bindgen 0.60, polyengine @ 9e17dc9. One batch of 8009 operations (a synthetic create-1000-rows render), decoded into the sameOpSink both ways, no DOM in the loop. Box-relative; read the ratio.
path
ms (median of 9)
ns/element
stream<u8>readDirect + hand-written decode
0.610
76
stream<u8>read + hand-written decode
0.469
59
stream<operation>read + switch on the lifted value
39.890
4981
(guest-side batch construction, byte encoder)
0.548
68
(guest-side batch construction, typed encoder)
0.339
42
Guest-side construction is cheaper for the typed encoder — pushing structs beats byte-serialising them — so the cost is entirely in lowering, lifting and adapting.
Attribution, with the layout memoization already applied (i.e. these are the residual shares, and cabi/load is still the majority after the cheap fix is in):
stage
share
per-element load() in runtime/src/cabi/
~64%
toHost adaptation in runtime/src/embedder/values.ts
~20%
rendezvous, guest-side lowering, promise plumbing
~10%
the consumer's own switch over the lifted values
~1–4%
The cheap fix, measured
WeakMap caches keyed by type identity on alignment, elemSize, maxCaseAlignment and despecialize (plus module-level singletons for discriminantType's three return values). 71 lines across runtime/src/cabi/layout.ts and runtime/src/cabi/types.ts; nothing else touched.
workload
stock
memoized
stream<operation>, 8009 elements (above)
4981 ns/el
1909 ns/el
2.6x
consumer bench create-10k (90 006 ops) vs its byte channel
5.07x slower
1.74x slower
consumer bench clear (10 002 ops) vs its byte channel
12.18x slower
2.08x slower
(A second sitting on a noisier box measured 5426 → 1464 ns/el, 3.7x. Treat the range as 2.5–3.5x, not the third digit.)
runtime's suite passes unchanged with it: 661 passed, 0 failed, 24 ignored — including tests/conventions/ (the golden host-ABI transcripts, which is what would catch an observable change), layout_flatten_test.ts, bulk_list_test.ts, nan_test.ts, values_test.ts. The corpus lane was not run (the third_party/component-model submodule is not checked out in my dep checkout), so treat that as unverified.
The soundness condition is that despecialized results are never mutated by callers, since the memo returns a shared object where each call previously got a fresh one. I checked all 23 despecialize( call sites in runtime/src: every one reads .kind/.fields/.cases and none mutates. Worth making that an explicit invariant in the doc comment (or freezing the cached value) rather than leaving it implicit, if this is taken.
I have the patch and can open a PR if wanted — happy for someone to write it differently, the numbers are the point.
Why it stayed invisible
bench/boundary's shape table is list<u8>, u32 and stream<u8> — every payload is a flat scalar type, which is exactly the set #63/#67 moved onto bulk paths. There is no compound-element-type shape anywhere in the instrument. A stream<record-with-strings> or a list<variant> lane would have shown this; it is the natural companion row to #68's stream shapes.
There is also a floor worth recording for #8's ledger: reconstructing the lifted {kind, value} object graph in plain JS — no runtime, no ABI, just allocating the objects an embedder must hand out and walking them — costs ~190 ns/element against ~45 ns/element to decode the equivalent bytes. A compiled lift can approach that; it cannot go below it, because the host value shape mandates the allocations. For consumers with tens of thousands of elements per batch that gap is the honest cost of a typed boundary, and it is worth having in the contract's performance notes so consumers can design around it rather than discover it.
Checklist for whoever takes this
memoize alignment / elemSize / maxCaseAlignment by (ValType, PtrType) identity; despecialize by input identity; singleton discriminantType returns
state (and ideally enforce) the "despecialized results are read-only" invariant the despecialize memo relies on
hoist toHost's per-field camelCase and per-element variant-case lookup to per-type tables
add a compound-element-type lane to bench/boundary — a stream<T> and a list<T> where T is a variant of records with a string — so this has a regression instrument
Found while evaluating an explicit-WIT-schema alternative to a hand-rolled byte protocol in a consumer (polyengine-dioxus, which streams DOM mutations guest→host). The schema is a
variant operationwith 17 cases over records — several carryingstring,option<u16>,list<u8>, and a nestedvariant— delivered asstream<operation>.A
stream<operation>element lifts at ~5.0 µs. The same batch asstream<u8>decodes at ~76 ns/op. Linear in element count across a 100→4000-row sweep (5225 / 4920 / 4310 / 4266 ns per element), so this is a per-element constant factor, not a size effect.Most of it is layout recomputation the runtime already has enough information to cache. Memoizing four functions by type identity — 71 lines, no contract change, no emission machinery — takes the same workload from 4981 → 1909 ns/element (2.6x) in one sitting, and end-to-end in the consumer's benchmark from 5.07x → 1.74x slower than the byte channel on its heaviest operation.
Root cause
loadListFromValidRange(runtime/src/cabi/load.ts:127) hoistselemSizeout of the loop, but everything below it recomputes from scratch, per element and per field:loadVariant(load.ts:169) callsmaxCaseAlignment(cases)(layout.ts:80) on every element. That walks all 17 cases, andalignmenton each case recurses through its record's fields into nested variants and options. One full type-tree walk per element.loadRecord(load.ts:153) calls bothalignment(field.type)andelemSize(field.type)per field, per element — each an independent recursive walk of that field's subtree.despecialize(types.ts:267) allocates on every call foroption/tuple/enum/result— a fresh{kind:"variant", cases:[…]}object. It sits at the top ofload,alignmentandelemSize, so everyoption<u16>field allocates several times per element just to be measured.discriminantType(types.ts:306) allocates a fresh{kind:"u8"}per call, and it is called from insideloadVariant,alignmentVariantandelemSizeVariant.None of this is data-dependent.
alignment/elemSizeare pure functions of(ValType, PtrType); the descriptor's type objects are stable for the lifetime of a plan.Second contributor, in the embedder adapter rather than the CABI:
toHost(runtime/src/embedder/values.ts) callscamelCase(f.label)— asplit/map/join, allocating — per field per element (:193,:202), and resolves the variant case by linear scan over all 17 (:212,:427). (ItscheckNoCollisionsis correctly memoised per type and is not a per-element cost.)Measurements
Box: linux-arm64 dev box, Deno 2.9.5, guest wit-bindgen 0.60, polyengine @ 9e17dc9. One batch of 8009 operations (a synthetic
create-1000-rowsrender), decoded into the sameOpSinkboth ways, no DOM in the loop. Box-relative; read the ratio.stream<u8>readDirect+ hand-written decodestream<u8>read+ hand-written decodestream<operation>read+ switch on the lifted valueGuest-side construction is cheaper for the typed encoder — pushing structs beats byte-serialising them — so the cost is entirely in lowering, lifting and adapting.
Attribution, with the layout memoization already applied (i.e. these are the residual shares, and
cabi/loadis still the majority after the cheap fix is in):load()inruntime/src/cabi/toHostadaptation inruntime/src/embedder/values.tsThe cheap fix, measured
WeakMap caches keyed by type identity on
alignment,elemSize,maxCaseAlignmentanddespecialize(plus module-level singletons fordiscriminantType's three return values). 71 lines acrossruntime/src/cabi/layout.tsandruntime/src/cabi/types.ts; nothing else touched.stream<operation>, 8009 elements (above)create-10k(90 006 ops) vs its byte channelclear(10 002 ops) vs its byte channel(A second sitting on a noisier box measured 5426 → 1464 ns/el, 3.7x. Treat the range as 2.5–3.5x, not the third digit.)
runtime's suite passes unchanged with it: 661 passed, 0 failed, 24 ignored — includingtests/conventions/(the golden host-ABI transcripts, which is what would catch an observable change),layout_flatten_test.ts,bulk_list_test.ts,nan_test.ts,values_test.ts. The corpus lane was not run (thethird_party/component-modelsubmodule is not checked out in my dep checkout), so treat that as unverified.The soundness condition is that despecialized results are never mutated by callers, since the memo returns a shared object where each call previously got a fresh one. I checked all 23
despecialize(call sites inruntime/src: every one reads.kind/.fields/.casesand none mutates. Worth making that an explicit invariant in the doc comment (or freezing the cached value) rather than leaving it implicit, if this is taken.I have the patch and can open a PR if wanted — happy for someone to write it differently, the numbers are the point.
Why it stayed invisible
bench/boundary's shape table islist<u8>,u32andstream<u8>— every payload is a flat scalar type, which is exactly the set #63/#67 moved onto bulk paths. There is no compound-element-type shape anywhere in the instrument. Astream<record-with-strings>or alist<variant>lane would have shown this; it is the natural companion row to #68's stream shapes.Scope, against the neighbouring issues
load(), so the cheap fix does not remove P1: emitted specialized-JS host-boundary executor (AOT-shaped, no eval) #8's motivation, it just stops the runtime paying for the same arithmetic tens of thousands of times per batch. P1: emitted specialized-JS host-boundary executor (AOT-shaped, no eval) #8 is gated on "a measured gap from Perf baseline vs the jco legs (and consumer case-budget fit) #17, not the calendar"; this is a measured gap from a real consumer, on a shape Perf baseline vs the jco legs (and consumer case-budget fit) #17's lanes do not cover.There is also a floor worth recording for #8's ledger: reconstructing the lifted
{kind, value}object graph in plain JS — no runtime, no ABI, just allocating the objects an embedder must hand out and walking them — costs ~190 ns/element against ~45 ns/element to decode the equivalent bytes. A compiled lift can approach that; it cannot go below it, because the host value shape mandates the allocations. For consumers with tens of thousands of elements per batch that gap is the honest cost of a typed boundary, and it is worth having in the contract's performance notes so consumers can design around it rather than discover it.Checklist for whoever takes this
alignment/elemSize/maxCaseAlignmentby(ValType, PtrType)identity;despecializeby input identity; singletondiscriminantTypereturnsdespecializememo relies ontoHost's per-fieldcamelCaseand per-element variant-case lookup to per-type tablesbench/boundary— astream<T>and alist<T>whereTis a variant of records with a string — so this has a regression instrument