Skip to content

Materialize static template subtrees in one step - #21570

Open
NullVoxPopuli wants to merge 1 commit into
mainfrom
nvp/cloneable-static-subtrees
Open

Materialize static template subtrees in one step#21570
NullVoxPopuli wants to merge 1 commit into
mainfrom
nvp/cloneable-static-subtrees

Conversation

@NullVoxPopuli

@NullVoxPopuli NullVoxPopuli commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Note

Written by Claude Opus 5 (claude-opus-5) driving Claude Code, from @NullVoxPopuli's direction. All numbers come from runs on one machine.

Static template structure is now built in one step, not one opcode per element, attribute and text node. 34 DOM calls per row become 10.

Numbers

pnpm bench against main, 8x throttle, n=20. Overall duration -527ms, p=0.0056.

phase delta p
render1000Items1End -5.20% 0.039
render1000Items2End -7.71% 0.0051
render10000Items1End -5.79% 0.0028
render10000Items2End -5.57% 0.0066
render1000Items3End -5.56% 0.0043
updateEvery10thItem2End +3.84% 0.036

All five render phases improved, and the pattern replicated across two runs. The one regression is a single phase inside this harness's spurious-hit rate. It needs a replication run.

How it works

The pre-pass runs in compileStatements. No wire-format change, so published templates benefit.

A run must be an element-rooted subtree of 2 or more elements. Its holes must be dynamic attributes or last-child dynamic content. Components, blocks, splattributes, modifiers, comments and trusted HTML end a run. Bailing is safe: the caller then compiles the statements the ordinary way.

Each run emits 2 paths:

  1. The tree builder can clone. Build the subtree in one step, then fill the holes.
  2. The tree builder cannot clone. Run the original statements, in the original order.

Path 2 is not optional. Rehydration matches server-rendered nodes, and the SSR serializer interleaves block markers. Both need each step in order. An earlier single-path version failed 16 rehydration tests and left stray %-b:0% markers.

Cloning keeps namespaces correct at no cost, because the cached node is built with the ordinary DOM operations in context.

Why structure and not the rest

Rendering JS for a 1000-row create is about 37ms. Static structure is about 14.6ms of that. A hand-written clone of the same row costs about 4ms. The other 22ms is per-hole reactive machinery with no single hot spot, and this PR does not touch it.

Measured and rejected first, so nobody repeats them:

  • Deferred element insertion until close. Neutral, twice.
  • Bulk replaceChildren for clears. Saves 6ms of 32ms, not the 83% a profile implied.
  • Pooling tracking-frame Trackers. Already reverted in 39c062e72d.

Profiler self-time on insertBefore is Blink style invalidation charged to the calling frame. It is not insertion cost.

Coverage

Measured over 766 test templates in this repo: 40% of static structure sits in runs of 2 or more elements. 77% of runs hold one element, which sets the threshold at 2. Both benchmark rows are one 8-element run.

Cost

Every app pays for this, including apps that never hit a qualifying run. @glimmer/opcode-compiler ships to the browser, so the extractor is in the app bundle.

app raw brotli
v2-app-hello-world-template 132.4 kB to 135.3 kB (+2%) 37 kB to 38 kB (+3%)
v2-app-template 342.5 kB to 345.5 kB (+0.9%) 95 kB to 95.9 kB (+0.9%)
dist/prod total 1.9 MB (+0.6%) 449.2 kB to 451.9 kB (+0.6%)

+2% on a hello-world app is the main thing to weigh against a 5-8% render win. A build-time flag would let Rollup drop both the extractor and the runtime path for apps that do not opt in. Say the word and I will add one.

Size

446 lines of production code, 100 of tests, 5 deleted. Nothing existing was replaced. This adds a compile-time extractor, 3 opcodes and their runtime, so there is no old path to remove. Three candidate consolidations were checked; two do not work, and the one that does (merging the 4 dynamic-attribute opcodes into 2, about 30 lines) is unrelated to this change and belongs in its own PR.

Tests

StaticTreeSuite asserts the mechanism, not just the output, using the step log:

  • a static subtree is built once, then cloned
  • a cloned subtree gets its own dynamic values
  • a component ends a run
  • a single element stays below the threshold
  • namespaced elements survive cloning

rere-benchmark

No regression on any of the 15 benches. 6 interleaved rounds, alternating order, 10 samples per bench per round, 8x throttle. Per-round medians compared within the round, then a sign test across rounds.

One bench swept all 6 rounds: 1 item, 1k updates, -6.8%, p=0.031. Treat it as noise. It is the smallest bench in the set (6.40ms to 6.00ms), and this PR touches construction only, so a win on a pure update bench has no mechanism behind it.

