From ec7d03976272e6dd9dce6d27f67e56b54b3ef478 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent Date: Fri, 29 May 2026 08:47:23 -0400 Subject: [PATCH 1/5] perf(reference): make {{#each}} item params cheap "cell" references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each `{{#each}}` item binds two block params — the item value and its index — and both were created as full compute references via `createIteratorItemRef`. That meant, per item: - a `ReferenceImpl` + a dirtyable tag, plus *two* closures (the `compute` getter and the `update` setter), and - on every read, `valueForRef` took the generic compute path and opened a `track()` frame (a `Tracker` + `Set` allocation) purely to re-discover a tag that never changes. For a 10k-row table that is 20k references and 20k tracking frames per render pass (create/clear/append/update/swap all hit this), all to model a value that is just "a stored value behind one tag". This introduces a dedicated `Cell` reference type. A cell stores its value directly on the reference behind a fixed tag, so: - `valueForRef` reads the stored value and re-snapshots the tag without opening a tracking frame (there are no dependencies to discover), and - `updateRef` mutates the value inline with the same equality gate as before — no `compute`/`update` closures are allocated at all. Behavior is identical: same tag consumed on read, same equality-gated dirty on update. `isUpdatableRef` reports cells as updatable, and `createDebugAliasRef` no longer inherits the `Cell` type (a debug alias is a genuine compute reference). Microbench (real `valueForRef`/`updateRef`, 1000 items, prod build): initial render (create+read) 198µs/698kb -> 86µs/261kb (2.3x, -63% mem) re-render (update+read) 185µs/417kb -> 79µs/137kb (2.3x, -67% mem) allocation only 31µs/320kb -> 22µs/~4kb (1.4x, ~0 garbage) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../@glimmer/interfaces/lib/references.d.ts | 5 +- packages/@glimmer/reference/lib/iterable.ts | 22 +----- packages/@glimmer/reference/lib/reference.ts | 76 ++++++++++++++++--- 3 files changed, 70 insertions(+), 33 deletions(-) diff --git a/packages/@glimmer/interfaces/lib/references.d.ts b/packages/@glimmer/interfaces/lib/references.d.ts index 9bb1415eb57..5d32314793e 100644 --- a/packages/@glimmer/interfaces/lib/references.d.ts +++ b/packages/@glimmer/interfaces/lib/references.d.ts @@ -4,19 +4,22 @@ export type ConstantReference = 0; export type ComputeReference = 1; export type UnboundReference = 2; export type InvokableReference = 3; +export type CellReference = 4; export interface ReferenceTypes { readonly Constant: ConstantReference; readonly Compute: ComputeReference; readonly Unbound: UnboundReference; readonly Invokable: InvokableReference; + readonly Cell: CellReference; } export type ReferenceType = | ConstantReference | ComputeReference | UnboundReference - | InvokableReference; + | InvokableReference + | CellReference; declare const REFERENCE: unique symbol; export type ReferenceSymbol = typeof REFERENCE; diff --git a/packages/@glimmer/reference/lib/iterable.ts b/packages/@glimmer/reference/lib/iterable.ts index 71134eb5c2b..27a29e422de 100644 --- a/packages/@glimmer/reference/lib/iterable.ts +++ b/packages/@glimmer/reference/lib/iterable.ts @@ -3,13 +3,13 @@ import type { Nullable } from '@glimmer/interfaces'; import { getPath, toIterator } from '@glimmer/global-context'; import { EMPTY_ARRAY } from '@glimmer/util/lib/array-utils'; import { isIndexable } from '@glimmer/util/lib/collections'; -import { consumeTag } from '@glimmer/validator/lib/tracking'; -import { createTag, DIRTY_TAG as dirtyTag } from '@glimmer/validator/lib/validators'; import type { Reference, ReferenceEnvironment } from './reference'; import { createComputeRef, valueForRef } from './reference'; +export { createIteratorItemRef } from './reference'; + export interface IterationItem { key: unknown; value: T; @@ -185,24 +185,6 @@ export function createIteratorRef(listRef: Reference, key: string) { }); } -export function createIteratorItemRef(_value: unknown) { - let value = _value; - let tag = createTag(); - - return createComputeRef( - () => { - consumeTag(tag); - return value; - }, - (newValue) => { - if (value !== newValue) { - value = newValue; - dirtyTag(tag); - } - } - ); -} - class IteratorWrapper implements OpaqueIterator { constructor( private inner: IteratorDelegate, diff --git a/packages/@glimmer/reference/lib/reference.ts b/packages/@glimmer/reference/lib/reference.ts index c7232d0469a..17a6a16f11a 100644 --- a/packages/@glimmer/reference/lib/reference.ts +++ b/packages/@glimmer/reference/lib/reference.ts @@ -1,5 +1,6 @@ import { DEBUG } from '@glimmer/env'; import type { + CellReference, ComputeReference, ConstantReference, InvokableReference, @@ -10,11 +11,18 @@ import type { UnboundReference, } from '@glimmer/interfaces'; import type { Revision } from '@glimmer/validator/lib/validators'; -import type { Tag } from '@glimmer/interfaces'; +import type { DirtyableTag, Tag } from '@glimmer/interfaces'; import { expect } from '@glimmer/debug-util/lib/platform-utils'; import { getProp, setProp } from '@glimmer/global-context'; import { isDict } from '@glimmer/util/lib/collections'; -import { CONSTANT_TAG, INITIAL, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; +import { + CONSTANT_TAG, + createTag, + DIRTY_TAG as dirtyTag, + INITIAL, + validateTag, + valueForTag, +} from '@glimmer/validator/lib/validators'; import { consumeTag, track } from '@glimmer/validator/lib/tracking'; export const REFERENCE: ReferenceSymbol = Symbol('REFERENCE') as ReferenceSymbol; @@ -23,6 +31,7 @@ const CONSTANT: ConstantReference = 0; const COMPUTE: ComputeReference = 1; const UNBOUND: UnboundReference = 2; const INVOKABLE: InvokableReference = 3; +const CELL: CellReference = 4; export type { Reference as default }; export type { Reference }; @@ -115,6 +124,28 @@ export function createComputeRef( return ref; } +/** + * A `Cell` reference holds a value directly behind a single dirtyable tag. It is + * the reference used for `{{#each}}` block params (the item value and its index), + * which are created and updated by the millions when rendering large lists. + * + * Unlike a generic compute reference, a cell has no dependencies to discover: its + * value lives on the reference itself and its tag never changes. That lets + * `valueForRef` skip the `track()` frame (a `Tracker` + `Set` allocation per read) + * and lets `updateRef` mutate the value inline, so a cell needs no `compute`/ + * `update` closures at all — just the reference object and its tag. + */ +export function createIteratorItemRef(value: T): Reference { + const ref = new ReferenceImpl(CELL); + const tag = createTag(); + + ref.tag = tag; + ref.lastValue = value; + ref.lastRevision = valueForTag(tag); + + return ref; +} + export function createReadOnlyRef(ref: Reference): Reference { if (!isUpdatableRef(ref)) return ref; @@ -145,7 +176,7 @@ export function isConstRef(_ref: Reference) { export function isUpdatableRef(_ref: Reference) { const ref = _ref as ReferenceImpl; - return ref.update !== null; + return ref[REFERENCE] === CELL || ref.update !== null; } export function valueForRef(_ref: Reference): T { @@ -161,21 +192,29 @@ export function valueForRef(_ref: Reference): T { let lastValue; if (tag === null || !validateTag(tag, lastRevision)) { - const { compute } = ref; + if (ref[REFERENCE] === CELL) { + // A cell's value is stored on the reference and gated by a fixed tag, so + // there are no dependencies to (re)discover — read the stored value and + // re-snapshot the tag without opening a tracking frame. + lastValue = ref.lastValue; + ref.lastRevision = valueForTag(tag as Tag); + } else { + const { compute } = ref; - const newTag = track(() => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme - lastValue = ref.lastValue = compute!(); - }, DEBUG && ref.debugLabel); + const newTag = track(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme + lastValue = ref.lastValue = compute!(); + }, DEBUG && ref.debugLabel); - tag = ref.tag = newTag; + tag = ref.tag = newTag; - ref.lastRevision = valueForTag(newTag); + ref.lastRevision = valueForTag(newTag); + } } else { lastValue = ref.lastValue; } - consumeTag(tag); + consumeTag(tag as Tag); return lastValue as T; } @@ -183,6 +222,16 @@ export function valueForRef(_ref: Reference): T { export function updateRef(_ref: Reference, value: unknown) { const ref = _ref as ReferenceImpl; + if (ref[REFERENCE] === CELL) { + // Equality-gated inline update — no closure indirection. Mirrors the old + // `createIteratorItemRef` setter semantics. + if (ref.lastValue !== value) { + ref.lastValue = value; + dirtyTag(ref.tag as DirtyableTag); + } + return; + } + const update = expect(ref.update, 'called update on a non-updatable reference'); update(value); @@ -260,7 +309,10 @@ if (DEBUG) { const update = isUpdatableRef(inner) ? (value: unknown): void => updateRef(inner, value) : null; const ref = createComputeRef(() => valueForRef(inner), update); - ref[REFERENCE] = inner[REFERENCE]; + // A debug alias is a genuine compute reference (it recomputes through + // `inner`); never inherit the CELL type, whose fast paths assume the value + // lives directly on the reference. + ref[REFERENCE] = inner[REFERENCE] === CELL ? COMPUTE : inner[REFERENCE]; ref.debugLabel = debugLabel; From d1442f5bbdc87d2073949d2f40849a7c06fe3c8a Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent Date: Fri, 29 May 2026 14:04:57 -0400 Subject: [PATCH 2/5] perf(reference): flatten track() frame and {{#each}} key resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more extraneous layers in the reference/iteration hot paths, removed: 1. `valueForRef` recompute went through `track(thunk)`, which allocates a closure on *every* (re)compute. This is the single hottest function in the VM — every reference read that needs evaluation passes through it (all refs on initial render, and again on each invalidation). Inlining `beginTrackFrame()`/`endTrackFrame()` drops that per-read allocation. Microbench (1000 recompute frames): 63.2µs -> 57.0µs (~10%) and 282kb -> 188kb (~33% less garbage). 2. `{{#each}}` key derivation: - `makeKeyFor` was re-resolved on every diff and wrapped *every* strategy — including `@index`/`@key`, whose keys are unique by construction — in the duplicate-key dedup machinery. The strategy is now resolved once when the iterator ref is created, and index keys skip dedup entirely. - The per-pass `seen` set used `WeakMapWithPrimitives` (lazy-getter + object/primitive dispatch on every get/set). Since it lives only for one synchronous pass, a plain `Map` is both simpler and faster; the weak-keyed map is kept only for the long-lived global `IDENTITIES`. Microbench (1000-item iteration): `@index` 23.0µs vs `@identity` 48.9µs — index keys no longer pay the dedup cost they used to. Behavior is unchanged: same keys produced, same duplicate-key semantics, same tag consumption. Verified headless in Chrome — each (571), iterable (24), tracked (242), Updating (175), Helpers (1173), Components (328), fn (36) all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/@glimmer/reference/lib/iterable.ts | 36 ++++++++++++++------ packages/@glimmer/reference/lib/reference.ts | 16 +++++---- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/@glimmer/reference/lib/iterable.ts b/packages/@glimmer/reference/lib/iterable.ts index 27a29e422de..20d5fe491d0 100644 --- a/packages/@glimmer/reference/lib/iterable.ts +++ b/packages/@glimmer/reference/lib/iterable.ts @@ -50,28 +50,35 @@ const IDENTITY: KeyFor = (item) => { return item; }; -function keyForPath(path: string): KeyFor { +function pathKeyFor(path: string): KeyFor { if (DEBUG && path[0] === '@') { throw new Error(`invalid keypath: '${path}', valid keys: @index, @identity, or a path`); } - return uniqueKeyFor((item) => { + return (item) => { if (item === null || item === undefined) { return item; } return getPath(item, path); - }); + }; } -function makeKeyFor(key: string) { +/** + * Resolve the key strategy for a `{{#each}}` once, when the iterator reference is + * created — not on every diff. `base` is the stateless function that derives a + * key from an item; `dedup` says whether per-pass duplicate-key tracking is + * needed. `@index`/`@key` produce a unique value per position by construction, so + * they never need deduping; only identity and path keys can collide. + */ +function keyStrategy(key: string): { base: KeyFor; dedup: boolean } { switch (key) { case '@key': - return uniqueKeyFor(KEY); + return { base: KEY, dedup: false }; case '@index': - return uniqueKeyFor(INDEX); + return { base: INDEX, dedup: false }; case '@identity': - return uniqueKeyFor(IDENTITY); + return { base: IDENTITY, dedup: true }; default: - return keyForPath(key); + return { base: pathKeyFor(key), dedup: true }; } } @@ -147,8 +154,11 @@ function identityForNthOccurence(value: unknown, count: number) { * and encounter an item for the nth time, we can get the _same_ key, and let * Glimmer know that it should reuse the DOM for the previous nth occurence. */ -function uniqueKeyFor(keyFor: KeyFor) { - let seen = new WeakMapWithPrimitives(); +function uniqueKeyFor(keyFor: KeyFor): KeyFor { + // Per-pass state, discarded when the iteration completes — a plain `Map` + // (which keys on objects and primitives alike) is enough; the weak-keyed + // dual-map dance is only needed for the long-lived global `IDENTITIES`. + let seen = new Map(); return (value: unknown, memo: unknown) => { let key = keyFor(value, memo); @@ -165,11 +175,15 @@ function uniqueKeyFor(keyFor: KeyFor) { } export function createIteratorRef(listRef: Reference, key: string) { + // Resolve the key strategy once; only the (cheap) per-pass dedup state is + // rebuilt on each evaluation. + const { base, dedup } = keyStrategy(key); + return createComputeRef(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let iterable = valueForRef(listRef) as { [Symbol.iterator]: any } | null | false; - let keyFor = makeKeyFor(key); + let keyFor = dedup ? uniqueKeyFor(base) : base; if (Array.isArray(iterable)) { return new ArrayIterator(iterable, keyFor); diff --git a/packages/@glimmer/reference/lib/reference.ts b/packages/@glimmer/reference/lib/reference.ts index 17a6a16f11a..2a5edc3a789 100644 --- a/packages/@glimmer/reference/lib/reference.ts +++ b/packages/@glimmer/reference/lib/reference.ts @@ -23,7 +23,7 @@ import { validateTag, valueForTag, } from '@glimmer/validator/lib/validators'; -import { consumeTag, track } from '@glimmer/validator/lib/tracking'; +import { beginTrackFrame, consumeTag, endTrackFrame } from '@glimmer/validator/lib/tracking'; export const REFERENCE: ReferenceSymbol = Symbol('REFERENCE') as ReferenceSymbol; @@ -201,14 +201,18 @@ export function valueForRef(_ref: Reference): T { } else { const { compute } = ref; - const newTag = track(() => { + // Inlined `track()`: opening the frame directly avoids allocating a thunk + // closure on every (re)compute. This is the hottest path in the VM — every + // reference read that needs evaluation passes through here. + beginTrackFrame(DEBUG && ref.debugLabel); + try { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme lastValue = ref.lastValue = compute!(); - }, DEBUG && ref.debugLabel); - - tag = ref.tag = newTag; + } finally { + tag = ref.tag = endTrackFrame(); + } - ref.lastRevision = valueForTag(newTag); + ref.lastRevision = valueForTag(tag); } } else { lastValue = ref.lastValue; From 99c9eb805bbc566f24b85f25313e543fe29c7654 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent Date: Fri, 29 May 2026 15:34:59 -0400 Subject: [PATCH 3/5] perf(reference): flatten childRefFor to a data-driven Property reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `{{a.b}}` path access compiled to a compute reference holding two closures — a getter (`getProp(valueForRef(parent), path)`) and a setter (`setProp(...)`) — that captured nothing but `(parent, path)`. That is two closure allocations per property reference, on a path hit by essentially every template (`{{this.foo}}`, `{{row.id}}`, `{{row.label.current}}`, …). Add a `Property` reference type that stores `parent` + `path` as plain fields and is read/written inline by `valueForRef`/`updateRef` (the same approach as the `Cell` type used for `{{#each}}` block params). No closures are allocated; reads still open a tracking frame, since `getProp` consumes dynamic tags. `isUpdatableRef` reports Property refs as updatable, and `createDebugAliasRef` no longer inherits the Property type. Microbench (1000 childRefFor calls): 72.2µs/633kb -> 62.4µs/477kb (~14% faster, ~25% less allocation). Also fixes a throw-semantics bug introduced when `track()` was inlined into `valueForRef`: committing `ref.tag` inside the `finally` updated the tag even when the compute threw, leaving `tag` and `lastRevision` inconsistent. The new tag/revision are now committed only on success (the frame is still ended in `finally` to keep the tracking stack balanced), matching the original `track()` behavior. This restores correct handling of throwing getters — caught by the `debug render tree: emberish curly components` test. Full browser suite green: 9340 tests, 9323 pass, 17 skip, 0 fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../@glimmer/interfaces/lib/references.d.ts | 5 +- packages/@glimmer/reference/lib/reference.ts | 77 ++++++++++++------- 2 files changed, 54 insertions(+), 28 deletions(-) diff --git a/packages/@glimmer/interfaces/lib/references.d.ts b/packages/@glimmer/interfaces/lib/references.d.ts index 5d32314793e..900210fbb23 100644 --- a/packages/@glimmer/interfaces/lib/references.d.ts +++ b/packages/@glimmer/interfaces/lib/references.d.ts @@ -5,6 +5,7 @@ export type ComputeReference = 1; export type UnboundReference = 2; export type InvokableReference = 3; export type CellReference = 4; +export type PropertyReference = 5; export interface ReferenceTypes { readonly Constant: ConstantReference; @@ -12,6 +13,7 @@ export interface ReferenceTypes { readonly Unbound: UnboundReference; readonly Invokable: InvokableReference; readonly Cell: CellReference; + readonly Property: PropertyReference; } export type ReferenceType = @@ -19,7 +21,8 @@ export type ReferenceType = | ComputeReference | UnboundReference | InvokableReference - | CellReference; + | CellReference + | PropertyReference; declare const REFERENCE: unique symbol; export type ReferenceSymbol = typeof REFERENCE; diff --git a/packages/@glimmer/reference/lib/reference.ts b/packages/@glimmer/reference/lib/reference.ts index 2a5edc3a789..90744a90fab 100644 --- a/packages/@glimmer/reference/lib/reference.ts +++ b/packages/@glimmer/reference/lib/reference.ts @@ -5,6 +5,7 @@ import type { ConstantReference, InvokableReference, Nullable, + PropertyReference, Reference, ReferenceSymbol, ReferenceType, @@ -32,6 +33,7 @@ const COMPUTE: ComputeReference = 1; const UNBOUND: UnboundReference = 2; const INVOKABLE: InvokableReference = 3; const CELL: CellReference = 4; +const PROPERTY: PropertyReference = 5; export type { Reference as default }; export type { Reference }; @@ -54,6 +56,11 @@ class ReferenceImpl implements Reference { public compute: Nullable<() => T> = null; public update: Nullable<(val: T) => void> = null; + // For PROPERTY references: the parent reference and the property path, stored + // as data instead of being captured in getter/setter closures. + public propertyParent: Nullable = null; + public propertyPath: Nullable = null; + public debugLabel?: string; constructor(type: ReferenceType) { @@ -175,8 +182,9 @@ export function isConstRef(_ref: Reference) { export function isUpdatableRef(_ref: Reference) { const ref = _ref as ReferenceImpl; + const type = ref[REFERENCE]; - return ref[REFERENCE] === CELL || ref.update !== null; + return type === CELL || type === PROPERTY || ref.update !== null; } export function valueForRef(_ref: Reference): T { @@ -199,20 +207,33 @@ export function valueForRef(_ref: Reference): T { lastValue = ref.lastValue; ref.lastRevision = valueForTag(tag as Tag); } else { - const { compute } = ref; - // Inlined `track()`: opening the frame directly avoids allocating a thunk // closure on every (re)compute. This is the hottest path in the VM — every // reference read that needs evaluation passes through here. beginTrackFrame(DEBUG && ref.debugLabel); + let newTag!: Tag; try { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme - lastValue = ref.lastValue = compute!(); + if (ref[REFERENCE] === PROPERTY) { + // A property reference reads `path` off its parent's value. Holding + // the parent + path as data (rather than getter/setter closures) is + // what lets `childRefFor` avoid two closure allocations per access. + const parent = valueForRef(ref.propertyParent as Reference); + lastValue = ref.lastValue = isDict(parent) + ? (getProp(parent, ref.propertyPath as string) as T) + : undefined; + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme + lastValue = ref.lastValue = ref.compute!(); + } } finally { - tag = ref.tag = endTrackFrame(); + // Always end the frame to keep the tracking stack balanced, but commit + // the new tag/revision only on success (below) — matching `track()`'s + // semantics so a throwing getter leaves the reference's tag untouched. + newTag = endTrackFrame(); } - ref.lastRevision = valueForTag(tag); + tag = ref.tag = newTag; + ref.lastRevision = valueForTag(newTag); } } else { lastValue = ref.lastValue; @@ -236,6 +257,15 @@ export function updateRef(_ref: Reference, value: unknown) { return; } + if (ref[REFERENCE] === PROPERTY) { + // Inline `setProp` on the parent's value — mirrors the old childRefFor setter. + const parent = valueForRef(ref.propertyParent as Reference); + if (isDict(parent)) { + setProp(parent, ref.propertyPath as string, value); + } + return; + } + const update = expect(ref.update, 'called update on a non-updatable reference'); update(value); @@ -269,26 +299,18 @@ export function childRefFor(_parentRef: Reference, path: string): Reference { child = UNDEFINED_REFERENCE; } } else { - child = createComputeRef( - () => { - const parent = valueForRef(parentRef); - - if (isDict(parent)) { - return getProp(parent, path); - } - }, - (val) => { - const parent = valueForRef(parentRef); - - if (isDict(parent)) { - return setProp(parent, path, val); - } - } - ); + // A PROPERTY reference: `getProp`/`setProp` of `path` on the parent's value. + // Storing the parent + path as data (handled inline by valueForRef/updateRef) + // avoids allocating the getter and setter closures this used to need. + const propertyRef = new ReferenceImpl(PROPERTY); + propertyRef.propertyParent = parentRef; + propertyRef.propertyPath = path; if (DEBUG) { - child.debugLabel = `${parentRef.debugLabel}.${path}`; + propertyRef.debugLabel = `${parentRef.debugLabel}.${path}`; } + + child = propertyRef; } children.set(path, child); @@ -314,9 +336,10 @@ if (DEBUG) { const ref = createComputeRef(() => valueForRef(inner), update); // A debug alias is a genuine compute reference (it recomputes through - // `inner`); never inherit the CELL type, whose fast paths assume the value - // lives directly on the reference. - ref[REFERENCE] = inner[REFERENCE] === CELL ? COMPUTE : inner[REFERENCE]; + // `inner`); never inherit the CELL/PROPERTY types, whose fast paths assume + // the value (or parent + path) lives directly on the reference. + const innerType = inner[REFERENCE]; + ref[REFERENCE] = innerType === CELL || innerType === PROPERTY ? COMPUTE : innerType; ref.debugLabel = debugLabel; From 26f304ef2365b7c16ec44c519da723c78a4c0522 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent Date: Fri, 29 May 2026 17:04:44 -0400 Subject: [PATCH 4/5] perf(validator): pool trackers and lazily allocate the consumed-tag Set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `beginTrackFrame` allocated a `new Tracker()` and the Tracker allocated a `new Set()` — two objects per frame — on *every* reference recompute and every cache group, every revalidation. The overwhelming majority of frames consume zero or one tag. - The Tracker now holds the first consumed tag in a field and allocates the `Set` only when a second, distinct tag arrives. 0/1-tag frames never touch a Set (and still dedupe / combine correctly). - Trackers are pooled on a LIFO freelist. Frames are strictly nested and a tracker is dead the instant `combine()` runs in `endTrackFrame`, so it can be reset and reused by the next `beginTrackFrame`. Net: the common tracking frame now allocates ~nothing. Microbench: a frame that opens, consumes one tag, and closes drops from two object allocations to ~0 b/iter (measured 0.10 b for the 0-tag case). Full browser suite green: 9340 tests, 9323 pass, 17 skip, 0 fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/@glimmer/validator/lib/tracking.ts | 67 +++++++++++++++++---- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index d94ae456e6b..b4d11890ce1 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -9,34 +9,74 @@ import { combine, CONSTANT_TAG, isConstTag, validateTag, valueForTag } from './v /** * An object that that tracks @tracked properties that were consumed. + * + * The vast majority of tracking frames consume zero or one tag (a single + * `{{property}}` read, a constant, etc.), so the first tag is held in a plain + * field and the `Set` is allocated lazily only when a *second, distinct* tag is + * consumed. Trackers themselves are pooled (see `allocTracker`/`freeTracker`) + * because frames are strictly nested, so the per-frame `new Tracker()` + + * `new Set()` pair — previously allocated on every reference recompute — is + * avoided entirely in the common case. */ class Tracker { - private tags = new Set(); - private last: Tag | null = null; + first: Tag | null = null; + set: Set | null = null; add(tag: Tag) { if (tag === CONSTANT_TAG) return; - this.tags.add(tag); - if (DEBUG) { unwrap(debug.markTagAsConsumed)(tag); } - this.last = tag; + let { set } = this; + + if (set !== null) { + set.add(tag); + return; + } + + let { first } = this; + + if (first === null) { + this.first = tag; + } else if (first !== tag) { + set = this.set = new Set(); + set.add(first); + set.add(tag); + } } combine(): Tag { - let { tags } = this; + let { first, set } = this; - if (tags.size === 0) { + if (set !== null) { + return combine(Array.from(set)); + } else if (first === null) { return CONSTANT_TAG; - } else if (tags.size === 1) { - return this.last as Tag; } else { - return combine(Array.from(this.tags)); + return first; } } + + reset() { + this.first = null; + this.set = null; + } +} + +// Trackers are pooled: a frame's tracker is dead the moment `combine()` has run +// in `endTrackFrame`, and frames are strictly nested (LIFO), so a closed +// tracker can be reset and handed to the next `beginTrackFrame`. +const TRACKER_POOL: Tracker[] = []; + +function allocTracker(): Tracker { + return TRACKER_POOL.pop() ?? new Tracker(); +} + +function freeTracker(tracker: Tracker): void { + tracker.reset(); + TRACKER_POOL.push(tracker); } /** @@ -59,7 +99,7 @@ const OPEN_TRACK_FRAMES: (Tracker | null)[] = []; export function beginTrackFrame(debuggingContext?: string | false): void { OPEN_TRACK_FRAMES.push(CURRENT_TRACKER); - CURRENT_TRACKER = new Tracker(); + CURRENT_TRACKER = allocTracker(); if (DEBUG) { unwrap(debug.beginTrackingTransaction)(debuggingContext); @@ -79,7 +119,10 @@ export function endTrackFrame(): Tag { CURRENT_TRACKER = OPEN_TRACK_FRAMES.pop() || null; - return unwrap(current).combine(); + let tracker = unwrap(current); + let tag = tracker.combine(); + freeTracker(tracker); + return tag; } export function beginUntrackFrame(): void { From 1b23588fc054e6f3d0924a601b2a40e01df9c563 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent Date: Fri, 29 May 2026 17:10:18 -0400 Subject: [PATCH 5/5] perf(validator): fast path tag [COMPUTE] for subtag-less tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MonomorphicTagImpl[COMPUTE]` is called by `validateTag`/`valueForTag` on every reference read. For a tag with no subtag — property tags, cell tags, plain dirtyable/updatable tags, i.e. the overwhelming majority — the result is always just `revision` (kept current by `dirtyTag`). The `lastChecked`/`isUpdating`/cycle-guard/`try-finally` machinery exists only to memoize subtag recursion, so it is pure overhead for these tags. Return `this.revision` directly when `subtag === null`. The combinator path is unchanged (it now reuses the already-read `subtag`). Microbench (1000 subtag-less [COMPUTE]s during a revalidation pass): ~4.71µs -> ~3.90µs (~17%), and no try/finally or field writes on the read. Full browser suite green: 9340 tests, 9323 pass, 17 skip, 0 fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/@glimmer/validator/lib/validators.ts | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/@glimmer/validator/lib/validators.ts b/packages/@glimmer/validator/lib/validators.ts index a701a133e53..c4c8805ca2a 100644 --- a/packages/@glimmer/validator/lib/validators.ts +++ b/packages/@glimmer/validator/lib/validators.ts @@ -122,6 +122,18 @@ class MonomorphicTagImpl { } [COMPUTE](): Revision { + // Fast path for subtag-less tags (property tags, cell tags, plain + // dirtyable/updatable tags — the overwhelming majority). With no subtags to + // fold in, the value is always just `revision`, which `dirtyTag` keeps + // current, so none of the `lastChecked`/`isUpdating`/cycle machinery below + // (which exists purely to memoize subtag recursion) is needed. This runs on + // every `validateTag`/`valueForTag`, i.e. every reference read. + let { subtag } = this; + + if (subtag === null) { + return this.revision; + } + let { lastChecked } = this; if (this.isUpdating) { @@ -135,24 +147,22 @@ class MonomorphicTagImpl { this.lastChecked = $REVISION; try { - let { subtag, revision } = this; - - if (subtag !== null) { - if (Array.isArray(subtag)) { - for (const tag of subtag) { - let value = tag[COMPUTE](); - revision = Math.max(value, revision); - } + let { revision } = this; + + if (Array.isArray(subtag)) { + for (const tag of subtag) { + let value = tag[COMPUTE](); + revision = Math.max(value, revision); + } + } else { + let subtagValue = subtag[COMPUTE](); + + if (subtagValue === this.subtagBufferCache) { + revision = Math.max(revision, this.lastValue); } else { - let subtagValue = subtag[COMPUTE](); - - if (subtagValue === this.subtagBufferCache) { - revision = Math.max(revision, this.lastValue); - } else { - // Clear the temporary buffer cache - this.subtagBufferCache = null; - revision = Math.max(revision, subtagValue); - } + // Clear the temporary buffer cache + this.subtagBufferCache = null; + revision = Math.max(revision, subtagValue); } }