Skip to content

Make the value field of a TrackedValue general from the first instance - #21667

Merged
NullVoxPopuli merged 2 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/tracked-value-general-field
Oct 7, 2026
Merged

NullVoxPopuli merged 2 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/tracked-value-general-field

Conversation

@NullVoxPopuli-ai-agent

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

Copy link
Copy Markdown
Contributor

The constructor of a TrackedValue stores 0 before the value. This removes one deopt for each page load, and it costs no time and no memory.

This is a part of #21650, and it merges alone. The change is one line and its comment.

Why

  • All instances of TrackedValue have one hidden class, and V8 records which kind of value #value held so far.
  • Take a first instance that held a string, an object or undefined. The first number in any later instance then made V8 throw away the optimized code of each function that reads a TrackedValue.
  • The store of 0 makes the field general in the first instance, before V8 optimizes any code.

What it costs and what it gains

The numbers are from the head of #21650 with and without this line. The scripts are in this comment on #21650.

without the store with the store
bytes for one create, most kinds of value 184 184
bytes for one read 0 0
create, 11 kinds, mean ratio 1.000 0.988
read, 11 kinds, mean ratio 1.000 0.993
write, 11 kinds, mean ratio 1.000 0.994
time that one hot read loop loses when the first number comes 14 ms to 31 ms about 0
  • The table is for Node 24.20. Node 26.10 gives the same answer, with 1.1 ms to 16 ms for the last row.
  • A second build of the same code gave ratios of 0.988 to 0.999, so the ratios with the store are inside the noise.
  • The reactivity benchmark does not see this change: the weighted mean is 1.0x of main.
One group where the store looks slower, and why it is not the cause

After the first number comes, the read loop is 3% to 4% slower with the store. That is 0.06 ns to 0.09 ns for each read.

  • A second store is free: a build that stores undefined and then the value equals the build with one store.
  • A build with one store, which makes two instances at load, has no deopt and is as slow as the build with the store of 0.
  • --trace-opt shows that the deopt makes V8 compile the read loop a second time, and the second code is the faster one. The benchmark measures a different compile of the same loop.
All 20 cases of the reactivity benchmark
case main this PR
propagate: 1 chains x 1 deep 124 ns 121 ns (1.0x)
propagate: 10 chains x 10 deep 5.51 µs 5.58 µs (1.0x)
propagate: 100 chains x 100 deep 724.68 µs 713.25 µs (1.0x)
propagate: 1 chains x 1000 deep 57.18 µs 57.03 µs (1.0x)
propagate: 1000 chains x 1 deep 103.01 µs 101.02 µs (1.0x)
kairo: avoidable propagation 382 ns 374 ns (1.0x)
kairo: broad propagation 7.35 µs 7.33 µs (1.0x)
kairo: deep propagation 3.09 µs 3.04 µs (1.0x)
kairo: diamond 428 ns 423 ns (1.0x)
kairo: mux 20.23 µs 20.15 µs (1.0x)
kairo: repeated observers 365 ns 332 ns (0.9x)
kairo: triangle 755 ns 743 ns (1.0x)
kairo: unstable 574 ns 577 ns (1.0x)
rows: 1000 rows, write 1 7.19 µs 7.23 µs (1.0x)
rows: 1000 rows, write all 118.18 µs 118.28 µs (1.0x)
batch: 10 writes, 1 output 667 ns 651 ns (1.0x)
avoidable: write the same value 6 ns 6 ns (1.0x)
create: 1000 signals 17.84 µs 18.14 µs (1.0x)
create: 1000 computeds, read each 41.11 µs 39.86 µs (1.0x)
create: 1000 outputs 50.30 µs 53.29 µs (1.1x)
weighted geometric mean 1.0x 1.0x

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: 9495 pass, 18 skipped, 0 failed.
  • type-check:internals, ESLint and Prettier pass.
  • No new test. A test cannot see the deopt, and the tests of TrackedValue cover the constructor.

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 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 (this PR) 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

All instances have one hidden class, and V8 records which kind of value
the `#value` field held so far. If no instance held a number yet, the
first number makes V8 throw away the optimized code of each function
that reads a TrackedValue.

The constructor now stores a number before the value, so the field is
general from the first instance.

Split out of emberjs#21650.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ef4
ef4 previously approved these changes Oct 6, 2026
Updated comments in the constructor for clarity.

Linting
@NullVoxPopuli
NullVoxPopuli merged commit 9618011 into emberjs:main Oct 7, 2026
68 checks passed
@NullVoxPopuli
NullVoxPopuli deleted the nvp/tracked-value-general-field branch October 7, 2026 15:43
@github-actions github-actions Bot mentioned this pull request Oct 7, 2026
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