Skip to content

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

Draft
NullVoxPopuli wants to merge 1 commit into
mainfrom
nvp/extract-iteration-fastpath
Draft

Skip unchanged {{#each}} item subtrees during updates#21557
NullVoxPopuli wants to merge 1 commit into
mainfrom
nvp/extract-iteration-fastpath

Conversation

@NullVoxPopuli

@NullVoxPopuli NullVoxPopuli commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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

duration phase no difference [-419ms to 250ms]
renderEnd phase no difference [-15ms to 5ms]
render1000Items1End phase no difference [-19ms to 12ms]
clearItems1End phase no difference [-3ms to 1ms]
render1000Items2End phase no difference [-7ms to 28ms]
clearItems2End phase no difference [-1ms to 2ms]
render10000Items1End phase no difference [-59ms to 125ms]
clearManyItems1End phase no difference [-10ms to 9ms]
render10000Items2End phase no difference [-72ms to 63ms]
clearManyItems2End phase no difference [-9ms to 21ms]
render1000Items3End phase no difference [-12ms to 12ms]
append1000Items1End phase no difference [-41ms to 12ms]
append1000Items2End phase no difference [-31ms to 7ms]
updateEvery10thItem1End phase estimated improvement -23ms [-41ms to -9ms] OR -7.15% [-12.49% to -2.65%]
updateEvery10thItem2End phase estimated improvement -26ms [-41ms to -11ms] OR -7.26% [-11.69% to -3.18%]
selectFirstRow1End phase estimated regression +11ms [3ms to 18ms] OR +9.64% [2.34% to 16.07%]
selectSecondRow1End phase no difference [-2ms to 16ms]
removeFirstRow1End phase estimated improvement -18ms [-27ms to -7ms] OR -7.64% [-11.5% to -3.12%]
removeSecondRow1End phase estimated improvement -21ms [-32ms to -11ms] OR -9.21% [-13.79% to -4.7%]
swapRows1End phase estimated improvement -14ms [-23ms to -4ms] OR -8.22% [-13.11% to -2.27%]
swapRows2End phase estimated improvement -25ms [-47ms to -12ms] OR -12.61% [-23.94% to -6.27%]
clearItems4End phase no difference [0ms to 9ms]
paint phase no difference [-1ms to 0ms]
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:

  • 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

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

📊 Size report

Tarball size1.2 MB1.2 MB

dist/dev   0.3%↑

File Before (Size / Brotli) After (Size / Brotli)
./packages/shared-chunks/cache-{hash}.js 17.8 kB / 4.8 kB 3%↑18.4 kB / 4%↑5 kB
./packages/shared-chunks/render-{hash}.js 55.8 kB / 12.1 kB 11%↑61.9 kB / 14%↑13.8 kB
Total (Includes all files) 2 MB / 489.8 kB 0.3%↑2.1 MB / 0.4%↑491.8 kB

dist/prod   0.4%↑

File Before (Size / Brotli) After (Size / Brotli)
./packages/shared-chunks/cache-{hash}.js 697 B / 258 B 1,400%↑10.5 kB / 1,060%↑3 kB
./packages/shared-chunks/index-{hash}.js 59.8 kB / 12.1 kB -27.5%↓43.4 kB / -10.8%↓10.8 kB
./packages/shared-chunks/render-{hash}.js 52 kB / 11.2 kB 13%↑58.9 kB / 18%↑13.2 kB
Total (Includes all files) 1.9 MB / 447.2 kB 0.4%↑1.9 MB / 0.5%↑449.4 kB

smoke-tests/v2-app-template/dist   0.4%↑

File Before (Size / Brotli) After (Size / Brotli)
./assets/api-{hash}.js 288.5 kB / 77.8 kB 0.4%↑289.7 kB / 0.4%↑78.1 kB
Total (Includes all files) 340.6 kB / 94.8 kB 0.4%↑341.9 kB / 0.3%↑95.1 kB

smoke-tests/v2-app-hello-world-template/dist   1%↑

File Before (Size / Brotli) After (Size / Brotli)
./assets/main-{hash}.js 132.1 kB / 36.9 kB 1%↑133.4 kB / 1%↑37.3 kB
Total (Includes all files) 132.4 kB / 37 kB 1%↑133.7 kB / 1%↑37.4 kB

🤖 This report was automatically generated by wyvox/pkg-size

@NullVoxPopuli
NullVoxPopuli force-pushed the nvp/extract-iteration-fastpath branch 2 times, most recently from e7f2340 to a5dc821 Compare August 13, 2026 14:50
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
NullVoxPopuli force-pushed the nvp/extract-iteration-fastpath branch from a5dc821 to d61e33d Compare August 13, 2026 14:51
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