Skip to content

Commit 1320427

Browse files
NullVoxPopuliclaude
andcommitted
Materialize static template subtrees in one step
A run of statements that only builds static structure -- plus holes where its dynamic values go -- is described once and materialized by cloning a cached node, instead of compiling to one opcode per element, static attribute and static text node. Measured on a 1000-row krausest create before this change: of ~37ms of rendering JS, ~14.6ms is that static structure, ~7.4ms of it in DOM calls (8 createElement, 6 setAttribute, 13 insertBefore per row -- 34 DOM calls per row, 340k for 10k rows). The pre-pass runs in `compileStatements`, so it needs no wire-format change and works on already-published templates. A run is extracted only when it is an element-rooted subtree of at least two elements whose holes are dynamic attributes or last-child dynamic content. Components, blocks, splattributes, modifiers, comments, trusted HTML and mid-child content holes all end a run; bailing is always safe, since the caller then compiles the statements the ordinary way. Coverage measured over 766 of this repo's own test templates: 40% of static structure sits in runs of two or more elements, and both benchmark rows (krausest and dbmon) are a single 8-element run covering all of theirs. Two paths are emitted for each run, chosen at runtime: - when the tree builder can clone, the subtree is materialized in one step and the dynamic values are filled in at their holes - otherwise the run's original statements execute, in their original order The second path is not a nicety. Rehydration matches against server-rendered nodes and the SSR serializer interleaves block markers with construction, so both need each step in order: filling holes after the fact sets attributes after their element was flushed, and looks for a dynamic block's markers after its enclosing elements are already closed. An earlier attempt to serve both paths from a descriptor walk failed 16 rehydration tests for exactly that reason, leaving stray `%-b:0%` markers. Cloning also keeps namespaces correct for free -- the cached node is built with the ordinary DOM operations, in context -- which a serialized HTML skeleton would not. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
1 parent 2607140 commit 1320427

11 files changed

Lines changed: 535 additions & 5 deletions

File tree

