Skip to content

SPIKE: RFC 957 end state — no runloop in the render path - #21519

Closed
NullVoxPopuli-ai-agent wants to merge 9 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:scheduler-endstate
Closed

SPIKE: RFC 957 end state — no runloop in the render path#21519
NullVoxPopuli-ai-agent wants to merge 9 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:scheduler-endstate

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

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/runloop imports. Backburner still exists as a userland library, but it no longer participates in rendering at all.

What changed (one commit on top of #21518)

  • Tag invalidation notifies the scheduler directly. The classic wiring was: every dirty tag → _backburner.ensureInstance() → autorun instance → begin hook rerenders all renderers. That per-dirt autorun machinery is deleted; scheduleRevalidate in the global context now calls the renderer's scheduler.
  • Destruction is scheduler-native. scheduleDestroy/scheduleDestroyed queue 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.
  • renderSettled resolves at the end of any flush that leaves every renderer valid instead of in backburner's end event.
  • The flush calls the revalidation loop directly — no join, no begin/end hooks, no framePending handshake (all deleted).
  • SSR fallback flushes on a microtask instead of scheduleOnce('render').

Numbers (three-way, same-batch, medians; machine noisier than #21518's table so ratios matter, not absolutes)

bench (8x) main #21518 end state
1 item, 100k updates (async) 9380ms 1011ms 413ms (22x vs main, 2.4x vs #21518)
1 item, 1k updates (async) 267ms 59ms 47ms
1k items, 1 update each (seq, async) 5330ms 758ms 519ms
Incrementing Render Effect (dependent chain) 27.2s 42.4s 27.9s (~main parity; was the #21518 regression)
DB Monitor (4x throttle) 6.3 fps 14.4 fps 13.0 fps (equal within spread)

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/end hook traffic per step — that per-step autorun deletion is what erased most of the chain regression.

What this proves for the RFC

  • The runloop's render-path role really was reducible to three migratable jobs (invalidation wiring, destroy queues, renderSettled resolution) — each fit in a few dozen lines against the scheduler.
  • later/debounce/throttle/schedule remain purely userland concerns; nothing in rendering needs them.
  • The remaining migration work for a real (non-spike) landing is the compat story, not the mechanics: run() no longer forces a synchronous render (classic tests rely on that), settled() needs a scheduler-aware waiter, and userland schedule('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

NullVoxPopuli and others added 9 commits July 8, 2026 16:32
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>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Now composed with #21512's subtree-skipping (fork/dbmon-perf merged in) — the other mergeable-track lever, so the branch represents the full non-deprecation end state. Fresh three-way on a healthy machine (8x throttle, medians of 4, fresh browser per bench):

bench main end state end state + #21512
DB Monitor 7.2 fps 17.0 fps 20.9 fps (2.9x)
1 item, 100k updates (async) 2058ms 86ms (24x) ~equal (spreads overlap)
1k items, 1 update each (seq, async) 1419ms 211ms 201ms
1 item, 1k updates (async) 66ms 24ms 21ms
Incrementing Render Effect 4963ms 5163ms (~par) ~equal
sync one-shots ~par ~par

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-set machinery (@trackedtagFor WeakMap lookups → validator bump), which is outside this PR's scheduling scope.

Note for anyone comparing against posted rere-benchmark results: the official runner's :done oracle retries on a bare requestIdleCallback (common/src/tests/utils.js tryVerify), which taxes any framework that defers rendering past the dirtying task by idle-grant latency. The numbers above anchor on the last DOM mutation instead.

🤖 Generated with Claude Code

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Consolidated into #21520 (the runloop-free render path is lever 3 there, composed with subtree-skipping).

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