Skip to content

[SPIKE / not for merge] Push-invalidation: tags queue their consumers, no revalidation walk#21514

Closed
NullVoxPopuli-ai-agent wants to merge 6 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:push-invalidation
Closed

[SPIKE / not for merge] Push-invalidation: tags queue their consumers, no revalidation walk#21514
NullVoxPopuli-ai-agent wants to merge 6 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:push-invalidation

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

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)

  • tags gain subscribers; dirtyTag notifies 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 misses trackedArray.shift)
  • render roots render inside a tracking frame and subscribe to what they read; items/blocks do NOT propagate deps upward, so a root re-render fires only when the root's own non-delegated deps change
  • no full walk exists: with complete coverage, unsubscribed dirt provably affects nothing rendered and is ignored; item bootstrap flows through the queue (blocks enqueue never-collected children)
  • drains coalesce to one requestAnimationFrame

Why declined

Same-batch dbmon at 8x CPU throttle (svelte anchors stable):

configuration fps vs svelte
stock 7.3.0-alpha.5 7.2 22%
#21512 subtree-skip 13.7 42%
#21513 coalesced-walk stack 27.1 82%
this branch (walk-free push) 22.3 68%

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:

  1. Partial subscription coverage is silently incoherent — un-subscribed regions go stale and can be masked by unrelated fallback renders. Push is all-or-nothing.
  2. rAF deferral breaks render-latency-coupled patterns — a set→effect→advance loop went 0.4s → 33.6s (every iteration waits a frame). This caveat applies equally to [SPIKE] Stacked update-path optimizations: how fast can list-heavy updates get? #21513's coalescing lever and needs a sync-flush escape hatch in any landable frame-aligned design.

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

NullVoxPopuli-ai-agent and others added 6 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>
- 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>
@NullVoxPopuli

Copy link
Copy Markdown
Contributor

This is the branch that got good results

625393147-0027e647-080c-4911-9ed8-e6090b7ea0d6

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

pnpm bench for this branch (walk-free push-invalidation + rAF drains). Tracerbench krausest vs origin/main, 20 samples, per-phase medians + Mann-Whitney p from raw compare.json:

21 of 23 phases neutral; two phases reach significance:

phase control experiment delta
renderEnd (initial render) 28.5ms 30.3ms +1.9ms (+6.6%) regression (p=0.037)
render1000Items1 139.8ms 119.5ms -20.3ms (-14.5%) improvement (p=0.006)

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.

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

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.

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