From 32bc47f92fde20cec662a94d87347ba2699f0d55 Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Sat, 18 Jul 2026 17:33:57 +0200 Subject: [PATCH] ADR 0007: measure real protocol DCE payoff (~307 B), keep the baseline; add POC --- doc/ai/adr/0007-deferred-core-fn-costs.md | 174 +++++++++++++++++----- doc/ai/adr/0007-poc/README.md | 35 +++++ doc/ai/adr/0007-poc/dce.cjs | 46 ++++++ doc/ai/adr/0007-poc/extend.cjs | 30 ++++ doc/ai/adr/0007-poc/measure-ceiling.cjs | 15 ++ doc/ai/adr/0007-poc/measure-registry.cjs | 15 ++ doc/ai/adr/0007-poc/perf.cjs | 83 +++++++++++ 7 files changed, 357 insertions(+), 41 deletions(-) create mode 100644 doc/ai/adr/0007-poc/README.md create mode 100644 doc/ai/adr/0007-poc/dce.cjs create mode 100644 doc/ai/adr/0007-poc/extend.cjs create mode 100644 doc/ai/adr/0007-poc/measure-ceiling.cjs create mode 100644 doc/ai/adr/0007-poc/measure-registry.cjs create mode 100644 doc/ai/adr/0007-poc/perf.cjs diff --git a/doc/ai/adr/0007-deferred-core-fn-costs.md b/doc/ai/adr/0007-deferred-core-fn-costs.md index 427e2143..193540af 100644 --- a/doc/ai/adr/0007-deferred-core-fn-costs.md +++ b/doc/ai/adr/0007-deferred-core-fn-costs.md @@ -1,6 +1,6 @@ -# ADR 0007: Deferred structural costs in the protocol and variadic core +# ADR 0007: Structural costs in the protocol and variadic core -Status: Accepted (deferral) +Status: Accepted (protocol cost: keep baseline; variadic cost: deferred) ## Context @@ -11,34 +11,65 @@ defer, with the trigger that should reopen each. ### Protocol dispatch bundle cost -The protocol layer (0.14.203) made the common core fns reference their protocol -slot to dispatch to custom types: `assoc` names `IAssociative__assoc`, `conj` +Protocol dispatch arrived in two waves, both pinning machinery into plain-data +bundles. + +Wave 1, atoms (0.14.201): `IReset`/`ISwap`/`IWatchable`/`IAtom`/`IDeref` were +added to `atom`/`swap!`/`reset!`/`deref`/`add-watch`. An app using plain atoms +(`swap!`, `add-watch`, no custom `IAtom` type) pins the lot. + +Wave 2, collections (0.14.203-205): `assoc` names `IAssociative__assoc`, `conj` names `ICollection__conj`, `get` names `ILookup__lookup`, `=` names -`IEquiv__equiv`, and so on. ADR 0006 and `symbol-pure-dce` shake out the slots an -app does not use. But a broad app uses `assoc` and `conj` and `count` and `get` -and `=` together, so the union of its fns references the whole slot set, plus -`INSTANCE_TYPE`. That block is then pinned into the bundle. +`IEquiv__equiv`, plus `INSTANCE_TYPE`. A broad app uses these together and pins +the whole set. ADR 0006 and `symbol-pure-dce` shake out slots an app does not +use; the union a real app references survives. -Measured on the js-framework-benchmark reagami app (same compiled app, only the -runtime core.js swapped): +Measured with the js-framework-benchmark reagami app, a clean official +`build-prod` per squint version (compiled and bundled entirely on each version, +gzipped): -| runtime | raw (min) | gzip | +| squint | gzip | note | |---|---|---| -| main | 25973 | 9197 | -| 0.14.202 (pre-protocol) | 24136 | 8659 | -| delta | +1837 | **+538** | - -reagami and the demo define no `deftype`/`defrecord`/`extend-type`, so for them -the whole block is dead weight: it dispatches to custom impls that do not exist. -This is true of most plain-data apps. - -The fix is to invert the dispatch, like CLJS: base fns handle only native types -(Object/Array/primitive/Map/Set) and name no protocol slot; `extend-type` and -`deftype`/`defrecord` install the dispatch. An app with no protocol impls pulls -zero protocol machinery. The DCE-clean mechanism is for `extend-type` to wrap the -affected core fns, so the base fn holds no protocol reference to retain. The -binding constraint is that ESM imports are read-only, so the fns need a mutable -holder or a registry indirection. +| 0.14.197 | 7517 | before both waves | +| 0.14.200 | 7705 | before atoms | +| 0.14.201 | 8644 | +939, atom protocols | +| 0.14.202 | 8648 | before collections | +| 0.14.205 | 9520 | collection protocols, unfixed | +| 0.14.206 | 9190 | collections, after symbol-pure-dce + plain-data (-330 vs 205) | + +The bundle grew ~1.5 KB across those versions, but that number does NOT measure +recoverable protocol dispatch - it conflates three things: protocol slot +dispatch, non-slot additions (atom watch/validator expansion, `typeConst`, +`INSTANCE_TYPE`), and plain compiler-codegen evolution (0.14.197 -> 202 alone is ++1.1 KB with no protocol layer at all). Attributing the whole 1.5 KB to protocols +- as an earlier draft of this ADR did - is wrong. + +What is actually recoverable was measured directly: transform the shipping +0.14.206 core.js in place, hold everything else constant, rebuild the reagami +app (see doc/ai/adr/0007-poc/measure-*.cjs): + +| reagami app | gzip | recovers | +|---|---|---| +| baseline (as ships) | 9201 | - | +| protocol slot symbols removed, dispatch branches kept | 8894 | **307 B** | +| all protocol slot dispatch removed (branches + symbols) | 8301 | **900 B** | + +So the *most* any protocol-dispatch refactor can recover is **900 bytes**, and +that ceiling needs the dispatch branches gone - only the perf-regressing wrapping +mechanism does that. A registry, which keeps the branches inline, recovers just +the symbols: **~307 bytes**. Not 1.5 KB. reagami and the demo define no +`deftype`/`defrecord`/`extend-type`, so this machinery is genuinely dead weight +for them - it is just far smaller than the version-delta suggested. + +The fix inverts the dispatch, like CLJS: base fns handle native types +(Object/Array/primitive/Map/Set) inline and name no protocol slot; built-in +branded types (records, sorted collections, atoms) dispatch by brand; a +constructor registry, populated by `extend-type`/`deftype`/`defrecord`, is +consulted only for the remaining non-native types. An app with no protocol +impls pulls zero protocol machinery. A proof of concept (doc/ai/adr/0007-poc/) +validated this, ruled out one alternative on perf, and found the ABI blocker - +but the deciding fact turned out to be payoff: measured on the real app, the +registry recovers only ~307 bytes gzip (below), which is why the baseline wins. ### Variadic call-site cost @@ -55,32 +86,93 @@ analysis both succeed, and in throttled Chrome they did not (a fast-arity runtim #887 reverted it (double-evaluation); per-arity functions avoid that. This is the doc/ai/ideas.md "Emit direct fixed-arity calls" idea. +## Proof of concept + +Three standalone experiments in doc/ai/adr/0007-poc/ (run with `node`), modelling +`get`: + +- perf.cjs - the plain-data hot path is flat (~1x baseline) for the protocol-free + base. But the naive "wrap the fn" mechanism regresses 2.4x the moment any + `extend-type` exists, because every call then threads the wrapper (and it + compounds per extension). A registry the base consults only for non-native + types stays flat in both cases. +- dce.cjs - with a base that names no slot, a plain-data app bundle drops every + protocol symbol (12 -> 0 in the model) and shrinks. Conditional: built-in + types must not be eager-registered (`registry.set(T, ...)` at top level is a + side effect that pins T into every bundle), so they stay brand-dispatched. +- extend.cjs - a user can extend their own type to a built-in protocol (works via + the registry). Overriding a native type is ignored (the inline path wins) - + which is already true in today's squint, so no regression. + +Decision matrix (++ good, -- deal-breaker, ~ caveat; squint down each column): + +| criterion | baseline (slots) | protocol-free | wrapping | registry (hybrid) | +|------------------------------|:----------------:|:-------------:|:--------:|:-----------------:| +| hot path, plain data | ++ | ++ | ++ | ++ | +| hot path, extends present | ++ | ++ | **--** | ++ | +| DCE: slot symbols shake out | **--** | ++ | ++ | ++ | +| runtime-extensible | ++ | **--** | ++ | ++ | +| backward-compatible ABI | ++ | **--** | **--** | **--** | +| built-in types DCE cleanly | ++ | ~ | ~ | ~ | + +Reading the columns: baseline fails only DCE; protocol-free and wrapping each +carry two deal-breakers; the registry hybrid carries exactly one - the ABI break, +shared by *every* option that recovers DCE. So among mechanisms that fix DCE, +registry is strictly best. But the matrix only ranks *mechanisms*; it does not +weigh the *payoff*, and the measurement above settles that separately: the DCE +win is ~307 bytes. A strictly-best mechanism for a 307-byte win behind a breaking +change still loses to doing nothing. + +## The blocker: it is a breaking ABI change + +The mechanism moves protocol dispatch from prototype slots to a registry, so it +changes the contract between compiled code and core.js. `extend-type`/`deftype`/ +`defrecord` would emit registry calls instead of `prototype[SLOT] = fn`, and core +fns would consult the registry instead of `coll[SLOT]`. Consequences: + +- Code compiled by an older squint (slot-style) run against a newer registry + core.js has its protocol impls silently ignored, and vice versa. A pre-compiled + published library that uses protocols breaks against a mismatched core.js. +- It cannot be made backward-compatible. Keeping a slot check in the base fns for + old code re-references the slot symbol, which pins it, which forfeits the entire + DCE win. DCE and slot-compat are mutually exclusive. + +So this is a breaking change requiring recompilation of protocol-using code. Even +setting the small payoff aside, that alone would gate it to a deliberate window; +combined with the ~307-byte payoff, it is simply not worth doing. reagami +itself is unaffected (it defines no protocols), but the change is squint-wide. + ## Decision -Defer both. Ship neither fix now. +Keep the baseline for the protocol cost; defer the variadic one. -- The protocol bundle cost is ~1/2 KB gzip per broad plain-data app. Recovering - it means reworking the protocol system that landed weeks ago, with regression - risk to records, transients and `extend-type`. The trade does not clear the bar - today. +- Protocol dispatch: **do not do it.** The measurement is decisive - a registry + recovers ~307 bytes gzip, and even the 900-byte ceiling needs the wrapping + mechanism that regresses the hot path 2.4x per extend. Paying a breaking ABI + change and a `deftype`/`defrecord`/`extend-type` recompile to recover ~300 + bytes is a bad trade. The mechanism was feasible and perf-neutral; the payoff + simply is not there. The POC and matrix stand as the record of *why* the + baseline wins. - The variadic runtime patch was tried in #961 and dropped: a 2-arg `min`/`max` arity that node cannot show a win for and that Chrome does not benefit from. The - real fix is the compiler-level per-arity emission, which is larger. + real fix is the compiler-level per-arity emission, which is larger - deferred to + the doc/ai/ideas.md fixed-arity work. ## Consequences -- `bb test:size` runs in CI (this ADR's companion change), so the protocol cost - is visible per commit and a regression past the threshold fails the build. -- reagami sidesteps the `min` cost with `js/Math.min` and does not depend on - either fix. It stays current with squint and pays the ~1/2 KB, still the +- `bb test:size` runs in CI (this ADR's companion change), so any *future* size + regression is visible per commit and fails the build past the threshold. +- reagami sidesteps the `min` cost with `js/Math.min` and depends on neither fix. + It stays current with squint and pays the small protocol overhead, still the smallest app in the benchmark table. -- Both fixes remain open, documented with measurements, ready to execute. +- The protocol design is documented and disproven-on-payoff, so it does not get + re-litigated from scratch: the number to beat is ~307 bytes gzip. ## When to revisit -- Protocol bundle cost: when `test:size` shows the tax crossing a threshold that - matters, or a size-sensitive consumer is blocked by it - by then the protocol - code is battle-tested and the inversion is far less risky than doing it now - against fresh code. +- Protocol bundle cost: only if the recoverable grows materially - a much larger + protocol surface, or a use case where 300 bytes matters and a coordinated + recompile is cheap. On today's numbers it is not worth reopening. `test:size` + guards against the cost *growing* unnoticed. - Variadic call cost: alongside the fixed-arity codegen work in doc/ai/ideas.md, which supersedes the reverted runtime patch. diff --git a/doc/ai/adr/0007-poc/README.md b/doc/ai/adr/0007-poc/README.md new file mode 100644 index 00000000..26749f19 --- /dev/null +++ b/doc/ai/adr/0007-poc/README.md @@ -0,0 +1,35 @@ +# ADR 0007 proof of concept + +Experiments backing doc/ai/adr/0007-deferred-core-fn-costs.md. + +Mechanism POCs (standalone `node`, self-contained): + +- `perf.cjs` - hot-path perf of the dispatch mechanisms (`node perf.cjs`, or + `EXTENDED=1 node perf.cjs`). Shows the registry hybrid is perf-neutral and + wrapping regresses once an `extend-type` exists. +- `dce.cjs` - a toy model of whether a protocol-free base lets slot symbols shake + out (`node dce.cjs`; needs esbuild). NOTE: this only models the symbols, not + the dispatch-branch code, so it over-states the win - the real measurement + below corrects it. +- `extend.cjs` - whether a user can still extend a built-in protocol in the + registry model (`node extend.cjs`). + +Real measurement (the one that decided it): transform the shipping 0.14.206 +core.js in place, hold the reagami app/lib constant, rebuild, compare gzip. + +- `measure-ceiling.cjs` - rewrites every protocol-slot access to `(void 0)` so + esbuild folds the dispatch branches away AND drops the now-unused symbols. + Produces the *ceiling* core (all slot dispatch gone). +- `measure-registry.cjs` - collapses the 34 slot symbols to one, keeping the + dispatch branches - i.e. what a registry actually recovers (symbols only). + +Result on the reagami js-framework-benchmark app (gzip): + +| variant | gzip | recovers | +|---|---|---| +| baseline (as ships) | 9201 | - | +| registry (symbols gone, branches kept) | 8894 | 307 B | +| ceiling (all slot dispatch gone) | 8301 | 900 B | + +Conclusion: a registry recovers ~307 bytes, the ceiling 900; not the ~1.5 KB the +version-to-version delta suggested. Keep the baseline. diff --git a/doc/ai/adr/0007-poc/dce.cjs b/doc/ai/adr/0007-poc/dce.cjs new file mode 100644 index 00000000..99fea3d0 --- /dev/null +++ b/doc/ai/adr/0007-poc/dce.cjs @@ -0,0 +1,46 @@ +// ADR 0007 POC - DCE. Does a protocol-free base actually let the slot symbols +// shake out of a plain-data bundle? Generates a baseline core (fns name their +// slot) and a registry core (fns name no slot), bundles a plain-data app that +// uses several fns against each, and reports bytes + how many slot symbols +// survive. Needs esbuild (this repo's devDep). +// +// node dce.js + +const { buildSync } = require('esbuild'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adr7-dce-')); +const slots = ['ILookup', 'IAssoc', 'IColl', 'ICount', 'IMap', 'IEquiv', 'IIndexed', 'IReset', 'ISwap', 'IWatch', 'IDeref', 'IEmpty']; + +// baseline: 12 slot symbols; 5 fns each naming a few, so a broad app pins all 12 +let base = slots.map((s) => `export const ${s}__s = /* @__PURE__ */ Symbol('${s}_-s');`).join('\n') + '\n' + + `export function get(c,k,nf){ if(c==null)return nf; if(c.constructor===Object){const v=c[k];return v===undefined?nf:v;} if(c[ILookup__s]!==undefined)return c[ILookup__s](c,k,nf); if(c[IIndexed__s]!==undefined)return c[IIndexed__s](c,k); return nf; } +export function assoc(c,k,v){ if(c[IAssoc__s]!==undefined)return c[IAssoc__s](c,k,v); if(c[IMap__s]!==undefined){} return {...c,[k]:v}; } +export function conj(c,x){ if(c[IColl__s]!==undefined)return c[IColl__s](c,x); if(c[IEmpty__s]!==undefined){} return [...c,x]; } +export function deref(a){ if(a[IDeref__s]!==undefined)return a[IDeref__s](a); return a.v; } +export function swap(a,f){ if(a[ISwap__s]!==undefined)return a[ISwap__s](a,f); a.v=f(a.v); if(a[IWatch__s]!==undefined){} if(a[IReset__s]!==undefined){} if(a[IEquiv__s]!==undefined){} if(a[ICount__s]!==undefined){} return a.v; }`; + +// registry: same fns, native inline, no slot symbols; registry only for extend-type +let reg = `const reg=new Map(); +function dispatch(p,c){ const m=reg.get(p); return m&&m.get(c.constructor); } +export function extendType(p,T,fn){ (reg.get(p)||reg.set(p,new Map()).get(p)).set(T,fn); } +export function get(c,k,nf){ if(c==null)return nf; if(c.constructor===Object){const v=c[k];return v===undefined?nf:v;} if(reg.size){const f=dispatch('lookup',c);if(f)return f(c,k,nf);} return nf; } +export function assoc(c,k,v){ if(reg.size){const f=dispatch('assoc',c);if(f)return f(c,k,v);} return {...c,[k]:v}; } +export function conj(c,x){ if(reg.size){const f=dispatch('coll',c);if(f)return f(c,x);} return [...c,x]; } +export function deref(a){ if(reg.size){const f=dispatch('deref',a);if(f)return f(a);} return a.v; } +export function swap(a,f){ if(reg.size){const g=dispatch('swap',a);if(g)return g(a,f);} a.v=f(a.v); return a.v; }`; + +fs.writeFileSync(path.join(dir, 'core-base.js'), base); +fs.writeFileSync(path.join(dir, 'core-reg.js'), reg); +const app = `import {get,assoc,conj,deref,swap} from CORE; const a={v:0}; console.log(get({x:1},'x'), assoc({},'k',1), conj([],1), deref(a), swap(a,n=>n+1));`; + +for (const [name, core] of [['baseline (slots)', 'core-base.js'], ['registry', 'core-reg.js']]) { + const entry = path.join(dir, 'app.js'); + fs.writeFileSync(entry, app.replace('CORE', JSON.stringify('./' + core))); + const out = buildSync({ entryPoints: [entry], bundle: true, minify: true, format: 'esm', write: false, absWorkingDir: dir }).outputFiles[0].text; + const symbolsLeft = (out.match(/_-s/g) || []).length; + console.log(name.padEnd(18), 'bytes=' + out.length, ' slot-symbols-remaining=' + symbolsLeft); +} +fs.rmSync(dir, { recursive: true, force: true }); diff --git a/doc/ai/adr/0007-poc/extend.cjs b/doc/ai/adr/0007-poc/extend.cjs new file mode 100644 index 00000000..627fa6c9 --- /dev/null +++ b/doc/ai/adr/0007-poc/extend.cjs @@ -0,0 +1,30 @@ +// ADR 0007 POC - extensibility. In the registry model (base fn does native +// dispatch inline, then consults a registry for non-native types), can a user +// still extend a built-in protocol? Shows: yes for their own types; no for +// native types - which matches current squint, where natives are fast-pathed +// inline before any slot check. +// +// node extend.js + +const reg = new Map(); // protocol -> (constructor -> impl) +function extendType(proto, Type, fn) { + if (!reg.has(proto)) reg.set(proto, new Map()); + reg.get(proto).set(Type, fn); +} +function get(coll, key, nf) { + if (coll == null) return nf; + if (coll.constructor === Object) { const v = coll[key]; return v === undefined ? nf : v; } // native inline + if (Array.isArray(coll)) { const v = coll[key]; return v === undefined ? nf : v; } // native inline + if (reg.size) { const m = reg.get('ILookup'); const f = m && m.get(coll.constructor); if (f) return f(coll, key, nf); } + return nf; +} + +class RangeMap { constructor(lo, hi) { this.lo = lo; this.hi = hi; } } +extendType('ILookup', RangeMap, (rm, k, nf) => (k >= rm.lo && k <= rm.hi ? k * 10 : nf)); // own type -> built-in protocol +extendType('ILookup', Array, () => 'CUSTOM-ARRAY'); // native type -> built-in protocol + +const ok = (a, b, m) => console.log((a === b ? 'ok ' : 'FAIL') + ' ' + m + ' => ' + a); +ok(get(new RangeMap(1, 5), 3, 'nf'), 30, 'own type extends ILookup, hit'); +ok(get(new RangeMap(1, 5), 9, 'nf'), 'nf', 'own type extends ILookup, miss'); +ok(get([10, 20, 30], 1, 'nf'), 20, 'native Array: inline wins, extension ignored (as in current squint)'); +ok(get({ x: 1 }, 'x', 'nf'), 1, 'plain object: native inline, not overridable'); diff --git a/doc/ai/adr/0007-poc/measure-ceiling.cjs b/doc/ai/adr/0007-poc/measure-ceiling.cjs new file mode 100644 index 00000000..3851261d --- /dev/null +++ b/doc/ai/adr/0007-poc/measure-ceiling.cjs @@ -0,0 +1,15 @@ +// ADR 0007 - ceiling measurement. Rewrites every protocol-slot access in a +// core.js to (void 0) so esbuild folds the dispatch branches away AND drops the +// now-unused slot symbols. Produces the "protocol-free" ceiling core (all slot +// dispatch gone). Bundle the reagami app against it to see the max recoverable. +// node measure-ceiling.cjs +const { readFileSync, writeFileSync } = require('node:fs'); +const [inp, out] = process.argv.slice(2); +let src = readFileSync(inp, 'utf8'); +// drop assignment statements targeting a slot: X[Islot] = Y; +src = src.split('\n').filter(l => !/\w+\[I[A-Z][A-Za-z]*__[A-Za-z_?!]+\]\s*=[^=]/.test(l)).join('\n'); +// slot member reads obj[Islot] / obj?.[Islot] -> (void 0) +// makes `if ((void 0) !== undefined)` fold to false, so esbuild drops the branch +src = src.replace(/[A-Za-z_$][\w$]*(?:\?\.)?\[(I[A-Z][A-Za-z]*__[A-Za-z_?!]+)\]/g, '(void 0)'); +writeFileSync(out, src); +console.log('wrote', out); diff --git a/doc/ai/adr/0007-poc/measure-registry.cjs b/doc/ai/adr/0007-poc/measure-registry.cjs new file mode 100644 index 00000000..df62f540 --- /dev/null +++ b/doc/ai/adr/0007-poc/measure-registry.cjs @@ -0,0 +1,15 @@ +// ADR 0007 - registry measurement. Collapses all protocol slot symbols to ONE, +// keeping every dispatch branch intact - i.e. exactly what a registry recovers +// (the symbols; the branch code stays inline). Bundle the reagami app against +// the output to see the registry's real payoff. +// node measure-registry.cjs +const { readFileSync, writeFileSync } = require('node:fs'); +const [inp, out] = process.argv.slice(2); +let src = readFileSync(inp, 'utf8'); +src = src.split('\n').map(l => + /^export const I[A-Z][A-Za-z]*__[A-Za-z_?!]+ = .*Symbol\(/.test(l) ? '' : l +).join('\n'); +src = src.replace(/\bI[A-Z][A-Za-z]*__[A-Za-z_?!]+\b/g, '$S'); +src = src.replace(/^(import [^\n]*\n)/, `$1const $S = /* @__PURE__ */ Symbol('s');\n`); +writeFileSync(out, src); +console.log('wrote', out); diff --git a/doc/ai/adr/0007-poc/perf.cjs b/doc/ai/adr/0007-poc/perf.cjs new file mode 100644 index 00000000..4f7dcc74 --- /dev/null +++ b/doc/ai/adr/0007-poc/perf.cjs @@ -0,0 +1,83 @@ +// ADR 0007 POC - perf. Does making core fns protocol-free (so the slots DCE +// out) slow the hot path? Models `get` four ways and benchmarks the common +// case: read a key from a plain object. +// +// Each variant runs in its own process (spawned below) so it gets a clean, +// monomorphic JIT - a single shared call site would go megamorphic and the +// first variant measured would look artificially fast. +// +// node perf.js # run the interleaved benchmark +// EXTENDED=1 node perf.js # simulate an unrelated extend-type in the app +// node perf.js # (internal) benchmark one variant, print ns/op + +const ILookup__lookup = Symbol('ILookup_-lookup'); + +function get_baseline(coll, key, nf) { // current squint: names the slot -> pins it + if (coll == null) return nf; + if (coll.constructor === Object) { const v = coll[key]; return v === undefined ? nf : v; } + if (Array.isArray(coll)) { const v = coll[key]; return v === undefined ? nf : v; } + if (coll instanceof Map) return coll.has(key) ? coll.get(key) : nf; + if (coll[ILookup__lookup] !== undefined) return coll[ILookup__lookup](coll, key, nf); + return nf; +} +function get_free(coll, key, nf) { // native only, no slot (not runtime-extensible) + if (coll == null) return nf; + if (coll.constructor === Object) { const v = coll[key]; return v === undefined ? nf : v; } + if (Array.isArray(coll)) { const v = coll[key]; return v === undefined ? nf : v; } + if (coll instanceof Map) return coll.has(key) ? coll.get(key) : nf; + return nf; +} +const holder = { get: (c, k, nf) => get_free(c, k, nf) }; // extend-type wraps holder.get +function get_holder(coll, key, nf) { return holder.get(coll, key, nf); } +const registry = new Map(); // constructor -> impl ; base consults it only for non-native +function get_registry(coll, key, nf) { + if (coll == null) return nf; + if (coll.constructor === Object) { const v = coll[key]; return v === undefined ? nf : v; } + if (Array.isArray(coll)) { const v = coll[key]; return v === undefined ? nf : v; } + if (coll instanceof Map) return coll.has(key) ? coll.get(key) : nf; + if (registry.size) { const f = registry.get(coll.constructor); if (f) return f(coll, key, nf); } + return nf; +} +if (process.env.EXTENDED) { // an unrelated protocol extension exists somewhere in the app + class Other {} + const prev = holder.get; + holder.get = (c, k, nf) => (c instanceof Other ? nf : prev(c, k, nf)); + registry.set(Other, (c, k, nf) => nf); +} + +const objs = Array.from({ length: 1024 }, (_, i) => ({ id: i, label: 'row ' + i, selected: false })); + +function benchOne(V) { + const loop = (iters) => { + let s; const t0 = process.hrtime.bigint(); + switch (V) { + case 'baseline': for (let i = 0; i < iters; i++) s = get_baseline(objs[i & 1023], 'label', null); break; + case 'free': for (let i = 0; i < iters; i++) s = get_free(objs[i & 1023], 'label', null); break; + case 'holder': for (let i = 0; i < iters; i++) s = get_holder(objs[i & 1023], 'label', null); break; + case 'registry': for (let i = 0; i < iters; i++) s = get_registry(objs[i & 1023], 'label', null); break; + } + globalThis.__sink = s; + return Number(process.hrtime.bigint() - t0) / iters; + }; + loop(500000); + let best = Infinity; + for (let r = 0; r < 5; r++) best = Math.min(best, loop(3000000)); + return best; +} + +const NAMES = { baseline: 'baseline (inline slot)', free: 'protocol-free direct', holder: 'holder / wrapping', registry: 'registry guard' }; + +if (process.argv[2]) { console.log(benchOne(process.argv[2]).toFixed(4)); process.exit(0); } + +const { execFileSync } = require('node:child_process'); +const ROUNDS = 9, samples = { baseline: [], free: [], holder: [], registry: [] }; +for (let r = 0; r < ROUNDS; r++) + for (const n of Object.keys(samples)) + samples[n].push(parseFloat(execFileSync(process.execPath, [__filename, n], { encoding: 'utf8', env: process.env }))); +const median = (a) => [...a].sort((x, y) => x - y)[a.length >> 1]; +const base = median(samples.baseline); +console.log(`get "label" from a plain object, median of ${ROUNDS} procs${process.env.EXTENDED ? ' (unrelated extend-type present)' : ''}:`); +for (const n of Object.keys(samples)) { + const m = median(samples[n]); + console.log(' ' + NAMES[n].padEnd(24), m.toFixed(4), 'ns ' + (m / base).toFixed(3) + 'x'); +}