From fbc2ae25409fdddea943bc95c094979df00412d8 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 16 Sep 2026 00:01:13 -0700 Subject: [PATCH 1/2] perf(signals): omit() chains folded filters instead of copying them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an omit folds over another view — an omit of an omit, or of a merge with omit leaves — the two filters were combined into one key list per leaf, per layer. On a component chain (defaults -> omit -> statics -> omit ...) each copy held every key hidden so far, growing with the depth: at depth 7 the copies were the largest allocation of the views, 100-450 bytes a leaf. #3475's final form, slice() + push, made that worse than concat: V8 grows the backing store to n + n/2 + 16 slots on the push past capacity, 2.4-2.7x the bytes of an exact copy — which is where an 11% allocation increase on the polymorphic-chain SSR case between that PR's morning build and its merged form came from. A filter is now one two-field link (inner filter, own filter) over the filter it folds. hides() walks the links in one loop — a link is told from a list or a predicate by its constructor, a load and a compare, since instanceof and Array.isArray are builtin calls in the bytecode tiers — and does the same includes work the combined list did. A filter with nothing to hide adds no link; a predicate filter no longer needs a closure to combine; a chain arriving as the outer filter is re-hung link by link so every link's own filter stays atomic. The no-Proxy path is untouched: no views exist there, so the filter is the caller's list or predicate. Depth-7 Kobalte-shaped chain, min-of-N: TurboFan build -32%, build + consume -11%, Maglev consume -11%; sampled allocation -23%. The polymorphic-chain SSR case allocates 17% less per instance (24.7 -> 20.4 KB). In the interpreter and Sparkplug the check-heavy consume is +5-9%: one includes builtin over a flat list is the cheapest possible check in those tiers, and a walk over several links cannot match it. A per-leaf Set flatten at table-build time was tried and made the interpreter worse (the flatten loop and Set.has per key cost more than the walk), so the trade is taken as is, for the optimized tiers and the allocation rate a server sees. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/omit-hidden-chain.md | 9 ++ packages/signals/src/store/utils.ts | 82 +++++++++++++------ .../signals/tests/store/utilities.test.ts | 34 +++++++- 3 files changed, 97 insertions(+), 28 deletions(-) create mode 100644 .changeset/omit-hidden-chain.md diff --git a/.changeset/omit-hidden-chain.md b/.changeset/omit-hidden-chain.md new file mode 100644 index 000000000..60e820234 --- /dev/null +++ b/.changeset/omit-hidden-chain.md @@ -0,0 +1,9 @@ +--- +"@solidjs/signals": patch +--- + +`omit()` chains folded filters instead of copying them + +When an omit folds over another view — an omit of an omit, or of a merge with omit leaves — the two filters were combined into one key list per leaf, per layer. On a component chain (defaults → omit → statics → omit …) each copy held every key hidden so far, growing with the depth: at depth 7 the copies were the largest allocation of the views (100–450 bytes a leaf, more with `push` growth of the backing store). A filter is now one two-field link over the filter it folds; a check walks the links, doing the same `includes` work the combined list did. A filter with nothing to hide adds no link, and a predicate filter no longer needs a closure to combine. + +Depth-7 Kobalte-shaped chain, optimized: build −32%, build + consume −11%, bytes allocated −23%. The `polymorphic-chain` SSR case allocates 17% less per instance. In the bytecode tiers (interpreter, Sparkplug) the check-heavy consume is 5–9% more instructions, since one `includes` builtin over a flat list is the cheapest possible check there; that is the trade, made for the optimized tiers and the allocation rate a server sees. diff --git a/packages/signals/src/store/utils.ts b/packages/signals/src/store/utils.ts index 97ba143cf..d6b216246 100644 --- a/packages/signals/src/store/utils.ts +++ b/packages/signals/src/store/utils.ts @@ -102,31 +102,57 @@ export class OmitView { ) {} } -type Hidden = PropertyKey[] | ((key: PropertyKey) => boolean); +// What an omit view hides: the caller's key list or predicate, or — for a +// view folded over another (an omit of an omit, or of a merge with omit +// leaves) — both filters, CHAINED. A link is one two-field object where a +// combined key list was a copy of every key hidden so far: an omit over a +// merge combines once per leaf per layer, so on a component chain +// (defaults → omit → statics → omit …) the copies grew with the depth and +// were the largest allocation of the views (100–450 bytes a leaf on a +// Kobalte-shaped chain, more with `push` growth). A check walks the links, +// the same `includes` work a combined list did. +type Filter = PropertyKey[] | ((key: PropertyKey) => boolean); +type Hidden = Filter | HiddenChain; +class HiddenChain { + constructor( + /** the filter folded over: a list, predicate, or a chain of its own */ + public inner: Hidden, + /** this link's own filter — one list or predicate, never a chain */ + public outer: Filter + ) {} +} function isHidden(view: OmitView, key: PropertyKey): boolean { - const h = view.hidden; - return typeof h === "function" ? h(key) : h.includes(key); -} - -// Both filters as one. Two key lists stay a key list (one `includes`, no -// closure); a predicate on either side needs a closure. An omit over a -// merge builds one combined list per leaf, per component layer, so the -// copy's form matters in every tier: `concat` runs the species/spreadable -// protocol (2–3× the cost of a copy once optimized), a hand loop is 2–4× -// `concat` in the interpreter and baseline tiers (a bytecode per element -// against one builtin), and a presized `new Array(n)` is holey, which takes -// `includes` off its fast path. `slice` + `push` of the (short) second list -// is within a third of the best form in every tier, and packed. -function combineHidden(a: Hidden, b: Hidden): Hidden { - if (typeof a !== "function" && typeof b !== "function") { - const out = a.slice(); - for (let i = 0; i < b.length; i++) out.push(b[i]); - return out; + return hides(view.hidden, key); +} + +// One loop, no recursion, and a link is told from a list or a predicate by +// its constructor (a load and a compare; `instanceof` and `Array.isArray` +// are builtin calls in the bytecode tiers): the check runs per key of every +// leaf when a view is enumerated or its table is built, and on a chain that +// is several links deep, so it is written for those tiers as much as for +// the optimizer. +function hides(h: Hidden, key: PropertyKey): boolean { + for (;;) { + if (h.constructor !== HiddenChain) + return typeof h === "function" ? h(key) : (h as PropertyKey[]).includes(key); + const outer = (h as HiddenChain).outer; + if (typeof outer === "function" ? outer(key) : outer.includes(key)) return true; + h = (h as HiddenChain).inner; } - return key => - (typeof a === "function" ? a(key) : a.includes(key)) || - (typeof b === "function" ? b(key) : b.includes(key)); +} + +// Both filters as one — a link, never a copy. A filter with nothing to hide +// (an `omit(props)` with no keys) adds no link. `outer` is normally the +// current omit's own filter; when it is a chain (an omit over an omit that +// was itself over a merge), its links are re-hung over `inner` one by one so +// every link's own filter stays atomic. +function combineHidden(inner: Hidden, outer: Hidden): Hidden { + if (outer instanceof HiddenChain) + return combineHidden(combineHidden(inner, outer.inner), outer.outer); + if (Array.isArray(outer) && outer.length === 0) return inner; + if (Array.isArray(inner) && inner.length === 0) return outer; + return new HiddenChain(inner, outer); } // The object a view filters (see `leafOf`). @@ -933,17 +959,19 @@ export function omit(props: any, ...keys: any[]): any { } return new Proxy(new OmitView(source, kind, hidden, entries), omitTraps); } + // No Proxy: no views exist, so `hidden` is the caller's list or predicate. + const own = hidden as PropertyKey[] | ((key: PropertyKey) => boolean); const result: Record = {}; const propNames = Object.getOwnPropertyNames(props); const isHiddenKey: (key: string) => boolean = - typeof hidden === "function" - ? hidden - : hidden.length > 4 && propNames.length > hidden.length + typeof own === "function" + ? own + : own.length > 4 && propNames.length > own.length ? ( blocked => (key: string) => blocked.has(key) - )(new Set(hidden)) - : key => hidden.includes(key); + )(new Set(own)) + : key => own.includes(key); for (const propName of propNames) { if (!isHiddenKey(propName)) { diff --git a/packages/signals/tests/store/utilities.test.ts b/packages/signals/tests/store/utilities.test.ts index b19cc4b0a..e30bd9b93 100644 --- a/packages/signals/tests/store/utilities.test.ts +++ b/packages/signals/tests/store/utilities.test.ts @@ -824,7 +824,9 @@ describe("view descriptors", () => { // l3's statics, l1's defaults, the user's props — in merge order expect((leaves[0] as OmitView).hidden).toEqual(["as"]); expect((leaves[2] as OmitView).source).toBe(user); - expect((leaves[2] as OmitView).hidden).toEqual(["type", "as"]); + // both filters, chained rather than copied: the inner omit's list and the + // outer's, in that order + expect((leaves[2] as OmitView).hidden).toEqual({ inner: ["type"], outer: ["as"] }); // and the truth reaches the top expect(Object.getOwnPropertyDescriptor(l5, "class")!.value).toBe("btn"); expect(typeof Object.getOwnPropertyDescriptor(l5, "label")!.get).toBe("function"); @@ -833,6 +835,36 @@ describe("view descriptors", () => { expect(Object.getOwnPropertyDescriptor(l5, "role")!.value).toBe("button"); expect(Object.keys(l5).sort()).toEqual(["class", "extra", "label", "role"]); }); + test("folded filters chain: lists, predicates and empty omits over each other", () => { + const user = { a: 1, b: 2, c: 3, d: 4, e: 5 }; + // omit of omit of predicate-omit of a no-key omit — every combination + const o1 = omit(user); // nothing hidden: adds no link + const o2 = omit(o1, (key: PropertyKey) => key === "a"); + const o3 = omit(o2, "b"); + const o4 = omit(o3); // still nothing added + const o5 = omit(o4, "c", "zz"); + const view = viewOf(o5) as OmitView; + expect(view.source).toBe(user); + expect(view.hidden).toEqual({ + inner: { inner: expect.any(Function), outer: ["b"] }, + outer: ["c", "zz"] + }); + expect(Object.keys(o5)).toEqual(["d", "e"]); + expect("a" in o5).toBe(false); + expect("b" in o5).toBe(false); + expect("c" in o5).toBe(false); + expect("d" in o5).toBe(true); + expect(o5.a).toBeUndefined(); + expect(o5.d).toBe(4); + expect(Object.getOwnPropertyDescriptor(o5, "b")).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(o5, "e")!.value).toBe(5); + // through a merge, the chained filter travels with the leaf + const m = merge({ x: 0 }, o5); + expect(Object.keys(m)).toEqual(["x", "d", "e"]); + const o6 = omit(m, "d"); + expect(Object.keys(o6)).toEqual(["x", "e"]); + expect({ ...o6 }).toEqual({ x: 0, e: 5 }); + }); // A store-shaped proxy that logs every trap it is asked. `$PROXY in`, // `$TARGET` and `$PROXY` are a store's fast paths; anything else — an // unknown symbol taking its generic read path, `getPrototypeOf` from an From 93e535925d05c34b58a71d4d66e03b7a6e42a1b0 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 16 Sep 2026 01:04:00 -0700 Subject: [PATCH 2/2] perf(signals): copy folded filters by exact concat while short, chain only past 8 keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chain from the first fold cut allocation by a quarter at depth 7 and build time by a third under TurboFan, but the walk per key of every leaf cost the bytecode tiers more than the copies had: CodSpeed read the tier-1 polymorphic-chain SSR bench 10% worse, and omit-static reads paid an extra call layer for a case with no chain at all. Two lists now combine with concat — one builtin call, exact-size (the slice() + push it replaces grew the backing store to 1.5n + 16 slots) — while the result is within 8 keys; past that, or with a predicate on either side, they chain as before. The tier-1 bench's four omits hide 1 + 1 + 2 + 1 keys, so every leaf on that shape stays one flat list and a check is one includes; a deep leaf on a longer chain is one short list plus a few links. isHidden is the walk itself (no second call layer), and combineHidden tells a list, a predicate and a link apart by typeof and constructor rather than Array.isArray / instanceof, which are builtin calls in the bytecode tiers. Tier-1 polymorphic-chain SSR bench (Kobalte shape, renderToString, 200 rows), the harness driven in-process under each tier, min of 5 vs next: interpreter -7%, Sparkplug -12%, TurboFan -17%; the compiled control is flat. Depth-7 props chain: TurboFan build -14%, build + consume -9%; interpreter and Sparkplug +3-5%. A limit of 12 was measured and is worse in every tier (more copies, less chain). Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/omit-hidden-chain.md | 6 +- packages/signals/src/store/utils.ts | 65 ++++++++++++------- .../signals/tests/store/utilities.test.ts | 36 ++++++++-- 3 files changed, 72 insertions(+), 35 deletions(-) diff --git a/.changeset/omit-hidden-chain.md b/.changeset/omit-hidden-chain.md index 60e820234..b361f2ac2 100644 --- a/.changeset/omit-hidden-chain.md +++ b/.changeset/omit-hidden-chain.md @@ -2,8 +2,8 @@ "@solidjs/signals": patch --- -`omit()` chains folded filters instead of copying them +`omit()` combines folded filters into one exact-size list while short, and chains them past that -When an omit folds over another view — an omit of an omit, or of a merge with omit leaves — the two filters were combined into one key list per leaf, per layer. On a component chain (defaults → omit → statics → omit …) each copy held every key hidden so far, growing with the depth: at depth 7 the copies were the largest allocation of the views (100–450 bytes a leaf, more with `push` growth of the backing store). A filter is now one two-field link over the filter it folds; a check walks the links, doing the same `includes` work the combined list did. A filter with nothing to hide adds no link, and a predicate filter no longer needs a closure to combine. +When an omit folds over another view — an omit of an omit, or of a merge with omit leaves — the two filters were combined into one key list per leaf, per layer, by `slice()` + `push`. V8 grows the backing store to `1.5n + 16` slots on that push, so each copy allocated 2.4–2.7× an exact one, and on a component chain (defaults → omit → statics → omit …) the copies held every key hidden so far, growing with the depth. Two short lists now combine with `concat` — one builtin call, exact-size — up to 8 keys; past that, or with a predicate on either side, they chain as one two-field link over the filter folded, with no copy at all. A predicate filter no longer needs a closure to combine, and a filter with nothing to hide adds nothing. -Depth-7 Kobalte-shaped chain, optimized: build −32%, build + consume −11%, bytes allocated −23%. The `polymorphic-chain` SSR case allocates 17% less per instance. In the bytecode tiers (interpreter, Sparkplug) the check-heavy consume is 5–9% more instructions, since one `includes` builtin over a flat list is the cheapest possible check there; that is the trade, made for the optimized tiers and the allocation rate a server sees. +Tier-1 `polymorphic-chain` SSR bench (Kobalte shape, `renderToString`, 200 rows): −17% optimized, −12% Sparkplug, −7% interpreter. Depth-7 props chain: build −14%, build + consume −9% optimized; +3–5% in the bytecode tiers, where the walk over the links past the copy limit costs more than one `includes` over a flat list would. diff --git a/packages/signals/src/store/utils.ts b/packages/signals/src/store/utils.ts index d6b216246..f4ee2d501 100644 --- a/packages/signals/src/store/utils.ts +++ b/packages/signals/src/store/utils.ts @@ -104,13 +104,18 @@ export class OmitView { // What an omit view hides: the caller's key list or predicate, or — for a // view folded over another (an omit of an omit, or of a merge with omit -// leaves) — both filters, CHAINED. A link is one two-field object where a -// combined key list was a copy of every key hidden so far: an omit over a -// merge combines once per leaf per layer, so on a component chain -// (defaults → omit → statics → omit …) the copies grew with the depth and -// were the largest allocation of the views (100–450 bytes a leaf on a -// Kobalte-shaped chain, more with `push` growth). A check walks the links, -// the same `includes` work a combined list did. +// leaves) — both filters. Two short lists combine into one exact-size list +// (one `includes` per check, the cheapest check in every tier); past +// `COPY_LIMIT` keys, or with a predicate on either side, they CHAIN — one +// two-field link over the filter folded, no copy. An omit over a merge +// combines once per leaf per layer, so on a component chain (defaults → +// omit → statics → omit …) a list that kept growing by copy was the largest +// allocation of the views (100–450 bytes a leaf on a Kobalte-shaped chain, +// quadratic in the depth); a chain that started at the first fold cost a +// link walk per key of every leaf on the same shape, and the bytecode tiers +// paid more for the walk than the copies had cost. The limit keeps most +// leaves a flat list and bounds a deep leaf to a short list plus a few +// links. type Filter = PropertyKey[] | ((key: PropertyKey) => boolean); type Hidden = Filter | HiddenChain; class HiddenChain { @@ -121,18 +126,15 @@ class HiddenChain { public outer: Filter ) {} } - -function isHidden(view: OmitView, key: PropertyKey): boolean { - return hides(view.hidden, key); -} +const COPY_LIMIT = 8; // One loop, no recursion, and a link is told from a list or a predicate by // its constructor (a load and a compare; `instanceof` and `Array.isArray` // are builtin calls in the bytecode tiers): the check runs per key of every -// leaf when a view is enumerated or its table is built, and on a chain that -// is several links deep, so it is written for those tiers as much as for -// the optimizer. -function hides(h: Hidden, key: PropertyKey): boolean { +// leaf when a view is enumerated or its table is built, so it is written +// for those tiers as much as for the optimizer. +function isHidden(view: OmitView, key: PropertyKey): boolean { + let h = view.hidden; for (;;) { if (h.constructor !== HiddenChain) return typeof h === "function" ? h(key) : (h as PropertyKey[]).includes(key); @@ -142,17 +144,30 @@ function hides(h: Hidden, key: PropertyKey): boolean { } } -// Both filters as one — a link, never a copy. A filter with nothing to hide -// (an `omit(props)` with no keys) adds no link. `outer` is normally the -// current omit's own filter; when it is a chain (an omit over an omit that -// was itself over a merge), its links are re-hung over `inner` one by one so -// every link's own filter stays atomic. +// Both filters as one. A filter with nothing to hide (an `omit(props)` with +// no keys) adds nothing. Two lists within the limit copy — `concat`, an +// exact-size allocation in one builtin call (a `slice` + `push` grows the +// backing store to 1.5n + 16 slots on the push; a hand loop is a bytecode +// per element). `outer` is normally the current omit's own filter; when it +// is a chain (an omit over an omit that was itself over a merge), its links +// are re-hung over `inner` one by one so every link's own filter stays +// atomic. function combineHidden(inner: Hidden, outer: Hidden): Hidden { - if (outer instanceof HiddenChain) - return combineHidden(combineHidden(inner, outer.inner), outer.outer); - if (Array.isArray(outer) && outer.length === 0) return inner; - if (Array.isArray(inner) && inner.length === 0) return outer; - return new HiddenChain(inner, outer); + if (outer.constructor === HiddenChain) { + const o = outer as HiddenChain; + return combineHidden(combineHidden(inner, o.inner), o.outer); + } + // From here `outer` is a list or a predicate; a list is told by `length` + // (a predicate's is its arity — never read, the typeof comes first). + const outerList = typeof outer !== "function"; + if (outerList && (outer as PropertyKey[]).length === 0) return inner; + if (typeof inner !== "function" && inner.constructor !== HiddenChain) { + const list = inner as PropertyKey[]; + if (list.length === 0) return outer; + if (outerList && list.length + (outer as PropertyKey[]).length <= COPY_LIMIT) + return list.concat(outer as PropertyKey[]); + } + return new HiddenChain(inner, outer as Filter); } // The object a view filters (see `leafOf`). diff --git a/packages/signals/tests/store/utilities.test.ts b/packages/signals/tests/store/utilities.test.ts index e30bd9b93..6d889b083 100644 --- a/packages/signals/tests/store/utilities.test.ts +++ b/packages/signals/tests/store/utilities.test.ts @@ -824,9 +824,8 @@ describe("view descriptors", () => { // l3's statics, l1's defaults, the user's props — in merge order expect((leaves[0] as OmitView).hidden).toEqual(["as"]); expect((leaves[2] as OmitView).source).toBe(user); - // both filters, chained rather than copied: the inner omit's list and the - // outer's, in that order - expect((leaves[2] as OmitView).hidden).toEqual({ inner: ["type"], outer: ["as"] }); + // both filters as one short list: the inner omit's keys, then the outer's + expect((leaves[2] as OmitView).hidden).toEqual(["type", "as"]); // and the truth reaches the top expect(Object.getOwnPropertyDescriptor(l5, "class")!.value).toBe("btn"); expect(typeof Object.getOwnPropertyDescriptor(l5, "label")!.get).toBe("function"); @@ -835,20 +834,43 @@ describe("view descriptors", () => { expect(Object.getOwnPropertyDescriptor(l5, "role")!.value).toBe("button"); expect(Object.keys(l5).sort()).toEqual(["class", "extra", "label", "role"]); }); - test("folded filters chain: lists, predicates and empty omits over each other", () => { + test("folded filters: short lists copy, predicates and long lists chain", () => { const user = { a: 1, b: 2, c: 3, d: 4, e: 5 }; // omit of omit of predicate-omit of a no-key omit — every combination - const o1 = omit(user); // nothing hidden: adds no link + const o1 = omit(user); // nothing hidden: adds nothing const o2 = omit(o1, (key: PropertyKey) => key === "a"); - const o3 = omit(o2, "b"); + const o3 = omit(o2, "b"); // a predicate cannot copy: one link const o4 = omit(o3); // still nothing added - const o5 = omit(o4, "c", "zz"); + const o5 = omit(o4, "c", "zz"); // a list over a chain: another link const view = viewOf(o5) as OmitView; expect(view.source).toBe(user); expect(view.hidden).toEqual({ inner: { inner: expect.any(Function), outer: ["b"] }, outer: ["c", "zz"] }); + // lists copy while short, then chain: eight keys in one list, the ninth + // fold a link over it + let deep: any = user; + for (let i = 0; i < 4; i++) deep = omit(deep, `k${i}a`, `k${i}b`); + expect((viewOf(deep) as OmitView).hidden).toEqual([ + "k0a", + "k0b", + "k1a", + "k1b", + "k2a", + "k2b", + "k3a", + "k3b" + ]); + deep = omit(deep, "k4a", "k4b"); + expect((viewOf(deep) as OmitView).hidden).toEqual({ + inner: ["k0a", "k0b", "k1a", "k1b", "k2a", "k2b", "k3a", "k3b"], + outer: ["k4a", "k4b"] + }); + deep = omit(deep, "a"); + expect(Object.keys(deep)).toEqual(["b", "c", "d", "e"]); + expect("a" in deep).toBe(false); + expect("k4a" in deep).toBe(false); expect(Object.keys(o5)).toEqual(["d", "e"]); expect("a" in o5).toBe(false); expect("b" in o5).toBe(false);