Repository navigation
All rendering optimizations, measured again on top of the scheduler - #21656
Draft
NullVoxPopuli-ai-agent wants to merge 19 commits into
Draft
NullVoxPopuli-ai-agent wants to merge 19 commits into
NullVoxPopuli-ai-agent wants to merge 19 commits into
Conversation
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>
NullVoxPopuli
added a commit
to NullVoxPopuli/rere-benchmark
that referenced
this pull request
Oct 4, 2026
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>
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>
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 |
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 was referenced Oct 5, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The rendering optimizations of the #21520 spike, measured again on top of the scheduler (#21655), plus three more. This PR is 62% faster than
mainon 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.
f3ce59665d,eb9b76b5390c057ed0687732ae26d5..6e08894792TrackedValuefunctions8315e8f1c3d6f42cb25c61b679903a62e7898dbfset(),[],unknownProperty)acd5dbd2b5resetTracking()skips the pool depths that have no tracker85d5ea8727eb91952d57,b259b909da3b1f35cf7c@trackedwrite dirtiesSELF_TAGthrough the cell of the field, without a registry lookup678c1952fb68a1ae32d58315e8f1c3: 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 tagMeasured the same way, and not in this PR:
{{#each}}item subtrees (#21544)2b703acdc3)5530b80746)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 4mainruns and the last 4 differ by 0.4% (geometric mean).mainTimes 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.678c1952fb) came after this round and is not in the table. Measured alone in 4 cycles, it makes Incrementing Render Effect 11% faster, and1 item, 1k updates (async)and1k items, 1 update each (sequentially, async)10% to 13% faster.mainrenders after each burst. The scheduler does too, unless Chrome holds frames back, and then several bursts become one render. A sample below half of themainmedian counts as fast: 0 of 40 formain, and 23 of 40 for this PR. So one number does not describe this bench.Method
--disable-frame-rate-limit), because the 60 fps limit of headless Chrome holds DB Monitor at 60 fps for every build.Where the time goes now
From CPU profiles of this PR without the breaking commit, in separate profiled runs:
queueMicrotaskcalls took 8% before the last commit, and tag validation takes 6%.1 item, 100k updates: the@trackedwrite path takes about 33%, and garbage collection 17%.Machine
performancegovernorKnown gaps
runTaskexpect a synchronous render. The new commits add no failures to theeach,tracked, andUpdatingtest groups, and the 155@glimmer/validatortests pass.mainrun was 14.5 hours before its PR run, so a run of both builds in one session is still necessary.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 thanmain, so the paint lands inside the measured time more often. The update still reaches the next frame in both builds.🤖 Generated with Claude Code