From dfe3529fe0db2dd8eb1d457cc739ae7135fc1797 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:52:07 -0400 Subject: [PATCH 1/3] [PERF] Skip unchanged blocks in the updating VM with a per-block tag 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 Claude-Session: https://claude.ai/code/session_012wPae8QnFbKHKn5xbgaQVq --- packages/@glimmer/runtime/lib/vm/append.ts | 23 ++++++ packages/@glimmer/runtime/lib/vm/update.ts | 83 +++++++++++++++++++--- 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/packages/@glimmer/runtime/lib/vm/append.ts b/packages/@glimmer/runtime/lib/vm/append.ts index b27b704785b..cf0716c4a4a 100644 --- a/packages/@glimmer/runtime/lib/vm/append.ts +++ b/packages/@glimmer/runtime/lib/vm/append.ts @@ -74,6 +74,7 @@ class Stacks { readonly cache = new Stack(); readonly list = new Stack(); readonly destroyable = new Stack(); + readonly block = new Stack(); constructor(scope: Scope, dynamicScope: DynamicScope) { this.scope.push(scope); @@ -402,6 +403,8 @@ export class VM { enter(args: number) { let updating: UpdatingOpcode[] = []; + beginTrackFrame(); + let state = this.capture(args); let block = this.tree().pushResettableBlock(); @@ -438,6 +441,8 @@ export class VM { enterItem({ key, value, memo }: OpaqueIterationItem): ListItemOpcode { let { stack } = this; + beginTrackFrame(); + let valueRef = createIteratorItemRef(value); let memoRef = createIteratorItemRef(memo); @@ -479,6 +484,10 @@ export class VM { enterList(iterableRef: Reference, offset: number) { let updating: ListItemOpcode[] = []; + // The list block reads its iterable in the constructor, so the frame must + // be open before that read. + beginTrackFrame(); + let addr = this.lowlevel.target(offset); let state = this.capture(0, addr); let list = this.tree().pushBlockList(updating) as AppendingBlockList; @@ -509,10 +518,23 @@ export class VM { private didEnter(opcode: BlockOpcode) { this.associateDestroyable(opcode); this.#stacks.destroyable.push(opcode); + this.#stacks.block.push(opcode); this.updateWith(opcode); this.pushUpdating(opcode.children); } + /** + * Re-enter a block that the updating VM is re-rendering. Its tracking frame + * is already open, opened by the updating VM before it evaluated the block. + * + * [!] push Block Stack <- `opcode` + * [!] push Updating Stack <- `children` + */ + resumeBlock(opcode: BlockOpcode, children: UpdatingOpcode[]) { + this.#stacks.block.push(opcode); + this.pushUpdating(children); + } + /** * ## Opcodes * @@ -529,6 +551,7 @@ export class VM { this.#stacks.destroyable.pop(); this.#tree.popBlock(); this.popUpdating(); + expect(this.#stacks.block.pop(), 'VM BUG: expected a block to exit').didExit(); } /** diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index ca239649fd3..c1ab66ef546 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -11,6 +11,7 @@ import type { ResettableBlock, Scope, SimpleComment, + Tag, UpdatingOpcode, UpdatingVM as IUpdatingVM, } from '@glimmer/interfaces'; @@ -24,7 +25,13 @@ import { updateRef, valueForRef } from '@glimmer/reference/lib/reference'; import { logStep } from '@glimmer/util/lib/debug-steps'; import { StackImpl as Stack } from '@glimmer/util/lib/collections'; import { debug } from '@glimmer/validator/lib/debug'; -import { resetTracking } from '@glimmer/validator/lib/tracking'; +import { + beginTrackFrame, + consumeTag, + endTrackFrame, + resetTracking, +} from '@glimmer/validator/lib/tracking'; +import { INITIAL, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; import type { Closure } from './append'; import type { AppendingBlockList } from './element-builder'; @@ -75,10 +82,12 @@ export class UpdatingVM implements IUpdatingVM { this.try(opcodes, handler); while (!frameStack.isEmpty()) { - let opcode = this.frame.nextStatement(); + let frame = this.frame; + let opcode = frame.nextStatement(); if (opcode === undefined) { frameStack.pop(); + frame.finish(); continue; } @@ -94,10 +103,19 @@ export class UpdatingVM implements IUpdatingVM { this.frame.goto(index); } - try(ops: UpdatingOpcode[], handler: Nullable) { - this.frameStack.push(new UpdatingVMFrame(ops, handler)); + try( + ops: UpdatingOpcode[], + handler: Nullable, + block: Nullable = null + ) { + this.frameStack.push(new UpdatingVMFrame(ops, handler, block)); } + /* + * The handler re-renders its block with the append VM, and that render ends + * the block's tracking frame itself when it exits the block. So the frame is + * popped without `finish()`. + */ throw() { this.frame.handleException(); this.frameStack.pop(); @@ -111,11 +129,23 @@ export interface VMState { readonly stack: unknown[]; } +/* + * Every block records the combined tag of what its render consumed, the same + * way a component cache group does. While that tag validates, the updating VM + * skips the block's children, so a list of unchanged rows costs one validation + * per row instead of one per dynamic reference. + * + * Introduction and background in https://github.com/emberjs/ember.js/pull/21596 + */ export abstract class BlockOpcode implements UpdatingOpcode, Bounds { [DESTROYABLE_META_KEY]: object | undefined; public children: UpdatingOpcode[]; + /** Combined tag of everything consumed during the last render or update of this block. */ + private tag: Tag | null = null; + private lastRevision = INITIAL; + protected readonly bounds: AppendingBlock; constructor( @@ -141,7 +171,31 @@ export abstract class BlockOpcode implements UpdatingOpcode, Bounds { } evaluate(vm: UpdatingVM) { - vm.try(this.children, null); + let { tag } = this; + + if (tag !== null && !vm.alwaysRevalidate && validateTag(tag, this.lastRevision)) { + consumeTag(tag); + return; + } + + beginTrackFrame(); + this.evaluateChildren(vm); + } + + protected evaluateChildren(vm: UpdatingVM) { + vm.try(this.children, null, this); + } + + /** + * Called when the block's tracking frame ends: from the append VM when the + * block exits, and from the updating VM when the block's frame finishes. + */ + didExit() { + let tag = endTrackFrame(); + + this.tag = tag; + this.lastRevision = valueForTag(tag); + consumeTag(tag); } } @@ -150,8 +204,8 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { declare protected bounds: ResettableBlock; // Shadows property on base class - override evaluate(vm: UpdatingVM) { - vm.try(this.children, this); + protected override evaluateChildren(vm: UpdatingVM) { + vm.try(this.children, this, this); } handleException() { @@ -170,7 +224,7 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { let result = vm.execute((vm) => { vm.updateWith(this); - vm.pushUpdating(children); + vm.resumeBlock(this, children); }); associateDestroyableChild(this, result.drop); @@ -227,7 +281,7 @@ export class ListBlockOpcode extends BlockOpcode { this.opcodeMap.set(opcode.key, opcode); } - override evaluate(vm: UpdatingVM) { + protected override evaluateChildren(vm: UpdatingVM) { let iterator = valueForRef(this.iterableRef); if (this.lastIterator !== iterator) { @@ -249,7 +303,7 @@ export class ListBlockOpcode extends BlockOpcode { } // Run now-updated updating opcodes - super.evaluate(vm); + super.evaluateChildren(vm); } private sync(iterator: OpaqueIterator) { @@ -431,9 +485,16 @@ class UpdatingVMFrame { constructor( private ops: UpdatingOpcode[], - private exceptionHandler: Nullable + private exceptionHandler: Nullable, + private block: Nullable ) {} + finish() { + if (this.block !== null) { + this.block.didExit(); + } + } + goto(index: number) { this.current = index; } From fdb505e9076865b587649ae6319c5cf2bc044592 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:13:19 -0400 Subject: [PATCH 2/3] Drop a missed guard, and bring it back later 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 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 brings the guard back at once. 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 Opus 5.5 (1M context) --- packages/@glimmer/runtime/lib/vm/update.ts | 120 ++++++++++++++++----- 1 file changed, 95 insertions(+), 25 deletions(-) diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index c1ab66ef546..3ac41431d97 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -87,7 +87,11 @@ export class UpdatingVM implements IUpdatingVM { if (opcode === undefined) { frameStack.pop(); - frame.finish(); + + if (frame.block !== null) { + frame.block.didExit(); + } + continue; } @@ -130,10 +134,15 @@ export interface VMState { } /* - * Every block records the combined tag of what its render consumed, the same - * way a component cache group does. While that tag validates, the updating VM - * skips the block's children, so a list of unchanged rows costs one validation - * per row instead of one per dynamic reference. + * A block records the combined tag of what its render consumed, the same way + * a component cache group does. While that tag validates, the updating VM + * skips the block's children, so a list of unchanged rows costs one + * validation per row instead of one per dynamic reference. + * + * A guard only pays when it skips more work than its own validation. A block + * with one opcode, or with fewer than three consumed tags, is not guarded: a + * condition plus one component cache group is the common two-tag shape, and + * the group already guards itself. * * Introduction and background in https://github.com/emberjs/ember.js/pull/21596 */ @@ -142,9 +151,14 @@ export abstract class BlockOpcode implements UpdatingOpcode, Bounds { public children: UpdatingOpcode[]; - /** Combined tag of everything consumed during the last render or update of this block. */ - private tag: Tag | null = null; - private lastRevision = INITIAL; + /** + * Combined tag of everything consumed during the last render or update of + * this block, or null when the block is not guarded. + */ + protected tag: Tag | null = null; + protected lastRevision = INITIAL; + /** Updates evaluated without a guard since the guard was dropped. */ + protected unguarded = 0; protected readonly bounds: AppendingBlock; @@ -170,29 +184,65 @@ export abstract class BlockOpcode implements UpdatingOpcode, Bounds { return this.bounds.lastNode(); } + /** + * An unguarded block costs what it did before guards existed: one frame + * push. A guarded block validates first, and opens a tracking frame that + * the updating VM closes through `didExit` when the block's opcodes are done. + */ evaluate(vm: UpdatingVM) { let { tag } = this; - if (tag !== null && !vm.alwaysRevalidate && validateTag(tag, this.lastRevision)) { + if (tag === null) { + if (this.rearm()) { + beginTrackFrame(); + vm.try(this.children, null, this); + } else { + vm.try(this.children, null, null); + } + return; + } + + if (!vm.alwaysRevalidate && validateTag(tag, this.lastRevision)) { consumeTag(tag); return; } - beginTrackFrame(); - this.evaluateChildren(vm); + // A block that changed is likely to change again, and a guard on it only + // costs. Drop it without a frame; `rearm` opens one later. + this.tag = null; + vm.try(this.children, null, null); } - protected evaluateChildren(vm: UpdatingVM) { - vm.try(this.children, null, this); + /** + * A dropped guard comes back after a few 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 few. + */ + protected rearm(): boolean { + if (++this.unguarded < REARM_AFTER) return false; + + this.unguarded = 0; + return true; + } + + /** + * Whether the block's tracking frame is open. The append VM always opens one + * before it enters a block; the updating VM opens one when it validates a + * guard or re-arms one, and a re-render of a dropped block opens its own. + */ + protected get framed(): boolean { + return this.tag !== null; } /** * Called when the block's tracking frame ends: from the append VM when the - * block exits, and from the updating VM when the block's frame finishes. + * block exits, and from the updating VM when a guarded block's frame + * finishes. Decides whether the block stays guarded. */ didExit() { let tag = endTrackFrame(); + this.unguarded = 0; this.tag = tag; this.lastRevision = valueForTag(tag); consumeTag(tag); @@ -204,8 +254,26 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { declare protected bounds: ResettableBlock; // Shadows property on base class - protected override evaluateChildren(vm: UpdatingVM) { - vm.try(this.children, this, this); + override evaluate(vm: UpdatingVM) { + let { tag } = this; + + if (tag === null) { + if (this.rearm()) { + beginTrackFrame(); + vm.try(this.children, this, this); + } else { + vm.try(this.children, this, null); + } + return; + } + + if (!vm.alwaysRevalidate && validateTag(tag, this.lastRevision)) { + consumeTag(tag); + return; + } + + this.tag = null; + vm.try(this.children, this, null); } handleException() { @@ -215,6 +283,12 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { context: { env }, } = this; + // The re-render exits the block through the append VM, which closes one + // frame; a dropped guard has none open. + if (!this.framed) { + beginTrackFrame(); + } + destroyChildren(this); let tree = NewTreeBuilder.resume(env, bounds); @@ -281,7 +355,7 @@ export class ListBlockOpcode extends BlockOpcode { this.opcodeMap.set(opcode.key, opcode); } - protected override evaluateChildren(vm: UpdatingVM) { + override evaluate(vm: UpdatingVM) { let iterator = valueForRef(this.iterableRef); if (this.lastIterator !== iterator) { @@ -303,7 +377,7 @@ export class ListBlockOpcode extends BlockOpcode { } // Run now-updated updating opcodes - super.evaluateChildren(vm); + vm.try(this.children, null, null); } private sync(iterator: OpaqueIterator) { @@ -480,21 +554,17 @@ export class ListBlockOpcode extends BlockOpcode { } } +const REARM_AFTER = 8; + class UpdatingVMFrame { private current = 0; constructor( private ops: UpdatingOpcode[], private exceptionHandler: Nullable, - private block: Nullable + readonly block: Nullable ) {} - finish() { - if (this.block !== null) { - this.block.didExit(); - } - } - goto(index: number) { this.current = index; } From d7a316d71e8731744348f853ed2cdb0b66cd5746 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:01:17 -0400 Subject: [PATCH 3/3] Test blocks through more updates than a guard needs to come back A dropped guard comes back after 8 updates. Each test runs a block, a nested block, or a list through 20 updates of one kind and then changes something else, so the block is skipped, dropped, and guarded again while the test checks the output. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test/block-guards-test.ts | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 packages/@glimmer-workspace/integration-tests/test/block-guards-test.ts diff --git a/packages/@glimmer-workspace/integration-tests/test/block-guards-test.ts b/packages/@glimmer-workspace/integration-tests/test/block-guards-test.ts new file mode 100644 index 00000000000..8b81e72f522 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/block-guards-test.ts @@ -0,0 +1,150 @@ +import { jitSuite, RenderTest, test, tracked } from '@glimmer-workspace/integration-tests'; + +// More updates than a dropped guard needs to come back. +const MANY = 20; + +class Item { + @tracked text: string; + + constructor( + public id: number, + text: string + ) { + this.text = text; + } +} + +class BlockGuardsTest extends RenderTest { + static suiteName = 'Block guards'; + + @test + 'a block updates after many renders that did not change it'() { + this.render('{{#if this.show}}

