Skip to content

[SPIKE] Stacked update-path optimizations: how fast can list-heavy updates get?#21513

Draft
NullVoxPopuli-ai-agent wants to merge 4 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:dbmon-perf-max
Draft

[SPIKE] Stacked update-path optimizations: how fast can list-heavy updates get?#21513
NullVoxPopuli-ai-agent wants to merge 4 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:dbmon-perf-max

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

Draft / spike — not for merging. This stacks every update-path optimization we've identified on top of #21512, to measure the achievable ceiling before investing in any one direction. We'll break it out into individual PRs afterwards.

What's stacked (on top of #21512's {{#each}} subtree-skip)

  1. rAF-coalesced revalidationscheduleRevalidate defers to at most one requestAnimationFrame per frame instead of a backburner render-queue flush per runloop. Invalidation bursts (sockets/workers at ~130 msg/sec) otherwise buy a full revalidation pass per message while only ~15 frames paint. loopEnd learns a frame is pending so it neither spins NO_OP runloops nor trips the infinite-invalidation guard.
  2. ListBlockOpcode same-order fast path — deriving a fresh array from tracked state in a getter is the idiomatic pattern, so iterator identity changes every render even when keys didn't. When the new iteration matches existing children in order/count, just updateRef the items: no diff bookkeeping, no marker DOM churn, no children-array rebuild. Falls back to full sync() via a PrefixedIterator that replays consumed items on first mismatch.
  3. combine() flattening — nested combinator tags are flattened (capped at 64) so validating an item's combined tag is one flat loop instead of a pointer-chasing tree walk.

Results — rere-benchmark dbmon, headless runner, 8x CPU throttle

build fps (avg of 5 windows)
stock 7.3.0-alpha.5 ~9.4
#21512 subtree-skip only ~13.6
this spike ~18.7

Context at the same throttle: vue ~17, solid 2 ~20, react ~34, svelte ~40. The spike moves ember from last place to solid's neighborhood. Remaining frame cost is genuinely-changed-row re-render plus per-frame validation — closing on react/svelte needs finer-grained updates, a bigger architectural conversation.

Status / caveats

  • All five rere-benchmark ember apps pass their DOM-verifying e2e tests on this build; dbmon rendering verified live (rows/chats stream correctly).
  • Lever 1 breaks the test suite (384 of 574 in the each filter): tests assert DOM synchronously after runloop settle, and rendering now lands on the next frame. Landing it for real means routing the renderer through the RFC 957 scheduler with test-waiter integration (renderSettled already resolves correctly — it's the synchronous-assert pattern that breaks).
  • Levers 2-3 are suite-clean in isolation and are straightforward break-out candidates alongside Skip unchanged {{#each}} item subtrees during updates #21512.
  • An earlier version of lever 1 hollow-rendered (0 rows at a perfect 60fps) because loopEnd destroyed the renderer via the infinite-invalidation guard — worth noting that the bench runner's verify() did not catch an empty DOM; tightening that is a rere-benchmark follow-up.

🤖 Generated with Claude Code

NullVoxPopuli-ai-agent and others added 3 commits July 22, 2026 12:36
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>
On top of the {{#each}} subtree-skip (emberjs#21512):

1. rAF-coalesced revalidation: scheduleRevalidate defers to one
   requestAnimationFrame per frame instead of a backburner render-queue
   flush per runloop; loopEnd learns a frame is pending so it neither
   spins NO_OP runloops nor trips the infinite-invalidation guard.
2. ListBlockOpcode same-order fast path: when a fresh iterator yields
   the same keys in the same order (the derived-array-in-a-getter
   idiom), update item refs in place; no diff bookkeeping, no marker
   DOM, no children rebuild. Falls back to full sync via a
   PrefixedIterator replaying consumed items.
3. combine() flattens nested combinator tags (capped) so validating a
   combined tag is one flat loop instead of a tree walk.

rere-benchmark dbmon, headless runner, 8x CPU throttle (fps avg):
  stock 7.3.0-alpha.5        ~9.4
  subtree-skip only          ~13.6
  this spike                 ~18.7  (solid 2 ~20, vue ~17, react ~34)

Known cost: rAF-coalescing breaks tests that assert DOM synchronously
after a runloop settle (384 failures in the "each" filter); landing it
for real means RFC 957 scheduler integration + test waiters. Levers 2
and 3 are suite-clean in isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every template property read paid for:
- consumeTag(tagFor(obj, key)): a per-(object, key) tag created and
  consumed on arbitrary objects, so Ember.set() on POJOs invalidates
  renders
- a bonus '[]' EmberArray tag consume for array-valued reads
- the unknownProperty (ObjectProxy) check

Data-heavy templates reading throwaway plain objects pay all three per
read; the tags then fatten every combined tag above them. Deleting
them (modern semantics: plain-data reads don't entangle; reactivity
via @Tracked, tracked collections, and replacement) doubled the
previous spike's dbmon result: ~18.7 -> ~37.5 fps avg at 8x throttle,
~80% of svelte measured in the same session.

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

Copy link
Copy Markdown
Contributor Author

Update: legacy read-path deletion doubles the spike again — ember now reaches ~80% of svelte on this workload.

New commit deletes three things _getProp does on every template property read:

  1. consumeTag(tagFor(obj, key)) — creates + consumes a per-(object, key) tag on arbitrary objects, purely so Ember.set() on POJOs invalidates renders
  2. the '[]' EmberArray tag consume for every array-valued read
  3. the unknownProperty (ObjectProxy) check

Data-heavy templates reading throwaway plain objects (dbmon replaces ~800 row objects/sec) pay all three per read, and the created tags then fatten every combined tag above them — so this deletion also shrinks tag validation, combine, and GC costs downstream.

Runner, dbmon, 8x CPU throttle, same machine/session:

build fps
stock 7.3.0-alpha.5 ~9.4
#21512 subtree-skip ~13.6
+ coalescing/fast-path/flatten ~18.7
+ legacy read-path deletion ~37.5 (31.9-41.1)
svelte 5.56 (reference ceiling) ~47.6 (41.3-51.4)

Two investigation notes:

  • A boot flake chased during measurement turned out to be the bench harness's bare requestIdleCallback never firing in idle headless pages (fixed in rere-benchmark#34), not this branch — earlier "hollow render" suspicions about the coalescing lever were partly that.
  • The read-path deletion is obviously not landable as-is: it changes semantics for Ember.set() on untracked POJOs, unknownProperty/ObjectProxy, and EmberArray '[]' invalidation. But it quantifies precisely what those legacy reactivity paths cost modern apps: they're worth a 2x on data-heavy rendering, which seems like strong motivation for deprecation RFCs.

Takeaway shaping up: with wasted revalidation, legacy read overhead, and per-message rendering removed, the VM architecture itself gets within ~20% of a compiled framework on this workload — suggesting deprecate-and-delete is a better ROI than a rendering rearchitecture.

- flat frame stack: parallel arrays in UpdatingVM instead of an
  UpdatingVMFrame allocation per block per render
- allocation-free list fast path: optional nextInto(target) on
  iterators writes into a shared scratch item; the rare mismatch
  fallback reconstructs the already-applied prefix from the opcodes'
  own refs instead of buffering every item

Measured fps-neutral on dbmon at 8x throttle (GC was ~1% of wall time;
allocations were not the bottleneck). Kept for hygiene: ~250 frame +
~210 item allocations per render removed.

Same-batch interleaved runner measurement: ember at 80% of svelte on
dbmon (26.5-28.5 vs 31.5-37.1 fps at 8x).

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

Copy link
Copy Markdown
Contributor Author

Round 3 findings: allocation levers are a dead end; the 80%-of-svelte ratio is robust; remaining costs identified.

Tried two allocation levers (flat frame stack replacing per-block UpdatingVMFrame allocations; allocation-free list fast-path iteration via a nextInto(target) scratch item with an opcode-reconstructed fallback): fps-neutral on dbmon at 8x. GC was only ~1% of wall time — allocation was not the bottleneck. Kept for hygiene (~460 allocations/render removed), clearly labeled neutral.

Measurement honesty notes:

  • This machine drifts ±30% across hours, so only same-batch interleaved comparisons are meaningful. Interleaved runner batch: ember 26.5-28.5 vs svelte 31.5-37.1 → 80%, matching the earlier 37.5-vs-47.6 reading taken under faster conditions.
  • The each-filter failure count on the current branch is 415 (vs 384 at the coalescing-only stage). Spot-checking the delta: all pushObject/Ember.set()-invalidation semantics — i.e. the intentional breakage of the legacy read-path deletion commit, not the VM rewrite.

Where the remaining ~20% lives, per fine-grained profile after all levers: the changed-row opcode walk (_execute + leaf evaluate + valueForRef ≈ 200ms/10s attributable) and a large un-attributable (program) bucket (V8 dispatch/IC overhead of interpreting ~15 polymorphic opcode shapes). The next meaningful moves are compiler-shaped, not cache-shaped: fusing adjacent text/attr updates into single opcodes, and/or numeric-switch dispatch to monomorphize the update loop. That's a different scale of change — this spike stops here.

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Round 4: the "no revalidation" experiment (push-invalidation), plus a correction to an earlier claim.

Branch push-invalidation (stacked on this one) answers "what if dirtying a tag queued its consumers directly, and the flush processed only the queue — no walk?":

  • tags gain subscribers; dirtyTag notifies synchronously; unsubscribed dirt sets a flag that forces a full-walk fallback
  • list items subscribe to their collected subtree-tag leaves; list blocks collect sync-consumed tags in a tracking frame (iteration reads collection cells lazily, so subscribing to the iterable ref alone misses e.g. trackedArray.shift)
  • the backburner render flush drains the queue inside normal runloop semantics — no rAF deferral, settled() works

Verdict: mechanically sound, loses on numbers. 87% of flushes take the push path, rendering is correct, but same-batch dbmon at 8x: 69% of svelte vs 80% for the coalesced-walk+subtree-skip in this branch. The theoretical win is eaten by per-flush subscription churn, still-per-message flushes, and ~13% fallback walks from genuinely hard-to-subscribe dirt. Two design lessons worth recording: (1) partial subscription coverage is silently incoherent — items below the triviality gate went stale and were only kept fresh by accidental fallback walks; push requires subscribing everything, which is exactly where the retrofit cost concentrates. (2) The walk-with-cheap-skips model is more forgiving than its reputation: validating 40 clean tags per flush is nearly free.

Correction: I earlier attributed the 384 each-filter failures to rAF coalescing. Push-v3 has coalescing removed and still fails 411 (vs 415 with it) — so the suite breakage is dominated by the legacy read-path deletion (the each suite is pushObject/set()-heavy), possibly with contributions from the list fast-path, not primarily coalescing. Isolating which lever breaks which tests needs a factorial suite run (skip-only + one lever at a time) before any break-out PRs — I'd treat every per-lever suite claim in this thread as unverified until then.

Recommendation stands with sharpened rationale: coalesced-walk + skip is both the faster and the simpler direction; push-invalidation is documented here as explored-and-declined.

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Round 5: walk-free push + rAF drains — the full version of the "no revalidation" design.

Per review discussion, the two objections to round 4 were addressed head-on: (1) why ever full-walk? — subscription coverage is now complete: each render root renders inside a tracking frame and subscribes to what it read, while items/blocks stop propagating deps upward — so a root re-render happens only when the root's own non-delegated deps change (that's the minimal correct response, not a fallback), and unsubscribed dirt is deliberately ignored. Item bootstrap happens through the queue (blocks enqueue never-collected children), so no tree walk exists anywhere in steady state. (2) rAF deferral — drains coalesce to one animation frame.

Results, same-batch dbmon at 8x: 66% of svelte on average (best run ~79%) with high run-to-run variance — at best parity with the coalesced-walk+skip (80%) that needs none of this machinery. Correctness held everywhere, including the no-{{#each}} apps whose updates flow purely through root subscriptions.

The expensive discovery: rAF deferral is brutal for render-latency-coupled code. The incrementing-render-effect bench (set → effect → advance → repeat) went 0.4s → 33.6s — every iteration now waits a frame. This applies to any rAF-deferred design, including round 2's coalescing lever, where it went unmeasured — so frame-aligned rendering needs an escape hatch (sync flush on demand, or opt-in per app) before it's landable in any form.

Closing the push question: two full rounds, mechanically working, honestly measured — push-invalidation retrofitted onto glimmer peaks at parity with the simple design while costing subscribers-on-every-tag, upward-propagation semantics, bootstrap machinery, and root bookkeeping. I consider "no revalidation" answered: not worth it for this VM. The walk was never the enemy — per-message flushing and legacy read tracking were.

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

pnpm bench for this branch head (full spike: skip + rAF coalescing + list fast-path + tag flatten + legacy read-path deletion + alloc hygiene). Tracerbench krausest vs origin/main, 20 samples, per-phase medians + Mann-Whitney p from raw compare.json:

All 23 phases statistically neutral (no p < 0.05). Overall duration -7.8ms (p=0.87). Largest point deltas are noise (append1000Items1 +35ms at p=0.12, render10000Items2 -18ms at p=0.47).

Two honest observations:

  • This includes the rAF coalescing lever, and krausest phase timings did not stretch by a frame — the harness's markers absorb the single-frame hop on interaction-shaped work. The pathological case remains sustained set→effect→advance loops (the 0.4s→33.6s incrementing finding), which krausest doesn't exercise.
  • The legacy read-path deletion (worth ~2x on dbmon) is also invisible here — krausest's per-interaction read volume is too small for the ±10ms noise floor of this harness.

Net: krausest continues to say "no regressions" for the whole stack; the wins live entirely in the sustained-update workload class, measured in the PR description and comments.

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Round 6: what Angular is doing, and porting it — branch race-flush (stacked on this PR).

Angular 22 landed in rere-benchmark and dominates the rank sums (24 vs react's 51), almost entirely via the async bench variants: 1 item, 100k updates (async) 41ms vs ember 654 / react 379 / svelte 920; 1k items sequential (async) 28ms vs ember 631 / solid 1627. The app code is unremarkable (signals + OnPush) — the win is the zoneless change-detection scheduler: writes only mark dirty, the flush is deferred past the current task + its microtasks, so an awaited (await 0) update loop pays one render for the whole chain. Ember's backburner autorun renders synchronously per resume — one render per awaited update.

The port evolved through the bench evidence into a tri-mode adaptive scheduler:

  1. Chain dirt (arrives <1ms after the previous flush — a render→microtask→set advance loop): flush via queueMicrotask, same task turn like the classic runloop. Provably can't defeat coalescing: awaited loops drain their entire microtask chain before their first flush runs.
  2. Stream dirt (fresh tasks, e.g. worker messages): flush on a race of unclamped macrotask (MessageChannel — setTimeout's 4ms nesting clamp cripples chains) + rAF, with the macrotask leg standing down under sustained bursts so flushes ride the frame.
  3. Flush-until-stable for dirt produced synchronously by the render itself.

Runner at 8x throttle vs stock:

bench stock race-flush
dbmon ~9 fps 26.1 fps
1 item 100k updates (async) 654ms 162ms
1k items sequential (async) 631ms@4x 336ms@8x
incrementing (dependent chain) ~2.8s 13.5s ⚠️

The coalescing wins are real and large; the remaining blocker is chain-flush overhead (~135µs/step vs the classic runloop's ~28µs — flush machinery, not architecture). Also note this subsumes and replaces this branch's rAF-only coalescing lever, fixing its worst deficiency (incrementing was 33-39s frame-bound under rAF-only).

Direction takeaway: this is the missing scheduling half of RFC 957 territory — "defer past the task turn, classify the dirt" — and it's what makes ember competitive with Angular's headline numbers without touching the rendering architecture at all.

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.

1 participant