Skip to content

[PERF] Give Glimmer's own destroyables a meta slot - #21568

Draft
NullVoxPopuli-ai-agent wants to merge 3 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:perf/destroyable-meta-hybrid
Draft

[PERF] Give Glimmer's own destroyables a meta slot#21568
NullVoxPopuli-ai-agent wants to merge 3 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:perf/destroyable-meta-hybrid

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

Replaces #21565, which did the same thing in a way that was breaking.

getDestroyableMeta is the hottest Glimmer function in a CPU profile of smoke-tests/benchmark-app, at 1.7% self time. Every association costs a WeakMap.get, and the first use of an object as a WeakMap key forces an identity hash onto it. Rendering a list associates one per item, so a 10,000 row list pays about 40,000 lookups before anything is destroyed.

Classes Glimmer constructs declare a slot for their meta and skip the map. Everything else keeps the WeakMap, untouched.

Numbers

VM work, measured in Node against SimpleDOM so DOM cost does not mask it. 15 interleaved pairs, medians in ms:

phase main branch delta
update every 10th 12.30 5.86 -50.6%
clear 8.96 6.36 -31.9%
select row 2.72 2.05 -22.6%
append 1000 37.57 32.50 -11.2%
swap rows 1.93 1.73 -9.6%
remove row 1.85 1.69 -9.6%
create 23.05 23.23 +1.0%
total 88.54 74.28 -15.3%

Browser, pnpm bench at fidelity 20 and 8x CPU throttle:

phase delta
duration (total) -2.4% [-4.26%, -0.37%]
selectFirstRow1 -10.58%
clearManyItems2 -6.66%
clearItems4 -6.65%
append1000Items2 -5.72%
render10000Items2 -4.34%
clearManyItems1 -3.17%
render10000Items1 -2.85%
clearItems2 -2.37%
the other 14 phases no difference

No phase regresses. For scale, running the same harness from a main worktree against itself reported duration as no difference across -1368ms to +739ms, so a total whose whole interval sits below zero is a real result. That A/A run also produced two falsely significant phase results at about 7%, so read the total first.

#21565 measured -3.66% on the same harness. This keeps about two thirds of that, and gives up none of @ember/destroyable's guarantees.

How it avoids touching user objects

Destroyables are public through @ember/destroyable, and a destroyable can be any object a user hands in. The docs' own example is associateDestroyableChild(this, {}).

So the rule is: read the slot always, write it only when the slot already exists.

let own = slotted[DESTROYABLE_META_SLOT];

if (own !== undefined) return own;

// `in` rather than a write, so this stays a read for everything else.
if (DESTROYABLE_META_SLOT in slotted) {
  // one of ours
}

// anything else: the WeakMap, exactly as before

Only BlockOpcode and its subclasses (TryOpcode, ListItemOpcode, ListBlockOpcode) and the VM's root destroyable declare the slot. Those are what the list benchmark associates per item.

Why #21565 could not ship

It wrote the meta onto the destroyable unconditionally. Probing the built branch found four problems, and the second is the one that killed it:

#21565 this PR
registerDestructor(Object.freeze({}), fn) TypeError ok
spread copy shares one destroy state yes no
own symbol on a user object 1 0
proxy set trap fires yes no

The second row means two distinct instances could report a single destroy state, since a plain assignment creates an enumerable own symbol and spread copies it:

copy shares the original meta object: true
after destroy: isDestroying(original) = true
after destroy: isDestroying(copy)     = true

Instances need their own destroy state. Under this PR a user instance is never written to, so that cannot happen.

Also in here

Destroyable tracking enumerates meta to find leaks, and slotted objects bypass the WeakMap. While enableDestroyableTracking() is on, the slot path also records into the tracking map, so assertDestroyablesDestroyed() still sees them. All 99 destroyable tests pass.

Testing

9449 tests, 9432 pass, 17 skip, 0 fail. Identical to main on the same machine. tsc, eslint and prettier are clean.

Next

Component managers, modifiers and helper caches are also framework-owned and still take the WeakMap path. Widening the slot to them is a follow-up rather than more surface here.

`getDestroyableMeta` is the hottest Glimmer function in a CPU profile of
`smoke-tests/benchmark-app`, at 1.7% self time. Every association costs a
`WeakMap.get`, and the first use of an object as a `WeakMap` key forces an
identity hash onto it. Rendering a list associates one per item.

Classes Glimmer constructs declare a slot for their meta and skip the map:
`BlockOpcode` and its subclasses, and the VM's root destroyable. Meta is only
written to the slot when the slot already exists, so anything arriving from
outside falls through to the `WeakMap` and is never touched.

