[SPIKE / not for merge] Push-invalidation: tags queue their consumers, no revalidation walk#21514
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>
- 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>
…the queue What if there is no revalidation walk? Tags gain subscribers; dirtying notifies them synchronously; list blocks and items subscribe to the leaves they actually read (blocks collect sync-consumed tags in a tracking frame, since iteration reads collection cells lazily); the backburner render flush drains the queue -- exactly the dirty opcodes, no walk -- falling back to a full walk whenever unsubscribed dirt was seen. Runs inside normal runloop semantics: no rAF deferral (the rAF coalescing from earlier in this branch is reverted here). Verdict on dbmon at 8x (same-batch vs svelte): push-v3 (full coverage) 69% of svelte coalesced walk + skip (v4) 80% of svelte 87% of flushes take the push path; the remainder falls back due to genuinely hard-to-subscribe dirt (e.g. trackedArray shift dirtying the removed tail cell nobody consumed). Subscription churn (unsubscribe + resubscribe per processed opcode per flush) plus per-message flushes eat the theoretical win. Also: partial subscription coverage is silently incoherent -- with the triviality gate in place, trivial items went stale and were only kept fresh by accidental fallback walks. Push requires subscribing everything, which is where the retrofit cost concentrates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Answers "why ever full-walk?" and "we need rAF deferral": - subscription coverage completed with ROOTS: each render root renders inside a tracking frame and subscribes to what it read; items and blocks no longer propagate their deps upward (or the root would fire on every item change), so a root re-render happens only when the root's own non-delegated deps change -- the minimal correct response, not a fallback. Unsubscribed dirt is now deliberately ignored. - item bootstrap without walks: blocks enqueue any child item that has never collected (fresh inserts, initial flush); the item queue walks exactly those once. - drains coalesce to one requestAnimationFrame via the earlier framePending machinery. dbmon same-batch at 8x: 66% of svelte average (21.8/36.8 vs 42.3/46.3, high variance; best run ~79% -- parity with the far simpler coalesced-walk+skip at 80%). Costly discovery: rAF deferral breaks render-latency-coupled patterns -- incrementing-render-effect (set -> effect -> advance) goes 0.4s -> 33.6s because every iteration waits a frame. Applies to any rAF-deferred design, including the earlier coalescing lever. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
21 of 23 phases neutral; two phases reach significance:
Both are coherent with the design rather than noise-shaped: initial render now pays subscription setup (root tracking frame + leaf subscription per item bootstrap), and the first big list render benefits from items skipping the collection walk that the base branch's finalizer does eagerly. The rest — updates, selects, swaps, removes — is flat; krausest's single-interaction shapes don't exercise the sustained-stream scenario where the push-vs-walk difference lives (and where this branch measures worse than #21513: 68% vs 82% of svelte on dbmon). This completes the bench triptych: #21512 fully neutral, #21513 fully neutral, this branch neutral-with-two-small-signals. None of the krausest results changes the conclusion in the PR description: explored, measured, declined in favor of #21513's direction. |
|
Closing: the push-invalidation question was answered — at best parity with coalesced-walk+subtree-skip at far higher complexity, and the walk was never the bottleneck. Findings are recorded in the round-4/5 analysis comments; the winning direction lives in #21520. |

Documentation PR — explored and declined. This is the "what if there is no revalidation?" experiment referenced from #21513 (rounds 4-5), given its own diff so the design and its costs are reviewable. It is stacked on #21513 but replaces that branch's revalidation design rather than adding to it — the two are mutually exclusive.
The design (final form)
subscribers;dirtyTagnotifies them synchronously{{#each}}items subscribe to their collected subtree-tag leaves; list blocks collect sync-consumed tags in a tracking frame (iteration reads collection cells lazily — subscribing to the iterable ref alone missestrackedArray.shift)requestAnimationFrameWhy declined
Same-batch dbmon at 8x CPU throttle (svelte anchors stable):
The theoretical O(changed) win is eaten by per-flush subscription churn (unsubscribe/resubscribe per processed opcode), root bookkeeping, and high run-to-run variance. Two design findings worth keeping regardless:
Conclusion: the walk was never the enemy — per-message flushing and legacy read tracking were. See #21513 for the direction worth landing.
🤖 Generated with Claude Code