Skip to content

Commit 7d3e71d

Browse files
committed
ADR 0007: POC, decision matrix, and the breaking-ABI blocker
1 parent 9e5efe8 commit 7d3e71d

5 files changed

Lines changed: 242 additions & 15 deletions

File tree

doc/ai/adr/0007-deferred-core-fn-costs.md

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,13 @@ to custom impls that do not exist. (An earlier note swapped only the runtime
4444
core.js and reported +538; that captured the collection wave alone, undercounting
4545
the total by ~3x.)
4646

47-
The fix is to invert the dispatch, like CLJS: base fns handle only native types
48-
(Object/Array/primitive/Map/Set) and name no protocol slot; `extend-type` and
49-
`deftype`/`defrecord` install the dispatch. An app with no protocol impls pulls
50-
zero protocol machinery. The DCE-clean mechanism is for `extend-type` to wrap the
51-
affected core fns, so the base fn holds no protocol reference to retain. The
52-
binding constraint is that ESM imports are read-only, so the fns need a mutable
53-
holder or a registry indirection.
47+
The fix inverts the dispatch, like CLJS: base fns handle native types
48+
(Object/Array/primitive/Map/Set) inline and name no protocol slot; built-in
49+
branded types (records, sorted collections, atoms) dispatch by brand; a
50+
constructor registry, populated by `extend-type`/`deftype`/`defrecord`, is
51+
consulted only for the remaining non-native types. An app with no protocol
52+
impls pulls zero protocol machinery. A proof of concept (doc/ai/adr/0007-poc/)
53+
validated this, ruled out one alternative on perf, and surfaced one blocker.
5454

5555
### Variadic call-site cost
5656