That matters because destroyables are public through `@ember/destroyable` and
can be any object a user hands in. Writing meta onto them would break frozen
and sealed instances, and a spread copy would share one destroy state between
two instances.

Worth 15% of VM time on that workload, measured in Node against SimpleDOM,
15 interleaved pairs:

| phase             | main  | branch | delta  |
| ----------------- | ----: | -----: | -----: |
| update every 10th | 12.30 |   5.86 | -50.6% |
| clear             |  8.96 |   6.36 | -31.9% |
| select row        |  2.72 |   2.05 | -22.6% |
| append 1000       | 37.57 |  32.50 | -11.2% |
| total             | 88.54 |  74.28 | -15.3% |

Tests: 9449 tests, 9432 pass, 17 skip, 0 fail. Identical to main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
These three read `DESTROYABLE_META` directly, so a slotted destroyable missed
its own meta and answered `false` to all three, including while it was
destroying.

Nothing calls them on a `BlockOpcode` today, so the suite did not catch it.
`invokeHelper` does call `isDestroying` and `isDestroyed` on its cache, which
is the next thing worth slotting, so this had to be right first.

    slotted object, before
      during destroy: isDestroying = false
      after  destroy: isDestroyed  = false

    slotted object, after
      during destroy: isDestroying = true
      after  destroy: isDestroyed  = true

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The slot check costs an `in` test for every object that does not have one, and
without this the check has nothing to pay for it. rere-benchmark caught the
result: `1k items, 1 update on 5% (random, async)` was 3.9%, 8.3% and 13.4%
slower than main across three rounds.

An empty slot is proof the object is one of ours and has no meta, so the map
lookup can be skipped. `VM_HELPER_OP` asks `_hasDestroyableChildren` on every
helper invocation and almost always gets `false`.

Neither `pnpm bench` nor a Node microbenchmark showed the regression. Both are
dominated by mass insertion and teardown; this one is a fine-grained update
path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NullVoxPopuli-ai-agent pushed a commit to NullVoxPopuli-ai-agent/ember.js that referenced this pull request Aug 17, 2026
Stacked on emberjs#21568.

Three more classes Glimmer constructs itself get the slot: `ReferenceImpl`,
`RenderResultImpl` and `AppendingBlockImpl`.

Component and modifier destroyables are deliberately left alone.
`manager.getDestroyable(state)` hands back the user's own instance, and writing
to those is what made emberjs#21565 unmergeable.

The symbol moves to `@glimmer/util` so that `@glimmer/reference` can declare
the slot. It does not depend on `@glimmer/destroyable`, and moving a symbol
down is lighter than adding that edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

rere-benchmark found a regression in this PR as originally filed, and it is now fixed on the branch.

The slot check costs an in test for every object that does not have a slot. The thing that pays for it, skipping the map when the slot is empty, was sitting in #21569. Split that way, this PR was slower than main on fine-grained updates:

1k items, 1 update on 5% (random, async) main this PR, as filed
round 1 138.8 157.4 (-13.4%)
round 2 142.1 153.9 (-8.3%)
round 3 137.3 142.7 (-3.9%)

Slower in all three rounds.

Neither pnpm bench (-2.4%) nor a Node microbenchmark (-15.3%) showed it. Both are dominated by mass insertion and teardown, and this is an update path. Thanks to @NullVoxPopuli for pointing out that the two benchmarks answer different questions.

The short-circuit now lives here, so this PR stands on its own. Re-measuring on rere now and I will post the numbers.

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Re-measured on rere-benchmark after moving the short-circuit into this PR. Three rounds, --count=10, 8x throttle, positive means this PR beats main.

The regression is gone:

1k items, 1 update on 5% (random, async) before after
round 1 -13.4% +3.4%
round 2 -8.3% +2.3%
round 3 -3.9% +1.2%

Consistent across all three rounds now:

bench median
1 value, 1k consumers, 10k updates (bursts of 1000) +11.4%
1 value, 1k consumers, 10k updates (bursts of 100) +8.4%

No bench is consistently slower.

On the mass insertion and teardown side, pnpm bench measured -2.4% [-4.26%, -0.37%] with no phase regressing, and the Node microbenchmark -15.3%.

One caution for anyone reading per-bench numbers here. 1 item, 100k updates (async) came back at -1.7% to -3.6% in this set of rounds and +0.2% to +1.1% in the previous set, on identical code. Three agreeing rounds resolve an 8% effect on these benches and do not resolve a 3% one.

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