Nothing else reached p<0.10 with an effect of 3% or more. The one candidate regression at n=4 (1k items 1 update on 5% (random), +3.1%) fell to 2/6 rounds at n=6.

Checks

  • pnpm test:wip: 9,454 tests, 0 failures, 17 skipped
  • pnpm type-check:internals, prettier, eslint: clean

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

📊 Size report

Tarball size1.2 MB1.2 MB

dist/dev   0.5%↑

File Before (Size / Brotli) After (Size / Brotli)
./packages/shared-chunks/api-{hash}.js 26.2 kB / 5.8 kB 12%↑29.2 kB / 14%↑6.6 kB
./packages/shared-chunks/arguments-{hash}.js 62.5 kB / 12.2 kB 1%↑63.1 kB / 1%↑12.4 kB
./packages/shared-chunks/immediate-hdm0aMqc.js 6.6 kB / 1.5 kB
./packages/shared-chunks/index-{hash}.js 62.6 kB / 12.5 kB 10%↑69 kB / 12%↑14 kB
./packages/shared-chunks/rehydrate-{hash}.js 15.6 kB / 3.1 kB 0.7%↑15.7 kB / 1%↑3.2 kB
./packages/shared-chunks/serialize-{hash}.js 3.6 kB / 1 kB 3%↑3.8 kB / 4%↑1.1 kB
./packages/shared-chunks/syscall-ops-CkPT1Kfx.js 6.4 kB / 1.5 kB
./packages/shared-chunks/template-{hash}.js 1.1 kB / 401 B -54%↓491 B / -49.4%↓203 B
Total (Includes all files) 2.1 MB / 491.8 kB 0.5%↑2.1 MB / 0.5%↑494.3 kB

dist/prod   0.6%↑

File Before (Size / Brotli) After (Size / Brotli)
./packages/shared-chunks/api-{hash}.js 25.7 kB / 5.7 kB 12%↑28.7 kB / 14%↑6.5 kB
./packages/shared-chunks/arguments-{hash}.js 58 kB / 11.3 kB 1%↑58.6 kB / 1%↑11.4 kB
./packages/shared-chunks/cache-{hash}.js 9.9 kB / 2.8 kB -92.9%↓697 B / -90.7%↓258 B
./packages/shared-chunks/immediate-hdm0aMqc.js 6.6 kB / 1.5 kB
./packages/shared-chunks/index-{hash}.js 59.8 kB / 12.1 kB 11%↑66.3 kB / 12%↑13.6 kB
./packages/shared-chunks/rehydrate-{hash}.js 15.6 kB / 3.1 kB 0.7%↑15.7 kB / 1%↑3.2 kB
./packages/shared-chunks/serialize-{hash}.js 3.6 kB / 1 kB 3%↑3.8 kB / 4%↑1.1 kB
./packages/shared-chunks/syscall-ops-CkPT1Kfx.js 6.4 kB / 1.5 kB
Total (Includes all files) 1.9 MB / 449.2 kB 0.6%↑1.9 MB / 0.6%↑451.9 kB

smoke-tests/v2-app-template/dist   0.9%↑

File Before (Size / Brotli) After (Size / Brotli)
./assets/api-{hash}.js 290.2 kB / 78 kB 1%↑293.2 kB / 1%↑78.8 kB
Total (Includes all files) 342.5 kB / 95 kB 0.9%↑345.5 kB / 0.9%↑95.9 kB

smoke-tests/v2-app-hello-world-template/dist   2%↑

File Before (Size / Brotli) After (Size / Brotli)
./assets/main-{hash}.js 132.1 kB / 36.9 kB 2%↑135 kB / 3%↑37.8 kB
Total (Includes all files) 132.4 kB / 37 kB 2%↑135.3 kB / 3%↑38 kB

🤖 This report was automatically generated by wyvox/pkg-size

@NullVoxPopuli
NullVoxPopuli force-pushed the nvp/cloneable-static-subtrees branch from 1320427 to 4da33f6 Compare August 17, 2026 19:57
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) <noreply@anthropic.com>
@NullVoxPopuli
NullVoxPopuli force-pushed the nvp/cloneable-static-subtrees branch from 4da33f6 to ca16550 Compare August 17, 2026 20:45
@NullVoxPopuli

NullVoxPopuli commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

worth it -- but annoying its so much code

image image

@NullVoxPopuli
NullVoxPopuli marked this pull request as ready for review August 18, 2026 13:10
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.

1 participant