Store perf: structural hot-path pass, shallow stores + markRaw, effect-run trims#2931
Conversation
…f, wrap-free reads Audit-driven (four parallel deep audits over reconcile, the trap read path, the scheduler, and peer implementations), validated per-change on a signals-only dbmon micro-bench (keyed reconcile of a fresh 7k-object graph + 13k effect re-reads per tick): 7.14 -> 4.98ms (-30%). - storeLookup maps raw -> TARGET; reconcile recurses target-to-target (applyStateChild), dropping the wrap()/$PROXY/$TARGET round-trip per child, and a lookup miss skips the never-observed subtree outright: the diff is O(observed), not O(payload). Family lookups keep their proxy contract. - Trap fast path returns node values without re-wrapping: all node writes wrap before setSignal (dev assertion enforces it); snapshot capture keeps the wrapping path. - Keyed arrays: identity-equal slots skip dispatch; fully-matched equal-length passes return before staging allocations/membership sync. - applyDescendants + syncArrayNodeMembership iterate in place (no key arrays per object per pass). - Effect values direct-commit on plain sync flushes (the pending round-trip sequences transition reveals; both-null paid it for free). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
createStore(value, { shallow: true }): root keys fully reactive, values
raw records replaced by reference. Sub-boundary setter mutation throws
(replacement-only contract); reconcile at the boundary is a positional
per-slot reference compare (applyStateShallow); optimistic staging and
ambient revert flow through the existing override layers with the raw
base untouched (test-pinned). Plumbed through createProjection and
createOptimisticStore options.
markRaw(value): sticky raw registry — wrap() serves marked values as-is
in every store and family (checked on creation/ingest paths only; reads
untouched), so a record served raw once keeps a single identity
everywhere. Shallow ingestion marks automatically.
Measured (signals micro-bench, dbmon shape): reconcile 3.2 -> 0.22ms,
tick 6.5 -> 2.6ms; octane dbmon harness all gates pass at 1.74x octane
(1.36x with textContent bindings) — ahead of React on every op.
+1.4KB gz. 1111 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rap read Four small, individually A/B-measured cuts of dead per-run work on the plain sync path (E2-E4 + P2 from the store perf audit): - recompute: skip clearStatus on status-free nodes — every branch of its body is gated on one of the nine guarded fields, so the call is a guaranteed no-op there (effects carry _notifyStatus and still run it). - recompute: skip insertSubs when _subs is null (verified total no-op — pre-loop locals are pure reads, no scheduling of its own). - setSignal/optimistic write: gate the _syncCompanions hook on companion existence — its entire body is the _pendingSignal / _latestValueComputed pokes, and companions install the hook when created. (The audit's suggested transition gate was wrong: pending signals observably update outside transitions.) - store get trap: readNodeFast — read()'s plain-signal fast path as a standalone entry, bailing to full read() on any global read window or node layer. Consistent ~2-3% on store-read-heavy flushes (isolated 7-rep A/B, every with-rep <= every without-rep). Honest baseline note: earlier session numbers were machine-warmup inflated; against a clean stash A/B baseline (shallow 2.52ms tick) these land at ~2.49 with all four applied. 1111 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urface Adversarial audit follow-up (three verified blockers shared one root cause: reconcile recursion treated any wrappable pair as a recursable store child, which raw-marked values broke — the recursion silently no-op'd instead of replacing the slot): - diffNodeKey / applyArrayItem / keyed+positional loops (fast and slow) route raw-marked pairs to leaf slot replacement; applyStateChild and descendKey route STORE_SHALLOW targets to applyStateShallow. - applyStateShallow folds setter-staged overrides into its diff (override-aware prev resolution, layer cleared with the swap) instead of delegating to a slow path whose recursion could not see raws. - Write-scope reads on shallow targets serve the raw: read-then-replace, filter/pop removal idioms, storePath slot writes, and shallow projection derives all work; in-place mutation of a raw is inert by construction (same contract as markRaw children in deep stores). - markRawIngest dev-guards against split identity (value already tracked deep); wrapShallow returns the existing shallow proxy and guards proxy inputs; deep() skips wrapping below raw values. - Surface: markRaw re-exported from solid-js (client + server identity stub, export parity green); plain-form createStore / createOptimisticStore accept options; ProjectionOptions.shallow typed. - New CodSpeed bench: tests/store/reconcile-dbmon.bench.ts (deep + shallow reconcile lanes, full + partial ticks). 18 shallow tests (write-then-reconcile, markRaw-in-deep reconcile, shallow-in-deep, object stores, filter idiom, keyed-positional pin, dev guards); 1,118 signals / 492 solid / 544 web tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
🦋 Changeset detectedLatest commit: 1e4bcae The changes in this PR will be included in the next version bump. This PR includes changesets to release 9 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 |
frames/package.json names its nested package "@solidjs/web/frames", so under NodeNext the self-reference rule never applies to the "@solidjs/web/server-functions/client" import from frames/src — and a fresh install has no node_modules/@solidjs/web to fall back on (dev checkouts that ever ran the `link` script have the symlink, which is why this passed locally). Map the one specifier to the generated declaration via tsconfig paths; types:copy-server-functions emits it earlier in the same sequence. Reproduced on a fresh clone of next (same TS2307 as CI); with this mapping the full turbo build passes 12/12. Unblocks the CodSpeed run on this PR; base branch needs the same line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
CI note: the initial red was inherited from |
size-limit report 📦
|
Coverage Report for CI Build 30032310167Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Warning No base build found for commit Coverage: 75.328%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats
💛 - Coveralls |
… flag) The signals:+createStore budget (11.6 KB brotli) is exceeded by 728 B: shallow stores + markRaw land in the plain createStore graph (the options.shallow branch retains wrapShallow/applyStateShallow under tree-shaking) along with reconcile's raw-leaf handling. Bumped to 12.5 KB with the reasoning inline. If the cost is unacceptable, the alternatives are a separate createShallowStore entry (tree-shakeable, different API than the agreed shallow option) or deeper code golf — explicitly a maintainer trade-off to adjudicate on the PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Second CI item: the |
Merging this PR will regress 1 benchmark
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing |
CodSpeed flagged two deep-recursive reconcile benches (-7.6% tree shuffle, -6.5% deep() effect): the raw-leaf checks cost two WeakSet probes per recursable pair, paid by every keyed reconcile whether or not shallow stores exist. isRawValue (and wrap's refusal) now gate on a rawValuesUsed flag flipped at first mark — shallow-free apps pay one predictable branch, shallow users unchanged. markRaw is no longer public API (Ryan: shallow: true is the only surface for now) — removed from @solidjs/signals and solid-js exports (client + server stub), kept internal for shallow ingestion and future proxy-props/external-instance work; export parity green again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CodSpeed still flagged the two deep-recursive reconcile benches (~ -6-7%) after the rawValuesUsed gate: the real cost was the per-key function-call extractions on the hottest loops — diffNodeKey (7-arg call per key of every diffed record), the eachNodeKey closure pair in syncArrayNodeMembership, and descendKey (call per key of every descended object). Wall-clock pipelines these; instruction counts don't. - diffNodeKey: body duplicated inline across the tracked/array and for-in loops (comment marks the duplication as deliberate). - syncArrayNodeMembership: back to inline branch loops, no closures. - applyDescendants: cheap bails (noded key, primitive) inline; only genuine descent candidates pay a call (descendInto keeps the shared dispatch). Raw-leaf semantics unchanged; 1118 tests green; size scenarios pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The residual instruction-count regression on the deep-recursive reconcile benches included two isRawValue CALLS per recursable pair — the gate boolean lived inside the function, so the call survived its own short-circuit. rawValuesUsed is now exported as a live binding and every reconcile/deep() call site checks it before calling: apps with no shallow store or raw mark anywhere pay a single imported-boolean branch per pair. Shallow users unchanged (the WeakSet probes only ever ran when marks exist). 1118 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documents the shallow store option shipped in #2931: single-layer stores for record-granularity data (root keys reactive, values plain records replaced by reference), the replacement contract (setter reads serve the record, in-place mutation is inert, once-plain-always-plain identity), positional reconcile at the boundary with keyed identity owned by <For keyed>, projection/optimistic support, and when to prefer the default deep store. Also updates reconcile's signature docs: key defaults to "id", null selects positional matching (the 1.x { key: null, merge: true } pattern). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four commits from the store/reconcile performance effort, each independently measured on a signals-only dbmon-shaped micro-bench (1000 rows × 13 bindings, fresh keyed payload per tick) and validated on the octane benchmark harness.
Commits
Store structural hot-path pass —
storeLookupmaps raw → target (reconcile recurses target-to-target, no wrap/$PROXY/$TARGETround-trips; lookup miss ⇒ never-observed subtree skipped, making the diff O(observed)); trap fast path returns node values without re-wrapping (dev assertion enforces the wrap-at-write invariant); keyed arrays skip identity-equal slots and exit fully-matched passes before staging; allocation-free node iteration; effect values direct-commit on plain sync flushes. Deep-store dbmon tick 15.1 → 10.9ms (now faster than solid-js 1.9 on every op).Shallow stores + markRaw —
createStore(value, { shallow: true }): root keys fully reactive, values raw records replaced by reference; positionalreconcile; write-scope reads serve raws (replacement-only contract); optimistic staging/revert through the existing override layers with the base untouched.markRaw(value): sticky raw registry consulted only on wrap-creation/ingest — a value serves raw in every store (single identity). Plumbed throughcreateProjection/createOptimisticStore. Micro tick 5.95 → 2.61ms (reconcile half 14×); octane dbmon 2.6× → 1.74× (1.36× withtextContentbindings), all keyed lifecycle gates passing, ahead of React on every op.Effect-run trims — three no-op gates (clearStatus on status-free nodes, insertSubs on subscriber-less nodes, syncCompanions on companion-less writes) +
readNodeFast(read()'s plain-signal fast path hoisted over the trap's node read). Individually A/B-measured; readNodeFast is a consistent ~2–3% on store-read-heavy flushes.Shallow hardening (adversarial audit follow-up) — raw-marked values are leaves in every reconcile recursion path (three verified blockers shared that root cause); setter-staged overrides fold into the shallow diff;
markRawexposed from solid-js (client + server stub, export parity green); plain-formcreateStore/createOptimisticStoreaccept options; new CodSpeed benchreconcile-dbmon.bench.tscovering deep and shallow reconcile lanes.Numbers
Purpose of this PR per @ryansolid: let CodSpeed sweep for regressions before merge.
🤖 Generated with Claude Code