Skip unchanged blocks in the updating VM - #21612
Draft
NullVoxPopuli-ai-agent wants to merge 3 commits into
Draft
Conversation
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
| * 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[] = []; |
Contributor
There was a problem hiding this comment.
don't put tractors into pools
Contributor
|
this is the biggest, most significant impact to perf the bot has done so far. nice |
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.
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
validateTagcalls for 1,000 rows. That is five per row, one per dynamic reference: the class attribute, twoonclickattributes, two text nodes. Each also cost one megamorphicevaluatedispatch. The tag walk itself is already fast: 5 ns per visit, monomorphic, TurboFan-compiled (measured in #21596). The count is the cost.What changes
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.REARM_AFTER), so a block that changed once and then stayed still is skipped again. A re-render re-arms it at once.Set.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:
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.alwaysRevalidatebypasses the guards.lengthis 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 onetags.length = 0per 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)
Size and memory are unchanged.
rere-benchmark (cpu throttle 8x)
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
Open point
fan-out, bursts of 1000stays 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 testin Chrome 152: 9444 pass, 0 failpnpm test:nodeandpnpm test:node:vitest: passpnpm type-check:internals, eslint, prettier: clean🤖 Generated with Claude Code
https://claude.ai/code/session_012wPae8QnFbKHKn5xbgaQVq