Skip to content

signals: benchmarks measure the built package; the fold queue's pre-batch record (CodSpeed follow-up to #3774) - #3776

Merged
ryansolid merged 3 commits into
nextfrom
audit/post-l2-store-shapes
Oct 4, 2026
Merged

ryansolid merged 3 commits into
nextfrom
audit/post-l2-store-shapes

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Follow-up to #3774. CodSpeed (not a required check) listed 14 regressions on 1b9ceb679. Its method — vite-node, one ESM module per source file, every cross-module call a namespace-object property load — penalizes the carve's module layout and contradicts the dist on the core suite (plan §41.1). Four store shapes had not been dist-measured. This PR is both halves: make the next report measure shipped code, and fix the two shapes that were real.

1. Benchmarks measure the built package (packages/signals/vite.config.ts)

  • In benchmark mode (vitest bench) the benches' src/index.js import is aliased to the dist entry of the selected tier — SIGNALS_TIER, defaulting to prod for benchmarks (the artifact users ship; the test suite keeps dev).
  • The dist is externalized so Node links it natively. The prod build preserves modules; vite-node's SSR transform would have turned its cross-module imports straight back into the namespace loads the alias exists to remove.
  • SIGNALS_BENCH=source keeps a from-source loop for local iteration.
  • A missing or stale dist (any src/ file newer than the entry) fails the run with the rebuild instruction rather than silently timing code not under test. A fresh checkout bumps src mtimes — pnpm build:js first.
  • No workflow change: codspeed.yml already builds before benching. Web's benches already measured signals through the package exports; signals' own were the only ones on source.

Consequence: the first CodSpeed run after this lands re-baselines every signals bench — a different series (bundled prod vs per-module dev source). The 14 entries on 1b9ceb679 are superseded by that re-baseline.

2. The four unmeasured shapes, audited on the dist

CodSpeed's benches reproduced exactly on the prod dist (Node 26, fork vs 1b9ceb679, interleaved):

shape fork L2 @ head
store reconcile tree reverse: 1111 keyed nodes 1.21–1.25 ms 1.36–1.38 +11%
store reconcile tree shuffle: 1111 keyed nodes 1.02–1.04 1.12–1.18 +10–13%
reconcile: deep tree, all ~12k paths subscribed 3.79 4.37–4.41 +15%
reconcile: deep tree, 10 of ~12k paths subscribed 0.37 0.22–0.38 parity
dbmon shallow full tick (1000 rows) 0.89–0.93 0.67–0.69 L2 −25%

Two real (the tree pair is one shape), one the harness (L2 is a quarter faster on dbmon shallow), one noise.

Cause and fix (store/store.ts)

The fold queue kept each adopted container's pre-batch backing in a WeakMap keyed by target — no churn, but retention: an entry lives as long as its target, so every container held its last adopted-away backing until its next adoption. For a keyed reconcile the previous tick's entire tree (5,400 containers, 12k strings) stayed reachable through the scavenges, was promoted, and became old-generation garbage every tick — the profile showed GC at 16% vs 6%, and removing the two weak-map operations alone took the saturated shape under the fork. For a store adopted once, the old tree was retained for the store's lifetime. A memory bug as much as a speed one.

The record now lives beside the target: foldOlds, parallel to foldList, set to t.v at queueFold for every queued target and released with the drain. That one record is the fold's base in every case (draft, eager adoption, mid-batch privatization, draft over an adopted raw), so privatizedOlds, draftedAdoptions and the adoption flag go. The target has no slot for it (ARRAY SHAPE RULE: 20 named fields), so the by-target lookups (preBatch: a container node born onto an open staging, a committed-frame read of a node-less staging, a park) go through an index map built on first use per batch; the drain walks the two lists together and never builds it.

After (same harness, medians of 3 interleaved rounds): tree reverse +3%, shuffle +4%, saturated +1.5%, sparse parity. The residual is the queue itself (~3.5% of a tick). No regression elsewhere: fresh stores, 2000 owned backings, enumerate, storebench at or ahead of the fork; GC totals over the store battery identical to the head.

Verification

  • Signals 4894 / 0, web 1132 / 0, solid 819 / 0 (web/solid against the rebuilt dist).
  • Size: every cap holds (+ createStore 14519 B, every-store 28755, page base 44029, live 47654) — a few bytes over the head per scenario (arrays + lazy index against three collections).
  • rules-index --check green.
  • Design record: plan §43 (documentation/plans/size-reduction-carve-step1.md).

Public API changes

None. (Tooling: new SIGNALS_BENCH env var and a prod default tier for vitest bench in packages/signals — bench configuration only.)

Made with Cursor

ryansolid and others added 2 commits October 4, 2026 02:01
In benchmark mode the benches' src/index.js import is aliased to the dist
entry of the selected tier (SIGNALS_TIER, default prod for benchmarks) and
the dist is externalized so Node links it natively — the prod build
preserves modules, and vite-node's SSR transform would turn its
cross-module imports back into the namespace-object property loads the
alias exists to take out of the measurement. SIGNALS_BENCH=source keeps a
from-source loop for local iteration. A missing or stale dist fails the
run with the rebuild instruction instead of timing code not under test.

