Skip to content

perf: optimize signals hot paths - #2756

Closed
brenelz wants to merge 1 commit into
solidjs:nextfrom
brenelz:perf/signals-hot-paths
Closed

brenelz wants to merge 1 commit into
solidjs:nextfrom
brenelz:perf/signals-hot-paths

Conversation

@brenelz

@brenelz brenelz commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Four targeted optimizations to @solidjs/signals hot paths, driven by the CodSpeed flamegraphs from run 6a2a5421 plus local --cpu-prof profiles of the same workloads.

1. O(1) dependency revalidation (graph.ts, types.ts, core.ts)

When a computation re-reads a dependency it already saw earlier in the same recompute pass non-consecutively (e.g. doc.meta.author then doc.meta.timestamp — doc.meta read twice), link() fell back to isValidLink, which scans the dep list from the head to check membership. With large dep lists this is O(n²): it was 51% of total time in the reconcile: deep tree, all ~12k paths subscribed bench.

Links now carry a _gen stamp of the subscriber's _depGen recompute counter, set on creation and on in-order reuse. The membership question "was this dep already revalidated this pass?" becomes one integer compare. Equivalence: a link is inside the [head.._depsTail] prefix iff it was created or (B)-reused during the current pass, which is exactly what the stamp records.

2. Skip redundant subscriber walks on batched writes (core.ts, heap.ts, effect.ts)

Writing the same signal N times in one batch walked the full subscriber list N times — after the first walk, every subscriber is already queued, so the remaining walks are provable no-ops (the updateSignals:update1to1000 flamegraph is ~95% insertSubs + insertIntoHeap).

setSignal now skips the walk while a global notifyEpoch is unchanged. The epoch advances whenever a queued notification is consumed — any heap removal (deleteFromHeap, which all recompute paths go through) or a tracked-effect run draining _modified — and a signal's cached epoch is invalidated when it gains a new subscriber (link()) and on optimistic writes, which mutate subscriber lane state and always walk.

3. Reconcile allocation reduction (reconcile.ts)

  • getAllKeys allocated [...keys, ...nextKeys] + a Set + Array.from for every tracked node in the tree; it now returns the existing keys array when key sets match (the common shape), and merges without the spread copies otherwise.
  • unwrap did $TARGET symbol lookups on primitive leaves, forcing boxing; primitives now return immediately.

