signals: benchmarks measure the built package; the fold queue's pre-batch record (CodSpeed follow-up to #3774) - #3776
Conversation
In benchmark mode the benches' src/index.js import is aliased to the dist entry of the selected tier (SIGNALS_TIER, default prod for benchmarks) and the dist is externalized so Node links it natively — the prod build preserves modules, and vite-node's SSR transform would turn its cross-module imports back into the namespace-object property loads the alias exists to take out of the measurement. SIGNALS_BENCH=source keeps a from-source loop for local iteration. A missing or stale dist fails the run with the rebuild instruction instead of timing code not under test. CodSpeed already builds before benching; its first run after this re-baselines every signals bench. Plan sec. 43.1. Co-authored-by: Cursor <cursoragent@cursor.com>
The weak map keyed by target kept every container's last adopted-away backing alive until its next adoption: for a keyed reconcile the previous tick's whole tree promoted out of the nursery every tick (saturated listened-paths +15%, reconcile tree +10-13% over next on the dist, all of it GC); for a store adopted once, the old tree for the store's lifetime. The record is now foldOlds, parallel to foldList: t.v at queueFold for every queued target, released with the drain. That one record is the fold's base in every case (draft, eager adoption, mid-batch privatization, draft over an adopted raw), so privatizedOlds, draftedAdoptions and the adoption flag go. The target has no slot for it (ARRAY SHAPE RULE), so the by-target lookups (preBatch: a node born onto an open staging, a committed-frame read of a node-less staging, a park) index the list through a map built on first use per batch; the drain never builds it. After, on the dist vs next: tree reverse +3%, shuffle +4%, saturated +1.5%, sparse parity; fresh stores / owned backings / enumerate / storebench unchanged; GC totals identical to the head. Signals 4894/0, web 1132/0, solid 819/0; every size cap holds. Plan sec. 43.2. Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: 04dc66e The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Size (brotli, eager entry chunk)
Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps in |
Coverage Report for CI Build 37192567121Coverage remained the same at 75.991%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will regress 4 benchmarks
|
The warm-up store write sat inside createRoot, where the dev tier's owned-scope write check throws — the file failed to load under SIGNALS_TIER=dev, so CodSpeed compared its prod-dist values against a stale base (two of the four 'regressions' on #3776). Moved outside the root. Plan sec. 43.1 records the first dist-mode CodSpeed run. Co-authored-by: Cursor <cursoragent@cursor.com>
…e base 44.03 -> 44.78, page live 47.67 -> 48.43 Refetched content lands at the transition's commit (the staging, the content token, the two halves of the mount's follow effect, the staged data tables, and on L2 the switch's rebind in the effect half with the frameless host waiter). Measured against `next` @ 1a3f87f (after #3776): frames 12,997 -> 13,770 B (+773; +2,262 B minified, frames client +2,255), page base 44,029 -> 44,762 B (+733; +2,265 B minified), page live 47,654 -> 48,418 B (+764; +2,265 B minified). Caps set at head + 10 B. Ledger notes in scenarios.js re-applied from the PR's 1b347c7 against these numbers. Size-Exception: frames: eager client consumer 12,997 -> 13,770 B (cap 13.00 -> 13.78 KB); page: base server components 44,029 -> 44,762 B (cap 44.03 -> 44.78 KB); page: live server components 47,654 -> 48,418 B (cap 47.67 -> 48.43 KB) — #3759's staging so refetched content lands at the transition's commit; accepted by the maintainer (2026-10-04). Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Follow-up to #3774. CodSpeed (not a required check) listed 14 regressions on
1b9ceb679. Its method — vite-node, one ESM module per source file, every cross-module call a namespace-object property load — penalizes the carve's module layout and contradicts the dist on the core suite (plan §41.1). Four store shapes had not been dist-measured. This PR is both halves: make the next report measure shipped code, and fix the two shapes that were real.1. Benchmarks measure the built package (
packages/signals/vite.config.ts)vitest bench) the benches'src/index.jsimport is aliased to the dist entry of the selected tier —SIGNALS_TIER, defaulting to prod for benchmarks (the artifact users ship; the test suite keepsdev).SIGNALS_BENCH=sourcekeeps a from-source loop for local iteration.src/file newer than the entry) fails the run with the rebuild instruction rather than silently timing code not under test. A fresh checkout bumpssrcmtimes —pnpm build:jsfirst.codspeed.ymlalready builds before benching. Web's benches already measured signals through the packageexports; signals' own were the only ones on source.Consequence: the first CodSpeed run after this lands re-baselines every signals bench — a different series (bundled prod vs per-module dev source). The 14 entries on
1b9ceb679are superseded by that re-baseline.2. The four unmeasured shapes, audited on the dist
CodSpeed's benches reproduced exactly on the prod dist (Node 26, fork vs
1b9ceb679, interleaved):Two real (the tree pair is one shape), one the harness (L2 is a quarter faster on dbmon shallow), one noise.
Cause and fix (
store/store.ts)The fold queue kept each adopted container's pre-batch backing in a
WeakMapkeyed by target — no churn, but retention: an entry lives as long as its target, so every container held its last adopted-away backing until its next adoption. For a keyed reconcile the previous tick's entire tree (5,400 containers, 12k strings) stayed reachable through the scavenges, was promoted, and became old-generation garbage every tick — the profile showed GC at 16% vs 6%, and removing the two weak-map operations alone took the saturated shape under the fork. For a store adopted once, the old tree was retained for the store's lifetime. A memory bug as much as a speed one.The record now lives beside the target:
foldOlds, parallel tofoldList, set tot.vatqueueFoldfor every queued target and released with the drain. That one record is the fold's base in every case (draft, eager adoption, mid-batch privatization, draft over an adopted raw), soprivatizedOlds,draftedAdoptionsand the adoption flag go. The target has no slot for it (ARRAY SHAPE RULE: 20 named fields), so the by-target lookups (preBatch: a container node born onto an open staging, a committed-frame read of a node-less staging, a park) go through an index map built on first use per batch; the drain walks the two lists together and never builds it.After (same harness, medians of 3 interleaved rounds): tree reverse +3%, shuffle +4%, saturated +1.5%, sparse parity. The residual is the queue itself (~3.5% of a tick). No regression elsewhere: fresh stores, 2000 owned backings, enumerate, storebench at or ahead of the fork; GC totals over the store battery identical to the head.
Verification
+ createStore14519 B, every-store 28755, page base 44029, live 47654) — a few bytes over the head per scenario (arrays + lazy index against three collections).rules-index --checkgreen.documentation/plans/size-reduction-carve-step1.md).Public API changes
None. (Tooling: new
SIGNALS_BENCHenv var and aproddefault tier forvitest benchinpackages/signals— bench configuration only.)Made with Cursor