@@ -67,15 +67,70 @@ analysis both succeed, and in throttled Chrome they did not (a fast-arity runtim
6767
#887 reverted it (double-evaluation); per-arity functions avoid that. This is the
6868
doc/ai/ideas.md "Emit direct fixed-arity calls" idea.
6969

70+
## Proof of concept
71+
72+
Three standalone experiments in doc/ai/adr/0007-poc/ (run with `node`), modelling
73+
`get`:
74+
75+
- perf.cjs - the plain-data hot path is flat (~1x baseline) for the protocol-free
76+
base. But the naive "wrap the fn" mechanism regresses 2.4x the moment any
77+
`extend-type` exists, because every call then threads the wrapper (and it
78+
compounds per extension). A registry the base consults only for non-native
79+
types stays flat in both cases.
80+
- dce.cjs - with a base that names no slot, a plain-data app bundle drops every
81+
protocol symbol (12 -> 0 in the model) and shrinks. Conditional: built-in
82+
types must not be eager-registered (`registry.set(T, ...)` at top level is a
83+
side effect that pins T into every bundle), so they stay brand-dispatched.
84+
- extend.cjs - a user can extend their own type to a built-in protocol (works via
85+
the registry). Overriding a native type is ignored (the inline path wins) -
86+
which is already true in today's squint, so no regression.
87+
88+
Decision matrix (++ good, -- deal-breaker, ~ caveat; squint down each column):
89+
90+
| criterion | baseline (slots) | protocol-free | wrapping | registry (hybrid) |
91+
|------------------------------|:----------------:|:-------------:|:--------:|:-----------------:|
92+
| hot path, plain data | ++ | ++ | ++ | ++ |
93+
| hot path, extends present | ++ | ++ | **--** | ++ |
94+
| DCE: slot symbols shake out | **--** | ++ | ++ | ++ |
95+
| runtime-extensible | ++ | **--** | ++ | ++ |
96+
| backward-compatible ABI | ++ | **--** | **--** | **--** |
97+
| built-in types DCE cleanly | ++ | ~ | ~ | ~ |
98+
99+
Reading the columns: baseline fails only DCE (the whole reason for this ADR);
100+
protocol-free and wrapping each carry two deal-breakers; the registry hybrid
101+
carries exactly one - the ABI break - and that break is shared by *every* option
102+
that recovers DCE. So the matrix collapses to a single trade: **DCE win vs ABI
103+
stability**. Every row-pair has one and only one `++`; you cannot have both.
104+
Among the mechanisms that do fix DCE, registry is strictly best.
105+
106+
## The blocker: it is a breaking ABI change
107+
108+
The mechanism moves protocol dispatch from prototype slots to a registry, so it
109+
changes the contract between compiled code and core.js. `extend-type`/`deftype`/
110+
`defrecord` would emit registry calls instead of `prototype[SLOT] = fn`, and core
111+
fns would consult the registry instead of `coll[SLOT]`. Consequences:
112+
113+
- Code compiled by an older squint (slot-style) run against a newer registry
114+
core.js has its protocol impls silently ignored, and vice versa. A pre-compiled
115+
published library that uses protocols breaks against a mismatched core.js.
116+
- It cannot be made backward-compatible. Keeping a slot check in the base fns for
117+
old code re-references the slot symbol, which pins it, which forfeits the entire
118+
DCE win. DCE and slot-compat are mutually exclusive.
119+
120+
So this is a breaking change requiring recompilation across the ecosystem, and it
121+
should be batched into a major version, not shipped in a point release. reagami
122+
itself is unaffected (it defines no protocols), but the change is squint-wide.
123+
70124
## Decision
71125

72126
Defer both. Ship neither fix now.
73127

74128
- The protocol bundle cost is ~1.5 KB gzip (~17% of the shipping app), in two
75-
waves. At that size the win clearly justifies the work; what defers it is
76-
risk, not payoff. Recovering it means reworking the protocol system that
77-
landed weeks ago, with regression risk to records, transients and
78-
`extend-type`. Do it once that code has settled, not against fresh code.
129+
waves. The size clearly justifies the work and the POC found a viable,
130+
perf-neutral mechanism (the registry hybrid; wrapping rejected on perf). What
131+
defers it is not payoff: it is a breaking ABI change (above) that belongs in a
132+
major version, plus the regression risk of reworking protocol code that landed
133+
weeks ago. Do it in a deliberate breaking window, once that code has settled.
79134
- The variadic runtime patch was tried in #961 and dropped: a 2-arg `min`/`max`
80135
arity that node cannot show a win for and that Chrome does not benefit from. The
81136
real fix is the compiler-level per-arity emission, which is larger.
@@ -91,10 +146,11 @@ Defer both. Ship neither fix now.
91146

92147
## When to revisit
93148

94-
- Protocol bundle cost: the ~17% is already a size that matters, so the gate is
95-
not the payoff but the protocol code settling. Revisit once records,
96-
transients and `extend-type` have proven stable in the wild, or sooner if a
97-
size-sensitive consumer is blocked. `test:size` keeps the number honest
149+
- Protocol bundle cost: the ~17% is already a size that matters, and the POC
150+
de-risked the mechanism, so the gate is the ABI break. Revisit at the next
151+
major/breaking-change window, once records, transients and `extend-type` have
152+
proven stable in the wild - or sooner if a size-sensitive consumer is blocked
153+
and a coordinated recompile is acceptable. `test:size` keeps the number honest
98154
meanwhile.
99155
- Variadic call cost: alongside the fixed-arity codegen work in doc/ai/ideas.md,
100156
which supersedes the reverted runtime patch.

doc/ai/adr/0007-poc/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# ADR 0007 proof of concept
2+
3+
Standalone `node` experiments backing doc/ai/adr/0007-deferred-core-fn-costs.md.
4+
No build step; each is self-contained.
5+
6+
- `perf.cjs` - hot-path perf of the dispatch mechanisms (`node perf.cjs`, or
7+
`EXTENDED=1 node perf.cjs`). Shows the registry hybrid is perf-neutral and
8+
wrapping regresses once an `extend-type` exists.
9+
- `dce.cjs` - whether a protocol-free base lets the slot symbols shake out of a
10+
plain-data bundle (`node dce.cjs`; needs esbuild, this repo's devDep).
11+
- `extend.cjs` - whether a user can still extend a built-in protocol in the
12+
registry model (`node extend.cjs`).

doc/ai/adr/0007-poc/dce.cjs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// ADR 0007 POC - DCE. Does a protocol-free base actually let the slot symbols
2+
// shake out of a plain-data bundle? Generates a baseline core (fns name their
3+
// slot) and a registry core (fns name no slot), bundles a plain-data app that
4+
// uses several fns against each, and reports bytes + how many slot symbols
5+
// survive. Needs esbuild (this repo's devDep).
6+
//
7+
// node dce.js
8+
9+
const { buildSync } = require('esbuild');
10+
const fs = require('node:fs');
11+
const os = require('node:os');
12+
const path = require('node:path');
13+
14+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adr7-dce-'));
15+
const slots = ['ILookup', 'IAssoc', 'IColl', 'ICount', 'IMap', 'IEquiv', 'IIndexed', 'IReset', 'ISwap', 'IWatch', 'IDeref', 'IEmpty'];
16+
17+
// baseline: 12 slot symbols; 5 fns each naming a few, so a broad app pins all 12
18+
let base = slots.map((s) => `export const ${s}__s = /* @__PURE__ */ Symbol('${s}_-s');`).join('\n') + '\n' +
19+
`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; }
20+
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}; }
21+
export function conj(c,x){ if(c[IColl__s]!==undefined)return c[IColl__s](c,x); if(c[IEmpty__s]!==undefined){} return [...c,x]; }
22+
export function deref(a){ if(a[IDeref__s]!==undefined)return a[IDeref__s](a); return a.v; }
23+
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; }`;
24+
25+
// registry: same fns, native inline, no slot symbols; registry only for extend-type
26+
let reg = `const reg=new Map();
27+
function dispatch(p,c){ const m=reg.get(p); return m&&m.get(c.constructor); }
28+
export function extendType(p,T,fn){ (reg.get(p)||reg.set(p,new Map()).get(p)).set(T,fn); }
29+
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; }
30+
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}; }
31+
export function conj(c,x){ if(reg.size){const f=dispatch('coll',c);if(f)return f(c,x);} return [...c,x]; }
32+
export function deref(a){ if(reg.size){const f=dispatch('deref',a);if(f)return f(a);} return a.v; }
33+
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; }`;
34+
35+
fs.writeFileSync(path.join(dir, 'core-base.js'), base);
36+
fs.writeFileSync(path.join(dir, 'core-reg.js'), reg);
37+
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));`;
38+
39+
for (const [name, core] of [['baseline (slots)', 'core-base.js'], ['registry', 'core-reg.js']]) {
40+
const entry = path.join(dir, 'app.js');
41+
fs.writeFileSync(entry, app.replace('CORE', JSON.stringify('./' + core)));
42+
const out = buildSync({ entryPoints: [entry], bundle: true, minify: true, format: 'esm', write: false, absWorkingDir: dir }).outputFiles[0].text;
43+
const symbolsLeft = (out.match(/_-s/g) || []).length;
44+
console.log(name.padEnd(18), 'bytes=' + out.length, ' slot-symbols-remaining=' + symbolsLeft);
45+
}
46+
fs.rmSync(dir, { recursive: true, force: true });

doc/ai/adr/0007-poc/extend.cjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// ADR 0007 POC - extensibility. In the registry model (base fn does native
2+
// dispatch inline, then consults a registry for non-native types), can a user
3+
// still extend a built-in protocol? Shows: yes for their own types; no for
4+
// native types - which matches current squint, where natives are fast-pathed
5+
// inline before any slot check.
6+
//
7+
// node extend.js
8+
9+
const reg = new Map(); // protocol -> (constructor -> impl)
10+
function extendType(proto, Type, fn) {
11+
if (!reg.has(proto)) reg.set(proto, new Map());
12+
reg.get(proto).set(Type, fn);
13+
}
14+
function get(coll, key, nf) {
15+
if (coll == null) return nf;
16+
if (coll.constructor === Object) { const v = coll[key]; return v === undefined ? nf : v; } // native inline
17+
if (Array.isArray(coll)) { const v = coll[key]; return v === undefined ? nf : v; } // native inline
18+
if (reg.size) { const m = reg.get('ILookup'); const f = m && m.get(coll.constructor); if (f) return f(coll, key, nf); }
19+
return nf;
20+
}
21+
22+
class RangeMap { constructor(lo, hi) { this.lo = lo; this.hi = hi; } }
23+
extendType('ILookup', RangeMap, (rm, k, nf) => (k >= rm.lo && k <= rm.hi ? k * 10 : nf)); // own type -> built-in protocol
24+
extendType('ILookup', Array, () => 'CUSTOM-ARRAY'); // native type -> built-in protocol
25+
26+
const ok = (a, b, m) => console.log((a === b ? 'ok ' : 'FAIL') + ' ' + m + ' => ' + a);
27+
ok(get(new RangeMap(1, 5), 3, 'nf'), 30, 'own type extends ILookup, hit');
28+
ok(get(new RangeMap(1, 5), 9, 'nf'), 'nf', 'own type extends ILookup, miss');
29+
ok(get([10, 20, 30], 1, 'nf'), 20, 'native Array: inline wins, extension ignored (as in current squint)');
30+
ok(get({ x: 1 }, 'x', 'nf'), 1, 'plain object: native inline, not overridable');

doc/ai/adr/0007-poc/perf.cjs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// ADR 0007 POC - perf. Does making core fns protocol-free (so the slots DCE
2+
// out) slow the hot path? Models `get` four ways and benchmarks the common
3+
// case: read a key from a plain object.
4+
//
5+
// Each variant runs in its own process (spawned below) so it gets a clean,
6+
// monomorphic JIT - a single shared call site would go megamorphic and the
7+
// first variant measured would look artificially fast.
8+
//
9+
// node perf.js # run the interleaved benchmark
10+
// EXTENDED=1 node perf.js # simulate an unrelated extend-type in the app
11+
// node perf.js <variant> # (internal) benchmark one variant, print ns/op
12+
13+
const ILookup__lookup = Symbol('ILookup_-lookup');
14+
15+
function get_baseline(coll, key, nf) { // current squint: names the slot -> pins it
16+
if (coll == null) return nf;
17+
if (coll.constructor === Object) { const v = coll[key]; return v === undefined ? nf : v; }
18+
if (Array.isArray(coll)) { const v = coll[key]; return v === undefined ? nf : v; }
19+
if (coll instanceof Map) return coll.has(key) ? coll.get(key) : nf;
20+
if (coll[ILookup__lookup] !== undefined) return coll[ILookup__lookup](coll, key, nf);
21+
return nf;
22+
}
23+
function get_free(coll, key, nf) { // native only, no slot (not runtime-extensible)
24+
if (coll == null) return nf;
25+
if (coll.constructor === Object) { const v = coll[key]; return v === undefined ? nf : v; }
26+
if (Array.isArray(coll)) { const v = coll[key]; return v === undefined ? nf : v; }
27+
if (coll instanceof Map) return coll.has(key) ? coll.get(key) : nf;
28+
return nf;
29+
}
30+
const holder = { get: (c, k, nf) => get_free(c, k, nf) }; // extend-type wraps holder.get
31+
function get_holder(coll, key, nf) { return holder.get(coll, key, nf); }
32+
const registry = new Map(); // constructor -> impl ; base consults it only for non-native
33+
function get_registry(coll, key, nf) {
34+
if (coll == null) return nf;
35+
if (coll.constructor === Object) { const v = coll[key]; return v === undefined ? nf : v; }
36+
if (Array.isArray(coll)) { const v = coll[key]; return v === undefined ? nf : v; }
37+
if (coll instanceof Map) return coll.has(key) ? coll.get(key) : nf;
38+
if (registry.size) { const f = registry.get(coll.constructor); if (f) return f(coll, key, nf); }
39+
return nf;
40+
}
41+
if (process.env.EXTENDED) { // an unrelated protocol extension exists somewhere in the app
42+
class Other {}
43+
const prev = holder.get;
44+
holder.get = (c, k, nf) => (c instanceof Other ? nf : prev(c, k, nf));
45+
registry.set(Other, (c, k, nf) => nf);
46+
}
47+
48+
const objs = Array.from({ length: 1024 }, (_, i) => ({ id: i, label: 'row ' + i, selected: false }));
49+
50+
function benchOne(V) {
51+
const loop = (iters) => {
52+
let s; const t0 = process.hrtime.bigint();
53+
switch (V) {
54+
case 'baseline': for (let i = 0; i < iters; i++) s = get_baseline(objs[i & 1023], 'label', null); break;
55+
case 'free': for (let i = 0; i < iters; i++) s = get_free(objs[i & 1023], 'label', null); break;
56+
case 'holder': for (let i = 0; i < iters; i++) s = get_holder(objs[i & 1023], 'label', null); break;
57+
case 'registry': for (let i = 0; i < iters; i++) s = get_registry(objs[i & 1023], 'label', null); break;
58+
}
59+
globalThis.__sink = s;
60+
return Number(process.hrtime.bigint() - t0) / iters;
61+
};
62+
loop(500000);
63+
let best = Infinity;
64+
for (let r = 0; r < 5; r++) best = Math.min(best, loop(3000000));
65+
return best;
66+
}
67+
68+
const NAMES = { baseline: 'baseline (inline slot)', free: 'protocol-free direct', holder: 'holder / wrapping', registry: 'registry guard' };
69+
70+
if (process.argv[2]) { console.log(benchOne(process.argv[2]).toFixed(4)); process.exit(0); }
71+
72+
const { execFileSync } = require('node:child_process');
73+
const ROUNDS = 9, samples = { baseline: [], free: [], holder: [], registry: [] };
74+
for (let r = 0; r < ROUNDS; r++)
75+
for (const n of Object.keys(samples))
76+
samples[n].push(parseFloat(execFileSync(process.execPath, [__filename, n], { encoding: 'utf8', env: process.env })));
77+
const median = (a) => [...a].sort((x, y) => x - y)[a.length >> 1];
78+
const base = median(samples.baseline);
79+
console.log(`get "label" from a plain object, median of ${ROUNDS} procs${process.env.EXTENDED ? ' (unrelated extend-type present)' : ''}:`);
80+
for (const n of Object.keys(samples)) {
81+
const m = median(samples[n]);
82+
console.log(' ' + NAMES[n].padEnd(24), m.toFixed(4), 'ns ' + (m / base).toFixed(3) + 'x');
83+
}

0 commit comments

Comments
 (0)