{{this.inner}}

{{/if}}{{this.other}}', { + show: true, + inner: 'a', + other: 0, + }); + this.assertHTML('

a

0'); + + for (let i = 1; i <= MANY; i++) { + this.rerender({ other: i }); + this.assertHTML(`

a

${i}`); + } + + this.rerender({ inner: 'b' }); + this.assertHTML(`

b

${MANY}`); + this.assertStableNodes(); + } + + @test + 'a block that changes on every render updates, rests, and updates again'() { + this.render('{{#if this.show}}

{{this.inner}}

{{/if}}{{this.other}}', { + show: true, + inner: 'a', + other: 0, + }); + + for (let i = 1; i <= MANY; i++) { + this.rerender({ inner: `v${i}` }); + this.assertHTML(`

v${i}

0`); + } + + for (let i = 1; i <= MANY; i++) { + this.rerender({ other: i }); + this.assertHTML(`

v${MANY}

${i}`); + } + + this.rerender({ inner: 'last' }); + this.assertHTML(`

last

${MANY}`); + this.assertStableNodes(); + } + + @test + 'a block that renders again on every update keeps the right content'() { + this.render('{{#if this.cond}}

yes {{this.text}}

{{else}}

no {{this.text}}

{{/if}}', { + cond: true, + text: 'a', + }); + this.assertHTML('

yes a

'); + + for (let i = 1; i <= MANY; i++) { + let cond = i % 2 === 0; + + this.rerender({ cond, text: `t${i}` }); + this.assertHTML(`

