perf(signals,web): omit over a merge holds the merge record — one record per layer, one-pass owners walk for ssrElement - #3497
Conversation
🦋 Changeset detectedLatest commit: 328580f The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 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 |
Coverage Report for CI Build 35162887846Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage remained the same at 71.46%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will degrade performance by 2.86%
|
| 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)
…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>
bf7ae40 to
328580f
Compare
Part of #3389 (item 3 of the plan: "omit over a merge: nested record vs per-leaf flattening").
What
An
omit()over amerge()used to flatten at construction: oneOmitViewplus one combined hidden-key list per flattened leaf, and the nextmerge()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 beatslice+pushon instruction count; this PR does not make them.The omit now holds the
MergeViewrecord itself (new source kindSOURCE_MERGE) and is one record however many leaves the merge has. A latermerge()carries it as one entry; a lateromit()folds into it. Nothing on the way is a proxy trap:sourceKeys/sourceHas/sourceGet, descriptors,hasStaticKeysand 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:
MISSINGsentinel) — has-then-get per level was (2^{depth}) walks.collectTablewith a filter stack) — the naive recursion built a Map per layer (14 at depth 7 where flat built 2).sourceOwners(source, keys, owners)(@internal): every key of a plain object, store, or view in merged order with its owning object, in one pass.ssrElementcollects any spread that isn't plain-objects-only — a view, a store, or the array form with one among them — this way and readsowners[i][keys[i]]directly: the flat form's read cost without its construction cost.pushEntryis gone fromserver.ts.Semantics to note in review:
$SOURCESanswers nothing now (previously its filtered leaf views); consumers reach the record viaviewOfand walk it as one filtered entry.merge()re-merging an omit carries the record.Measured
Against
next, interleaved, min of N, quiet machine,compiledcontrol within ±1.3%:polymorphic-chainSSR, allocationpolymorphic-chainSSR, timemultifile-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-chain0.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.tsthat 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,$SOURCESnot leaking the unfiltered merge (#3014), one table per asked view, andsourceOwners. All suites green: signals 2 734, web server 986 / client 825 / hydrate 185, solid 620, universal 56. Size scenarios within limits.