[SPIKE] Compile the updating pass to a static plan per template - #21616
Closed
NullVoxPopuli-ai-agent wants to merge 4 commits into
Closed
[SPIKE] Compile the updating pass to a static plan per template#21616NullVoxPopuli-ai-agent wants to merge 4 commits into
NullVoxPopuli-ai-agent wants to merge 4 commits into
Conversation
The append VM used to build the structure of the updating pass at render time: it pushed guard, track-frame, block, and leaf opcodes into growing arrays, and the UpdatingVM re-discovered that structure with a frame stack, jump targets, and try/throw. The opcode compiler now derives that structure once per compiled unit. Every instruction whose handler can record an updating opcode gets a slot in an update plan; Enter, EnterList, and the call instructions open nested plans. A render instance is the plan plus a slot array of the same length. The append pass writes each recorded opcode into the slot its instruction owns, found through a side table parallel to the program heap. The updating pass is a loop over the plan with a switch on the slot kind, so the megamorphic per-opcode dispatch and the frame stack are gone. Slots stay empty when an instruction recorded nothing: a constant reference, an untaken branch, or a debug-only opcode in production. Instructions whose emission count depends on runtime data (component attribute flushes, debug render tree nodes, modifiers) own one array slot. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GhCsx9qptiMuhjjeovJzPf
The tests print the compiled plan of a few templates and the slots of a rendered instance, so the shape of the static structure is visible in one place. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GhCsx9qptiMuhjjeovJzPf
A table sized like the program heap costs 4 MB per app before any template compiles. Grow it in 16k-entry pages as instructions get slots, and store 16-bit indices, which is plenty for the slots of one plan. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GhCsx9qptiMuhjjeovJzPf
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GhCsx9qptiMuhjjeovJzPf
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.
A counter, before and after
The wire format is unchanged by this PR:
Before
The append pass builds the structure of the updating pass at render time, once per instance, as a list of objects.
UpdatingVMwalks it with a frame stack and one virtualevaluatecall per entry.After
Everything below is dumped from a browser run of the integration tests at 6b0903d, with
JSON.stringifyon the real objects (object-valued fields shown as<ClassName>,undefinedslots asnull).The compiler turns the wire format into these 19 instructions. Three of them can record an updating opcode, and each of those gets a slot index in a side table keyed by instruction address:
The plan stored for the counter's handle. Kinds: 1 leaf, 2 multi, 3 guard, 4 guard-end, 5 block, 6 list, 7 call.
{ "kinds": [2, 2, 7], "children": [null, null, null], "links": [-1, -1, -1], "size": 3 }The plan stored for the stdlib
cautious-appendroutine, which the counter calls at address 26. ItsEnteropens a block, and the block has one slot per content-type clause;linkspairs the guard at 6 with its end at 11:{ "kinds": [5], "children": [ { "kinds": [1, 1, 1, 1, 7, 1, 3, 1, 2, 7, 2, 4, 1], "children": [null, null, null, null, null, null, null, null, null, null, null, null, null], "links": [-1, -1, -1, -1, -1, -1, 11, -1, -1, -1, -1, 6, -1], "size": 13 } ], "links": [-1], "size": 1 }The root instance after the first render. An instance is
{ plan, slots }; the append pass wrote each recorded opcode into the slot of the instruction that ran:{ "plan": { "kinds": [2, 2, 7], "children": [null, null, null], "links": [-1, -1, -1], "size": 3 }, "slots": [ [ { "$class": "UpdateModifierOpcode", "lastUpdated": 1, "tag": "<MonomorphicTagImpl>", "modifier": "<Object>" } ], null, { "plan": { "kinds": [5], "children": ["<the 13-slot block plan above>"], "links": [-1], "size": 1 }, "slots": [ { "$class": "TryOpcode", "type": "try", "state": "<Closure>", "bounds": "<ResettableBlockImpl>", "plan": "<the 13-slot block plan above>", "slots": [ { "$class": "AssertFilter", "last": 2, "ref": "<ReferenceImpl>" }, null, null, null, null, null, null, null, null, null, null, null, { "$class": "DynamicTextContent", "node": "<Text>", "reference": "<ReferenceImpl>", "lastValue": "0" } ] } ] } ] }Slot 1 is empty because
FlushElementonly records component attributes and there are none. In the block, the 11 empty slots are the clauses for node, fragment, safe string, helper, and component content, which a string never takes. The updating pass is one loop overkindswith a switch; the frame stack,goto,try, andthroware gone.Spike. Not for merge as is.
What this does
The opcode compiler derives the structure of the updating pass while it emits bytecode. Every instruction whose handler can record an updating opcode gets a slot in an update plan.
Enter,EnterList, and the four call instructions open nested plans. The program heap keeps a side table from instruction address to slot index, and one plan per handle.A render instance is the plan plus a slot array of the same length. The append pass writes each recorded opcode into the slot its instruction owns. The updating pass is one loop over the plan with a switch on the slot kind.
Removed from the runtime: the
UpdatingVMframe stack,goto,try,throw, and theJumpIfNotModified,BeginTrackFrame, andEndTrackFrameopcode classes.A slot stays empty when its instruction recorded nothing: a constant reference, an untaken branch, or a debug-only opcode in production. Instructions whose emission count depends on runtime data (component attribute flushes, modifiers, debug render tree nodes) own one array slot.
The test file
packages/@glimmer-workspace/integration-tests/test/update-plan-test.tsprints the plan of a few templates and the slots of a rendered instance. A rendered<div class={{this.cls}}>{{this.name}}</div>{{#if this.a}}{{this.b}}{{/if}}reads as:Open points
{{#each}}body needs a new compiler marker (EndItem), because theExitatFINALLYcloses both an item and the outer block.rere-benchmark
Base
bdb2580255vs8c2814f96e, mirrored rounds (base, plan, plan, base), cpu throttle 8. The third commit only changes memory use.js-framework-benchmark
Base
bdb2580255vs7e9a9efd93. The first run with8c2814f96eshowed ready memory up 4 MB from an eagerly sized slot table; the third commit fixes that, and this table is the rerun. The first-paint row is a single sample and moves 20% between runs of identical code.🤖 Generated with Claude Code
https://claude.ai/code/session_01GhCsx9qptiMuhjjeovJzPf