Skip to content

perf(signals,web): omit over a merge holds the merge record — one record per layer, one-pass owners walk for ssrElement - #3497

Merged
ryansolid merged 1 commit into
nextfrom
perf/omit-nested-record
Sep 17, 2026
Merged

ryansolid merged 1 commit into
nextfrom
perf/omit-nested-record

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Part of #3389 (item 3 of the plan: "omit over a merge: nested record vs per-leaf flattening").

What

An omit() over a merge() used to flatten at construction: one OmitView plus one combined hidden-key list per flattened leaf, and the next merge() copied those entries into its own arrays. On a Kobalte-shaped component chain (defaults + omit + spread, four layers) that was ~19 records and as many list copies per element — the largest allocation of the render. #3487 tried to make those copies cheaper and could not beat slice+push on instruction count; this PR does not make them.

The omit now holds the MergeView record itself (new source kind SOURCE_MERGE) and is one record however many leaves the merge has. A later merge() carries it as one entry; a later omit() folds into it. Nothing on the way is a proxy trap: sourceKeys/sourceHas/sourceGet, descriptors, hasStaticKeys and the resolved tables recurse into the record by function call. The property #3454 established — consumers read the leaves, never through a proxy — is kept; the per-leaf copies are gone.

Three things had to hold for it to pay, each found by measuring a variant that didn't:

  1. One walk per read. A nested entry answers presence and value together (MISSING sentinel) — has-then-get per level was (2^{depth}) walks.
  2. Inner records don't count reads toward their own 16-read table threshold, and the outer view's table is collected in one pass over the leaves (collectTable with a filter stack) — the naive recursion built a Map per layer (14 at depth 7 where flat built 2).
  3. sourceOwners(source, keys, owners) (@internal): every key of a plain object, store, or view in merged order with its owning object, in one pass. ssrElement collects any spread that isn't plain-objects-only — a view, a store, or the array form with one among them — this way and reads owners[i][keys[i]] directly: the flat form's read cost without its construction cost. pushEntry is gone from server.ts.

Semantics to note in review:

  • An omit's $SOURCES answers nothing now (previously its filtered leaf views); consumers reach the record via viewOf and walk it as one filtered entry. merge() re-merging an omit carries the record.
  • Reads through an omit no longer count toward the inner merge's table threshold — the view that was asked decides for the whole tree. On the client that is the outer view the component reads, which still crosses the threshold and builds its (single-pass) table.

Measured

Against next, interleaved, min of N, quiet machine, compiled control within ±1.3%:

next → this
tier-1 polymorphic-chain SSR, allocation 14.6 → 12.8 KB/row (−12%)
tier-1 polymorphic-chain SSR, time −5.6% interpreter · −4.8% Sparkplug · −2.0% Maglev · −2.4% TurboFan
props-chain build (depth 1/3/7, interpreter) −35% / −48% / −65%
props-chain build+consume (depth 7) −31% interpreter · −26% Sparkplug · −9% TurboFan
omit/merge micro-suite (min of 3) 22 shapes better >5%, 77 within ±5%, 2 in the noise floor on code-identical paths
yak-bench SSR, all-primitives lane +7% geomean; multifile-composition +36%, tabs +35%, polymorphic-chain +20%

On yak-bench that brings the all-Solid-primitives lane to parity with yak's hand-rolled runtime on the composition cases (0.74× → 0.99×) and moves the React gap on them from 0.36–0.39× to 0.50–0.51× (polymorphic-chain 0.24× → 0.29×). Element-dense cases move 0–4%: they are one or two layers deep and had little nesting to save.

The interpreter-tier results are the closest local proxy to CodSpeed's instruction counts (where #3487 lost); this is the tier where the change wins most.

Tests

