Skip to content

Skip unchanged {{#each}} item subtrees during updates - #21544

Open
NullVoxPopuli-ai-agent wants to merge 2 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:extract-each-skip
Open

Skip unchanged {{#each}} item subtrees during updates#21544
NullVoxPopuli-ai-agent wants to merge 2 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:extract-each-skip

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

Re-cut of #21512 (closed during the #21520 spike consolidation) onto current main, now with the full-suite verification that was outstanding: 9443 tests, 0 failures locally.

During revalidation, each {{#each}} item collects the tags its subtree consumed (via a tracking-frame finalizer on the item's try-frame); on later passes, an item whose collected tag validates clean is skipped entirely instead of walking every opcode of its subtree. A triviality gate (≤2 opcodes and no nested block) keeps the bookkeeping off items too small to profit. Measured standalone at ~1.9x on an 8x-throttled dbmon (walk-heavy, many clean items per frame); no observable behavior change — the same subtrees re-render for the same reasons, cheaper.

This was the campaign's first lever and its most reviewed artifact; it needs no RFC, no flag, and composes with (but does not require) the scheduler work in #21493/#21520.

🤖 Generated with Claude Code

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>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Isolated same-batch measurement (today's main vs this branch, 8x throttle, mutation-anchored, zero render failures) — confirmed strong win, no regressions:

bench main this PR
DB Monitor 7.3 fps (7–11) 17.1 fps (13–18) — 2.3x, spreads fully separated
1k items, 1 update each (seq, async) 1823ms 756ms (2.4x)
1k items, 25% random (async) 577ms 317ms (1.8x)
1k items, 5% random (async) 187ms 122ms
everything else neutral (±5%, overlapping spreads)

🤖 Generated with Claude Code

@NullVoxPopuli

Copy link
Copy Markdown
Contributor

By benches on my machine:

pnpm bench 1
duration phase no difference [-21ms to 23ms]
renderEnd phase no difference [0ms to 0ms]
render1000Items1End phase no difference [-1ms to 1ms]
clearItems1End phase no difference [0ms to 2ms]
render1000Items2End phase estimated improvement -1ms [-3ms to 0ms] OR -2.57% [-6.4% to -0.25%]
clearItems2End phase no difference [0ms to 0ms]
render10000Items1End phase no difference [-6ms to 3ms]
clearManyItems1End phase no difference [-1ms to 1ms]
render10000Items2End phase no difference [-3ms to 10ms]
clearManyItems2End phase no difference [-2ms to 1ms]
render1000Items3End phase no difference [0ms to 1ms]
append1000Items1End phase no difference [0ms to 6ms]
append1000Items2End phase no difference [-2ms to 2ms]
updateEvery10thItem1End phase no difference [-3ms to 1ms]
updateEvery10thItem2End phase no difference [-3ms to 1ms]
selectFirstRow1End phase no difference [0ms to 1ms]
selectSecondRow1End phase no difference [0ms to 1ms]
removeFirstRow1End phase estimated improvement -1ms [-3ms to 0ms] OR -5.68% [-12.69% to -0.45%]
removeSecondRow1End phase no difference [-3ms to 0ms]
swapRows1End phase no difference [-2ms to 1ms]
swapRows2End phase no difference [-1ms to 2ms]
clearItems4End phase estimated regression +1ms [0ms to 2ms] OR +6.78% [2.08% to 11.88%]
paint phase no difference [0ms to 2ms]
pnpm bench 2
duration phase no difference [-31ms to 6ms]
renderEnd phase no difference [0ms to 0ms]
render1000Items1End phase no difference [-1ms to 1ms]
clearItems1End phase no difference [-1ms to 2ms]
render1000Items2End phase no difference [-1ms to 1ms]
clearItems2End phase no difference [0ms to 0ms]
render10000Items1End phase estimated improvement -4ms [-9ms to -1ms] OR -0.91% [-2.03% to -0.11%]
clearManyItems1End phase no difference [-1ms to 1ms]
render10000Items2End phase no difference [-9ms to 2ms]
clearManyItems2End phase no difference [-2ms to 0ms]
render1000Items3End phase no difference [-2ms to 0ms]
append1000Items1End phase no difference [-4ms to 2ms]
append1000Items2End phase no difference [-3ms to 5ms]
updateEvery10thItem1End phase no difference [-2ms to 1ms]
updateEvery10thItem2End phase no difference [-2ms to 1ms]
selectFirstRow1End phase no difference [-1ms to 1ms]
selectSecondRow1End phase no difference [-1ms to 0ms]
removeFirstRow1End phase no difference [-2ms to 0ms]
removeSecondRow1End phase estimated improvement -1ms [-3ms to 0ms] OR -6.55% [-12.91% to -0.28%]
swapRows1End phase no difference [-2ms to 0ms]
swapRows2End phase no difference [-1ms to 0ms]
clearItems4End phase no difference [-1ms to 0ms]
paint phase no difference [-1ms to 1ms]

and my rere-benchmark comparison: overall very improved:

image

}
}

export class ListItemOpcode extends TryOpcode {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all these changes are for the list op

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — the entire mechanism lives in the list opcodes (ListItemOpcode/ListBlockOpcode). That's deliberate: the VM's existing skip machinery (cache groups / JumpIfNotModified) exists only at component boundaries, and list items are exactly where large clean-subtree walks concentrate without any component boundary to catch them — dbmon's rows being the canonical case. Non-list opcodes keep their behavior untouched, which is also what keeps the blast radius reviewable.

@NullVoxPopuli

Copy link
Copy Markdown
Contributor

Ran a few timees frm diffrnt machine
image

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

The two suites agreeing-by-disagreeing is the mechanism working as designed: the skip's win scales with (walk size) × (clean-item fraction) × (update frequency).

  • tracerbench neutral = the no-regression result. Its phases are dominated by initial render, teardown, and order changes — paths the skip doesn't touch — and its update phases run unthrottled, where walking 900 clean subtrees costs well under the ±1–3ms confidence intervals. dbmon at 8x with continuous bursts is the same mechanism magnified ~30x.
  • selectFirstRow/selectSecondRow neutral is expected for a subtler reason: selection compares each row against a shared selectedId, so every item's collected tag includes that tag — dirtying it invalidates all items and the skip correctly stands down (same walk as before, no penalty). Worth stating in the description: lists whose items all consume a common tag see no benefit, by design, and no cost — likewise @index-keyed lists under insertion.
  • clearItems4End +6.78% (run 1, CI excludes zero): doesn't reproduce in run 2 ([-1ms, 0ms]) and its clearManyItems siblings are neutral in both runs — most likely noise. Candidate mechanism if it ever reproduces: items now retain a subtreeTag + finalizer closure, marginally more to collect at teardown. A deep-sample run of just the clear phases would settle it if desired.

🤖 Generated with Claude Code

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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