SPIKE: RFC 957 end state — no runloop in the render path - #21519
SPIKE: RFC 957 end state — no runloop in the render path#21519NullVoxPopuli-ai-agent wants to merge 9 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>
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>
|
Now composed with #21512's subtree-skipping (
Subtree-skip contributes exactly where predicted: +23% dbmon (walk-heavy, per-frame flushes), neutral on single-item and single-flush benches. Remaining distance to the fine-grained frameworks on dependent chains is per- Note for anyone comparing against posted rere-benchmark results: the official runner's 🤖 Generated with Claude Code |
|
Consolidated into #21520 (the runloop-free render path is lever 3 there, composed with subtree-skipping). |
Stacked on #21518 (first three commits are that branch). This adds the end state of RFC #957's migration story: the renderer has zero
@ember/runloopimports. Backburner still exists as a userland library, but it no longer participates in rendering at all.What changed (one commit on top of #21518)
_backburner.ensureInstance()→ autorun instance →beginhook rerenders all renderers. That per-dirt autorun machinery is deleted;scheduleRevalidatein the global context now calls the renderer's scheduler.scheduleDestroy/scheduleDestroyedqueue in the environment module and drain at the end of each flush (classic actions-before-destroy ordering preserved), with a fallback microtask when nothing is rendering.renderSettledresolves at the end of any flush that leaves every renderer valid instead of in backburner'sendevent.join, nobegin/endhooks, noframePendinghandshake (all deleted).scheduleOnce('render').Numbers (three-way, same-batch, medians; machine noisier than #21518's table so ratios matter, not absolutes)
The end-state deltas vs #21518 reproduced across two independent batches (2.3–2.4x on 100k async; chain regression closing to 1.0–1.4x of main). Two effects compound: stream dirt no longer pays an autorun instance per event, and dependent chains no longer pay
ensureInstance+begin/endhook traffic per step — that per-step autorun deletion is what erased most of the chain regression.What this proves for the RFC
renderSettledresolution) — each fit in a few dozen lines against the scheduler.later/debounce/throttle/scheduleremain purely userland concerns; nothing in rendering needs them.run()no longer forces a synchronous render (classic tests rely on that),settled()needs a scheduler-aware waiter, and userlandschedule('afterRender')needs a mapping onto the phase API (layout()).Expected breakage
Same class as #21518 but broader: anything assuming
run()/runloop-end implies rendered DOM. This is the far end of the migration — the optional-feature-flagged middle states are the landable path.Related: #21493 (interface, mergeable) → #21518 (scheduler drives rendering) → this (runloop fully out of the render path).
🤖 Generated with Claude Code