${cond ? 'yes' : 'no'} t${i}

`); + } + + for (let i = 1; i <= MANY; i++) { + this.rerender({ text: `u${i}` }); + this.assertHTML(`

yes u${i}

`); + } + + this.rerender({ cond: false }); + this.assertHTML(`

no u${MANY}

`); + } + + @test + 'a nested block updates when the outer block has nothing else to do'() { + this.render( + '{{#if this.outer}}
{{#if this.inner}}

{{this.text}}

{{/if}}
{{/if}}{{this.other}}', + { outer: true, inner: true, text: 'a', other: 0 } + ); + this.assertHTML('

a

0'); + + for (let i = 1; i <= MANY; i++) { + this.rerender({ other: i }); + } + + this.rerender({ text: 'b' }); + this.assertHTML(`

b

${MANY}`); + + for (let i = 1; i <= MANY; i++) { + this.rerender({ text: `c${i}` }); + this.assertHTML(`

c${i}

${MANY}`); + } + + this.rerender({ inner: false }); + this.assertHTML(`
${MANY}`); + + this.rerender({ inner: true, text: 'd' }); + this.assertHTML(`

d

${MANY}`); + } + + @test + 'an item of a list updates after many renders that changed another item'() { + let items = [new Item(1, 'a'), new Item(2, 'b'), new Item(3, 'c')]; + + this.render('{{#each this.items key="id" as |item|}}

{{item.text}}

{{/each}}', { items }); + this.assertHTML('

a

b

c

'); + + for (let i = 1; i <= MANY; i++) { + items[0]!.text = `a${i}`; + this.rerender(); + this.assertHTML(`

a${i}

b

c

`); + } + + items[2]!.text = 'z'; + this.rerender(); + this.assertHTML(`

a${MANY}

b

z

`); + this.assertStableNodes(); + } + + @test + 'a list updates its items after many renders with new arrays'() { + let items = [new Item(1, 'a'), new Item(2, 'b'), new Item(3, 'c')]; + + this.render('{{#each this.items key="id" as |item|}}

{{item.text}}

{{/each}}', { items }); + + for (let i = 1; i <= MANY; i++) { + items = [new Item(1, 'a'), new Item(2, `b${i}`), new Item(3, 'c')]; + this.rerender({ items }); + this.assertHTML(`

a

b${i}

c

`); + } + + items = [new Item(3, 'c'), new Item(1, 'first'), new Item(4, 'new')]; + this.rerender({ items }); + this.assertHTML('

c

first

new

'); + } +} + +jitSuite(BlockGuardsTest);