Skip to content

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

Description

@lannbot

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 same OpSink 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.

Scope, against the neighbouring issues

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
  • re-run the corpus lane, which I could not

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions