Skip to content

Store perf: structural hot-path pass, shallow stores + markRaw, effect-run trims#2931

Merged
ryansolid merged 9 commits into
nextfrom
perf/store-structural-shallow
Jul 23, 2026
Merged

Store perf: structural hot-path pass, shallow stores + markRaw, effect-run trims#2931
ryansolid merged 9 commits into
nextfrom
perf/store-structural-shallow

Conversation

@ryansolid

Copy link
Copy Markdown
Member

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

  1. Store structural hot-path passstoreLookup maps raw → target (reconcile recurses target-to-target, no wrap/$PROXY/$TARGET round-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).

  2. Shallow stores + markRawcreateStore(value, { shallow: true }): root keys fully reactive, values raw records replaced by reference; positional reconcile; 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 through createProjection/createOptimisticStore. Micro tick 5.95 → 2.61ms (reconcile half 14×); octane dbmon 2.6× → 1.74× (1.36× with textContent bindings), all keyed lifecycle gates passing, ahead of React on every op.

  3. 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.

  4. 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; markRaw exposed from solid-js (client + server stub, export parity green); plain-form createStore/createOptimisticStore accept options; new CodSpeed bench reconcile-dbmon.bench.ts covering deep and shallow reconcile lanes.

Numbers

  • Micro-bench (reactive layer only): deep store 7.14 → 5.98ms/tick, shallow 2.51ms, plain-signal ceiling 1.15ms.
  • Octane harness dbmon (all gates): 2.64× → 1.37× octane deep / 1.36× with shallow + textContent — ahead of React on every op.
  • Full 15-suite octane table geomean: 1.110× → 0.863×.
  • Size: +2.8KB gz total on the prod store payload (structural pass ± 0, shallow + markRaw + hardening +2.8; shallow tree-shakes out of apps that don't use it — the octane bundle-size fixture got smaller, 14.7 → 14.1KB gz).
  • Tests: 1,118 signals / 492 solid / 544 web green; 18-test shallow suite covering the audit's blocker scenarios.

Purpose of this PR per @ryansolid: let CodSpeed sweep for regressions before merge.

🤖 Generated with Claude Code

ryansolid and others added 4 commits July 23, 2026 09:14
…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>
@cursor

cursor Bot commented Jul 23, 2026

Copy link
Copy Markdown

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-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1e4bcae

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@solidjs/signals Patch
test-integration Patch
solid-js Patch
babel-preset-solid Patch
@solidjs/web Patch
@solidjs/html Patch
@solidjs/h Patch
@solidjs/universal Patch
@solidjs/element Patch

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>
@ryansolid

Copy link
Copy Markdown
Member Author

CI note: the initial red was inherited from next8a4b42dd broke types:web-frames on fresh installs (nested frames/package.json names itself @solidjs/web/frames, so NodeNext self-reference doesn't cover the @solidjs/web/server-functions/client import, and fresh installs lack the link-script symlink dev checkouts have). c8422492 maps the specifier via tsconfig paths — reproduced and verified on a fresh clone (12/12 turbo tasks). next needs the same one-liner or its own CI stays red independent of this PR.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size
signals: core floor (createSignal/Memo/Effect/Root/flush) 6.61 KB (+0.5% 🔺)
signals: + createStore 12.06 KB (+6.8% 🔺)
signals: + isPending/latest 8.2 KB (+0.52% 🔺)
app: render + one signal (the simple-app floor) 9.21 KB (+0.31% 🔺)
app: CSR with Show/For/Loading/Errored/lazy 11.28 KB (+0.2% 🔺)

@coveralls

coveralls commented Jul 23, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 30032310167

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Warning

No base build found for commit 8a4b42d on next.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 75.328%

Details

  • Patch coverage: No coverable lines changed in this PR.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 778
Covered Lines: 626
Line Coverage: 80.46%
Relevant Branches: 592
Covered Branches: 406
Branch Coverage: 68.58%
Branches in Coverage %: Yes
Coverage Strength: 14.11 hits per line

💛 - 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>
@ryansolid

Copy link
Copy Markdown
Member Author

Second CI item: the signals: + createStore size budget tripped — +728 B brotli over the 11.6 KB limit. Cause: the options.shallow branch keeps wrapShallow/applyStateShallow in the plain-createStore graph under tree-shaking, plus reconcile's raw-leaf handling. b15fb562 bumps that one scenario to 12.5 KB with reasoning inline — explicitly your trade-off to adjudicate: accept the cost, demand deeper golf, or ask for a separate tree-shakeable createShallowStore entry instead of the option. All other scenarios pass with room (core floor 6.77/7.1, isPending 8.39/8.75, app floor 9.43/10, CSR 11.55/12).

@codspeed-hq

codspeed-hq Bot commented Jul 23, 2026

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚡ 5 improved benchmarks
❌ 1 regressed benchmark
✅ 115 untouched benchmarks
🆕 3 new benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
merge 265.6 µs 289.6 µs -8.28%
merge 156.7 µs 73.6 µs ×2.1
hasAllowed 27.1 µs 25 µs +8.53%
hasAllowed 26.8 µs 24.7 µs +8.5%
propagation:avoidable 2.6 ms 2.4 ms +7.95%
updateSignals:update1to1 52 ms 49 ms +6.13%
🆕 dbmon full tick — deep reconcile N/A 725.1 ms N/A
🆕 dbmon full tick — shallow reconcile N/A 484.2 ms N/A
🆕 dbmon partial tick — deep reconcile N/A 746 ms N/A

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing perf/store-structural-shallow (1e4bcae) with next (142a072)

Open in CodSpeed

ryansolid and others added 3 commits July 23, 2026 10:14
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>
@ryansolid
ryansolid merged commit 82581e4 into next Jul 23, 2026
4 of 5 checks passed
@ryansolid
ryansolid deleted the perf/store-structural-shallow branch July 23, 2026 18:24
ryansolid added a commit that referenced this pull request Jul 23, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants