Skip to content

All rendering optimizations, measured again on top of the scheduler - #21656

Draft
NullVoxPopuli-ai-agent wants to merge 19 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/all-optimizations-v2-final
Draft

NullVoxPopuli-ai-agent wants to merge 19 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/all-optimizations-v2-final

Conversation

@NullVoxPopuli-ai-agent

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

Copy link
Copy Markdown
Contributor

The rendering optimizations of the #21520 spike, measured again on top of the scheduler (#21655), plus three more. This PR is 62% faster than main on the rere-benchmark ember apps (geometric mean of 15 benches). The scheduler alone gives 50%.

Supersedes #21520.

Commits

Each change was measured alone, without a profiler, against the build just below it. The number is the geometric mean of the 13 steady benches (see Method), and negative is faster.

Commit Change Alone
f3ce59665d, eb9b76b539 #21655: the RFC 957 scheduler, without the runloop, RSVP, or backburner
0c057ed068 Notify the renderer once per tick -2.5%
7732ae26d5 .. 6e08894792 #21650: pooled trackers, reused combined tags, lazy TrackedValue functions -1.8%
8315e8f1c3 One cell per tracked field (value and tag together) -9.8%
d6f42cb25c Update a list in place when its keys keep their order -3.4%
61b679903a Flatten nested combined tags. Reverted below +0.4%
62e7898dbf Breaking: template reads skip the legacy tags (POJO set(), [], unknownProperty) -5.3%
acd5dbd2b5 The fix from #21650: resetTracking() skips the pool depths that have no tracker
85d5ea8727 Revert of the flatten commit: no gain, and Incrementing Render Effect was 11.5% slower with it
eb91952d57, b259b909da Block guards, from #21612: skip a block or a list item whose tag did not change. A missed guard is dropped and comes back after 8 updates -8.1%
3b1f35cf7c A @tracked write dirties SELF_TAG through the cell of the field, without a registry lookup -2.2%
678c1952fb A chain of ticks queues one microtask per tick, not two: the microtask that closes the window after a tick also runs the next tick -3.5%
68a1ae32d5 Fix for 8315e8f1c3: the cell of a tracked field takes its tag from the tag registry. Before, the cell replaced a tag that an observer or a computed chain already had, and no write dirtied that tag

Measured the same way, and not in this PR:

