Skip to content

Keep the value and the tag of a tracked accessor in its storage - #21675

Draft
NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/tracked-accessor-cell
Draft

NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/tracked-accessor-cell

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

A read of a @tracked accessor is about 2 times faster, and a write is about 1.6 times faster. The first read of each accessor of each instance may cost a little more.

How it works

This is the "side signal" pattern: the value is plain data, and a tag next to it reports a change.

On main, the accessor keeps the plain value in its storage and asks the tag registry for the tag on each read and each write. That is a WeakMap lookup and a Map lookup each time.

With this PR, the storage of the accessor holds a cell after the first read:

class AccessorCell {
  value: unknown;
  tag: UpdatableTag;
}
  • A read takes the cell from the storage, then consumeTag(cell.tag). No map lookup.
  • A write takes the cell from the storage, then DIRTY_TAG(cell.tag, true). No map lookup for the field. The write to the self tag of the object stays as it is.
  • The cell takes its tag from the tag registry. Observers, computed chains and notifyPropertyChange find the tag of a key there, so they work on the same tag as before.
  • Until the first read, the storage holds the plain value, and a write goes through the registry as on main. An instance whose accessor is never read has no cell and no tag, as on main.
  • The getter tells a cell from a plain value with instanceof. The class is private to the module, so a value of the user cannot be a cell.

A first version made the cell when the instance was made. It had the same read and write times, but the construction of 1,000 instances with 4 accessors each took 5 times as long. So the cell is made at the first read.

Benchmark

A node script applies tracked to a class with four accessors, the way a stage 3 transform does it. Each number is the median over 20 processes for each build. Control is main at 9bec1cb2a8.

case main ms this PR ms change range of the 20 pairs
make 20,000 instances, no read 0.135 0.135 0.0% -0.7% to +12.6%
make 20,000 instances, read each accessor one time 16.66 17.26 +3.6% -4.4% to +12.5%
200,000 reads in one tracking frame 6.62 3.41 -48.6% -51.3% to -30.3%
200,000 writes 8.11 4.96 -38.8% -42.3% to -27.3%
100,000 times: one write, then one read through a cache 8.15 6.40 -21.5% -30.7% to -12.8%
  • The first read case is 3.6% slower in the median, and its pairs are on both sides of 0. A cost is plausible there: the first read makes the cell and writes it to the storage.
  • The benchmark has no DOM and no render. In an app, the tracked read is a small part of a render, so the change for a full render is smaller than these numbers.
How this was measured
  • Node 26.10, pinned to one CPU core. The script imports tracked from dist/prod/packages/@glimmer/tracking/index.js of the packed ember-source of each build.
  • One process runs one case for one build, so the two builds never share JIT state. A round runs the builds in the order main, PR, PR, main for each case. 10 rounds.
  • In a process: 30 warm-up runs of the case, then 41 measured runs. The process reports the median.
  • The class has four accessors that start as a number, a string, undefined, and null.

Memory

  • An accessor that was read has one more object for each instance: the cell, with two fields.
  • I did not measure memory.

Tests

  • New file metal/tests/tracked/accessor_test.js, 10 tests. The repo had no unit test for the accessor form, only a smoke test scenario. The tests apply the decorator by hand, because the legacy decorator build of the tests cannot compile the accessor keyword.
  • The tests cover: read and write, a write before the first read, two instances, a tag that tagForProperty gave before the first read or write, notifyPropertyChange, a computed property with the accessor as a dependent key, self-assignment, the equals option, an array value, and the dev assertion for a write after a read.
  • All 10 pass on main and with this PR.
  • The full suite passes locally: 9533 pass, 18 skipped, 0 failed. The tracked tests also pass with VITE_STABLE_DECORATORS=true.
  • tsc --noEmit, ESLint, Prettier and pnpm lint:docs pass.

🤖 Generated with Claude Code

The accessor form of @Tracked asked the tag registry for the tag of the
field on each read and each write: a WeakMap lookup and a Map lookup.

At the first read, the storage of the accessor now gets a cell with the
value and the tag. A read or a write after that does no map lookup.

The cell takes its tag from the registry, so observers, computed chains
and notifyPropertyChange work on the same tag as before.

Until the first read, the storage holds the plain value, and a write goes
through the registry as before. So an instance whose accessor is never
read has no cell and no tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants