Skip to content

perf(signals): mark heap insertions in place instead of invalidating the markHeap memo - #3356

Merged
ryansolid merged 2 commits into
nextfrom
fix/markheap-mount
Sep 10, 2026
Merged

ryansolid merged 2 commits into
nextfrom
fix/markheap-mount

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Fixes #3350

Problem

Mounting N rows in one flush was O(N²) when each row created a createEffect whose source was written during row creation — the ref effect pattern (createEffect(() => el(), ...) + <div ref={setEl} />).

Writes schedule subscribers by heap insertion alone; DIRTY/CHECK marks are propagated lazily by markHeap, memoized on heap._marked until runHeap resets it. In insertIntoHeap, an unmarked node entering an already-marked heap set heap._marked = false, so the next mid-tick pull re-walked the whole heap. During a synchronous mount each row's user effect is inserted unmarked (creation-time enqueueSub) right after the row's sync render effect has pulled a memo (marking the heap). Instrumented at N=500: 500 full walks, 124,750 nodes visited (N²/2).

Fix

The unmarked insertion now runs markNode(n) in place instead of invalidating the memo. It produces exactly the flags the deferred walk would have (DIRTY on the node, CHECK on its subscribers), so the #2922 semantics (write between two mid-tick pulls must be visible) are preserved — those tests still pass. When _marked is false nothing changes; marking stays lazy for the plain write path.

Numbers

Reporter's repro against the built dist, prod, N=8000:

ref effects / row before after
1 231 ms 12.5 ms
4 686 ms 20.7 ms

Now ~1.5–2.5 µs/row and linear (was 4× per 2× N).

New bench tests/mount-rows.bench.ts (CodSpeed): 4000 rows 221 → 16.4 ms; the reference shape without the effect is unchanged (~8 ms).

Tests

tests/heap-mark-incremental.test.ts:

  • wall-clock tripwire at N=8000 (< 250 ms; fixed 33 ms, next 761 ms — fails on next, follows the overlay.test.ts tripwire convention)
  • mid-flush freshness guard: a write landing between two sync render-effect pulls must be visible through a two-memo chain in the same pass (the eager mark has to propagate CHECK past the inserted node)

Verification

  • signals dev suite 1698/1698; prod-tier failure count identical to next (238)
  • solid 595/595, web 734/734
  • size gate: app: render + one signal 10.895 → 10.924 KB and app: CSR observe + attribution 26.598 → 26.652 KB against caps of 10.92 / 26.65 — brotli layout drift on a one-call swap (createStore and isPending scenarios shrank on the same build); caps bumped with dated notes

Note: #3337 touches the same insertIntoHeap comment block; whichever lands second gets a trivial comment conflict.

Made with Cursor

…the markHeap memo

Mounting N rows in one flush was O(N²) when each row created a user effect
whose source was written during row creation (the ref-effect pattern). An
unmarked node entering an already-marked pure heap set heap._marked = false,
so every later mid-tick memo pull re-walked the whole heap: N full walks,
N²/2 nodes visited. The insertion now runs markNode(n) in place — the same
flags the deferred walk would have produced (#2922 semantics unchanged) —
and the mount is linear (8000 rows, one ref effect each, prod: 231 → 12 ms).

Adds a mount-scaling bench and a tripwire + mid-flush freshness test.

Fixes #3350

Co-authored-by: Claude via Cursor <noreply@cursor.com>
@changeset-bot

changeset-bot Bot commented Sep 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 20f3ac0

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

This PR includes changesets to release 11 packages
Name Type
@solidjs/signals Patch
test-integration Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
solid-js Patch
@solidjs/universal 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

The absolute bound (250 ms at 8000 rows) measured 403 ms on the
coverage-instrumented CI job (12× slower than local). Compare 1000 vs 8000
rows within one process instead: ~8-10× fixed, ~50× on next; threshold 24.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 34538775353

Coverage remained the same at 71.842%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1007
Covered Lines: 772
Line Coverage: 76.66%
Relevant Branches: 790
Covered Branches: 519
Branch Coverage: 65.7%
Branches in Coverage %: Yes
Coverage Strength: 15.03 hits per line

💛 - Coveralls

@codspeed-hq

codspeed-hq Bot commented Sep 10, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 154 untouched benchmarks
🆕 4 new benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
🆕 memo + sync render effect + user effect over a ref signal (#3350) N/A 43 ms N/A
🆕 memo + sync render effect only (reference) N/A 21.6 ms N/A
🆕 memo + sync render effect + user effect over a ref signal (#3350) N/A 191.7 ms N/A
🆕 memo + sync render effect only (reference) N/A 81.7 ms N/A

Comparing fix/markheap-mount (20f3ac0) with next (645ec0d)

Open in CodSpeed

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