Skip to content

perf(signals): the plain flush skips the seam's effect-queue merge - #3790

Merged
ryansolid merged 2 commits into
nextfrom
perf/settle-fast-path
Oct 5, 2026
Merged

ryansolid merged 2 commits into
nextfrom
perf/settle-fast-path

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

The change

GlobalQueue.settle() collects three groups of effect runs apart — the lane reveals', this flush's own, the landings' — and ordered them with two concats on every flush, after two [[], []] pairs and two length = 0 resets that ran whether or not anything was in them. On the plain flush (nothing parked, no lane reveal, no landing) all of it was a copy of own into a fresh array: ~125 ns of fixed cost per flush, 59% of it the merge.

  • Fast path: when nothing is parked, the lane queue is empty and nothing was enqueued during the commits, the merge hands own[i] over as this._queues[i] — the same array, not a copy (run() takes it whole). Every other seam builds the ordered queue exactly as before: lanes, then own (unless parked), then the landings' released runs and anything a commit enqueued.
  • Parked flush: the flush's runs are stashed into the transaction's queue with append (in place) instead of a concat copy — no alias of a transaction's queue exists anywhere.
  • Guarded, de-duplicated resets: heldTrims is reset once after both branches, and only when non-empty; stagedReaders likewise. length = 0 is a runtime call even on an empty array (~15 ns each).
  • run() reads its queue once.

Invariant preserved: effect order across seams is unchanged — lane reveals → this flush's own runs → the landings' released runs (#3540, #3528). The fast path only applies when the first and third groups are empty, in which case the ordered queue is own.

Origin

Found while verifying CodSpeed's createStore setter row on #3776 — the +8–10% on that micro-bench was entirely this per-flush cost, not anything in the store fold.

Measurements

Built prod dist, interleaved A/B against next @ 1a3f87f (#3791 and #3788/#3789 since then touch the store fold and tests, not the seam) (fresh process per run, median of round medians, 3 rounds, 1.5 s budget per run). Node 26.4.0, Apple Silicon.

(a) projection-root-write.bench.ts shapes, (b) flush fixed cost — JIT

scenario base (ns/op) head (ns/op) Δ
store setter: delete + set one root key, 20k keys 894 796 −11.0%
projection root write 1,298 1,225 −5.6%
projection nested write 815 725 −11.0%
one signal + one render effect + flush 257 138 −46.3%
1000 signals + flush 82,375 80,167 −2.7%

Same, CodSpeed's V8 flags (--interpreted-frames-native-stack --allow-natives-syntax --hash-seed=1 --random-seed=1 --no-opt --predictable --predictable-gc-schedule --expose-gc --no-concurrent-sweeping)

scenario base (ns/op) head (ns/op) Δ
store setter: delete + set one root key, 20k keys 1,071 994 −7.2%
projection root write 1,560 1,481 −5.1%
projection nested write 1,017 921 −9.4%
one signal + one render effect + flush 328 214 −34.8%
1000 signals + flush 122,833 123,459 +0.5%

(c) whole-app shapes (measured before the rebase against next @ 1b9ceb6; #3776 does not touch these paths)

scenario base (ns/op) head (ns/op) Δ
dbmon full tick (JIT, 3 rounds) 3,266,250 3,249,041 −0.5%
update1to1 (JIT, 3 rounds) 741,500 754,708 +1.8%
update1to1 (JIT, 2 × 8 rounds) 726,875 / 728,375 736,063 / 722,417 +1.3% / −0.8%
update1to1 (CodSpeed flags, 3 rounds) 1,239,125 1,235,833 −0.3%

The update1to1 JIT spread is noise: order-swapped runs flip its sign, --trace-turbo-inlining output is identical between base and head, and the interpreter-mode number is −0.3%. Those two shapes do one flush per thousands of node updates, so a ~100 ns per-flush saving is below their noise floor by design.

Tests

package result
@solidjs/signals 260 files, 4915 passed, 3 expected-fail, 2 skipped (4920) — identical counts to next @ 924d909
@solidjs/web 120 files, 1131 passed, 1 expected-fail (the pre-existing hydrate test.fails pin), 0 failures
solid-js 41 files, 819 passed, 0 failures
rules-index.mjs --check green (57 ids; every live A-rule cited)

Size

+5 B minified in the signals core (every scenario that retains the core moves by +4..+6 B minified). Brotli layout spreads that −16..+28 B across the scenarios; three caps end up over. scripts/size against next @ 924d909:

scenario base (B) head (B) Δ br Δ min cap
signals: core floor 7,315 7,321 +6 +5 7.33 KB
signals: + createStore 14,524 14,508 −16 +6 14.53 KB
signals: + isPending/latest 9,438 9,446 +8 +4 9.45 KB
app: render + one signal (simple-app floor) 9,801 9,812 +11 +5 9.81 → 9.83 KB
app: hydrating (no stores) 17,632 17,650 +18 +4 17.64 → 17.66 KB
app: hydrating + every store primitive family 28,757 28,785 +28 +5 28.78 → 28.80 KB
app: CSR with Show/For/Loading/Errored/lazy 12,807 12,807 0 +5 12.82 KB
app: CSR, observe tier 14,378 14,389 +11 +4 14.39 KB
app: CSR, observe tier + attribution 28,599 28,590 −9 +5 28.61 KB
frames: eager client consumer 12,997 12,997 0 0 13.00 KB
page: base server components 44,031 44,048 +17 +5 44.05 KB (2 B under)
page: live server components 47,649 47,670 +21 +5 47.67 KB (at the cap, 0 B under)
server: floor 1,331 1,331 0 0 1.34 KB
server: renderToString 20,412 20,412 0 0 20.42 KB

44 equivalent encodings of the fast path were measured (ternary/if-else/whole-tuple forms, !== 0 vs truthy, append vs concat, with and without each guard and the run() tweak); none fits every cap at once, and the committed form is the smallest minified (next-best: +18 B minified, two caps over by 25/16 B). The raised caps are measured + 10 B, rounded up to 0.01 KB. The two pages were over before #3791 and are within their caps after it (page live sits exactly at 47.67 KB — not over, so not raised; the next byte on that page will need its own note). Per-scenario notes are in scripts/size/scenarios.js. Accepted by the maintainer (2026-10-04):

Size-Exception: settle fast path, +5 B minified in the signals core; brotli layout puts three caps over by 2/10/5 B (simple-app 9.81 -> 9.83 KB, hydrating no-stores 17.64 -> 17.66 KB, hydrating + every store 28.78 -> 28.80 KB); 44 encodings measured, none under every cap; accepted by the maintainer 2026-10-04.

Public API changes

None.

Deliberately not changed

The two [[], []] pairs per flush (this._queues = [[], []] and the lane-queue reset) stay. They cost ~6 ns together, and the scratch-array alternative — reusing a module-level pair — would alias the queue a throwing effect leaves behind into the next flush's own. Not worth the invariant for 6 ns.

@changeset-bot

changeset-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e686a5c

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

This PR includes changesets to release 12 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
todos-server-example 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

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.32 KB +6 B (+0.1%) 7.33 KB ✅
signals: + createStore 14.51 KB −16 B (−0.1%) 14.53 KB ✅
signals: + isPending/latest 9.45 KB +8 B (+0.1%) 9.45 KB ✅
app: render + one signal (the simple-app floor) 9.81 KB +11 B (+0.1%) 9.83 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.65 KB +18 B (+0.1%) 17.66 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 28.79 KB +28 B (+0.1%) 28.80 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.81 KB 0 B 12.82 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.39 KB +11 B (+0.1%) 14.39 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.59 KB −9 B (−0.0%) 28.61 KB ✅ lazy-page.js 0.04 KB
frames: eager client consumer (frames client + transport, lazy codec) 13.00 KB 0 B 13.00 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 44.05 KB +17 B (+0.0%) 44.05 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
page: live server components (base + live/GET + action + isPending/latest) 47.67 KB +21 B (+0.0%) 47.67 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.41 KB 0 B 20.42 KB ✅

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body).

@coveralls

coveralls commented Oct 5, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37267804353

Coverage remained the same at 75.991%

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: 1195
Covered Lines: 962
Line Coverage: 80.5%
Relevant Branches: 925
Covered Branches: 649
Branch Coverage: 70.16%
Branches in Coverage %: Yes
Coverage Strength: 27.95 hits per line

💛 - Coveralls

@ryansolid
ryansolid force-pushed the perf/settle-fast-path branch from d6d257d to 03d332a Compare October 5, 2026 04:32
@codspeed

codspeed Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚡ 3 improved benchmarks
❌ 1 regressed benchmark
✅ 184 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ dynamic-tag update 100/1000 rows × 16: Dynamic 35 ms 38.1 ms -8.16%
⚡ input burst: 200 single-key writes, 1 subscriber 2.8 ms 2.4 ms +14.18%
⚡ commit boundary: flush after every 2-key setter, no subscribers (#3044) 7.9 ms 7.3 ms +9.32%
⚡ selection map: toggle 2 of 1000 subscribed keys 2.9 ms 2.8 ms +5.54%

Tip

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


Comparing perf/settle-fast-path (e686a5c) with next (924d909)

Open in CodSpeed

ryansolid and others added 2 commits October 4, 2026 22:23
`settle()` collects three groups of runs apart — the lane reveals', this
flush's own, the landings' — and ordered them with two `concat`s every
flush, after two `[[], []]` pairs and two `length = 0` resets that ran
whether or not anything was in them. On the plain flush (nothing parked,
no reveal, no landing) all of it was a copy of `own` into a fresh array:
~125 ns of fixed cost per flush, 59% of it the merge.

- The merge keeps `own[i]` as the queue when there is nothing to order
  around it; every other seam builds the ordered queue as before (lanes,
  then own unless parked, then the landings' and anything a commit queued).
- A parked flush stashes its runs with `append` (in place) instead of a
  `concat` copy — no alias of a transaction's queue exists anywhere.
- `heldTrims` resets once after both branches and only when non-empty;
  `stagedReaders` likewise. `length = 0` is a runtime call even on an
  empty array (~15 ns each).
- `run()` reads its queue once.

Measured on the built prod dist, interleaved A/B against origin/next,
fresh process per run, medians: store setter delete+set one root key
(20k keys) 844 -> 748 ns/commit (-11%); projection root write 1244 ->
1167 (-6%); nested write 746 -> 660 (-12%); one signal + one render
effect + flush 240 -> 133 ns (-45%); 1000 signals + flush, dbmon full
tick and update1to1 within noise (interpreter mode -0.3%).

Size: +5 B minified in the core; brotli moves each scenario -45..+67 B
by layout (four frozen caps are over, see the next commit).

Co-authored-by: Cursor <cursoragent@cursor.com>
The seam change is +5 B minified wherever the signals core is retained;
brotli layout moves the scenarios -16..+28 B against `next` @ 924d909.
Three caps end up over: the simple-app floor (9,812 B, +11, 2 over
9.81 KB), hydrating without stores (17,650 B, +18, 10 over 17.64 KB) and
hydrating with every store primitive family (28,785 B, +28, 5 over
28.78 KB). The two pages land within their caps (base 44,048 B under
44.05 KB; live 47,670 B at 47.67 KB exactly). 44 equivalent encodings of
the fast path measured; none fits every cap at once, this one is the
smallest minified. Each scenario's ledger note records the measurement.

Caps: simple-app 9.81 -> 9.83 KB, hydrating (no stores) 17.64 -> 17.66 KB,
hydrating + every store 28.78 -> 28.80 KB — each the measurement + 10 B,
rounded up to 0.01 KB. Accepted by the maintainer (2026-10-04).

Size-Exception: settle fast path, +5 B minified in the signals core; brotli layout puts three caps over by 2/10/5 B (simple-app 9.81 -> 9.83 KB, hydrating no-stores 17.64 -> 17.66 KB, hydrating + every store 28.78 -> 28.80 KB); 44 encodings measured, none under every cap; accepted by the maintainer 2026-10-04.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the perf/settle-fast-path branch from 03d332a to e686a5c Compare October 5, 2026 05:26
@ryansolid
ryansolid merged commit bde4299 into next Oct 5, 2026
6 checks passed
ryansolid added a commit that referenced this pull request Oct 5, 2026
…e base 44.05 -> 44.78, page live 47.67 -> 48.45

Refetched content lands at the transition's commit (the staging, the
content token, the two halves of the mount's follow effect, the staged
data tables, and on L2 the switch's rebind in the effect half with the
frameless host waiter). Measured against `next` @ bde4299 (after
#3790/#3791): frames 12,997 -> 13,770 B (+773; +2,262 B minified, frames
client +2,255), page base 44,048 -> 44,762 B (+714; +2,265 B minified),
page live 47,670 -> 48,436 B (+766; +2,265 B minified). Caps set at
head + 10 B. Ledger notes in scenarios.js re-applied from the PR's
1b347c7 against these numbers, after #3743's page-base note.

Size-Exception: frames: eager client consumer 12,997 -> 13,770 B (cap 13.00 -> 13.78 KB); page: base server components 44,048 -> 44,762 B (cap 44.05 -> 44.78 KB); page: live server components 47,670 -> 48,436 B (cap 47.67 -> 48.45 KB) — #3759's staging so refetched content lands at the transition's commit; accepted by the maintainer (2026-10-04).

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Oct 5, 2026
* frames: refetched content lands at the transition's commit (#3759, ported onto L2)

A refetch or single-flight region for a call a boundary is showing is
staged in the transport and the call resolves to a content token; the
mount lands it in the two halves of the render effect that follows its
address accessor. Under the hold model the two halves ride core's seams
unchanged: the compute half is the delivering transaction's pass (its
`preview` writes — the staged slot args — are held with it and commit in
the frame whose landing dissolves the lane's guesses), the effect half is
its `land` (the commit replays the staged chunks). Both signals pins
(`compute-write-joins-transition`, `settle-folds-queued-writes`) and the
optimistic-hold specs were green on untouched L2; the morph-in-transition
specs were red (3/3) and are closed by the staging.

Composes with e133516 (the switch gate re-arms in the pass): the rebind
— display — moves to the effect half so a switch's content waits for the
commit too (`frames-morph-in-transition` switch case), while the gate
settles on the new address's first write through a frameless waiter
registered on the host, so a second switch mid-flight still binds
(`call-driven-lifecycle`). No core seam touched. Size caps not raised:
frames 12,997 -> 13,770 B (cap 13.00 KB), page base 44,017 -> 44,765,
page live 47,656 -> 48,447 — reported for the maintainer's exception.

Signals 4898/0/2, web 1138/0 + server 1411/2 skipped + hydrate 275/0.

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

* size: Size-Exceptions for #3759 on L2 — frames 13.00 -> 13.78 KB, page base 44.05 -> 44.78, page live 47.67 -> 48.45

Refetched content lands at the transition's commit (the staging, the
content token, the two halves of the mount's follow effect, the staged
data tables, and on L2 the switch's rebind in the effect half with the
frameless host waiter). Measured against `next` @ bde4299 (after
#3790/#3791): frames 12,997 -> 13,770 B (+773; +2,262 B minified, frames
client +2,255), page base 44,048 -> 44,762 B (+714; +2,265 B minified),
page live 47,670 -> 48,436 B (+766; +2,265 B minified). Caps set at
head + 10 B. Ledger notes in scenarios.js re-applied from the PR's
1b347c7 against these numbers, after #3743's page-base note.

Size-Exception: frames: eager client consumer 12,997 -> 13,770 B (cap 13.00 -> 13.78 KB); page: base server components 44,048 -> 44,762 B (cap 44.05 -> 44.78 KB); page: live server components 47,670 -> 48,436 B (cap 47.67 -> 48.45 KB) — #3759's staging so refetched content lands at the transition's commit; accepted by the maintainer (2026-10-04).

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

---------

Co-authored-by: Claude <noreply@anthropic.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