Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 133 additions & 41 deletions doc/ai/adr/0007-deferred-core-fn-costs.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand All @@ -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.
35 changes: 35 additions & 0 deletions doc/ai/adr/0007-poc/README.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions doc/ai/adr/0007-poc/dce.cjs
Original file line number Diff line number Diff line change
@@ -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 });
30 changes: 30 additions & 0 deletions doc/ai/adr/0007-poc/extend.cjs
Original file line number Diff line number Diff line change
@@ -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');
15 changes: 15 additions & 0 deletions doc/ai/adr/0007-poc/measure-ceiling.cjs
Original file line number Diff line number Diff line change
@@ -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 <core.js in> <core-free.js out>
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);
Loading
Loading