[SPIKE] Stacked update-path optimizations: how fast can list-heavy updates get?#21513
[SPIKE] Stacked update-path optimizations: how fast can list-heavy updates get?#21513NullVoxPopuli-ai-agent wants to merge 4 commits into
Conversation
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>
|
Update: legacy read-path deletion doubles the spike again — ember now reaches ~80% of svelte on this workload. New commit deletes three things
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:
Two investigation notes:
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>
|
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 Measurement honesty notes:
Where the remaining ~20% lives, per fine-grained profile after all levers: the changed-row opcode walk ( |
|
Round 4: the "no revalidation" experiment (push-invalidation), plus a correction to an earlier claim. Branch
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 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. |
|
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- 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. |
|
All 23 phases statistically neutral (no p < 0.05). Overall duration -7.8ms (p=0.87). Largest point deltas are noise ( Two honest observations:
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. |
|
Round 6: what Angular is doing, and porting it — branch Angular 22 landed in rere-benchmark and dominates the rank sums (24 vs react's 51), almost entirely via the async bench variants: The port evolved through the bench evidence into a tri-mode adaptive scheduler:
Runner at 8x throttle vs stock:
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. |
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)scheduleRevalidatedefers to at most onerequestAnimationFrameper 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.loopEndlearns a frame is pending so it neither spins NO_OP runloops nor trips the infinite-invalidation guard.ListBlockOpcodesame-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, justupdateRefthe items: no diff bookkeeping, no marker DOM churn, no children-array rebuild. Falls back to fullsync()via aPrefixedIteratorthat replays consumed items on first mismatch.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
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
eachfilter): 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 (renderSettledalready resolves correctly — it's the synchronous-assert pattern that breaks).loopEnddestroyed the renderer via the infinite-invalidation guard — worth noting that the bench runner'sverify()did not catch an empty DOM; tightening that is a rere-benchmark follow-up.🤖 Generated with Claude Code