Skip to content

Pool the trackers of tracking frames - #21663

Merged
NullVoxPopuli merged 2 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/tracker-pool
Oct 6, 2026
Merged

NullVoxPopuli merged 2 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/tracker-pool

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

A tracking frame no longer makes a Tracker, a Set and an array. In a benchmark of createCache graphs, one update takes about half the time: the weighted mean is 0.6x of main.

This is the first part of #21650, and it merges alone. Only packages/@glimmer/validator changes.

case main this PR
propagate: 100 chains x 100 deep 724.68 µs 337.41 µs (0.5x)
kairo: diamond 428 ns 212 ns (0.5x)
rows: 1000 rows, write all 118.18 µs 67.92 µs (0.6x)
create: 1000 computeds, read each 41.11 µs 23.68 µs (0.6x)
weighted geometric mean 1.0x 0.6x

What changes

  • beginTrackFrame takes the tracker for its depth from a pool. Frames are strictly nested, so one tracker for each depth is enough.
  • A tracker keeps its tags in an array, not in a Set.
  • A tag keeps the index at which a tracker took it last, in a new field slot. A tracker has the tag if its entry at that index is the tag. A tag that the frame consumes again costs one comparison.
  • resetTracking() clears the pooled trackers.

What changes for callers

  • No function changes its signature.
  • Each tag has one more field, slot.
One edge case, and why the tracker uses an index
  • A tag that a nested frame takes at another index, between two consumptions of the outer frame, is in the outer frame two times. The combined tag has the same revision. If the nested frame takes the tag at the same index, the outer frame has the tag one time.
  • A stale index cannot hide a tag, because the tracker compares the entry at that index with the tag.
  • A frame counter was the first design. It leaves the small-integer range of V8 after about one billion frames, and the benchmark was then 1.1 to 1.3 times slower.
  • An untrack frame takes a depth but no tracker, so the pool can have holes. resetTracking() skips them.
  • In CPU profiles of three graphs on main, the tracker, its Set and beginTrackFrame took 27% to 45% of the samples.
All 20 cases
case main this PR
propagate: 1 chains x 1 deep 124 ns 63 ns (0.5x)
propagate: 10 chains x 10 deep 5.51 µs 2.89 µs (0.5x)
propagate: 100 chains x 100 deep 724.68 µs 337.41 µs (0.5x)
propagate: 1 chains x 1000 deep 57.18 µs 31.34 µs (0.5x)
propagate: 1000 chains x 1 deep 103.01 µs 51.98 µs (0.5x)
kairo: avoidable propagation 382 ns 166 ns (0.4x)
kairo: broad propagation 7.35 µs 3.81 µs (0.5x)
kairo: deep propagation 3.09 µs 1.45 µs (0.5x)
kairo: diamond 428 ns 212 ns (0.5x)
kairo: mux 20.23 µs 12.90 µs (0.6x)
kairo: repeated observers 365 ns 173 ns (0.5x)
kairo: triangle 755 ns 386 ns (0.5x)
kairo: unstable 574 ns 374 ns (0.7x)
rows: 1000 rows, write 1 7.19 µs 7.03 µs (1.0x)
rows: 1000 rows, write all 118.18 µs 67.92 µs (0.6x)
batch: 10 writes, 1 output 667 ns 368 ns (0.6x)
avoidable: write the same value 6 ns 6 ns (1.0x)
create: 1000 signals 17.84 µs 18.60 µs (1.0x)
create: 1000 computeds, read each 41.11 µs 23.68 µs (0.6x)
create: 1000 outputs 50.30 µs 35.34 µs (0.7x)
weighted geometric mean 1.0x 0.6x

The benchmark is https://github.com/NullVoxPopuli-ai-agent/ember-reactivity-bench. One measurement is the writes of one frame, then one flush that brings every output up to date. Each case of each build runs in its own process, pinned to one core. The numbers are the median of 6 mirrored rounds on Node 24.20.

The run has main, each of the five branches, and #21650 as columns: table and method.

Tests
  • The full suite passes locally: 9500 pass, 18 skipped, 0 failed.
  • type-check:internals, ESLint and Prettier pass.
  • New tests: a frame that consumes one tag many times, and a tag that a nested frame consumes between two consumptions.
  • A new test for nested frames that take one tag at the same index.
  • New tests for resetTracking(): with an open frame, and after a frame that began inside untrack frames.

The parts of #21650

#21650 has all five changes in one branch. It stays open as the reference for the combined numbers. The five branches merge with no conflict, and their merge is the same code as the head of #21650.

PR change reactivity benchmark, mean against main
#21663 (this PR) Pool the trackers of tracking frames 0.6x
#21664 Reuse the combined tag of a tracking frame. Needs #21663 0.6x, with the pool
#21665 Index loop over the subtags of a combined tag 1.0x
#21666 Make the functions of a TrackedValue on their first use 0.9x, create is 0.5x
#21667 Store 0 first in the value field of a TrackedValue 1.0x, removes one deopt
#21650 all five 0.6x

Rendering was measured for the combined change only: pnpm bench shows 2.1% less script time, in this comment on #21650.

🤖 Generated with Claude Code

Each tracking frame made one Tracker, one Set, and one array from that
Set. Frames are strictly nested, so beginTrackFrame now takes the
tracker for its depth from a pool.

A tracker keeps its tags in an array. A tag keeps the index at which a
tracker took it last, in a new field `slot`. A tracker has the tag if
its entry at that index is the tag, so a tag that the frame consumes
again costs one comparison.

resetTracking clears the pooled trackers. An untrack frame takes a depth
but no tracker, so the pool can have holes.

Split out of emberjs#21650.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The comment blocks now show the tags array of a tracker, how `slot`
finds a tag that the frame has already, the nested frame that gives a
duplicate, and which tracker of the pool each frame depth uses.

Comments only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@NullVoxPopuli
NullVoxPopuli marked this pull request as ready for review October 6, 2026 03:46
@NullVoxPopuli
NullVoxPopuli merged commit 95ddc22 into emberjs:main Oct 6, 2026
68 checks passed
@NullVoxPopuli

Copy link
Copy Markdown
Contributor

ci on a real app green here: NullVoxPopuli/limber#2288

(lots of unconventional reactivity timings, kinda)

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.

3 participants