Three structural tests in utilities.test.ts that asserted the flattened shape are rewritten to the nested one, with new assertions for filter scoping (a layer's filter applies to its own contribution only), merged key order through nesting, $SOURCES not leaking the unfiltered merge (#3014), one table per asked view, and sourceOwners. All suites green: signals 2 734, web server 986 / client 825 / hydrate 185, solid 620, universal 56. Size scenarios within limits.

@changeset-bot

changeset-bot Bot commented Sep 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 328580f

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
solid-js Patch
@solidjs/web Patch
test-integration Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/universal Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics 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

@coveralls

coveralls commented Sep 16, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 35162887846

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage remained the same at 71.46%

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: 1018
Covered Lines: 772
Line Coverage: 75.83%
Relevant Branches: 790
Covered Branches: 520
Branch Coverage: 65.82%
Branches in Coverage %: Yes
Coverage Strength: 14.93 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 16, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 2.86%

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

⚡ 29 improved benchmarks
❌ 11 regressed benchmarks
✅ 135 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
merge 23 µs 1,441 µs -98.4%
omit 25.9 µs 50.8 µs -49.06%
merge 24.5 µs 30.8 µs -20.61%
merge 24.1 µs 30.3 µs -20.54%
merge 24 µs 29.9 µs -19.74%
ownKeys 254 µs 282.1 µs -9.96%
merge 23.4 µs 25.3 µs -7.61%
merge 23.4 µs 25.4 µs -7.59%
construct 28.8 µs 31.1 µs -7.55%
merge 23 µs 24.4 µs -5.7%
merge 24.2 µs 25.6 µs -5.44%
ownKeys 2,773.1 µs 565.2 µs ×4.9
build + consume 841 µs 518 µs +62.36%
build 283.2 µs 203.9 µs +38.9%
build 163.2 µs 132.1 µs +23.5%
merge 31.6 µs 26.2 µs +20.34%
projection derive: write one NESTED field (reference) 2.1 ms 1.8 ms +16.75%
build 155.2 µs 134.7 µs +15.2%
build + consume 401.6 µs 360.5 µs +11.39%
merge 28.7 µs 26.3 µs +9.05%
... ... ... ... ...

ℹ️ 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 perf/omit-nested-record (328580f) with next (d741d1e)

Open in CodSpeed

…ord per layer, one-pass owners walk for ssrElement

An omit over a merge flattened at construction: one OmitView plus one
combined hidden-key list per flattened leaf, and the next merge() copied
those entries into its arrays — on a Kobalte-shaped chain (defaults +
omit + spread, four layers) ~19 records and as many list copies per
element, the largest allocation of the render. #3487 tried to make those
copies cheaper and could not beat slice+push on instruction count; this
does not make them.

The omit now holds the MergeView record itself (new source kind
SOURCE_MERGE) and is one record whatever the merge's leaf count; a later
merge() carries it as one entry, a later omit() folds into it. Nothing on
the way is a trap: sourceKeys/sourceHas/sourceGet, descriptors,
hasStaticKeys and the tables recurse into the record by function call —
the property #3454 established (consumers read the leaves, never through
a proxy) is kept, the per-leaf copies are not.

Three things had to hold for it to pay, each found by measurement:
- one walk per read: a nested entry answers presence and value together
  (MISSING sentinel), not has-then-get per level;
- a record reached through an outer view counts no reads toward its own
  table threshold, and the outer view's table is collected in one pass
  over the leaves (collectTable) — not one table per layer;
- sourceOwners(source, keys, owners): every key of a plain object, store
  or view in merged order with its owning object, one pass. ssrElement
  collects any non-plain spread (a view, a store, the array form with one
  among them) this way and reads owners[i][keys[i]] — the flat form's
  read cost without its construction cost. pushEntry is gone.

An omit's $SOURCES answers nothing now; consumers reach the record via
viewOf. Reads through an omit no longer count on the inner merge: the
view that was asked decides for the tree.

Measured against next (interleaved, min of N, quiet machine): tier-1
polymorphic-chain SSR −12% bytes/row, −2…−6% time across interp,
Sparkplug, Maglev, TurboFan; props-chain build −6…−65%, build+consume
−7…−31% by depth and tier; omit/merge micro-suite flat or better in
every shape; yak-bench SSR all-primitives lane +7% geomean, +20–36% on
the composition cases, which reach parity with yak's hand-rolled runtime.

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