SPIKE: every rendering-performance lever combined (RFC 957 end state + VM optimizations) - #21520
SPIKE: every rendering-performance lever combined (RFC 957 end state + VM optimizations)#21520NullVoxPopuli-ai-agent wants to merge 32 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. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The UpdatingVM walks every updating opcode of every list item on every render: cache groups (JumpIfNotModifiedOpcode) exist only at component boundaries, so a list of plain template rows revalidates every binding even when nothing in a row changed. Collect each item's consumed tags in a tracking frame (via a new frame-finalizer hook on UpdatingVMFrame) and skip the item's entire subtree while that combined tag validates. Trivial items opt out: for a text node or two, validating a combined tag costs as much as updating, so collection would be pure overhead. An item is trivial when it has <= 2 opcodes and no nested block -- a nested block child means an arbitrarily large subtree hides behind a small top-level count. dbmon-style workloads (fat rows, sparse changes): ~1.6x fps at 8x CPU throttle, ~6x (rAF-capped) at 4x. Dense-change / tiny-item workloads and the krausest bench: neutral. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On top of the {{#each}} subtree-skip (emberjs#21512):
1. rAF-coalesced revalidation: scheduleRevalidate defers to one
requestAnimationFrame per frame instead of a backburner render-queue
flush per runloop; loopEnd learns a frame is pending so it neither
spins NO_OP runloops nor trips the infinite-invalidation guard.
2. ListBlockOpcode same-order fast path: when a fresh iterator yields
the same keys in the same order (the derived-array-in-a-getter
idiom), update item refs in place; no diff bookkeeping, no marker
DOM, no children rebuild. Falls back to full sync via a
PrefixedIterator replaying consumed items.
3. combine() flattens nested combinator tags (capped) so validating a
combined tag is one flat loop instead of a tree walk.
rere-benchmark dbmon, headless runner, 8x CPU throttle (fps avg):
stock 7.3.0-alpha.5 ~9.4
subtree-skip only ~13.6
this spike ~18.7 (solid 2 ~20, vue ~17, react ~34)
Known cost: rAF-coalescing breaks tests that assert DOM synchronously
after a runloop settle (384 failures in the "each" filter); landing it
for real means RFC 957 scheduler integration + test waiters. Levers 2
and 3 are suite-clean in isolation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every template property read paid for: - consumeTag(tagFor(obj, key)): a per-(object, key) tag created and consumed on arbitrary objects, so Ember.set() on POJOs invalidates renders - a bonus '[]' EmberArray tag consume for array-valued reads - the unknownProperty (ObjectProxy) check Data-heavy templates reading throwaway plain objects pay all three per read; the tags then fatten every combined tag above them. Deleting them (modern semantics: plain-data reads don't entangle; reactivity via @Tracked, tracked collections, and replacement) doubled the previous spike's dbmon result: ~18.7 -> ~37.5 fps avg at 8x throttle, ~80% of svelte measured in the same session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- flat frame stack: parallel arrays in UpdatingVM instead of an UpdatingVMFrame allocation per block per render - allocation-free list fast path: optional nextInto(target) on iterators writes into a shared scratch item; the rare mismatch fallback reconstructs the already-applied prefix from the opcodes' own refs instead of buffering every item Measured fps-neutral on dbmon at 8x throttle (GC was ~1% of wall time; allocations were not the bottleneck). Kept for hygiene: ~250 frame + ~210 item allocations per render removed. Same-batch interleaved runner measurement: ember at 80% of svelte on dbmon (26.5-28.5 vs 31.5-37.1 fps at 8x). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 }.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the race-flush scheduler from the spike stack onto upstream main with no other levers (no subtree-skip, no legacy-read deletion, no iteration fast paths), to isolate what scheduling alone is worth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-dirt-event allocation is gone: the microtask, frame, and revalidate-until-stable callbacks are persistent fields, and the chain leg pays one performance.now() per event instead of two. Dependent chains (render -> effect -> set) re-enter scheduleRevalidate once per step, so closure churn there was measurable GC pressure. The macrotask stand-down now only applies while rAF is being serviced: backgrounded/occluded pages stop firing rAF, and since the per-frame counter is only reset by a frame firing, standing down there stranded all rendering until the tab became visible again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Instrumentation showed 146,989 flushes for a 100k-step dependent chain: dirt produced during a flush re-armed a whole new flush even though the flush-until-stable loop had already rendered it, because the scheduled flag cleared before the join. It now clears after, so in-flush dirt dedupes into the running flush. 8x-throttled chain bench: 16.7s -> 5.5s (stock runloop: 3.8s). Chain flushes also inflated the per-frame counter that stands the macrotask leg down, starving misclassified stream dirt of its unclamped leg for up to a full frame (373ms gaps observed under load). Only stream-scheduled flushes count now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tag invalidation now notifies the renderer's scheduler directly -- replacing the classic wiring where every dirty tag called _backburner.ensureInstance() and rendering was driven by the autorun's begin hook rerendering all renderers. Destruction queues in the environment module and is drained by the scheduler's flush (classic actions-before-destroy ordering preserved), with a fallback microtask when nothing is rendering. renderSettled resolves at the end of any flush that leaves every renderer valid, instead of in backburner's end event. The SSR fallback flushes on a microtask. The renderer no longer imports @ember/runloop at all; the runloop remains as a userland library that does not participate in rendering. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ions # Conflicts: # packages/@ember/-internals/glimmer/lib/base-renderer.ts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
The flush no longer loops until stable. Each tick renders the queue's current state once; code that keeps dirtying while we render simply accumulates work for the next tick. Ticks that end still-dirty get a few microtask-speed settle rounds (legitimate measure-then-adjust patterns), then degrade to the frame-paced stream legs -- so a pathological render->dirty loop paints between ticks instead of freezing the thread, and the old million-iteration loop guard is gone entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This comment was marked as outdated.
This comment was marked as outdated.
Chain-vs-stream classification no longer uses a wall clock. A tick's end arms a one-microtask window; dirt arriving inside it comes from the tick's own continuations (render-coupled follow-ups) and schedules the next tick at microtask speed, everything later takes the frame-paced legs. This is the classifier Angular's zoneless scheduler ships (useMicrotaskScheduler / switchToMicrotaskScheduler): semantically exact where the 1ms performance.now() delta was an approximation that misclassified under CPU throttle, and it removes performance.now() from the hot scheduling path entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This reverts commit 3e9fc3c.
Isolated main-vs-PR measurement (emberjs#21543) showed the pooling set neutral at best and genuinely regressive on walk-heavy paths -- reuse costs more than young-generation allocation there. The spike's own batches had already scored it neutral. A lever with no win has no place in the showcase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This comment was marked as outdated.
This comment was marked as outdated.
backburner.js is gone from every manifest, the exposed-dependencies map, and the renamed-modules map -- it no longer ships at all. The @ember/runloop module remains only as a ~370-line dependency-free compatibility surface so the specifier keeps resolving: run/join/bind are plain calls, queues collapse to microtasks (scheduleOnce keeps its per-target-and-method coalescing), timers are native timers, and the introspection hooks report the truth (there is never a current run loop, there are never scheduled timers). The package's own backburner-era tests are deleted with the machinery they tested. rsvp likewise stops shipping: dropped from dependencies (dev-only for test files that still import it) and from the renamed-modules and exposed-dependencies maps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
backburner is deleted. It no longer ships: gone from every manifest, the exposed-dependencies map, and renamed-modules. Isolation bench for the removal itself (same batch, 8x, medians of 4, zero render failures —
As expected: the bench apps never call runloop APIs, so deleting the machinery is perf-neutral — the point is what it proves. The spike now runs ember with zero backburner code in existence, not merely unused: rendering on the scheduler clock, destruction on its queues, boot and routing on microtasks, router on native promises, and a runloop module that is honest about there being no loop. Ready for the cross-machine re-bench. 🤖 Generated with Claude Code |
@ember/test-helpers and ember-qunit import _backburner from @ember/runloop (DEBUG, currentInstance, hasTimers, and a presence-check for getDebugInfo), so the module exports a stub again that always reports nothing pending. getDebugInfo stays absent so callers take their no-debug-info path. The local Deferred interface that replaced RSVP's declares resolve/reject with method syntax: property-syntax function types are strictly contravariant in T, which made Deferred<App> (via _bootResolver: Deferred<this>) unassignable to Deferred<Application> and broke setApplication(App.create(...)) in consuming apps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…troyed Classic ContainerProxyMixin#destroy wrapped container teardown in join(), which outside a run loop flushed every queue before returning. Embedders (SSR/prerender workers, FastBoot) rely on that: instance.destroy() tears down its rendered DOM before returning, and they then reset or reuse the document. With the drain deferred to a microtask, the RenderResult's clear() ran after the embedder had wiped the document and threw NotFoundError on the detached nodes. The drain now runs inline at the classic join site, and guards make that safe: a reentrancy latch (destroy during a drain falls into the outer drain loop) and a render-transaction depth (destroy mid-render defers to the tick drain that already follows revalidation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ves via a flush test-helpers' settled() decides 'is a re-render outstanding' by reading _backburner.currentInstance -- classic's autorun instance existed exactly while a flush was pending. A stub that always answers null made settled() resolve between a tracked set and its flush, so assertions ran against stale DOM. currentInstance is now a getter backed by a probe the renderer registers: truthy while any renderer is invalid or destruction awaits its drain. renderSettled()'s idle path resolved on a bare microtask, racing ahead of classic's latency (an autorun flush, then RSVP), and losing to the un-awaited-render()-then-await-renderSettled() pattern. It now requests a scheduler tick and lets the flush resolve on its way out -- the classic 'end of the next runloop flush' semantics -- so work that lands before that tick, like render() dirtying the renderer, is rendered before resolution. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed three commits fixing consumer breakage found while testing this branch against limber (NullVoxPopuli/limber#2212):
With these, limber's full build/lint/test matrix passes against the tarball. |
|
Fresh rere-benchmark numbers for this branch at |
|
Re-ran as a three-way (stock / spike@5916d3e8 / spike@7077507) — NullVoxPopuli/rere-benchmark#108 updated. The compat fixes are perf-neutral: pre-vs-post is within run-to-run noise on 13/15 benches and the two significant deltas both favor post. The small-sync-bench regressions vs stock (10–30% on tens-of-ms benches) exist in both spike runs, so they're spike behavior, not from the fixes. |
Backburner is gone, not stubbed: @ember/runloop no longer exports _backburner at all. The one real question consumers asked it -- 'is work still outstanding?' -- is now answered by the renderer itself: isRenderPending() from '@ember/renderer', true while any renderer awaits its flush or destruction awaits its drain. This is the synchronous probe @ember/test-helpers' getSettledState always TODO'd about wanting from the framework; consumers that still import _backburner need patching to use it (dist patches, not framework compat shims). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Per the no-backburner direction: |
|
Deployed here: NullVoxPopuli/limber#2212 Kinda flat for desktop tho (but is slightly faster, maybe just randomness kept the overall score the same) |
Rendering pendingness is pushed rather than probed: the renderer notifies a single observer when work becomes outstanding and when it completes (_onRenderSettledChange on @ember/renderer), sampling the level only at the sites that can change it -- first dirt after a flush, tick end, and the destroy queues going non-empty or draining. This replaces the isRenderPending() probe. Test infrastructure registers a bridge that turns those edges into an @ember/test-waiters waiter, so rendering settles through the one push-based protocol the ecosystem already has: no separate render clause in isSettled(), no polling cadence, and pending renders show up labeled in test-waiter debug output for free. With no observer registered (every non-test build) the notify sites pay a single null check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>



The consolidated performance spike: everything learned in the dbmon/scheduler campaign, on one branch. Supersedes and consolidates the individual experiment PRs (#21512, #21513, #21514, #21518, #21519), which are closed in its favor.
What's on the branch
@ember/scheduler) — the API from Implement RFC #957: Render Aware Scheduler Interface #21493 (which remains open as the landable implementation PR), evolved so the default strategy is the renderer's clock rather than a parallelFrameStrategywith its own rAF timebase. Phase functions need no registration:await render()resolves against the tick that actually updated the DOM,layout/compositefollow in microtask checkpoints of the same tick (pre-paint when the tick rode the frame), awaiting a phase on a clean renderer requests a tick, and ticks with nothing awaited pay a single null check.registerStrategyremains as the override seam.renderSettledresolves at flush end. The renderer has zero@ember/runloopimports. (from SPIKE: RFC 957 end state — no runloop in the render path #21519){{#each}}subtree skipping — clean list items are skipped during revalidation via per-item collected tags. (from Skip unchanged {{#each}} item subtrees during updates #21512)nextInto), flattened tracking-frame stack,combine()flattening. (from [SPIKE] Stacked update-path optimizations: how fast can list-heavy updates get? #21513)@ember/runlooporrsvpanywhere in the framework: destruction rides the scheduler's destroy queues, boot and routing run on microtasks (a ~50-line scheduling util replacesonce/scheduleOnce/cancel),router_jsruns on native promises, andext/rsvpis deleted. The runloop package remains only as an unreferenced public-API island.@trackedreads/writes are a single WeakMap hop via inline value+tag cells, bridged into the central tag registry sonotifyPropertyChange/computed interop keeps the same tag identity._getPropno longer pays per-property tag consumption forEmber.set()-on-POJO support,unknownProperty, or the'[]'EmberArray tag. This one is semantically breaking and represents what a deprecate-then-remove cycle would buy; it needs its own deprecation RFC and is included here to show the ceiling. (from [SPIKE] Stacked update-path optimizations: how fast can list-heavy updates get? #21513)a967e87..c09efdc8, found by running limber against the branch) —instance.destroy()drains scheduled destroys synchronously again at the classicjoinsite (SSR/prerender workers wipe the document right after destroy returns),renderSettled()'s idle path resolves via a requested scheduler flush rather than a bare microtask (the classic "end of the next runloop flush" semantics that un-awaitedrender()+renderSettled()patterns depend on), and@ember/application's boot deferred uses method-syntaxresolve/rejectso subclass instances stay assignable toApplication. There is deliberately no_backburnerexport: backburner is gone, not stubbed._onRenderSettledChange: pending when dirt first arrives or destroys queue, complete at the quiet flush) instead of exposing a pollable flag.@ember/test-helpersbridges those edges into an@ember/test-waiterswaiter (draft: [SPIKE] settled(): await settledness instead of polling for it ember-test-helpers#1574), so rendering settles through the one push-based protocol the ecosystem already has:isSettled()needs no render clause,settled()awaitsrenderSettled()rather than polling for it, and a stuck render is named in test-waiter debug output. Two findings worth carrying into the RFC 957 test story: quiet must be confirmed from a macrotask (already-queued task sources — worker messages, zero-delay timers — re-register waiters after a microtask-context observation says "settled"; this flaked ~1-in-4 until the boundary was made explicit, andwaitUntilhad been imposing it implicitly), and edge reporting is measurably cheaper than probing (dbmon 8.9 → 13.6 fps vs the pre-compat tip).Numbers
Same-batch three-way (8x CPU throttle, medians of 4, fresh browser per bench, times anchored on last DOM mutation), vs identically-built
mainand vs the scheduler+skip end state without the VM/legacy levers:The VM + legacy-read levers add ~9% on dbmon on top of the scheduler work and are neutral elsewhere — a fraction of their impact in the original spike stack, because the scheduler and subtree-skip already eliminated most of the walk work they used to accelerate. That diminishing return is itself a campaign finding: scheduling, not walk cost, was always the dominant lever, which re-ranks the priority of the legacy-read deprecation RFC from "required for performance" to "nice to have + simplification".
Fresh three-way numbers for the branch tip (stock vs pre-compat-work vs
c09efdc8, recorded on a second machine with a same-machine stock baseline) are in NullVoxPopuli/rere-benchmark#108: async benches 83–96% faster than stock, dbmon 6.3 → 13.6 fps (2.2x), fan-out bursts 29–35% faster — and the small sync benches that regressed on the pre-compat tip are now at or ahead of stock, so that regression was the compat gap rather than an inherent cost.Status
Spike, not landable: the full suite is not expected green (async rendering breaks classic runloop-timing assumptions; legacy-read removal breaks
Ember.set-on-POJO tests).Real-app validation: limber (repl + tutorial apps, ember-repl, SSG prerendering) builds, lints, and passes its full chrome/node test matrix against this branch's tarball — NullVoxPopuli/limber#2212 / #2213 / #2214. The only app-side accommodation is two dist patches on test packages (
@ember/test-helpersbridges render edges into a test waiter;ember-qunitdrops its_backburner.DEBUGtoggle), which doubles as a working prototype of the Class B test story below and is proposed upstream in emberjs/ember-test-helpers#1574.Landing strategy: every lever on
main, non-breakingThe levers fall into three risk classes, and each class has an established ember mechanism:
Class A — invisible optimizations (no RFC, no flag, land directly). Subtree-skipping, the iteration fast path (
nextIntostreaming compare), tracking-frame flattening, andcombine()flattening change no observable behavior when correct. Gate on the full test suite green per-lever (each cut as its own PR from this branch, validated independently — not as a bundle, so a revert never takes out an unrelated lever).Class B — timing changes (no new RFC needed; RFC 957 covers them; behind an optional feature, default off). The scheduler driving rendering, the clock semantics, and the runloop-free render path all change when rendering happens, which classic code and tests can observe. They ship behind the
use-async-scheduleroptional feature from RFC 957's own migration story (precedent:default-async-observers):renderSettledresolves at the end of any all-valid tick.The critical path for this class is the test story, not the scheduler: a test waiter registered while a tick is scheduled (so
settled()observes the scheduler),@ember/test-helperscoordination, aflushSync-style escape hatch for the rare latency-critical interaction and forrun()interop, and Angular's dev-mode "endless change notifications" error (thrown with collected stacks after N consecutive microtask ticks) as the DEBUG successor to_RERENDER_LOOP_LIMIT. Thesettled()half of that story is now prototyped on this branch and upstream in emberjs/ember-test-helpers#1574: rendering reports its edges and test-helpers represents it as a test waiter, so no new settledness channel is introduced at all.Class C — semantic removals (each needs a deprecation RFC).
_getProplegacy paths:Ember.set()-on-POJO invalidating renders,unknownProperty/ObjectProxyin templates, implicit'[]'array entanglement). New deprecation RFC; warnings that fire only when a render actually relies on the legacy path (a legacy-consumed tag later dirtied), not on everyget; an opt-out optional feature so consenting apps collect the win in a minor; removal at the next major. Benchmarks here re-rank this from performance-critical to simplification (~9% on dbmon once the scheduler and skip are in) — it should never block the scheduler track.@ember/runloopitself — already chartered by RFC 957 (its deprecation labels are on the RFC).schedule('render'/'afterRender')map onto the phase API (render()/layout());later/debounce/throttlebecome plain-timer userland utilities; backburner deletion lands a major after the runloop deprecations.Ideal landing order
@ember/schedulerAPI, behind a canary feature flag until RFC 957 is accepted (CI already green). Feed this PR's benchmark data into the RFC discussion to move it out ofS-Exploring.use-async-scheduleroptional feature PR — scheduler + clock + microtask window + transport legs + the test-waiter/settled()/escape-hatch story. This is the largest PR and the one that makes the flag usable, so it lands as one coherent unit.renderSettled).use-async-schedulerbecomes the default; runloop deprecations per RFC 957. Major after: delete backburner and the legacy read paths.Nothing in this order breaks an existing app until a major, every phase is independently revertable, and the performance is available immediately — to new apps by default and to existing apps as a one-line opt-in.
🤖 Generated with Claude Code