perf(signals): the plain flush skips the seam's effect-queue merge - #3790
Conversation
🦋 Changeset detectedLatest commit: e686a5c The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 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 |
Size (brotli, eager entry chunk)
Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps in |
Coverage Report for CI Build 37267804353Coverage remained the same at 75.991%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
d6d257d to
03d332a
Compare
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 |
`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>
03d332a to
e686a5c
Compare
…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>
* 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>
The change
GlobalQueue.settle()collects three groups of effect runs apart — the lane reveals', this flush's own, the landings' — and ordered them with twoconcats on every flush, after two[[], []]pairs and twolength = 0resets 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 ofowninto a fresh array: ~125 ns of fixed cost per flush, 59% of it the merge.own[i]over asthis._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.append(in place) instead of aconcatcopy — no alias of a transaction's queue exists anywhere.heldTrimsis reset once after both branches, and only when non-empty;stagedReaderslikewise.length = 0is 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 setterrow 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.tsshapes, (b) flush fixed cost — JITSame, 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)(c) whole-app shapes (measured before the rebase against
next@ 1b9ceb6; #3776 does not touch these paths)The update1to1 JIT spread is noise: order-swapped runs flip its sign,
--trace-turbo-inliningoutput 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
@solidjs/signalsnext@ 924d909@solidjs/webtest.failspin), 0 failuressolid-jsrules-index.mjs --checkSize
+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/sizeagainstnext@ 924d909:44 equivalent encodings of the fast path were measured (ternary/if-else/whole-tuple forms,
!== 0vs truthy,appendvsconcat, with and without each guard and therun()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 inscripts/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'sown. Not worth the invariant for 6 ns.