packages/@glimmer/constants/lib/syscall-ops.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type {
22
VmAppendDocumentFragment,
3+
VmAppendStaticTree,
4+
VmEnterHole,
5+
VmExitHole,
36
VmAppendHTML,
47
VmAppendNode,
58
VmAppendSafeHTML,
@@ -183,7 +186,16 @@ export const VM_IF_INLINE_OP = 109 satisfies VmIfInline;
183186
export const VM_NOT_OP = 110 satisfies VmNot;
184187
export const VM_GET_DYNAMIC_VAR_OP = 111 satisfies VmGetDynamicVar;
185188
export const VM_LOG_OP = 112 satisfies VmLog;
186-
export const VM_SYSCALL_SIZE = 113 satisfies VmSize;
189+
/**
190+
* Append a whole static subtree in one step: clone it when the tree builder
191+
* can, otherwise walk its descriptor through the ordinary builder calls.
192+
*/
193+
export const VM_APPEND_STATIC_TREE_OP = 113 satisfies VmAppendStaticTree;
194+
/** Position the builder at one of that subtree's dynamic holes. */
195+
export const VM_ENTER_HOLE_OP = 114 satisfies VmEnterHole;
196+
/** Leave a hole and restore the builder's position. */
197+
export const VM_EXIT_HOLE_OP = 115 satisfies VmExitHole;
198+
export const VM_SYSCALL_SIZE = 116 satisfies VmSize;
187199

188200
export function isOp(value: number): value is VmOp {
189201
return value >= 16;

packages/@glimmer/interfaces/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type * from './lib/runtime.d.ts';
1818
export type * from './lib/runtime/vm.d.ts';
1919
export type * from './lib/serialize.d.ts';
2020
export type * from './lib/stack.d.ts';
21+
export type * from './lib/static-tree.d.ts';
2122
export type * from './lib/tags.d.ts';
2223
export type * from './lib/template.d.ts';
2324
export type * from './lib/tier1/symbol-table.d.ts';

packages/@glimmer/interfaces/lib/dom/attributes.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Maybe, Nullable } from '../core.js';
22
import type { ElementOperations, Environment, ModifierInstance } from '../runtime.js';
33
import type { Stack } from '../stack.js';
4+
import type { StaticTree } from '../static-tree.js';
45
import type { Bounds, Cursor } from './bounds.js';
56
import type { GlimmerTreeChanges, GlimmerTreeConstruction } from './changes.js';
67
import type {
@@ -74,6 +75,16 @@ export interface DOMStack {
7475
): AttributeOperation;
7576

7677
closeElement(): Nullable<ModifierInstance[]>;
78+
79+
/**
80+
* Materialize a run of static structure in one step, and remember it so
81+
* its dynamic holes can be found by index.
82+
*/
83+
appendStaticTree(tree: StaticTree): boolean;
84+
/** Position the builder at hole `index` of the current static tree. */
85+
enterHole(index: number): void;
86+
/** Restore the position saved by `enterHole`. */
87+
exitHole(index: number): void;
7788
}
7889

7990
export interface TreeOperations {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { Nullable } from './core.js';
2+
3+
/**
4+
* A description of static template structure, shared by the compiler (which
5+
* extracts it) and the runtime (which materializes it).
6+
*
7+
* A run of statements that only builds structure -- plus holes where dynamic
8+
* values go -- is described once here rather than compiled to one opcode per
9+
* element, attribute and text node. The runtime clones it where the tree
10+
* builder allows, and otherwise walks it through the ordinary builder calls
11+
* so rehydration and SSR are unaffected.
12+
*/
13+
export interface StaticElement {
14+
readonly kind: 'element';
15+
readonly tag: string;
16+
/** `[name, value, namespace]`, in source order */
17+
readonly attrs: [string, string, Nullable<string>][];
18+
readonly children: StaticNode[];
19+
}
20+
21+
export interface StaticText {
22+
readonly kind: 'text';
23+
readonly chars: string;
24+
}
25+
26+
export type StaticNode = StaticElement | StaticText;
27+
28+
/**
29+
* Where a dynamic value goes, as a path of child-node indices from the run's
30+
* root element.
31+
*/
32+
export interface AttrHole {
33+
readonly kind: 'attr';
34+
readonly path: readonly number[];
35+
}
36+
37+
export interface ContentHole {
38+
readonly kind: 'content';
39+
readonly path: readonly number[];
40+
}
41+
42+
export type Hole = AttrHole | ContentHole;
43+
44+
export interface StaticTree {
45+
readonly root: StaticElement;
46+
readonly holes: readonly Hole[];
47+
/**
48+
* Clone source, filled in by the runtime the first time this tree is
49+
* materialized and shared by every later instance.
50+
*/
51+
cached?: unknown;
52+
}

packages/@glimmer/interfaces/lib/vm-opcodes.d.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ export type VmIfInline = 109;
109109
export type VmNot = 110;
110110
export type VmGetDynamicVar = 111;
111111
export type VmLog = 112;
112-
export type VmSize = 113;
112+
export type VmAppendStaticTree = 113;
113+
export type VmEnterHole = 114;
114+
export type VmExitHole = 115;
115+
export type VmSize = 116;
113116

114117
export type VmOp =
115118
| VmHelper
@@ -202,6 +205,9 @@ export type VmOp =
202205
| VmIfInline
203206
| VmNot
204207
| VmGetDynamicVar
205-
| VmLog;
208+
| VmLog
209+
| VmAppendStaticTree
210+
| VmEnterHole
211+
| VmExitHole;
206212

207213
export type SomeVmOp = VmOp | VmMachineOp;

packages/@glimmer/node/lib/serialize-builder.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@ class SerializeBuilder extends NewTreeBuilder implements TreeBuilder {
9999
return super.__appendText(string);
100100
}
101101

102+
/**
103+
* The serializer interleaves block markers with construction, so it needs
104+
* the descriptor walk rather than a cloned subtree.
105+
*/
106+
protected override get supportsCloning(): boolean {
107+
return false;
108+
}
109+
102110
override closeElement(): Nullable<ModifierInstance[]> {
103111
if (NEEDS_EXTRA_CLOSE.has(this.element)) {
104112
NEEDS_EXTRA_CLOSE.delete(this.element);

packages/@glimmer/opcode-compiler/lib/compilable-template.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import type {
1616
WireFormat,
1717
} from '@glimmer/interfaces';
1818
import { IS_COMPILABLE_TEMPLATE } from '@glimmer/constants/lib/brand';
19+
import { VM_JUMP_OP } from '@glimmer/constants/lib/vm-ops';
20+
import {
21+
VM_APPEND_STATIC_TREE_OP,
22+
VM_ENTER_HOLE_OP,
23+
VM_EXIT_HOLE_OP,
24+
} from '@glimmer/constants/lib/syscall-ops';
1925
import { LOCAL_TRACE_LOGGING } from '@glimmer/local-debug-flags';
2026
import { EMPTY_ARRAY } from '@glimmer/util/lib/array-utils';
2127

@@ -26,6 +32,9 @@ import { templateCompilationContext } from './opcode-builder/context';
2632
import { encodeOp } from './opcode-builder/encoder';
2733
import { meta } from './opcode-builder/helpers/shared';
2834
import { STATEMENTS } from './syntax/statements';
35+
import { HighLevelBuilderOpcodes } from './opcode-builder/opcodes';
36+
import { labelOperand } from './opcode-builder/operands';
37+
import { extractStaticTree } from './static-tree';
2938

3039
export const PLACEHOLDER_HANDLE = -1;
3140

@@ -97,8 +106,59 @@ export function compileStatements(
97106
encodeOp(encoder, evaluation, meta, op as BuilderOp | HighLevelOp);
98107
}
99108

100-
for (const statement of statements) {
101-
sCompiler.compile(pushOp, statement);
109+
for (let i = 0; i < statements.length; i++) {
110+
/**
111+
* A run of statements that only builds static structure (plus holes for
112+
* the dynamic values inside it) is materialized in one step instead of
113+
* one opcode per element, attribute and text node. The holes keep their
114+
* original opcodes; they just run with the builder positioned at them.
115+
*/
116+
let run = extractStaticTree(statements, i);
117+
118+
if (run) {
119+
/**
120+
* Two paths for the same run, chosen at runtime.
121+
*
122+
* When the tree builder can materialize the subtree in one step, the
123+
* dynamic values are then filled in at their holes. When it cannot --
124+
* rehydration matches against server-rendered nodes and the SSR
125+
* serializer interleaves block markers -- the run's original statements
126+
* run instead, in their original order. Order is the whole point:
127+
* filling holes after the fact would set attributes after their element
128+
* was flushed, and would look for a dynamic block's markers after its
129+
* enclosing elements had already been closed.
130+
*/
131+
let handle = syntaxContext.program.constants.value(run.tree);
132+
133+
pushOp(HighLevelBuilderOpcodes.StartLabels);
134+
// the jump target must be op1: label offsets are patched relative to
135+
// their own slot, and `goto` resolves them against the opcode address
136+
pushOp(VM_APPEND_STATIC_TREE_OP, labelOperand('STATIC_TREE_FALLBACK'), handle);
137+
138+
for (let h = 0; h < run.holeStatements.length; h++) {
139+
pushOp(VM_ENTER_HOLE_OP, h);
140+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- parallel to holes
141+
sCompiler.compile(pushOp, statements[run.holeStatements[h]!]!);
142+
pushOp(VM_EXIT_HOLE_OP, h);
143+
}
144+
145+
pushOp(VM_JUMP_OP, labelOperand('STATIC_TREE_END'));
146+
pushOp(HighLevelBuilderOpcodes.Label, 'STATIC_TREE_FALLBACK');
147+
148+
for (let s = i; s < i + run.length; s++) {
149+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked
150+
sCompiler.compile(pushOp, statements[s]!);
151+
}
152+
153+
pushOp(HighLevelBuilderOpcodes.Label, 'STATIC_TREE_END');
154+
pushOp(HighLevelBuilderOpcodes.StopLabels);
155+
156+
i += run.length - 1;
157+
continue;
158+
}
159+
160+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked
161+
sCompiler.compile(pushOp, statements[i]!);
102162
}
103163

104164
let handle = context.encoder.commit(meta.size);

0 commit comments

Comments
 (0)