Skip to content

Render through the scheduler, without the runloop, RSVP, or backburner (RFC 957 end state) - #21655

Draft
NullVoxPopuli-ai-agent wants to merge 2 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/render-aware-scheduler
Draft

NullVoxPopuli-ai-agent wants to merge 2 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/render-aware-scheduler

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Renders through @ember/scheduler with no runloop, RSVP, or backburner in the framework. This is the RFC 957 end state, and only that: it is the scheduler part of the closed spike #21520, rebuilt on current main, so it can be measured on its own.

Supersedes the scheduler half of #21520 and #21519. Implements (end state of) emberjs/rfcs#957.

Commits

  1. f3ce59665d is Implement RFC #957: Render Aware Scheduler Interface #21552's interface commit, rebased onto main (the same commit as in Spike the use-async-scheduler optional feature from RFC 957 #21554).
  2. eb9b76b539 hooks the renderer into the scheduler and removes the runloop, RSVP, and backburner. The commit message lists what changed.

The spike's VM and allocation changes, and its "notify once per tick" latch, are not in this PR. #21656 has them, on top of this branch.

Benchmark

On the rere-benchmark ember apps: headless at 60 fps, 8x CPU throttle, and no profiler. Chrome 154 runs on 3 CPUs of a VM.

main, this PR, and the two builds of #21656 ran in 4 mirrored cycles: 8 runs for each build, with 4 samples per bench in each run. Each number is the median of 32 samples. The first 4 main runs and the last 4 differ by 1.2% (geometric mean).

Bench main this PR change
1k items, 1 update each (sequentially, async) 911 46.5 -94.9%
1 item, 100k updates (async) 1050 68.9 -93.4%
1k items 1 update on 25% (random, async) 275 29.4 -89.3%
1 item, 1k updates (async) 54.0 13.4 -75.3%
1k items 1 update on 5% (random, async) 82.3 24.9 -69.7%
1 value, 1k consumers, 10k updates (bursts of 100) 1713 697 -59.3%
1 value, 1k consumers, 10k updates (bursts of 1000) 163 149 -8.2%
1k items 1 update on 25% (random) 28.0 28.5 +1.8%
1 value, 1k consumers, 10k updates (single burst) 46.4 48.2 +4.0%
Incrementing Render Effect 2334 2495 +6.9%
1k items, 1 update each (sequentially) 38.4 45.5 +18.6%
1 item, 100k updates 34.6 41.6 +20.1%
1k items 1 update on 5% (random) 20.3 25.0 +23.4%
1 item, 1k updates 7.3 11.1 +52.7%
DB Monitor w/ chat simulation (fps) 56.5 58.2 +3.0% fps

Times are medians in ms, and negative is faster. The geometric mean is -50%.

  • On main, backburner starts an autorun for every await. In a CPU profile, the async benches spend 76% of their time inside its autorun end. Here they render once per tick.
  • The sync benches are 2% to 53% slower, and Incrementing Render Effect is 7% slower. A CPU profile shows one cause: every set now walks the notify chain. On 1 item, 100k updates, scheduleRevalidate takes 15 ms and the renderer callback 12 ms, where backburner's ensureInstance took 1.6 ms. All rendering optimizations, measured again on top of the scheduler #21656 adds a latch that notifies once per tick, and measures it on top of this PR.
  • Fan-out bursts of 100 depends on frame timing. main renders after each burst. This PR does too, unless Chrome holds frames back, and then several bursts become one render. The samples of this PR range from 116 to 1201 ms.
  • The VM paints in software, which holds frames back more often than a machine with a GPU. On a 165 Hz machine with a GPU, All rendering optimizations, measured again on top of the scheduler #21656 (which contains this PR) was 10% faster on that bench, not 60%: results.
  • An earlier version of this description had numbers from profiled runs. The profiler changes the difference between two builds, so those numbers are replaced.

Machine

Item Value
VM QEMU Q35 with KVM
CPU 8 vCPUs of an AMD Ryzen 9 7900X, 1 thread per core
Memory 22.9 GiB RAM
GPU virtio GPU without 3D acceleration, so Chrome paints in software
Frame rate 60 fps (headless Chrome)
OS Pop!_OS 24.04, kernel 7.0.11
Software Chrome 154.0.8037.97, Node 26.10.0

Checks

  • tsc --noEmit, the type tests (stable and stable-decorators), pnpm build, ESLint with --report-unused-disable-directives, and Prettier pass.
  • The public types of @ember/runloop match main. The type tests changed only where this PR changes the API: _backburner is gone, Transition returns native promises, and the default strategy is RenderClockStrategy.
  • The browser suite stops early. Tests that set state in runTask and then assert the DOM in the same task expect a synchronous render, starting with the debug render tree tests. The spike SPIKE: every rendering-performance lever combined (RFC 957 end state + VM optimizations) #21520 stopped at the same place.

🤖 Generated with Claude Code

NullVoxPopuli and others added 2 commits October 3, 2026 02:57
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>

This branch has not been deployed

No deployments
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