4. Small allocation cleanups (store.ts, utils.ts, core.ts)

  • getKeys only wraps Object.keys in an untrack closure when the source is itself a wrapped store (plain objects can't trigger traps).
  • snapshotImpl's no-override walk reads each property once via the descriptor instead of three times.
  • Effects reuse one bound runner instead of allocating _runEffect.bind(null, el) per update (runEffect already no-ops on stale _modified, so re-enqueueing the same function is safe).

Results (local, dev build, same workloads as the CodSpeed suite)

Benchmark Before After Δ
reconcile: all ~12k paths subscribed 27.5 ms 5.6 ms 4.9x
reconcile: single deep() effect 13.4 ms 5.6 ms 2.4x
updateSignals:update1to1000 1.44 ms 0.06 ms 24x
reconcile: 10 sparse paths 0.43 ms 0.38 ms ~12%

Expected CodSpeed movement: update1to1000 (183.8 ms), reconcile all paths (84.2 ms), and reconcile deep() (76.6 ms) should drop substantially. The DOM-lane benches won't move much — their flamegraphs are 75–95% jsdom internals.

Trade-offs

  • +1 field per link (_gen), +2 fields per node (_depGen, _notifyEpoch): ~8–16 bytes each, may show as a sub-percent tick on the creation benches.
  • notifyEpoch is a monotonically increasing double — no wraparound concern in practice.

Testing

  • 749 solid-signals + 293 solid-web + 415 solid tests pass
  • tsc -p tsconfig.build.json clean
  • Verified the epoch-skip invalidation paths: heap pops, tracked-effect runs, sync pull-based recomputes, suppressComputedRecompute, dispose, and mid-batch new subscribers

🤖 Generated with Claude Code

Four targeted optimizations driven by CodSpeed flamegraphs and local CPU
profiles:

1. O(1) dependency revalidation. link() fell back to scanning the sub's
   dep list from the head whenever a dependency was re-read
   non-consecutively during the same recompute (e.g. doc.meta.author then
   doc.meta.timestamp), making dep tracking O(n^2) in dep-list length.
   Links now carry a per-recompute generation stamp and the membership
   test is a single integer compare. This was 51% of the saturated
   listened-paths reconcile bench (12k deps).

2. Batched-write subscriber-walk skip. Writing the same signal N times in
   one batch walked the full subscriber list N times; after the first
   walk every sub is already queued. setSignal now skips the walk while
   the global notify epoch is unchanged. The epoch advances whenever a
   notification is consumed (heap removal or tracked-effect run) and a
   signal's cache is invalidated when it gains a subscriber, so the skip
   only fires when the walk would be a provable no-op.

3. Reconcile allocation reduction. getAllKeys allocated spread arrays, a
   Set, and Array.from per tree node; it now returns the existing key
   array when key sets match. unwrap skips $TARGET symbol lookups on
   primitive leaves (which forced boxing).

4. Small allocation cleanups: getKeys only wraps Object.keys in untrack
   for proxy sources, snapshotImpl's no-override walk reads each property
   once via the descriptor, and effects reuse one bound runner instead of
   allocating a closure per update.

Local results (same workloads as the CodSpeed suite, dev build):
- reconcile, all ~12k paths subscribed: 27.5ms -> 5.6ms (4.9x)
- reconcile, single deep() effect:      13.4ms -> 5.6ms (2.4x)
- updateSignals 1->1000 fanout:         1.44ms -> 0.06ms (24x)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 91d1fd9

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

@brenelz
brenelz marked this pull request as draft June 12, 2026 03:56
@codspeed

codspeed Bot commented Jun 12, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 76.5%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 7 improved benchmarks
❌ 4 regressed benchmarks
✅ 107 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ createComputations:create1to1 81.5 ms 97.4 ms -16.33%
❌ read 25.3 µs 28.2 µs -10.4%
❌ read 32 µs 34.9 µs -8.27%
❌ construct 56.7 µs 60.5 µs -6.25%
⚡ updateSignals:update1to1000 183.8 ms 1.9 ms ×97
⚡ propagation:diamond 2.7 ms 1.2 ms ×2.3
⚡ propagation:avoidable 2 ms 1.2 ms +70.03%
⚡ reconcile: deep tree, single deep() effect 76.6 ms 54.6 ms +40.44%
⚡ reconcile: deep tree, all ~12k paths subscribed 84.2 ms 65.6 ms +28.39%
⚡ hasAllowed 26.5 µs 24.4 µs +8.76%
⚡ ownKeys 163.8 µs 152 µs +7.75%

Tip

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


Comparing brenelz:perf/signals-hot-paths (91d1fd9) with next (b26bc04)

Open in CodSpeed

@brenelz

brenelz commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

Used the new Fable 5 model to test so see if it had any perf suggestions and it came up with these.

I more or less wanted to see how codspeed would show up for these perf improvements.

Feel free to take any of them or discard them

ryansolid added a commit that referenced this pull request Jul 7, 2026
#2756)

Land the safe subset of brenelz's perf PR, re-implemented on the current
tree: gen-stamped links replace the isValidLink dep-list scan (O(n²) on
non-consecutive re-reads — deep reconcile all-paths ~7x, deep() effect
~5x), getAllKeys fast path, unwrap primitive early-return, getKeys
untrack skip, cached bound effect runner. notifyEpoch skip-walk and the
snapshotImpl walk deliberately not taken (invalidation risk / pending

Co-authored-by: Cursor <cursoragent@cursor.com>
#2850 ruling). +140B min.
ryansolid added a commit that referenced this pull request Jul 7, 2026
…closes #2850)

snapshotImpl now resolves values optimistic-over-regular via mergedOverlay,
the same order as every proxy trap and reconcile — an active optimistic
write is THE value for every reader (A17), and snapshot on regular stores
already read the pending-write overlay synchronously. Also lands the
specialized no-overlay walk deferred from #2756.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid

Copy link
Copy Markdown
Member

Great find on isValidLink — the head-scan on non-consecutive dep re-reads was exactly the right diagnosis, and it's the dominant cost in store-heavy recomputes (your 51% flamegraph number reproduced on our end). We've landed the gen-stamp revalidation plus the reconcile/store allocation trims (getAllKeys fast path, unwrap primitive early-return, getKeys untrack skip, cached bound runner) on next, re-implemented against the current tree since the branch predates a couple of link()/setSignal rewrites — with your numbers verified: ~7x on deep-tree reconcile with all paths subscribed, ~5x on a deep() effect.

The one piece we deliberately didn't take is the notifyEpoch skip-walk. It's a global invalidation scheme where correctness depends on catching every path that consumes a queued notification, and a missed one means silently stale UI — and the scheduler internals it has to track (optimistic lanes, transition holds, gated subscribers) have been substantially rebuilt since June. The bench it targets (writing one signal 1000× in a batch) is also not a shape real apps hit. If that path ever shows up in real workloads we'd want to re-derive the invalidation set against the current scheduler as its own change. The snapshotImpl specialized walk landed alongside the #2850 fix (it was waiting on that ruling, since it touches the same function).

Closing since the taken parts are in — thanks, this was a genuinely valuable profile-driven find.

@ryansolid ryansolid closed this Jul 7, 2026
ryansolid added a commit that referenced this pull request Jul 8, 2026
…2852)

The remaining #2769 variant: reconcile's diff loops enumerated with
Object.keys, dropping symbol keys — setSignal never ran for symbol-keyed
nodes while applyState* had already swapped STORE_VALUE, so the stale node
shadowed the new value even for untracked reads. Symbol keys now diff like
string keys: getAllKeys appends enumerable symbols (override $DELETED
re-applied after base symbols so deletes win), and the node-record loops
enumerate symbols only for records holding a user symbol node, tracked via
a symbolKeyedRecords WeakSet marked in getNode and dropped in unobserved.
Symbol-free diffs stay byte-for-byte on the Object.keys fast path,
including the #2756 identical-key-sets shortcut.

Re-implemented from PR #2852 against the current tree (the diff predates
the #2756 getAllKeys fast path); added the store-in-store untracked symbol
enumeration guard mirroring getKeys. All 7 PR tests included, symbol cases
verified red-first on the unfixed source.

Co-authored-by: yumemi-thomas <yumemi-thomas@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.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