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
5 changes: 5 additions & 0 deletions .changeset/store-fold-queue-prebatch-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

Store fold queue: the pre-batch committed backing is recorded beside the queued target and released with the drain. The weak map it replaces 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 (the saturated listened-paths and reconcile-tree shapes ran 10–15% over `next` on that alone); for a store adopted once, the old tree for the store's lifetime. The per-batch side maps for mid-batch privatizations and drafts over adoptions fold into the same record.
110 changes: 110 additions & 0 deletions documentation/plans/size-reduction-carve-step1.md
Original file line number Diff line number Diff line change
Expand Up @@ -5461,6 +5461,116 @@ A-rules carry `Mechanism (L2, 2026-10-04)` against the running code; the
pre-L2 paragraphs are under History verbatim. The branch's fate: **push
and open one PR against `next`**, this document the design record.

## 43. After the merge — CodSpeed against the dist; the fold queue's record (2026-10-04, 1:30–2:10 AM)

#3774 merged as `1b9ceb679`. CodSpeed (not a required check) listed 14
regressions on it; §41.1 had already shown its method — vite-node, one ESM
module per source file, every cross-module call a namespace-object
property load — penalizes the carve's layout and contradicts the dist on
the core suite. Four store shapes had not been dist-measured. This section
is both halves: the audit, and making the next report mean something.

### 43.1 Benchmarks measure the built package

`packages/signals/vite.config.ts`: in benchmark mode the benches'
`src/index.js` import is aliased to the dist entry of the tier
(`SIGNALS_TIER`, default **prod** for benchmarks — the artifact users ship;
the test suite keeps dev), and the dist is **externalized** so Node links it
natively — the prod build preserves modules, and vite-node's SSR transform
would have turned its cross-module imports straight back into the namespace
loads the alias exists to remove. `SIGNALS_BENCH=source` keeps a from-source
loop for local iteration. A stale or missing dist fails the run with the
rebuild instruction instead of silently timing code not under test (a fresh
checkout bumps `src` mtimes — rebuild). The CodSpeed workflow already
builds before benching; no workflow change. Web's benches already measured
signals through the package `exports` (its `development` condition →
`dist/dev.js`); signals' own were the only ones on source.

Consequence for CodSpeed: the first run after this lands re-baselines every
signals bench (a different series — bundled prod instead of per-module dev
source). The 14 "regressions" on `1b9ceb679` are superseded by that
re-baseline; the four shapes below are the only ones that were real, and
43.2 is their fix.

**The PR's first CodSpeed run** (#3776, `3ab88fd5e` vs `next`): 84
improved (×2–5 — the layout cost, now gone from the measurement: dbmon
full tick 750 → 337 ms of simulated time, createOwners ×5), 100 untouched,
4 "regressed", flagged by CodSpeed itself as compared across different
runtime environments. All four are the instrument: `merge-merge-mixed(deep
0, 100, 3, 2)` 46 → 819 µs is a GC landing inside a ~75 ns operation's
measured window (`--predictable-gc-schedule`; the operation itself is 72–98
ns on prod and dev dist alike, JIT or `--no-opt`, every variant);
`merge-static(deep)` −7% is the same window's small cousin; and the two
`projection-root-write` rows had no live base at all — that file **fails to
load under the dev tier** (a store write inside `createRoot` trips the
owned-scope check), so its base was stale while the prod run has values
(20 µs each, consistent with the file's own reference line). The warm-up
write moved outside the root so the file runs in every tier.

### 43.2 The four unmeasured shapes, on the dist

Harness `/tmp/carve/cs3.mjs`: the CodSpeed benches reproduced exactly (one
`createEffect` walking all 1111 tree nodes; one effect reading all ~12k
leaves), prod dist, Node 26, fork (`next` @ `8950bb7b7`) vs L2, interleaved.

| shape (CodSpeed name) | fork | L2 @ `1b9ceb679` | |
|---|---|---|---|
| store reconcile tree reverse: 1111 keyed nodes | 1.21–1.25 ms | 1.36–1.38 | +11% |
| store reconcile tree shuffle: 1111 keyed nodes | 1.02–1.04 | 1.12–1.18 | +10–13% |
| reconcile: deep tree, all ~12k paths subscribed | 3.79 | 4.37–4.41 | +15% |
| reconcile: deep tree, 10 of ~12k paths subscribed | 0.37 | 0.22–0.38 | parity/ahead |
| dbmon shallow full tick (1000 rows; `shapes4.mjs`) | 0.89–0.93 | 0.67–0.69 | L2 −25% |

So two of CodSpeed's four were real (the tree pair is one shape), one was
the harness (dbmon shallow — L2 is a quarter faster on the dist), one was
noise.

**Cause.** The profile's self-time differences were small (`queueFold`,
`drainFolds`, `preBatch`, `foldTarget`, `parentSlotKey` ≈ 7–9%) but
`(garbage collector)` was 16% against 6%, and removing the two weak-map
operations alone took the saturated shape *under* the fork. §41.5's fold
queue kept each adopted container's pre-batch backing in a `WeakMap`
keyed by target, "written in place batch after batch — no churn". 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; for a store adopted once, the
old tree is retained for the store's lifetime. A memory bug as much as a
speed one.

**Fix.** The record moves beside the target: `foldOlds`, parallel to
`foldList`, set to `t.v` at `queueFold` for **every** queued target
(adoption or draft) and cleared with the drain. That one record is the
fold's base in every case — a draft's backing does not move before the
fold (the record is `t.v` still), an adoption's swaps eagerly (the record
is the frame committed readers keep), a mid-batch privatization or a draft
over an adopted raw moves it again (the record is what the batch started
from) — so `privatizedOlds`, `draftedAdoptions` and the adoption flag on
`queueFold` go too. The target has no slot for the record (ARRAY SHAPE RULE:
20 named fields, 21 ≡ 0 mod 3 normalizes array targets to dictionary
mode), so the by-target lookups (`preBatch`: a container node born onto an
open staging, a committed-frame read of a node-less staging, `_storePark`)
go through an index map built on first use per batch and kept in step by
`queueFold`; the drain walks the two lists together and never builds it.
The arrays' `length = 0` releases the entries — nothing outlives the batch.

**After** (same harness, interleaved, 3 rounds, medians): tree reverse
1.232 → **1.267** (+3%), shuffle 1.196 → **1.245** (+4%, 1.137 vs 1.153 in
the profiled run), saturated 3.166 → **3.212** (+1.5%), sparse 0.333 →
0.330. The residual is the queue itself — `drainFolds` + `foldTarget` +
`parentSlotKey` ≈ 3.5% of a tick — the price of a staging per adopted
container under the hold model. `adoptPB` at 21% self is the per-adopted-raw
`storeLookup.set` registration, the same in both arms (the lever listed with the deep-tick ruling, §42).
No regression elsewhere: fresh stores 1.00–1.27 ms (fork 1.16), 2000 owned
backings 0.30–0.32 (fork 0.35), enumerate 7.3/7.9 (fork 8.5/8.1),
storebench parity; GC totals over the store battery identical to the head
(40 scavenges 87 ms, 4 major 94 ms; fork 38/108 + 5/98). Size: within every
cap (`+ createStore` 14519, every-store 28755, page base 44029 — 1 B under
its cap — live 47654); a few bytes over the head per scenario, the arrays
and the lazy index against the three collections. Signals **4894 / 0**,
web 1132 / 0, solid 819 / 0.

---

## Appendix — ledger (verbatim)
Expand Down
79 changes: 46 additions & 33 deletions packages/signals/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,12 +720,11 @@ function ensurePB(target: StoreTarget): Record<PropertyKey, any> {
// adoption and the draft is a mainline layer above it (#3612/#3688: a
// mainline setter mid-hold publishes mainline; the held keys stay held).
pb = target.pb = cloneRaw(pb, target);
draftedAdoptions.add(target);
const k = target.k;
if (k !== null && !(k._config & CONFIG_HELD)) k._pendingValue = pb;
} else if (pb === null) {
const v = target.v;
if (!directCommit) queueFold(target, false);
if (!directCommit) queueFold(target);
if (
!target.ch &&
!Array.isArray(v) &&
Expand All @@ -747,10 +746,8 @@ function privatizeCommitted(target: StoreTarget): void {
if (isOwned(target.v)) return;
const before = target.v;
const clone = cloneRaw(before, target);
// Mid-batch (a staging is open on this target): the fold must still see
// the backing the batch started from (`preBatch`).
if (target.pb !== null && target.pb !== before && !draftedAdoptions.has(target))
privatizedOlds.set(target, before);
// Mid-batch (a staging is open on this target) the fold still sees the
// backing the batch started from: the queue recorded it (`foldOlds`).
target.v = clone;
target.ch = false;
if (target.u) {
Expand Down Expand Up @@ -809,7 +806,7 @@ export function adoptPB(
incoming: Record<PropertyKey, any>,
notify = true
): void {
if (!directCommit) queueFold(target, true);
if (!directCommit) queueFold(target);
let base: Record<PropertyKey, any>;
if (target.pb !== null) {
if (target.ovl) materializePB(target);
Expand Down Expand Up @@ -852,35 +849,48 @@ export function adoptPB(
* of the tick's young-generation GC, and the whole of its gap to `next`
* (plan sec. 41.5).
*
* The pre-batch committed backing (`preBatch`): a DRAFT's is `t.v` itself —
* the backing does not move until the fold — so nothing is recorded for
* it (2000 fresh one-key stores per flush recorded 2000 weak-map entries
* for nothing, 2× the fork); an ADOPTION swaps the backing eagerly, so its
* pre-batch backing is kept in a weak map written in place batch after
* batch (the containers of a keyed reconcile are reused — no churn); the
* two rare cases that move a draft's backing mid-batch — a privatization
* (a child's path copy repointed the parent), a draft over an adopted raw
* (cloned) — are kept in small per-batch maps. */
* Beside each target, its PRE-BATCH committed backing — `t.v` as the batch
* opened on it (`foldOlds`, parallel to `foldList`). That one record is the
* fold's base in every case: a draft's backing does not move until the fold
* (so the record is `t.v` still), an adoption swaps it eagerly (the record
* is the frame committed readers keep), a mid-batch privatization or a
* draft over an adopted raw moves it again (the record is what the batch
* started from, which is all the fold wants to know). The record lives
* exactly as long as the batch: a weak map keyed by target kept every
* container's LAST adopted-away backing alive until its next adoption —
* for a keyed reconcile, the whole previous tree promoted out of the
* nursery every tick (the saturated listened-paths shape ran 15–25% over
* the fork, all of it this; plan sec. 43.2) — and for a store adopted
* once, for its lifetime.
*
* The target carries no slot for its record (ARRAY SHAPE RULE, target.ts),
* so a lookup by target (`preBatch`: a container node born onto an open
* staging, a committed-frame read of a node-less staging, a park) indexes
* the list through a map built on first use per batch — the drain itself
* walks the two lists in step and never builds it. */
let foldList: StoreTarget[] = [];
let foldOlds: Record<PropertyKey, any>[] = [];
let foldSpare: StoreTarget[] = [];
const adoptOlds = new WeakMap<StoreTarget, Record<PropertyKey, any>>();
const privatizedOlds = new Map<StoreTarget, Record<PropertyKey, any>>();
const draftedAdoptions = new Set<StoreTarget>();
let foldOldsSpare: Record<PropertyKey, any>[] = [];
let foldIndex: Map<StoreTarget, number> | null = null;

/** Before the staging is set and before an adoption swaps the backing. */
function queueFold(target: StoreTarget, adoption: boolean): void {
function queueFold(target: StoreTarget): void {
if (target.pb !== null) return;
schedule();
if (adoption) adoptOlds.set(target, target.v);
if (foldIndex !== null) foldIndex.set(target, foldList.length);
foldList.push(target);
foldOlds.push(target.v);
}

/** The committed backing before this batch (valid while `pb !== null`). An
* eager adoption nothing wrote after is `pb === v`; one a draft wrote over
* is listed; a privatized draft's is listed; any other draft's is `v`. */
/** The committed backing before this batch (valid while `pb !== null`). */
function preBatch(t: StoreTarget): Record<PropertyKey, any> {
if (t.pb === t.v || draftedAdoptions.has(t)) return adoptOlds.get(t) ?? t.v;
return privatizedOlds.get(t) ?? t.v;
if (foldIndex === null) {
foldIndex = new Map();
for (let i = 0; i < foldList.length; i++) foldIndex.set(foldList[i], i);
}
const at = foldIndex.get(t);
return at === undefined ? t.v : foldOlds[at];
}

/** The flush committed its pending nodes: fold every pending backing whose
Expand All @@ -891,21 +901,24 @@ function preBatch(t: StoreTarget): Record<PropertyKey, any> {
function drainFolds(): void {
if (foldList.length !== 0) {
const list = foldList;
const olds = foldOlds;
foldList = foldSpare;
foldOlds = foldOldsSpare;
foldSpare = list;
foldOldsSpare = olds;
foldIndex = null;
for (let i = 0; i < list.length; i++) {
const t = list[i];
if (t.pb === null) continue; // folded already (a direct commit)
const k = t.k;
if (k !== null && k._pendingValue !== NOT_PENDING)
foldList.push(t); // held: commits with its hold
else foldTarget(t, preBatch(t));
if (k !== null && k._pendingValue !== NOT_PENDING) {
// held: commits with its hold
foldList.push(t);
foldOlds.push(olds[i]);
} else foldTarget(t, olds[i]);
}
list.length = 0;
if (foldList.length === 0) {
if (privatizedOlds.size !== 0) privatizedOlds.clear();
if (draftedAdoptions.size !== 0) draftedAdoptions.clear();
}
olds.length = 0;
}
if (deferredReleases.size !== 0) {
for (const node of deferredReleases) {
Expand Down
12 changes: 8 additions & 4 deletions packages/signals/tests/store/projection-root-write.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,16 @@ describe(`one root key per commit, ${KEYS}-key record`, () => {

const [store, set] = createStore<Record<string, { n: number }>>(seed());
void store.k0;
set(d => {
d.k0 = { n: -1 };
});
flush();
setStore = set;
});
// The warm-up write happens outside the root: inside it, the dev tier's
// owned-scope write check throws and the whole file fails to load under
// `SIGNALS_TIER=dev` — leaving CodSpeed to compare a prod run against a
// stale base.
setStore(d => {
d.k0 = { n: -1 };
});
flush();

bench("projection derive: delete + set one ROOT key (#3352)", () => {
for (let c = 0; c < COMMITS; c++) {
Expand Down
Loading
Loading