Skip to content

Spike the use-async-scheduler optional feature from RFC 957 - #21554

Draft
NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:nvp/scheduler-interfacefrom
NullVoxPopuli-ai-agent:use-async-scheduler-flag
Draft

Spike the use-async-scheduler optional feature from RFC 957#21554
NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:nvp/scheduler-interfacefrom
NullVoxPopuli-ai-agent:use-async-scheduler-flag

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

Spike for the intermediary migration step discussed in the RFC 957 review meeting: one optional feature flag that moves Ember's own scheduling (rendering, runloop callbacks, RSVP flush) onto @ember/scheduler, while backburner, RSVP, and the full @ember/runloop API keep working exactly as today for everyone who has not opted in. Nothing is dropped or deprecated first — apps can absorb the timing change while still running addons that use the runloop, because the runloop API itself is re-implemented on the scheduler.

Targets nvp/scheduler-interface (#21552) since it needs @ember/scheduler.

The flag

EmberENV._USE_ASYNC_SCHEDULER, following the default-async-observers precedent (the app-facing use-async-scheduler name would come from @ember/optional-features once this is real). Read lazily at every decision point, so tests can flip it per-module.

Flag off (default)

No behavior change: every gated call site falls through to the exact code that exists today. Full suite: 9436 passed, 0 failed.

Flag on

Runloop, re-implemented on the scheduler (@ember/runloop/-private/scheduler-loop.ts), per the RFC's transition table:

runloop API becomes
run / join / bind execute the callback directly (same onerror routing)
schedule('render') scheduler render() phase
schedule('afterRender') scheduler layout() phase
schedule(anything else) Promise.resolve().then(...)
scheduleOnce / once same mapping, deduplicated by queue/target/method, latest args win
next scheduler next() phase
later / debounce / throttle / cancel setTimeout-backed timers with the same coalescing semantics
begin / end no-op

Backburner is not used at all on this path. If the app has not registered a strategy, the default FrameStrategy is registered on first use.

Rendering goes through the scheduler's render phase instead of backburner's render queue: scheduleRevalidate (both the global-context hook and the renderer's own) schedules one deduplicated revalidation pass before the next paint. The reflush loop (_RERENDER_LOOP_LIMIT) and renderSettled() semantics carry over — the render phase resolves in-window during its own flush, so reflushes stay within the frame.

RSVP resolves on the microtask queue like a native promise instead of joining a runloop; unhandled-rejection re-throw happens on a timeout instead of the private error queue. Ember error dispatch (Ember.onerror / dispatch override) is unchanged.

Tests

packages/@ember/runloop/tests/use_async_scheduler_test.js runs the runloop surface and renderSettled() with the flag enabled (14 tests): sync run/join/bind, microtask schedule('actions'), phase ordering (actionsrenderafterRender), scheduleOnce/once dedupe, cancellation, later/debounce/throttle timing, and renderSettled resolving with no backburner runloop.

Known gaps (why this is a spike)

  • settled()/test waiters: _hasScheduledTimers() reports the scheduler-backed timers, and the backend tracks all pending work (_hasPendingWork()), but @ember/test-helpers still watches backburner directly. The RFC's "test waiters observe the scheduler" story is the critical follow-up before the flag is adoptable.
  • Queue ordering: classic runloop guarantees actionsrouterTransitionsrenderafterRenderdestroy per loop. Microtask-mapped queues (actions, routerTransitions, destroy, custom queues) now run FIFO in scheduling order rather than queue order.
  • run() no longer forces a synchronous render flush — there is no runloop to flush. Interop relying on "DOM is updated when run() returns" needs await renderSettled().
  • Async observers flush at the start of the render pass (and on any schedule they request), not at runloop end.

Adds EmberENV._USE_ASYNC_SCHEDULER (the app-wide optional feature from
RFC 0957's migration roadmap, following the default-async-observers
precedent) as the intermediary migration step: when enabled, Ember's own
scheduling moves onto @ember/scheduler while backburner, RSVP, and the
full @ember/runloop API keep working unchanged for everyone who has not
opted in.

With the flag enabled:

- @ember/runloop is re-implemented on the scheduler: run/join/bind
  execute their callback directly, schedule('render'/'afterRender') map
  onto the render and layout phases, every other queue becomes a
  microtask, next maps onto the next phase, and later/debounce/throttle
  become setTimeout-backed timers with the same coalescing semantics.
  Backburner is not used at all.
- Rendering is scheduled into the scheduler's render phase (one
  deduplicated revalidation pass before the next paint) instead of
  backburner's render queue; the reflush loop and renderSettled()
  semantics carry over.
- RSVP resolves on the microtask queue like a native promise instead of
  joining a runloop.

With the flag disabled (the default) every gated call site falls through
to the exact code that exists today; the full suite is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

rere-benchmark numbers at 4x CPU throttle: main (f1c718c4), this PR with the flag off and on (c67c99c, flag flipped via globalThis.EmberENV = { _USE_ASYNC_SCHEDULER: true } in the app's index.html), and the all-optimizations spike #21520 (c09efdc). Medians of 5 interleaved runs per variant (3 for DB Monitor), headless Chrome, same machine, prod builds.

ms, lower is better (DB Monitor is fps, higher is better):

bench main #21554 flag off #21554 flag on #21520 spike
DB Monitor (fps) 47.0 49.4 57.6 59.4
Incrementing Render Effect 3190.3 3080.0 2723.5 3147.4
1 item, 1k updates (async) 51.2 52.9 12.1 11.8
1 item, 1k updates 7.2 9.1 15.0 10.2
1 item, 100k updates (async) 1313.1 1282.0 88.6 52.2
1 item, 100k updates 35.1 31.6 64.6 26.8
1k items, 1 update each (seq, async) 976.3 1019.0 55.2 48.5
1k items, 1 update each (seq) 42.1 45.0 51.6 47.7
1k items, 5% random (async) 93.3 95.1 33.1 26.4
1k items, 25% random (async) 303.8 317.2 39.9 31.6

Reading:

  • Flag off tracks main on every bench (deltas within run noise, both directions) — the gated call sites are perf-neutral when disabled.
  • Flag on lands most of the spike's async win: 10–25x on the async benches (RSVP/microtask resolution no longer pays a runloop per await) and +10 fps on DB Monitor. The remaining gap to SPIKE: every rendering-performance lever combined (RFC 957 end state + VM optimizations) #21520 is the spike's VM optimizations, not scheduling.
  • Known cost: the small synchronous benches pay for rAF-scheduled rendering (7.2→15.0, 35.1→64.6, 42.1→51.6 ms) — same shape we measured on the spike before its VM work. This is the race-flush/strategy-tuning follow-up.
  • Caveat: headless caps rAF at 60Hz, so the 57.6/59.4 fps readings are near ceiling; the fps gap is understated.

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Same setup as above, at 8x CPU throttle (medians of 5 interleaved runs, 3 for DB Monitor):

ms, lower is better (DB Monitor is fps, higher is better):

bench main #21554 flag off #21554 flag on #21520 spike
DB Monitor (fps) 6.8 6.8 19.1 23.6
Incrementing Render Effect 6200.1 6236.5 5372.2 6172.6
1 item, 1k updates (async) 98.5 101.4 29.5 22.3
1 item, 1k updates 13.9 14.7 24.6 16.9
1 item, 100k updates (async) 2670.7 2638.6 171.3 108.2
1 item, 100k updates 59.3 64.0 117.2 51.1
1k items, 1 update each (seq, async) 1918.7 2054.8 101.2 97.7
1k items, 1 update each (seq) 88.8 89.7 102.9 90.4
1k items, 5% random (async) 204.9 213.8 57.0 49.5
1k items, 25% random (async) 644.0 598.2 67.0 63.1

8x is out of the headless 60Hz rAF ceiling, so DB Monitor now discriminates: flag on nearly triples fps over main (6.8 → 19.1), with the spike's VM work adding the rest (23.6). Everything else mirrors the 4x picture — flag off ≈ main, flag on takes the async wins (10–20x), small sync benches pay the rAF-flush latency (13.9 → 24.6, 59.3 → 117.2).

@kategengler

Copy link
Copy Markdown
Member

Converting to draft since it is a spike

@kategengler
kategengler marked this pull request as draft August 11, 2026 16:14
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