Change Alone
Skip unchanged {{#each}} item subtrees (#21544) +3.9%
Lock path references onto their field's tag (spike 2b703acdc3) +0.1%
Updating VM without a frame object per block (spike 5530b80746) +0.7%
Step through an array list with a cursor, and check item guards before the VM runs +0.5%

Result

main, this PR before the last four commits and without the breaking commit (61b679903a), this PR without the breaking commit, and this PR ran in 4 mirrored cycles: 8 runs for each build, with 5 samples per bench in each run. Each number is the median of 40 samples. The first 4 main runs and the last 4 differ by 0.4% (geometric mean).

Bench main before change without the breaking commit change this PR change
1k items, 1 update each (sequentially, async) 788 34.2 -95.7% 34.9 -95.6% 32.6 -95.9%
1 item, 100k updates (async) 864 43.5 -95.0% 44.9 -94.8% 45.3 -94.8%
1k items 1 update on 25% (random, async) 234 21.1 -91.0% 19.8 -91.5% 16.5 -92.9%
1 item, 1k updates (async) 48.5 8.0 -83.6% 7.8 -83.8% 8.2 -83.2%
1k items 1 update on 5% (random, async) 69.9 16.4 -76.5% 13.7 -80.4% 13.5 -80.7%
1 value, 1k consumers, 10k updates (bursts of 100) 1252 560 -55.3% 339 -72.9% 502 -59.9%
1k items 1 update on 5% (random) 19.9 16.1 -19.3% 13.0 -34.7% 13.7 -30.9%
1 item, 100k updates 32.6 24.6 -24.7% 23.0 -29.6% 22.9 -29.9%
1k items 1 update on 25% (random) 24.6 20.0 -18.9% 18.5 -24.8% 17.7 -28.0%
1 value, 1k consumers, 10k updates (bursts of 1000) 146 109 -25.7% 130 -11.3% 120 -17.8%
1k items, 1 update each (sequentially) 32.7 29.7 -9.3% 30.6 -6.6% 28.1 -14.2%
Incrementing Render Effect 1967 2193 +11.5% 1995 +1.4% 1808 -8.1%
1 value, 1k consumers, 10k updates (single burst) 38.1 36.8 -3.2% 37.3 -1.8% 37.0 -2.6%
1 item, 1k updates 6.3 5.8 -7.1% 6.3 +0.8% 6.7 +6.3%
DB Monitor w/ chat simulation (fps) 97.3 101 +3.8% fps 101 +4.2% fps 104 +7.3% fps

Times are medians in ms, and negative is faster. Each change is against main. The geometric means are -60.0% before, -62.2% without the breaking commit, and -62.3% for this PR.

  • The async benches are 81% to 96% faster. Almost all of that comes from the scheduler.
  • The last four commits make Incrementing Render Effect 9% faster than before, and the random list benches 6% to 19% faster.
  • The breaking commit makes Incrementing Render Effect 9% faster again.
  • The last commit (678c1952fb) came after this round and is not in the table. Measured alone in 4 cycles, it makes Incrementing Render Effect 11% faster, and 1 item, 1k updates (async) and 1k items, 1 update each (sequentially, async) 10% to 13% faster.
  • Fan-out bursts of 100 has two kinds of samples. main renders after each burst. The scheduler does too, unless Chrome holds frames back, and then several bursts become one render. A sample below half of the main median counts as fast: 0 of 40 for main, and 23 of 40 for this PR. So one number does not describe this bench.

Method

  • rere-benchmark ember apps in headless Chrome 154, with no profiler. Chrome paints with a GPU and has no frame rate limit (--disable-frame-rate-limit), because the 60 fps limit of headless Chrome holds DB Monitor at 60 fps for every build.
  • The host cores are limited to about 3 GHz, so the CPU package stays near 65 C and the clock is constant. That makes the CPU about 2.2 times slower, so Chrome throttles 4x. The times are the same as with 8x and no limit.
  • The 8 vCPUs are pinned to 8 host cores. Chrome runs on 3 of them, and every other process of the user on the other 5.
  • Short runs in mirrored cycles put each build at 8 different times. A speed change of the host then does not favor one build.
  • A short calibration bench runs before and after each run. A run starts only when the calibration is at most 25% above the normal time, and a run whose closing calibration is above that limit runs again. No run of this round needed that.
  • The per-commit numbers come from a separate round with 13 builds, also 40 samples per build per bench. "Steady benches" are all benches but fan-out bursts of 100 and DB Monitor, which depend on frame timing.
  • The first version of this description had numbers from profiled runs. The profiler changes the difference between two builds, and the keep and drop decisions made with it were checked again here. One decision changed: the flatten commit.

Where the time goes now

From CPU profiles of this PR without the breaking commit, in separate profiled runs:

  • The sync list benches spend about 70% of their time in one walk over all 1,000 items. The guards make an unchanged item cheap, but the list still checks every item, because the bench app assigns the whole array again on every write.
  • Incrementing Render Effect: the updating VM loop takes about 19% of each tick, the queueMicrotask calls took 8% before the last commit, and tag validation takes 6%.
  • DB Monitor: 63% is browser work outside JavaScript, and Ember's code is under 20%.
  • 1 item, 100k updates: the @tracked write path takes about 33%, and garbage collection 17%.

Machine

Item Value
VM QEMU Q35 with KVM, 8 vCPUs pinned to 8 cores of an AMD Ryzen 9 7900X
Host CPU limit about 3 GHz for all cores, performance governor
Memory 22.9 GiB RAM
GPU the 7900X's integrated Radeon, passed through to the VM. Chrome uses it through ANGLE's Vulkan backend
OS Pop!_OS 24.04, kernel 7.0.11
Software Chrome 154.0.8037.97, Node 26.10.0

Known gaps

  • The browser suite stops early, as on Render through the scheduler, without the runloop, RSVP, or backburner (RFC 957 end state) #21655: tests that assert the DOM right after runTask expect a synchronous render. The new commits add no failures to the each, tracked, and Updating test groups, and the 155 @glimmer/validator tests pass.
  • The breaking commit needs an RFC and a deprecation before it can land.
  • A headed run of an older version of this PR on a 165 Hz machine showed smaller gains on some benches: results. Fan-out bursts of 100 was 10% faster there, with 2 fast samples of 10. Its main run was 14.5 hours before its PR run, so a run of both builds in one session is still necessary.
  • Fan-out single burst depends on the frame rate. With a fixed 60 fps frame rate it is 21% to 28% slower than main, and without a frame rate limit it is about equal. A trace shows the cause. The bench sets its end mark one task after the DOM is right, and the browser's own layout and paint of the 1,000 changed nodes (about 24 ms at 4x) can run before that task. The scheduler renders one task later than main, so the paint lands inside the measured time more often. The update still reaches the next frame in both builds.

🤖 Generated with Claude Code

NullVoxPopuli and others added 11 commits October 3, 2026 02:57
Adds the `@ember/scheduler` package proposed by RFC 0957:

- `render`, `layout`, `composite`, `next` and `idle` phase functions,
  each returning a promise that resolves according to the registered
  scheduling strategy
- `registerStrategy`, for providing the scheduling strategy when defining
  the Application
- `@ember/scheduler/strategy`, the default strategy implementation, which
  flushes the render/layout/composite phases in order via ordered
  requestAnimationFrame callbacks within a single frame, prior to paint

The deprecations of @ember/runloop and RSVP described by the RFC are left
to follow-up work; this is the additive API surface.

Fix Safari flake and idle() starvation in the default strategy

The "composite while composite is flushing" test asserted ordering across
two independent channels: a setTimeout task scheduled during frame 1
versus frame 2's requestAnimationFrame callbacks. The HTML spec does not
order pending timer tasks against the next rendering opportunity, and
Safari 15.6 runs the next frame's rAF callbacks first. The test now
anchors entirely to the rAF channel, using a raw requestAnimationFrame
registered ahead of the rescheduled phase windows as the frame-2
boundary.

idle() also armed requestIdleCallback without a timeout; fully-idle or
backgrounded pages can starve rIC indefinitely, leaving the promise
unresolvable. Cap the wait with { timeout: 500 }.
This is the RFC 957 end state on top of the scheduler interface
(the previous commit, from emberjs#21552). Tag invalidation tells the
renderer's scheduler directly. Nothing in the framework schedules
through @ember/runloop, and backburner and RSVP are gone.

- The default @ember/scheduler strategy is the renderer's clock: one
  render per tick, with bounded settle rounds, and a microtask,
  animation-frame, or task leg chosen by a microtask-window tick
  classifier.
- @ember/runloop is a compatibility layer without dependencies: run,
  join, and bind are plain calls, queues become microtasks, and
  timers are native timers. Its public types are the same as on main.
- Native promises replace RSVP in the router, the route managers,
  PromiseProxyMixin, and the tests.
- Render settledness is reported as edges (isRenderPending on
  @ember/renderer), for test waiters.
- Destroys drain synchronously when an engine instance is destroyed.

This commit holds only the scheduler parts of the closed spike emberjs#21520.
Its VM and allocation changes, and the "notify once per tick" latch,
are left out, so that each one can be measured on its own.

Known gap: the browser suite stops early. Tests that set state in
runTask and then assert the DOM in the same task expect a synchronous
render. The first ones are in the debug render tree tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A set walked the notify chain every time: the renderer callback and
the settledness sample. A 100k-set loop now notifies once and then
checks one boolean per set, until the end of the tick re-arms it.

The flag only latches when a renderer heard the notification, so
dirt during app boot cannot swallow later invalidations. Registering
a renderer re-arms it too.

From the spike emberjs#21520 (c93641e, d6be883).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tracking frame allocated one tracker, one Set, and one array from
that Set. In CPU profiles of three createCache graphs, the tracker, its
Set and beginTrackFrame took 27% to 45% of the samples.

Frames are strictly nested, so one tracker for each depth is enough.
beginTrackFrame takes the tracker of its depth from a pool.

A tracker keeps its tags in an array. Each frame has a number, and the
tracker writes that number on each tag that it takes, so a tag that the
frame consumes again costs one comparison and no Set.

endTrackFrame now takes the tag that the same frame produced the last
time. If the frame consumed the same tags again, that tag is the
result, with its memoized revision and no allocation. getValue passes
the tag of its cache.

The loop over the subtags of a combined tag uses an index in place of
an iterator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A TrackedValue made four arrow functions and one options object for
each instance. A value that the code reads and writes through `value`
did not use any of them.

`get`, `set`, `update` and `freeze` are now accessors that make the
bound function on its first read, and keep it. They are bound as
before, and each read gives the same function.

`trackedValue(value)` with no options shares one options object.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The tracker wrote the number of its frame on each tag. That counter
leaves the small-integer range of V8 after about one billion frames,
and the benchmark is then 1.1 to 1.3 times slower. A nested frame also
wrote its own number on a shared tag, so the outer frame took that tag
again each time: a computed that reads five computeds of one tag had a
combined tag with five entries.

A tag now keeps the index at which a tracker took it. A tracker has the
tag if its entry at that index is the tag. This needs no counter, and a
stale index cannot hide a tag, because the entry is then another tag or
no tag. A nested frame that takes the tag at the same index keeps the
check true, so the five computeds give one tag again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`get`, `set`, `update` and `freeze` were own properties before, so
code could assign to them. Each accessor now has a setter. A write
through `value` uses an assigned `set`, as it did before.

The shared default options are frozen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tracked read went through three map hops: the central tag
registry (a WeakMap to a Map per object) and a separate values
WeakMap. Now each field has one WeakMap from the instance to a cell
with its value and tag, so a read is one hop and consumeTag, and a
write is one hop and DIRTY_TAG.

The cell registers its tag in the central registry when it is made,
so notifyPropertyChange and computed chains dirty and read the same
tag as the field.

From the spike emberjs#21520 (c93641e, d6be883).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When a fresh iterator yields the same keys in the same order, as a
derived array in a getter does on every render, the list block now
updates each item's refs in place. It skips the diff bookkeeping, the
marker DOM, and rebuilding the children. On the first mismatch, a
PrefixedIterator replays the consumed items through the full sync.

From the spike emberjs#21520 (the list part of d9f1250).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
combine() now flattens nested combinators and drops constant tags, up
to 64 sub-tags, so validating a combined tag is one flat loop instead
of a tree walk.

A combinator keeps the tags it was made from in inputs. The tracker
from emberjs#21650 compares a frame's tags with those to reuse the previous
combinator, which the flattened list would no longer match.

From the spike emberjs#21520 (the combine part of d9f1250). The inputs
field is new.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every template property read consumed a tag for (object, key), so a
set() on a plain object re-renders. Array values also consumed the
'[]' tag, and each read checked for unknownProperty. This removes all
three: a plain-data read no longer joins the reactivity graph, so
only @Tracked fields, tracked collections, and replacing the object
trigger a re-render.

This breaks apps that set() plain objects shown in templates, rely
on EmberArray '[]' invalidation, or render ObjectProxy content. It
needs an RFC and a deprecation before it can land. It is in this
branch to measure the ceiling.

From the spike emberjs#21520 (2607466, 908f68b).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An untrack frame takes a depth but no tracker. A track frame that begins
inside one, at a depth that never had a tracker, leaves a hole below it.
resetTracking then called clear() on undefined. The QUnit setup calls
resetTracking after each test, so a filtered run of the tracking module
stopped at the first test after the hole. The full suite fills every
depth before that, so it did not show the error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
NullVoxPopuli and others added 4 commits October 5, 2026 09:11
This reverts commit 61b6799.

Measured alone, without a profiler, the change gives nothing: +0.4% over
13 benches (geometric mean), and the four cycles read +1%, -2%, -1%, -2%.
It makes Incrementing Render Effect 11.5% slower, because that bench makes
a new combined tag on every tick, and each one now pays for the flatten.

The earlier keep decision came from profiled runs, and the profiler
changes the difference between two builds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every block opcode (try, list, list item) now records the combined tag of
what its render or last update consumed, the way a component cache group
does. On update, a block whose tag still validates is skipped as a whole,
and its tag is consumed into the parent frame so parents stay correct.

Before, an unchanged row in a {{#each}} cost one validation per dynamic
reference: on the js-framework-benchmark row that is five validateTag
calls and five megamorphic opcode evaluations per row per update. Now it
costs one validation, and the five opcodes never run.

The append VM opens the frame before a block's opcode is constructed,
because the list block reads its iterable in the constructor, and closes it
when the block exits. On the updating side the frame is closed when the
block's updating frame finishes. When a block re-renders after a thrown
assertion, the append VM closes the frame on exit and the updating VM pops
the frame without closing it again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wPae8QnFbKHKn5xbgaQVq
…it later

Blocks and component cache groups pass their previous tag to
endTrackFrame(), so a block that re-renders with the same dependencies
keeps its tag and its memoized revision. The tag comparison against the
tracker's live entries comes from emberjs#21650, which is below this commit.

A guard that fails is dropped on the spot, with no tracking frame: a block
that changed is likely to change again, and a frame on every miss is what
made all-rows-change workloads slower. A dropped guard comes back after
eight unguarded updates, so a block that changed once and then stayed
still is skipped again, while a block that changes on every update pays
for one frame in every eight. A re-render re-arms the guard right away.

An unguarded block costs what it did before guards existed: one frame
push. A re-render of a dropped block opens its own tracking frame, because
the append VM closes one when the block exits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wPae8QnFbKHKn5xbgaQVq
A @Tracked write dirtied two tags through the central tag registry: the
tag of the field, and the SELF_TAG of the object. The second one cost a
WeakMap lookup on every write.

The cell of a field now keeps the object's map of the registry. The map
is made once per object and never replaced. The write reads SELF_TAG from
that map, and dirties it only if something made it.

Measured alone on top of the other commits: 2% faster over 13 benches
(geometric mean), 9% on `1 item, 100k updates (async)`.

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

Copy link
Copy Markdown
Contributor

3GHz CPU limit is a soft-limit for keeping thermal management well under the throttle zone to try to have benchmarks be more consistent 😬

seems like it's working

NullVoxPopuli and others added 2 commits October 5, 2026 11:12
After a tick, the renderer opens a microtask window: dirt that arrives
in it comes from the tick's own continuations, and the next tick runs at
microtask speed. Each such tick queued two microtasks, one to close the
window and one for the next tick.

The microtask that closes the window now also runs the next tick when
dirt arrived in the window. A chain of ticks queues one microtask per
tick. The settle rounds use the same path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The cell of a tracked field made its own tag and put it in the tag
registry. If other code asked the registry for the tag before the first
read or write of the field (an observer, a computed chain,
notifyPropertyChange), the cell replaced that tag. The other code then
held a tag that no write dirtied.

The cell now takes the tag that the registry has, or makes it there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The docs check failed in CI. The private `queues` property of the
runloop is gone with backburner, and `_onRenderSettledChange` of
`@ember/renderer` is new.

Co-Authored-By: Claude Opus 5.5 (1M context) <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