Skip unchanged {{#each}} item subtrees during updates - #21557
Draft
NullVoxPopuli wants to merge 1 commit into
Draft
Skip unchanged {{#each}} item subtrees during updates#21557NullVoxPopuli wants to merge 1 commit into
NullVoxPopuli wants to merge 1 commit into
Conversation
Contributor
📊 Size reportTarball size — dist/dev 0.3%↑
dist/prod 0.4%↑
smoke-tests/v2-app-template/dist 0.4%↑
smoke-tests/v2-app-hello-world-template/dist 1%↑
🤖 This report was automatically generated by wyvox/pkg-size |
NullVoxPopuli
force-pushed
the
nvp/extract-iteration-fastpath
branch
2 times, most recently
from
August 13, 2026 14:50
e7f2340 to
a5dc821
Compare
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.
Iteration fast path, flat update frames, and flattened combinators
Three allocation/dispatch levers for the update path, measured together
with subtree-skipping as the difference between ~14 and ~27 fps on an
8x-throttled dbmon:
- streaming same-order list compare: when a list's order is unchanged
(the overwhelmingly common case), items are matched in a single
streaming pass writing into a scratch item (nextInto) instead of
allocating an IterationItem per step, falling back to the general
diff via a reconstructed prefix iterator on first mismatch
- flat frame stack: the updating VM keeps parallel arrays indexed by
depth instead of allocating an UpdatingVMFrame per block per render
- combinator flattening: combine() flattens nested combinators and
drops constants (capped) so validating a combined tag is one flat
loop instead of a pointer-chasing tree walk
Keep only the two {{#each}} changes that measure, and fix the skip's unwind handling
Benchmarked every change in the previous commit independently against
`main`, interleaved (all variants measured in every round, order rotating
by round) so that machine drift over the session could not be mistaken
for a per-change effect. Measuring each variant once in sequence had done
exactly that: re-running one identical build later in a session moved
some benches by 30%, which is larger than four of the five changes.
Two changes carry the whole result, and three do not:
subtree skipping 1k items 1-each async 2.03x, 25%-random async 1.79x
tryFastSync 25%-random async 1.11x, no cost on any bench
nextInto 1.01x SLOWER inside tryFastSync, 0/6 rounds better
flat frame stack 1.02x slower on 100k-updates, 0/6 rounds better
combine flattening 1.05x slower on 100k-updates, 0/6 rounds better
So `nextInto`, the flat frame stack, and the `combine()` flattening are
reverted. `combine()` in particular could not have paid off: a
combinator's `[COMPUTE]` already memoizes per `$REVISION`, so flattening
bought nothing on validation while adding a pre-pass and an allocation to
one of the validator's hottest functions -- and it defeated
`markTagAsConsumed`'s early-out in dev.
Subtree skipping keeps its win at a price worth stating: it costs ~11% on
a list whose every row changes on every update, and ~9% when many updates
are batched into a single render, because the combined tag is collected
per item per render and only recouped when the item is actually skipped.
A gate keyed on how often an item is really skipped would address that;
the opcode-count gate in the previous commit did not, and could not --
every {{#each}} item body measured as one child that is a BlockOpcode
(dynamic content is wrapped in a TryOpcode), so it classified every real
template as non-trivial and never fired. It is removed rather than left
as dead code with a comment describing behaviour that does not happen.
Two unwind bugs in the skip, both from closing the tracking frame
somewhere other than where it was opened:
- An exception escaping the update loop left the frame open, and only the
DEBUG build reset tracking -- so in production one render error made
every later `endTrackFrame` pop the wrong frame. The production path
now resets too.
- `vm.throw()` unwinds a single frame, and a component's
Begin/EndTrackFrameOpcode pair shares the item's ops array, so an
`Assert` firing between them leaves the component's frame open. The
finalizer would then adopt that frame's partial tag and skip the item
against it forever. It now records the depth before opening, unwinds to
it, and keeps no tag if anything leaked in between.
`reconstructPrefix` read item refs with `valueForRef`, which consumes;
running inside an enclosing item's or component's frame that made the
frame depend on every item ref in the list, so any list mutation
invalidated the enclosing component. Those reads are now untracked.
Tests. Skipping is invisible in the DOM -- the rendered output is
identical whether a subtree was skipped or walked and found clean -- so
asserting HTML and node stability does not test it, and the suite already
covers those. `ListItemOpcode` therefore logs a `list-item-subtrees` step
(LOCAL_DEBUG only, a separate step type so the existing `list-updates`
assertions are untouched), and three #each tests assert the actual
skip/walk decision per item: that clean items are skipped at all, that
only the dirtied item is walked, that it returns to being skipped once
clean, and that an item whose subtree is rebuilt -- via {{#if}}, and via
{{#in-element}}, which unwinds the item's own block -- is walked again and
has its new children tracked. All three fail if skipping is disabled, and
all three fail if an item is skipped while dirty.
Not covered, stated because the tests look like they would cover it: the
finalizer's `didError`/frame-identity guards. Removing either leaves the
suite green -- they guard an unwind with an enclosing cache group open,
which I could not construct a template for. `handleException` clearing the
tag is likewise unobservable, and provably so: whatever threw did so
because a ref the item's tag covers changed, so the tag is already
invalid.
ope
NullVoxPopuli
force-pushed
the
nvp/extract-iteration-fastpath
branch
from
August 13, 2026 14:51
a5dc821 to
d61e33d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Perf improvement for update-based behavior in apps
I could not find a way to test this :(
(without benchmarking)
Note
My goal is to not regress anything -- improve update performance without sacrificing initial rendering
pnpm bench
(remember that this bench is mostly insert / append focused, and I haven't gotten around to figuring out how we can optimize that without wholly needing a new approach to the VM )
Ran on d61e33d
local comparison of main vs this branch on rere-benchmark
Messages from:
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.
Iteration fast path, flat update frames, and flattened combinators
Three allocation/dispatch levers for the update path, measured together with subtree-skipping as the difference between ~14 and ~27 fps on an 8x-throttled dbmon: