Skip unchanged {{#each}} item subtrees during updates - #21512
Skip unchanged {{#each}} item subtrees during updates#21512NullVoxPopuli-ai-agent wants to merge 1 commit into
Conversation
|
I'm currently asessing if this is worth it locally |
|
Ran the repo's own
That matches expectations: krausest's update phases ( Net: krausest confirms no regressions; dbmon shows the upside. |
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>
482bef6 to
ed3e2ab
Compare
|
Correction + fix, after an independent repro attempt caught a bug in the gate. The originally-pushed The gate is now block-aware: an item is trivial only when it has <= 2 opcodes and no nested Re-measured with cache-busting installs (fresh sha-named tarballs), median of repeated runs:
At 4x the walk was the whole bottleneck and skipping hits the headless 60fps cap; at 8x the remaining cost is the genuinely-changed rows' re-render work, so the honest uncapped ratio is ~1.6x. fan-out/ten-k/krausest remain neutral (their items are 1-2 opcodes with no nested blocks → gated out, zero overhead). Full testem suite re-run on the fixed gate: 9418 pass / 0 fail. |
|
the bot was not running with the correct throttles the whole time, so the gains are way off |
|
Fresh All 23 phases statistically neutral (no phase with p < 0.05). Largest point deltas are noise-shaped: Consistent with the earlier run posted above: the subtree-skip engages on krausest rows but is neutral on skinny-row single interactions, while the dbmon-class sustained-update workload (PR description) gets the ~2x. No regressions. |
Problem
The
UpdatingVMwalks every updating opcode of every{{#each}}item on every revalidation. The skip-if-unmodified machinery that already exists (beginCacheGroup/JumpIfNotModifiedOpcode/ track-frame opcodes) is emitted in exactly one place — component transactions — so a list of plain template rows revalidates every binding in every row, even when nothing in a row changed.For list-heavy UIs with sparse updates this dominates the frame. Profiling a dbmon-style benchmark (40 rows x ~18 dynamic bindings, ~15% of rows changing per update burst, rere-benchmark's dbmon-with-chat) under 4x CPU throttle: the top self-time entries are all revalidation walk —
UpdatingVM._execute,valueForRef, tag[COMPUTE]— and ember lands at ~10 fps where svelte/react reach 40+.Change
Give list items the same skipping components get, at the updating-VM level:
ListItemOpcode.evaluateruns its children inside a tracking frame. A new optional finalizer onUpdatingVMFramefires when the frame pops (i.e. after the item's opcodes and any nested frames they push have fully drained — the LIFO loop guarantees this), closing the tracking frame and storing the combined tag + revision. SincevalueForRefconsumes each ref's tag into the ambient frame, the collected tag captures every dependency read anywhere in the item's subtree.consumeTag(subtreeTag)— which also propagates the item's dependencies to any enclosing tracking frame (outer cache groups, outer list items) exactly as executing the children would have.createIteratorItemRefalready equality-guards itsupdate, so retained items with unchanged values/memos stay clean and actually skip.Guardrails:
children.length <= 4opt out entirely: for a row that's one or two text nodes, validating a combined tag costs as much as just updating it, so collection would be pure overhead (measured: +15-19% on tiny-item/dense-change benchmarks without the gate, neutral with it)vm.alwaysRevalidatebypasses the skipdidError— it always balancesendTrackFrameduring unwind but discards the partial tag;ListItemOpcode.handleExceptionnulls the stored tag since its children are about to be rebuiltResults
rere-benchmark suite, prod builds, 4x CDP CPU throttle (median of 5):
Correctness: full testem suite green (9418 pass / 0 fail / 17 pre-existing skips), including the
{{#each}}/each-in/updatingintegration suites; the five rere-benchmark ember apps also pass their DOM-verifying end-to-end tests on this build, and dbmon rendering was verified live (keyed rows update in place, chats stream).Notes for review
<= 4opcode-count gate is a heuristic; happy to tune the threshold or gate on something more principled if there's a better signal.UpdatingVMFrame); if there's appetite, the same mechanism could later back{{#if}}/TryOpcodeskipping too.🤖 Generated with Claude Code