Skip to content

Skip unchanged blocks in the updating VM - #21612

Draft
NullVoxPopuli-ai-agent wants to merge 3 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/block-guards
Draft

Skip unchanged blocks in the updating VM#21612
NullVoxPopuli-ai-agent wants to merge 3 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/block-guards

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

Unchanged blocks are skipped on update. An unchanged row in a {{#each}} costs one validation instead of one per dynamic reference plus one opcode dispatch each.

JFB select is 45% faster, update-every-10th 17%, remove 15%, swap 11%. The rere partial-update list benches are 34% to 42% faster. Create paths and single-item apps are flat. Tables below.

Why

One update-every-10th pass on the js-framework-benchmark row made about 5,200 validateTag calls for 1,000 rows. That is five per row, one per dynamic reference: the class attribute, two onclick attributes, two text nodes. Each also cost one megamorphic evaluate dispatch. The tag walk itself is already fast: 5 ns per visit, monomorphic, TurboFan-compiled (measured in #21596). The count is the cost.

What changes

  1. Every block opcode (TryOpcode, ListItemOpcode, ListBlockOpcode) records the combined tag of what its render consumed, the same way a component cache group does. On update, a block whose tag still validates is skipped as a whole. Its tag is consumed into the parent frame, so parents stay correct.
  2. A guard that fails is dropped with no tracking frame. A block that changed is likely to change again. A dropped guard comes back after eight unguarded updates (REARM_AFTER), so a block that changed once and then stayed still is skipped again. A re-render re-arms it at once.
  3. Trackers are pooled by frame depth. Frames are strictly nested, so one tracker per depth is enough. Frames with up to 16 tags keep them in an array with a size counter. Larger frames fall back to a Set.
  4. endTrackFrame(previous) returns the previous tag when the frame consumed the same tags again. Blocks and component cache groups pass their previous tag, so a re-render with the same dependencies keeps its tag and its memoized revision.

Details that matter for review:

  • The append VM opens the frame before a block opcode is constructed, because the list block reads its iterable in its constructor.
  • UpdatingVM.throw() pops the frame without closing the tracking frame. The re-render closes it when the block exits through the append VM. A re-render of a dropped block opens its own frame first.
  • alwaysRevalidate bypasses the guards.
  • An unguarded block costs what it did before: one frame push.
  • Setting an array's length is a slow path in V8. The pooled tracker uses a size counter for that reason. The first version of this branch lost 5% to 12% on every rere bench from one tags.length = 0 per frame.

Results

Base commit bdb2580. Both sides are built the same way and measured in the same pipeline. rere: mirrored rounds (main, PR, PR, main). JFB: both apps in one invocation.

js-framework-benchmark (keyed)

benchmark main (median) PR (median) change
01_run1k 78.9 ms (±2.7) 76.1 ms (±1.1) -3.5%
02_replace1k 101.7 ms (±4.3) 92.7 ms (±3.6) -8.8%
03_update10th1k_x16 38.1 ms (±2.1) 31.8 ms (±2.0) -16.5%
04_select1k 15.4 ms (±1.2) 8.5 ms (±1.3) -44.8%
05_swap1k 50.0 ms (±4.1) 44.6 ms (±3.3) -10.8%
06_remove-one-1k 32.4 ms (±1.9) 27.4 ms (±1.9) -15.4%
07_create10k 757.6 ms (±22.2) 751.0 ms (±7.6) -0.9%
08_create1k-after1k_x2 98.9 ms (±4.7) 94.2 ms (±2.7) -4.8%
09_clear1k_x8 40.2 ms (±2.2) 40.8 ms (±3.1) +1.5%

Size and memory are unchanged.

rere-benchmark (cpu throttle 8x)

benchmark unit main p50 (round 1 / round 2) PR #21611 p50 (round 1 / round 2) change (median of rounds)
DB Monitor w/ chat simulation FPS 21.7 / 33.4 32.3 / 33.8 +19.8%
Incrementing Render Effect ms 2328.1 / 2227.1 2134.6 / 2135.3 -6.3% (better)
1 item, 1k updates (async) ms 59.3 / 59.9 61.1 / 57.5 -0.5%
1 item, 1k updates ms 8.3 / 9.4 9.1 / 9.1 +3.7%
1 item, 100k updates (async) ms 1027.2 / 1016.3 935.3 / 949.5 -7.8% (better)
1 item, 100k updates ms 38.4 / 37.6 36.2 / 38.4 -1.9%
1k items, 1 update each (sequentially, async) ms 918.2 / 948.6 532.7 / 559.3 -41.5% (better)
1k items, 1 update each (sequentially) ms 52.5 / 55.1 54.6 / 54.6 +1.5%
1k items 1 update on 5% (random, async) ms 115.8 / 120.7 80.3 / 76.7 -33.6% (better)
1k items 1 update on 5% (random) ms 31.4 / 31.8 22.1 / 24.3 -26.5% (better)
1k items 1 update on 25% (random, async) ms 316.8 / 318.1 203.8 / 203.6 -35.8% (better)
1k items 1 update on 25% (random) ms 37.5 / 38.3 33.8 / 36.3 -7.5% (better)
1 value, 1k consumers, 10k updates (bursts of 100) ms 2100.3 / 1945.4 2058.3 / 2094.7 +2.7%
1 value, 1k consumers, 10k updates (bursts of 1000) ms 257.8 / 238.2 282.8 / 265.3 +10.5% (worse)
1 value, 1k consumers, 10k updates (single burst) ms 57.7 / 65.8 60.6 / 60.3 -2.1%

An A/A run of identical code on this suite lands between -10% and +11% per bench at three runs. Single-digit deltas are noise.

What did not work

  • Guarding only blocks with two or more opcodes and three or more tags, and never lists. Rows with one opcode lost their guard, and the async list wins went with them. Skipping a block saves its frame push and every child dispatch, not the validations.
  • Dropping a guard after three misses, with a frame on each miss. The all-rows-change shapes paid a frame per row.
  • Dropping on the first miss without a re-arm. Rows that change once stayed unguarded, and half the async list win went with them.

Open point

fan-out, bursts of 1000 stays about +10% in both rounds. The CPU profile shows the same JS time on both builds, and identical code lands at +5% on it. That is the edge of what the suite resolves.

Tests

  • pnpm test in Chrome 152: 9444 pass, 0 fail
  • pnpm test:node and pnpm test:node:vitest: pass
  • pnpm type-check:internals, eslint, prettier: clean

🤖 Generated with Claude Code

https://claude.ai/code/session_012wPae8QnFbKHKn5xbgaQVq

NullVoxPopuli and others added 3 commits September 10, 2026 10:35
Every block opcode (try, list, list item) now records the combined tag of
what its render or last update consumed, the way a component cache group
does. On update, a block whose tag still validates is skipped as a whole,
and its tag is consumed into the parent frame so parents stay correct.

Before, an unchanged row in a {{#each}} cost one validation per dynamic
reference: on the js-framework-benchmark row that is five validateTag
calls and five megamorphic opcode evaluations per row per update. Now it
costs one validation, and the five opcodes never run.

The append VM opens the frame before a block's opcode is constructed,
because the list block reads its iterable in the constructor, and closes it
when the block exits. On the updating side the frame is closed when the
block's updating frame finishes. When a block re-renders after a thrown
assertion, the append VM closes the frame on exit and the updating VM pops
the frame without closing it again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wPae8QnFbKHKn5xbgaQVq
Tracking frames are strictly nested, so one tracker per depth is enough.
beginTrackFrame reuses the tracker for its depth instead of allocating a
tracker and a Set for every frame. Frames with up to 16 tags keep them in
an array with a linear dedupe scan and a size counter (setting an array's
length is a slow path in V8 and this runs on every frame); larger frames
fall back to a Set.

endTrackFrame() now takes the tag the same frame produced last time and
returns it unchanged when the consumed tags are the same, so a frame that
re-runs with the same dependencies keeps its combinator tag and its
memoized revision, and allocates nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wPae8QnFbKHKn5xbgaQVq
…it later

Blocks and component cache groups pass their previous tag to
endTrackFrame(), so a block that re-renders with the same dependencies
keeps its tag and its memoized revision. The tag comparison runs against
the tracker's live entries, so a frame end no longer copies its tags when
nothing changed.

A guard that fails is dropped on the spot, with no tracking frame: a block
that changed is likely to change again, and a frame on every miss is what
made all-rows-change workloads slower. A dropped guard comes back after
eight unguarded updates, so a block that changed once and then stayed
still is skipped again, while a block that changes on every update pays
for one frame in every eight. A re-render re-arms the guard right away.

An unguarded block costs what it did before guards existed: one frame
push. A re-render of a dropped block opens its own tracking frame, because
the append VM closes one when the block exits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wPae8QnFbKHKn5xbgaQVq
@NullVoxPopuli NullVoxPopuli changed the title [PERF] Skip unchanged blocks in the updating VM Skip unchanged blocks in the updating VM Sep 10, 2026
* again as soon as that frame ends. One tracker per depth is enough, and no
* frame allocates a tracker after the first time its depth is reached.
*/
const TRACKER_POOL: Tracker[] = [];

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.

don't put tractors into pools

@NullVoxPopuli

Copy link
Copy Markdown
Contributor

this is the biggest, most significant impact to perf the bot has done so far. nice

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