CodSpeed already builds before benching; its first run after this
re-baselines every signals bench. Plan sec. 43.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
The weak map keyed by target kept every container's last adopted-away
backing alive until its next adoption: for a keyed reconcile the previous
tick's whole tree promoted out of the nursery every tick (saturated
listened-paths +15%, reconcile tree +10-13% over next on the dist, all of
it GC); for a store adopted once, the old tree for the store's lifetime.

The record is now foldOlds, parallel to foldList: t.v at queueFold for
every queued target, released with the drain. That one record is the
fold's base in every case (draft, eager adoption, mid-batch privatization,
draft over an adopted raw), so privatizedOlds, draftedAdoptions and the
adoption flag go. The target has no slot for it (ARRAY SHAPE RULE), so the
by-target lookups (preBatch: a node born onto an open staging, a
committed-frame read of a node-less staging, a park) index the list
through a map built on first use per batch; the drain never builds it.

After, on the dist vs next: tree reverse +3%, shuffle +4%, saturated
+1.5%, sparse parity; fresh stores / owned backings / enumerate / storebench
unchanged; GC totals identical to the head. Signals 4894/0, web 1132/0,
solid 819/0; every size cap holds. Plan sec. 43.2.

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

changeset-bot Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 04dc66e

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 4, 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 0 B 7.33 KB ✅
signals: + createStore 14.52 KB +2 B (+0.0%) 14.53 KB ✅
signals: + isPending/latest 9.44 KB 0 B 9.45 KB ✅
app: render + one signal (the simple-app floor) 9.80 KB 0 B 9.81 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.63 KB 0 B 17.64 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 28.75 KB −15 B (−0.1%) 28.78 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.38 KB 0 B 14.39 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.60 KB 0 B 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.03 KB +12 B (+0.0%) 44.03 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
page: live server components (base + live/GET + action + isPending/latest) 47.65 KB −2 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 4, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37192567121

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.82 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 4 benchmarks

⚠️ 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

⚡ 82 improved benchmarks
❌ 4 regressed benchmarks
✅ 102 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ merge 46.2 µs 819.2 µs -94.37%
❌ createStore setter: delete + set one root key (#3044 overlay) 556.9 µs 1,815.7 µs -69.33%
❌ projection derive: write one NESTED field (reference) 1.8 ms 2.1 ms -14.47%
❌ merge 46.5 µs 50 µs -7.06%
⚡ createOwners 37.7 ms 7.6 ms ×5
⚡ updateSignals:update1to1000 2,977.6 µs 713.3 µs ×4.2
⚡ updateSignals:update1to1 78.5 ms 20.1 ms ×3.9
⚡ createComputations:create0to1 60.5 ms 15.5 ms ×3.9
⚡ createDispose:memoTree 80.7 ms 22.4 ms ×3.6
⚡ memo + sync render effect only (reference) 100.6 ms 32.3 ms ×3.1
⚡ memo + sync render effect + user effect over a ref signal (#3350) 51.9 ms 17.1 ms ×3
⚡ dbmon full tick — shallow reconcile 577.1 ms 191.7 ms ×3
⚡ createComputations:create1to1 93.2 ms 31.1 ms ×3
⚡ createSignals 15.8 ms 5.5 ms ×2.9
⚡ memo + sync render effect only (reference) 25.5 ms 9 ms ×2.8
⚡ propagation:avoidable 1,702.1 µs 612.5 µs ×2.8
⚡ createRenderEffects:create1to1 154.8 ms 56 ms ×2.8
⚡ memo + sync render effect + user effect over a ref signal (#3350) 225.2 ms 89.8 ms ×2.5
⚡ dbmon full tick — deep reconcile 740.5 ms 320.5 ms ×2.3
⚡ dbmon full tick 750.7 ms 336.2 ms ×2.2
... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

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


Comparing audit/post-l2-store-shapes (04dc66e) with next (1b9ceb6)

Open in CodSpeed

The warm-up store write sat inside createRoot, where the dev tier's
owned-scope write check throws — the file failed to load under
SIGNALS_TIER=dev, so CodSpeed compared its prod-dist values against a
stale base (two of the four 'regressions' on #3776). Moved outside the
root. Plan sec. 43.1 records the first dist-mode CodSpeed run.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid merged commit 1a3f87f into next Oct 4, 2026
6 of 7 checks passed
ryansolid added a commit that referenced this pull request Oct 5, 2026
…e base 44.03 -> 44.78, page live 47.67 -> 48.43

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` @ 1a3f87f (after
#3776): frames 12,997 -> 13,770 B (+773; +2,262 B minified, frames
client +2,255), page base 44,029 -> 44,762 B (+733; +2,265 B minified),
page live 47,654 -> 48,418 B (+764; +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.

Size-Exception: frames: eager client consumer 12,997 -> 13,770 B (cap 13.00 -> 13.78 KB); page: base server components 44,029 -> 44,762 B (cap 44.03 -> 44.78 KB); page: live server components 47,654 -> 48,418 B (cap 47.67 -> 48.43 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>
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