Skip to content

[SPIKE] Compile the updating pass to a static plan per template - #21616

Closed
NullVoxPopuli-ai-agent wants to merge 4 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/spike-static-update-plan
Closed

[SPIKE] Compile the updating pass to a static plan per template#21616
NullVoxPopuli-ai-agent wants to merge 4 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/spike-static-update-plan

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

A counter, before and after

<button {{on "click" this.increment}}>Count: {{this.count}}</button>

The wire format is unchanged by this PR:

[[[11,"button"],[4,[38,1],["click",[30,0,["increment"]]],null],[12],[1,"Count: "],[1,[30,0,["count"]]],[13]],[],["button","on"]]

Before

The append pass builds the structure of the updating pass at render time, once per instance, as a list of objects. UpdatingVM walks it with a frame stack and one virtual evaluate call per entry.

[
  UpdateModifierOpcode { lastUpdated: 1, tag, modifier },          // {{on}}
  TryOpcode {                                                     // {{this.count}}: the stdlib append switch
    state: Closure { pc, scope, dynamicScope, stack },
    bounds: ResettableBlockImpl,
    children: [
      AssertFilter { last: 2, ref },                              // 2 = ContentType.String
      DynamicTextContent { node, reference, lastValue: "0" },
    ],
  },
]

After

Everything below is dumped from a browser run of the integration tests at 6b0903d, with JSON.stringify on the real objects (object-valued fields shown as <ClassName>, undefined slots as null).

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:

addr  instruction                     slot
 0    PutComponentOperations
 1    OpenElement "button"
 3    PushFrame
 4    Primitive
 6    PrimitiveReference
 7    GetVariable 0
 9    GetProperty "increment"
11    PushArgs
15    Modifier on                     0
17    PopFrame
18    FlushElement                    1
19    Text "Count: "
21    PushFrame
22    GetVariable 0
24    GetProperty "count"
26    InvokeStatic cautious-append    2
28    PopFrame
29    CloseElement
30    Return

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-append routine, which the counter calls at address 26. Its Enter opens a block, and the block has one slot per content-type clause; links pairs 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 FlushElement only 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 over kinds with a switch; the frame stack, goto, try, and throw are 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 UpdatingVM frame stack, goto, try, throw, and the JumpIfNotModified, BeginTrackFrame, and EndTrackFrame opcode 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.ts prints 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:

[leaf call[block[leaf - - - - - - - - - - - leaf]] block[leaf call[call[block[leaf - - - - - - - - - - - leaf]]]]]

Open points

  • The plan builder ships with the JIT compiler, so the bundle grows by 2.5 kB raw and 0.7 kB gzip. Under an AOT compile it moves to build time.
  • Untaken branches are holes. The stdlib append routine has 13 slots and a plain text append fills 2. Per-clause sub-plans selected by the taken branch would remove those checks.
  • The {{#each}} body needs a new compiler marker (EndItem), because the Exit at FINALLY closes both an item and the outer block.

rere-benchmark

Base bdb2580255 vs 8c2814f96e, mirrored rounds (base, plan, plan, base), cpu throttle 8. The third commit only changes memory use.

benchmark unit main p50 (round 1 / round 2) plan p50 (round 1 / round 2) change (median of rounds)
DB Monitor w/ chat simulation FPS 30.3 / 24.0 28.7 / 26.9 +2.4%
Incrementing Render Effect ms 2282.0 / 2278.6 2163.8 / 2173.3 -4.9%
1 item, 1k updates (async) ms 59.7 / 59.3 55.5 / 54.9 -7.3% (better)
1 item, 1k updates ms 9.4 / 9.4 9.1 / 9.1 -3.2%
1 item, 100k updates (async) ms 1024.3 / 1016.7 967.2 / 992.6 -4.0%
1 item, 100k updates ms 37.8 / 39.4 41.4 / 37.7 +2.3%
1k items, 1 update each (sequentially, async) ms 950.5 / 949.1 823.3 / 843.7 -12.2% (better)
1k items, 1 update each (sequentially) ms 55.6 / 55.8 56.9 / 57.1 +2.3%
1k items 1 update on 5% (random, async) ms 128.7 / 130.7 107.0 / 116.3 -13.9% (better)
1k items 1 update on 5% (random) ms 30.0 / 31.0 24.3 / 27.3 -15.5% (better)
1k items 1 update on 25% (random, async) ms 325.0 / 312.4 291.7 / 301.1 -7.0% (better)
1k items 1 update on 25% (random) ms 39.0 / 35.0 33.3 / 35.5 -7.1%
1 value, 1k consumers, 10k updates (bursts of 100) ms 2184.8 / 2019.9 1920.9 / 2114.2 -4.0%
1 value, 1k consumers, 10k updates (bursts of 1000) ms 278.3 / 272.7 286.7 / 228.3 -6.5%
1 value, 1k consumers, 10k updates (single burst) ms 61.2 / 62.3 60.3 / 62.1 -0.9%

js-framework-benchmark

Base bdb2580255 vs 7e9a9efd93. The first run with 8c2814f96e showed 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.

benchmark type base (median) plan2 (median) change
01_run1k cpu 81.1 ms (±3.0) 82.1 ms (±3.3) +1.2%
02_replace1k cpu 98.1 ms (±4.1) 96.1 ms (±2.8) -2.0%
03_update10th1k_x16 cpu 37.8 ms (±2.7) 36.8 ms (±5.0) -2.6%
04_select1k cpu 14.8 ms (±1.3) 12.6 ms (±2.5) -14.9%
05_swap1k cpu 51.5 ms (±2.6) 46.9 ms (±2.5) -8.9%
06_remove-one-1k cpu 31.8 ms (±1.8) 31.2 ms (±1.6) -1.9%
07_create10k cpu 762.7 ms (±10.3) 772.0 ms (±17.3) +1.2%
08_create1k-after1k_x2 cpu 100.1 ms (±2.8) 98.7 ms (±3.2) -1.4%
09_clear1k_x8 cpu 40.6 ms (±2.7) 41.2 ms (±2.7) +1.5%
21_ready-memory memory 5.38 MB (±n/a) 5.43 MB (±n/a) +0.9%
22_run-memory memory 11.90 MB (±n/a) 12.08 MB (±n/a) +1.5%
25_run-clear-memory memory 6.00 MB (±n/a) 6.12 MB (±n/a) +1.9%
41_size-uncompressed size 139.30 kB (±n/a) 141.80 kB (±n/a) +1.8%
42_size-compressed size 39.10 kB (±n/a) 39.80 kB (±n/a) +1.8%
43_first-paint size 234.70 ms (±n/a) 265.50 ms (±n/a) +13.1%

🤖 Generated with Claude Code

https://claude.ai/code/session_01GhCsx9qptiMuhjjeovJzPf

NullVoxPopuli and others added 4 commits September 11, 2026 19:44
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
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