Skip to content

SPIKE: RFC 957 scheduler interface + adaptive render flushing, on clean main#21518

Draft
NullVoxPopuli-ai-agent wants to merge 6 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:scheduler-flush-only
Draft

SPIKE: RFC 957 scheduler interface + adaptive render flushing, on clean main#21518
NullVoxPopuli-ai-agent wants to merge 6 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:scheduler-flush-only

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

A testable build of the "ideal path" for RFC #957: the @ember/scheduler interface from #21493 plus the renderer flushing through an adaptive tri-mode scheduler — as the only changes on top of current main. No VM changes, no subtree-skipping (#21512), no legacy-read removal (#21513): this isolates what scheduling alone is worth, as evidence for the RFC discussion.

Numbers (rere-benchmark, 8x CPU throttle, same-batch vs identically-built main)

bench main this branch
DB Monitor 17.3 fps 28.7 fps 1.66x
1 item, 100k updates (async) 1587ms 182ms 8.7x
1k items, 1 update each (seq, async) 1326ms 204ms 6.5x
1k items, 25% random (async) 560ms 86ms 6.5x
1k items, 5% random (async) 188ms 51ms 3.7x
1 item, 1k updates (async) 59ms 23ms 2.6x
sync one-shot benches (3) ~par +8–36ms latency hop
Incrementing Render Effect (100k-step dependent chain) 3.8s 5.5s 1.45x worse

Times are anchored on the last DOM mutation (a MutationObserver), not the benchmark's :done mark — :done rides a bare requestIdleCallback whose grant latency adds hundreds of ms of noise for any framework that defers rendering.

How the scheduler works

scheduleRevalidate classifies dirt by arrival latency:

  • Chain dirt (<1ms since the last flush — render → microtask → set loops): flushes via queueMicrotask, same task turn, so dependent chains don't pay a task hop per step. This cannot defeat coalescing of awaited update loops: those drain their entire microtask chain before their first flush runs.
  • Stream dirt (fresh tasks — worker messages, events): flushes on a race between an unclamped macrotask (MessageChannel; setTimeout's 4ms nesting clamp would crawl) and requestAnimationFrame. Under sustained per-frame bursts the macrotask leg stands down and flushes ride the frame.
  • In-flush dirt (rendering itself dirtying state) is drained by a flush-until-stable loop inside the same flush.

Hard-won details, each worth a test in the real implementation:

  • The macrotask stand-down only applies while rAF is actually being serviced. Backgrounded/occluded pages stop firing rAF entirely, and since the per-frame counter is only reset by a frame firing, standing down there strands all rendering until the tab is foregrounded again.
  • The scheduled-flag must clear after the flush: clearing before meant dirt produced mid-flush re-armed a vacuous second flush (1.47 flushes per chain step, measured), which was most of an earlier 4x chain regression.
  • Chain flushes must not count toward the stand-down: they're same-task and invisible to frames, and counting them starved misclassified stream dirt of its macrotask leg for up to a full frame.
  • Scheduling allocates nothing per dirt event — all callbacks are persistent fields (chains re-enter once per step; closure churn there is measurable GC pressure).

How to test

git fetch <this remote> scheduler-flush-only && git checkout scheduler-flush-only
pnpm install && pnpm build
pnpm pack   # then point your app's ember-source at the tarball

The @ember/scheduler phase API (render()/layout()/composite()/next()/idle() + registerStrategy) is included and its tests pass; the renderer's flushing is hardwired in base-renderer.ts rather than routed through a registered strategy yet — that seam (plus an optional feature flag defaulting to today's backburner behavior, settled()/test-waiter integration, and a sync-flush escape hatch) is the landable follow-up.

Expected breakage

This is a spike: the full test suite is not expected green — classic runloop-timing tests assume renders flush synchronously at runloop end. The landable shape puts this behind an optional feature per the RFC's migration story.

The remaining chain-bench gap (5.5s vs 3.8s) is per-step performance.now() + queueMicrotask + flush-body overhead vs backburner's tuned autorun; the structural fix is running render-effects inside the flush's own drain loop (the RFC's phase model is exactly that seam — Angular's afterEveryRender shape as a public API).

Related: #21493 (interface, mergeable), #21512 (subtree-skip, independent), #21513 (full spike incl. legacy-read removal — that lever turns out unnecessary for the async story).

🤖 Generated with Claude Code

NullVoxPopuli and others added 6 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 "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>
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