SIGN IN SIGN UP

perf: port cnfast's cache-path wins and gate caching of joined strings (#10)

* perf: route object and array args through the arg-identity cache

Non-string args used to short-circuit to a full clsx join, so every
`cn(base, { active })` call hashed a fresh joined string. They now resolve
in place and continue down the string path (ported from cnfast): a one-key
object resolves to the key string itself, whose identity is stable across
renders, so the bucket probe and sequence prediction hit like an all-string
call. Multi-key objects still produce a fresh string but skip the clsx
apply, which alone halves their cost.

Paired A/B vs the previous build, 6 alternating rounds, median cand/base:
object-24 0.375 node / 0.564 bun, object-2key-24 0.505 / 0.501,
mixed-48 0.439 / 0.593; one-16, two-24, three-24, six-24, array-24 within
noise. 56,346 differential + 100k fuzz + 5,054 custom-config + CLI green,
plus a 300k-call stateful fuzz over object/array args with mutated
identities. Size gate: 10,338 gz.

* perf: re-probe resolved args, flatten one-arg arrays, JSC thin hit front

Three more cache-path ports from cnfast, each gated with the paired A/B
runner (alternating isolated processes, medians over 4 to 8 rounds):

- resolveArgs re-runs the prediction and self-repeat probes after a
  non-string arg resolves, since a one-key object resolves to its key
  string and that identity is stable: object-24 0.88, nested-array-24
  0.87 on node; 0.87 / 0.91 on bun.
- a single array argument takes the arg path (clsx flattens it to the
  same join), so `cn([base, variant])` hits by element identity:
  array-24 0.10 node, 0.15 bun.
- on JavaScriptCore the whole-string cache front is a two-line closure
  with the doorkeeper miss body outlined (JSC will not inline the big
  closure): short 0.62, long 0.71, workset 0.96, one-8k 0.93 on bun. V8
  keeps the single closure: the outlined form measured 1.17 on long
  strings there, so the split is selected once per engine.

An outlined arity-2/3 resolve helper was tried and dropped: inline it
blows V8's inline budget for the front (two-string calls 1.47), outlined
it is no better than the resolveArgs re-probe.

Gates: 56,346 differential, 100k fuzz, 5,054 custom-config, CLI, plus a
300k-call stateful object/array fuzz with mutated identities, on node and
bun. Size gate 10,453 gz (budget 10,500).

* refactor: one doorkeeper body, descriptive names in the new cache paths

The JSC hit front now falls through to the full mergeCached closure
instead of a copied miss body: zero duplication, and the repeated
dictionary lookup on a miss rides the hash the front just cached (bun
cold rows 1.03-1.04, hit rows unchanged). resolve1 is mergeSingleValue,
resolved is hasResolvedValue, and the single-letter locals in the new
code carry names.

* size: raise the transfer tripwire to 10,650 B for the arg-cache ports

CI's zlib measures the branch at 10,590 B against main's 10,507 B, so
the ~85 B for object/array args on the arg cache, the lone-array arg
path, and the JSC-only cache front do not fit the 43 B of headroom.
Recorded in the gate's history comment like the earlier raises.

* perf: let an arg-cache bucket hold 256 tuples instead of 8

The arg cache keys on the first argument, which is the component's base
string at every usage site, so one key routinely carries dozens of
tuples (54 under one key in the largest corpus repo; 13 keys exceed 8).
With an 8-entry cap each call evicted a sibling and inserted a fresh
entry, so the sequence chain never stabilised: 64 sites sharing a base
cost 301 ns per call, 6.4 ns with the cap raised (cnfast 6.9). Shuffled
order 313 to 13.8 ns. All other shapes within noise; 256 sites still
6.1 ns. Keying on the last arg, cnfast's choice, would be worse on this
corpus (90 keys over 8 tuples).

* changeset: note the arg-cache bucket cap

* perf: gate caching of joined strings on the doorkeeper

A string the arg path just built cannot be cached by identity, and a
never-seen key is the expensive dictionary case: V8 hashes and
internalizes it before the miss (~200 ns at 17 chars, ~1.1 µs at 360).
The engine now exposes seenBefore(), an O(1) doorkeeper probe that
records the sighting, and wrapClsx merges a first sighting straight
through with mergeUncached: no dictionary lookup, no cache entry, no
arg-cache entry, chain left untouched. A repeat pays the lookup once and
caches like before.

That also repairs the 256-entry bucket for sites whose third arg is
unique every call: one-shot joins never enter a bucket, so the miss no
longer scans 256 dead entries.

The doorkeeper's positional hash sampled the head, quarter points, and
tail, so `w-[123px]` strings with the digits a few chars from the end
all collided, looked "seen", and were admitted every call. It now folds
five more chars from each end in one loop (two reads per step; a bigger
fold pushed mergeCached out of V8's inline budget and cost 10-24% on
one-string hits).

Paired A/B vs the previous build, medians of 3-6 rounds:
  node: stable base + unique arbitrary arg 0.34, 3-string base + unique
        arg 0.43, 24 stable sites interleaved with 24 dynamic 0.16;
        cold arbitrary-value renders 0.83; one-16 1.03, one-8k 0.97,
        two/three/six-arg 1.00, ssr 1.02, workset 0.96
  bun:  0.32 / 0.58 / 0.14; arb 0.78; short 1.00, workset 1.01, ssr 1.00

Gates: 56,346 differential, 100k fuzz, 5,054 custom-config, CLI,
hardening, 300k stateful object/array fuzz, and a 1,009,855-call corpus
replay with dynamic suffixes, on node and bun. Size 10,555 gz; the
tripwire moves to 10,750 with a history note.

* perf: Map substrate for the whole-string cache on JavaScriptCore

JSC looks up a string key in a dictionary-mode object in ~21 ns and a
never-seen key in ~220 ns, where a Map takes 6 and 75; V8 is the reverse
(5 vs 18 ns on a hit), which is why the dictionary was right for node
and wrong for bun on anything larger than a few dozen strings. The JSC
front now runs mergeCachedMap, the same doorkeeper logic over two Maps,
and V8's mergeCached is untouched. An accessor layer shared by both was
tried first and cost V8 5% on every hit, so the Map version is a copy.

Paired A/B, bun, 4 rounds: one-8k 0.27, workset 0.31, ssr 0.92, arb
0.98, stable-24-after-dynamic 1.01; short 1.12 and long 1.16 (16 and 64
recurring strings, ~0.5 ns). Node, 8 rounds: one-16 1.01, one-8k 0.97,
two-24 1.00, short 1.03, workset 1.01.

Gates on node and bun: differential, fuzz, custom-config, CLI,
hardening, 300k stateful object/array fuzz, 1,009,855-call corpus
replay with dynamic suffixes. Size 10,602 gz; tripwire to 10,800.
A
Aiden Bai committed
bf918c26831c2bece0e779f78bf5fd0f2d7a57e0
Parent: dca99c4
Committed by GitHub <noreply@github.com> on 9/1/2026, 11:42:02 PM