Wireformat to importable opcodes: hello-world goes from 37.6 to 14.5 kB (brotli) - #21585
Wireformat to importable opcodes: hello-world goes from 37.6 to 14.5 kB (brotli)#21585NullVoxPopuli-ai-agent wants to merge 28 commits into
Conversation
Each syscall handler in `runtime/lib/compiled/opcodes/` is now a named
export that carries its opcode number: `syscall(VM_X_OP, fn)` returns
`{ type, evaluate }` and registers nothing. The compile-time code in
`opcode-compiler` pushes the handler object instead of the bare number,
and `encodeOp` registers it in the dispatch table the first time a
template pushes it.
This removes `runtime/lib/bootstrap.ts`, whose eight side-effect imports
forced every handler into any bundle that used the VM. A bundler can now
drop a handler when no compile-time code that emits it survives
tree-shaking. The `/*#__PURE__*/` annotation on each `syscall(...)` call
is what lets the bundler drop it.
The numeric dispatch table and the int32 heap layout are unchanged. In
DEBUG, an opcode with no registered handler throws with the opcode number
instead of failing inside `unwrap`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
Each statement and expression compiler in `opcode-compiler/lib/syntax/`
is now an exported op object, `{ id, compile }`, created with a
`/*#__PURE__*/` call. `@glimmer/opcode-compiler/ops` re-exports every op
under its `SexpOpcodes` name. A compiled template can put the op object
in the head position of a tuple, and `compileSexp` calls `head.compile`.
Because each op imports the runtime handlers it pushes, importing an op
pulls in only the handlers that op needs.
Templates that still ship a JSON block have numeric heads. The template
factory in `lib/template.ts` handles them: it fills a number-to-op table
from every op through `syntax/legacy.ts` and parses the block. A build
that emits op-headed blocks uses `lib/template-core.ts` through the new
`@ember/template-factory/modular` entry, so the legacy table and the
unused ops stay out of the bundle. `@glimmer/program` builds its default
template from `YieldOp` directly for the same reason.
Head comparisons in `helpers/resolution.ts` and the `Append` compiler go
through `headId`, which accepts either a number or an op object.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`precompileModule` in `@glimmer/compiler` compiles a template the same way `precompile` does, but prints the block as JavaScript whose tuple heads are identifiers such as `__wf_Append`. It returns the expression plus the list of identifiers to bind, each with the module and export name to import (`@glimmer/opcode-compiler/ops`). A build tool adds the imports and renames the identifiers. The template `id` is still hashed from the JSON form, so ids do not change between the two formats. `ember-template-compiler/minimal.ts` exports it, which is the entry the babel plugin loads, and the build wiring in `broccoli/` passes it through. `rollup.config.mjs` adds `@glimmer/opcode-compiler/ops` as an exposed entry so an app can import it from ember-source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
This is a temporary `pnpm patch` until the change lands upstream. When the template compiler exposes `precompileModule`, the plugin calls it, binds each opcode identifier to an import from `@glimmer/opcode-compiler/ops`, and emits `createTemplateFactory` from `@ember/template-factory/modular`. Compilers without `precompileModule` take the JSON path as before. `babel-import-util` matches its placeholder nodes by identity, so the plugin replaces every occurrence of an opcode identifier with a fresh node from `i.import()` instead of renaming in place. With this patch, ember-source's own templates and the smoke test apps compile to op-headed blocks. Hello world smoke app, Vite production build: 135.07 kB / 43.27 kB gzip before, 123.90 kB / 39.77 kB gzip after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The standard library used to compile all five routines eagerly when an
evaluation context was created. `StdAppend` references the component
invocation helpers and the content handlers, so every app shipped them
even when no template had a dynamic `{{value}}`.
A routine is now a `StdlibBuilder` object. `stdlibOperand` takes the
builder instead of a name, and `StdlibImpl.handle(builder)` compiles it
the first time a template asks for it. Because a routine compiles into
its own heap region, the encoder records a fixup for the operand and
patches in the handle after `commit`, once the asking program's region
is closed. Nested routines (`cautious-append` calls
`cautious-non-dynamic-append`) use the same fixup path.
`context.stdlib.main` still works. It compiles the root routine on first
access, which happens on the first render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The Glimmer global context and the renderer used to import `get` and `set` from metal, the Ember array iterator, proxy checks, and backburner directly. A strict app that renders one component with `renderComponent` shipped the whole object model and the run loop through those imports. `-internals/glimmer/lib/hooks.ts` now holds slim defaults: tracked reads and writes on plain objects, native arrays and iterables, and a run loop with `actions`, `render`, and `destroy` queues flushed on a microtask. The modules that own the richer behavior register their versions when they load: - `metal/lib/property_get.ts` and `property_set.ts`: `get` and `set` - `metal/lib/tags.ts`: `tagForProperty` - `@ember/array/index.ts`: `isEmberArray` and `objectAt` - the proxy mixin: `isProxy` - `@ember/runloop/index.ts`: backburner Every entry in the global context delegates at call time, and listeners attached to the default loop move to backburner on registration, so import order does not matter. An app that imports any of those modules behaves as before. The hello world smoke app drops from 114.1 kB to 94.7 kB raw (36.9 kB to 30.7 kB gzip). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
In a strict template, `mut`, `readonly`, `unbound`, and the keywords the AST transforms emit (`-each-in`, `-track-array`, `-in-el-null`, and so on) compiled to `GetStrictKeyword` and resolved through the resolver's built-in helper table. That table imported every keyword helper, and through them the object model, so the strict render path shipped all of it. Three changes: 1. The module printer takes a `lexicalKeywords` map. A keyword in the map is printed as `GetLexicalSymbol` with a slot after the template's own locals, its import is added to `imports`, and `precompileModule` adds the slot to the `scope` function. Ember's wrapper supplies the map for strict templates, pointing at `@ember/-internals/template-keywords`. 2. The resolver's built-in tables start empty and `ensureBuiltins()` fills them. The legacy JSON factory, the new `@ember/template-factory/loose` factory, and the classic `Renderer` call it, because those are the paths that resolve by name. `precompileModule` now reports which factory the build tool must import, and the babel plugin patch uses it. 3. `EachInWrapper` moves to its own module, so the default iterator no longer pulls in the `-each-in` helper and its dependencies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`NewTreeBuilder.setDynamicAttribute` was a class method, so every bundle carried the dynamic attribute classes, property normalization, and URL sanitization behind it. The logic is now `setDynamicAttribute()` in `vm/attributes/dynamic.ts`, and the two handlers that set dynamic attributes import it. A template with only static attributes drops it. The default iterator checked for `EachInWrapper` and carried the object and map iterators with it. `toIterator` now tries registered extensions first, and the `-each-in` helper registers the each-in iterator when it loads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`encodeOp` switched on five numeric resolution opcodes and imported all of `helpers/resolution.ts` to do so, which put the loose mode name resolution code in every bundle. The ops that resolve by name now push a `ResolutionHandler` object, and the encoder calls `head.resolve`. A build with only strict templates drops the resolution code with the ops that need it. `Local` and `TemplateLocal` stay numeric, since the encoder handles them inline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`enterList`, `registerItem`, and `exitList` were VM methods, so the VM
referenced `ListBlockOpcode` and every bundle carried the keyed list
diffing behind `{{#each}}`. They are functions in `vm/lists.ts` now, and
the list handlers import them. `enterItem` stays on the VM because
`ListBlockOpcode.sync` calls it, and moving it would create an import
cycle between `update.ts` and `lists.ts`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`ComponentElementOperations.flush` called `setDynamicAttribute` for reference values, which kept the dynamic attribute classes, property normalization, and URL sanitization in every component render. Callers of `setAttribute` now pass an applier along with the reference, and `flush` uses it. The `ComponentAttr` handler, the curly component manager, and the classic attribute bindings all pass `applyDynamicAttribute`, so a template-only component whose attributes are all static drops that code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The Glimmer environment constructed `DebugRenderTreeImpl` itself when
the delegate enabled debug tooling, so the implementation was in every
bundle even though Ember turns it on from a runtime flag. The delegate
now supplies the tree, and `EmberEnvironmentDelegate` constructs it
behind an `@embroider/macros` condition:
setConfig: { 'ember-source': { debugRenderTree: false } }
in an app's `buildMacros` config drops the implementation. Without the
config, behavior is unchanged: the tree exists when
`ENV._DEBUG_RENDER_TREE` is set, which the inspector does at runtime.
`@embroider/macros` moves to `dependencies` because published code now
imports it, and the test babel config evaluates the macros so the suite
runs. A type stub covers `@glint/template`, which the macros types
import and this repo does not depend on. The hello world smoke app opts
out to show the effect.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The component and modifier handlers carried the code that feeds the debug render tree inside `if (vm.env.debugRenderTree !== undefined)` blocks: args capture, custom lifecycle nodes, destructor registration, and two updating opcodes. That is about 5 kB of source in every bundle even when the tree is left out. Those blocks are now methods on `DebugRenderTreeImpl` (`componentDidGetSelf`, `componentDidRenderLayout`, `modifierDidAdd`, `remoteElementDidPush`), and the handlers call them through `debugTree(vm.env)?.`. A build that opts out of the tree drops the bookkeeping with it. The runtime only ever receives `DebugRenderTreeImpl`, which is why `debugTree()` can narrow the host-facing type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`BaseRenderer.strict` constructed `ResolverImpl` up front, which kept the resolver and `@ember/instrumentation` in every bundle. The strict path now starts with `LazyResolver`, whose lookups return `null` until `ensureBuiltins()` registers the real resolver. The paths that resolve by name already call `ensureBuiltins()`, so behavior is unchanged for them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
…uilder
`NewTreeBuilder` carried `{{#in-element}}` support (`RemoteBlock` and the
remote push and pop), the block list behind `{{#each}}`, and the appends
for `{{{html}}}`, fragments, and nodes as class methods, so every bundle
kept them. They are functions now:
- `vm/remote-element.ts`: `pushRemoteElement`, `popRemoteElement`,
`RemoteBlock`. Builders that customize the push (rehydration,
serialization) implement the optional `__pushRemoteElement` hook, and
the serializer reuses `defaultPushRemoteElement` after adding its
marker.
- `vm/dynamic-content.ts`: `appendDynamicHTML`, `appendDynamicFragment`,
`appendDynamicNode`.
- `vm/lists.ts`: `AppendingBlockList`, pushed directly by `enterList`.
`pushBlock` and `pushElement` become public for those functions. Every
tree builder in the repo extends `NewTreeBuilder`, which the functions
rely on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
…sImpl
`ConstantsImpl.helper()`, `.modifier()`, and `.resolvedComponent()` exist
for name resolution: loose mode templates and `{{component "name"}}`.
As class methods they stayed in every bundle, and through
`getInternalHelperManager` they kept the custom helper manager too.
They are functions in `@glimmer/program/lib/definitions.ts` now, with
their caches keyed by the constants pool. `ResolutionTimeConstants` keeps
`component()` and `defaultTemplate`, which every render uses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`api.ts` built `new CustomHelperManager(() => new FunctionHelperManager())` at module load, so the custom helper manager and the args proxy reached every bundle that looked up a component manager. The default is created the first time a plain function is used as a helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`@ember/-internals/browser-environment` computes `isChrome`, `isFirefox`, and the user agent on load. The renderer only needs `hasDOM`, so it imports that module directly. The hello world smoke app turns off Vite's module preload polyfill, which has nothing to do for a single script. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The transaction held the modifier install and update queues and the tracking code that flushes them, so every bundle carried modifier support whether or not a template used a modifier. The transaction now offers `schedule(hook, item)`: work queued under a hook runs once at commit with everything queued under it. `vm/modifiers.ts` owns the install and update hooks, and the modifier opcodes queue through it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
…loads The default template iterator knew about Ember arrays and objects with a `forEach` method. Both are Ember conventions, so `@ember/array` now registers them as an iterator extension when it loads. The default handles native arrays and native iterables. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The five standard library routines and the default component template were compiled in the browser on first use, which kept the encoder, the label patcher, and the component invocation helpers in every bundle. `bin/build-aot-stdlib.mjs` now compiles them in node and writes the words to `opcode-builder/stdlib-data.ts`, with the handlers each routine dispatches and the positions that hold another routine's handle. `StdlibImpl.handle(routine)` registers the handlers and copies the words into the heap, resolving fixups first so each routine keeps its own heap region. A test compares the committed data with a fresh build, so a change to a routine or an opcode number shows up as a failure that names the generator. The same machinery serves templates: - `lib/aot/record.ts` is an encoder that records words instead of writing them to a heap. It notes which words hold constants, stdlib handles, and nested block programs, and resolves labels. - `lib/aot/template.ts` loads a recorded template into a context: it interns the constants, patches the words, and returns the handle. - `template-core.ts` takes its layouts as a parameter. `template-jit.ts` supplies the browser compiler; the AOT loader supplies its own. The default template is the first AOT template, so a context no longer needs the browser compiler to exist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
`precompileAot` in `@glimmer/opcode-compiler/lib/aot/precompile.ts` compiles a strict template to VM words at build time. The output has the shape `precompileModule` already produces (an expression plus the imports to bind), so the babel plugin needs no change. Lexical scope values and strict keywords become constants by identifier, evaluated on first use so a template can reference a class declared later in its module. Ember's `precompileModule` wrapper now emits AOT output for strict templates and points the build tool at `@ember/template-factory/aot`. Loose templates keep the modular JIT path, since they resolve names at runtime. The build exposes the handler modules and the stdlib data as entry points, because compiled templates import them directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
A strict template resolved `mut`, `{{outlet}}`, and the helpers behind
`{{#each}}` through a keyword list and a built-in table. The AST
transforms now bind each keyword to its import, the way `array` and `fn`
already work, and a JavaScript binding with the same name shadows the
keyword. The runtime compiler supplies the same values through its
keywords object.
The keyword list, the keyword slots in both printers, and the built-in
keyword tables are gone. Loose templates still resolve the names through
the resolver.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
📊 Size reportTarball size — dist/dev 6%↑
dist/prod 7%↑
smoke-tests/v2-app-template/dist -4.32%↓
smoke-tests/v2-app-hello-world-template/dist -62.2%↓
🤖 This report was automatically generated by wyvox/pkg-size |
The compilers printed placeholder identifiers and returned an import list, and the babel plugin patch walked the expression to bind each one. The build tool's `jsutils` already rides along in `options.meta`, so the printers now call `jsutils.bindImport` directly and embed the returned name. Keywords, opcode handlers, stdlib routines, and sexp ops all bind through the same function, and the patch shrinks to: call `precompileModule`, wrap the expression in its factory. Without `jsutils` (direct API use, the stdlib generator), the import list remains as a fallback. `bindImport` accepts a null shadow target for generated references, since nothing in template scope can shadow them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The `-each-in` wrapper now iterates itself through a symbol method, and `@ember/array` extends iteration through the one environment hooks object, so the iterator extension list and the wrapper module go away. Loose templates bind their built-ins at build time, the names that already win over app helpers, so a compiled loose template resolves only user helpers and components by name. The renderer no longer fills the built-in tables, the loose factory no longer needs them, and the base renderer starts with no resolver instead of a lazy delegate. The tables stay for JSON blocks from published addons and for templates compiled in the browser. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
The browser compilers now bind every keyword to a bare local name, which
loose and strict templates can both invoke, and the evaluators supply
the values. That takes `compile()`, the test harness, and runtime
`template()` off the built-in name tables. The tables and their fill
remain only behind `@ember/template-factory`, for JSON blocks that
addons published before importable opcodes.
`-resolve` and `-disallow-dynamic-resolution` bind like every other
keyword, and the `{{#each-in}}` iterator moves into the helper module
that owns it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81
Benchmarks: main vs this PRBoth benchmark suites ran on one machine, with Summary:
Hang in
|
| benchmark | type | main (median) | PR #21585 (median) | change |
|---|---|---|---|---|
| 01_run1k | cpu | 87.3 ms (±4.1) | 86.3 ms (±2.7) | -1.1% |
| 02_replace1k | cpu | 97.6 ms (±4.1) | 100.3 ms (±3.9) | +2.8% |
| 03_update10th1k_x16 | cpu | 37.3 ms (±2.6) | 38.2 ms (±2.9) | +2.4% |
| 04_select1k | cpu | 15.5 ms (±1.5) | 15.9 ms (±1.2) | +2.6% |
| 05_swap1k | cpu | 49.7 ms (±4.1) | 48.9 ms (±2.9) | -1.6% |
| 06_remove-one-1k | cpu | 33.2 ms (±2.2) | 34.5 ms (±2.7) | +3.9% |
| 07_create10k | cpu | 784.5 ms (±25.5) | 795.6 ms (±12.4) | +1.4% |
| 08_create1k-after1k_x2 | cpu | 104.3 ms (±4.3) | 105.6 ms (±5.5) | +1.2% |
| 09_clear1k_x8 | cpu | 42.4 ms (±2.9) | 37.6 ms (±3.2) | -11.3% |
| 21_ready-memory | memory | 5.38 MB (±n/a) | 5.26 MB (±n/a) | -2.3% |
| 22_run-memory | memory | 11.93 MB (±n/a) | 12.44 MB (±n/a) | +4.3% |
| 25_run-clear-memory | memory | 6.08 MB (±n/a) | 5.82 MB (±n/a) | -4.4% |
| 41_size-uncompressed | size | 139.30 kB (±n/a) | 101.40 kB (±n/a) | -27.2% |
| 42_size-compressed | size | 39.10 kB (±n/a) | 29.00 kB (±n/a) | -25.8% |
| 43_first-paint | size | 253.20 ms (±n/a) | 218.30 ms (±n/a) | -13.8% |
rere-benchmark
Two rounds per side, in the order main, PR, PR, main. Each round is 10 samples per bench at 8x CPU throttle, not headless. The change column compares the median of the two rounds. A row is flagged only when the change is above 5% and above the spread between rounds on both sides.
| benchmark | unit | main p50 (round 1 / round 2) | PR #21585 p50 (round 1 / round 2) | change (median of rounds) |
|---|---|---|---|---|
| DB Monitor w/ chat simulation | FPS | 24.6 / 32.8 | 28.1 / 29.2 | -0.2% |
| Incrementing Render Effect | ms | 2340.3 / 2296.8 | 2270.4 / 2333.3 | -0.7% |
| 1 item, 1k updates (async) | ms | 60.3 / 55.0 | 63.6 / 54.4 | +2.4% |
| 1 item, 1k updates | ms | 8.8 / 8.0 | 8.5 / 8.3 | +0.3% |
| 1 item, 100k updates (async) | ms | 1038.6 / 1022.8 | 1030.3 / 1043.7 | +0.6% |
| 1 item, 100k updates | ms | 39.2 / 39.3 | 43.4 / 45.6 | +13.4% (worse) |
| 1k items, 1 update each (sequentially, async) | ms | 969.6 / 952.8 | 973.2 / 1000.6 | +2.7% |
| 1k items, 1 update each (sequentially) | ms | 53.1 / 51.7 | 58.9 / 57.4 | +10.9% (worse) |
| 1k items 1 update on 5% (random, async) | ms | 120.1 / 122.9 | 126.2 / 120.4 | +1.5% |
| 1k items 1 update on 5% (random) | ms | 30.4 / 29.0 | 30.2 / 29.1 | -0.2% |
| 1k items 1 update on 25% (random, async) | ms | 314.0 / 317.5 | 343.9 / 338.4 | +8.0% (worse) |
| 1k items 1 update on 25% (random) | ms | 36.3 / 37.1 | 40.8 / 39.5 | +9.5% (worse) |
| 1 value, 1k consumers, 10k updates (bursts of 100) | ms | 2066.3 / 1921.4 | 2023.2 / 2215.7 | +6.3% |
| 1 value, 1k consumers, 10k updates (bursts of 1000) | ms | 232.7 / 227.0 | 282.4 / 260.8 | +18.2% (worse) |
| 1 value, 1k consumers, 10k updates (single burst) | ms | 63.6 / 58.4 | 58.7 / 61.9 | -1.2% |
The flagged rows are the synchronous update benches and the async 25% random bench. Those are slower in both rounds, so they look real, not noise. The fan-out bench with bursts of 1000 is the largest at 18%.
Setup
- AMD Ryzen 9 7900X, Ubuntu, Google Chrome 152.0.7977.64, virtual display reported as 59 Hz.
- js-framework-benchmark at krausest/js-framework-benchmark afe7c11,
npm run bench -- --framework keyed/ember-main keyed/ember-pr21585. - rere-benchmark at NullVoxPopuli/rere-benchmark 5d76b77,
pnpm use-tar-for ember <tgz>thenpnpm bench --framework=ember --bench=all --cpu-throttle=8 --yes. The PR side has the babel plugin patch throughpnpm.patchedDependencies. - Result files: rere runs 9 and 12 (main), 10 and 11 (PR) under
results/public/results/.
📊 Size reportTarball size — dist/dev 7%↑
Show files (94 files)
smoke-tests/v2-app-template/dist -5.25%↓
smoke-tests/v2-app-hello-world-template/dist -62.3%↓
🤖 This report was automatically generated by wyvox/pkg-size |
Spike, not for merge. It stacks the eleven PRs below so you can build one branch and see the number. Review happens in the small PRs.
smoke-tests/v2-app-hello-world-template,pnpm buildmainSee it in five minutes
pnpm install && pnpm build.cd smoke-tests/v2-app-hello-world-template && pnpm build.dist/assets/main-*.js. Servedist/and the page rendershi.What changes
A template no longer ships as a JSON block that a browser compiler turns into VM words. The build compiles a strict template to VM words, and the output imports only the opcode handlers it uses. A bundler then drops every handler, helper, and keyword that no template in the app needs. The VM registration phase at module load is gone.
The compiled output for
<template>hi </template>:The serialized template keeps its shape:
id,block,moduleName,isStrictMode,scope. Onlyblockchanges.The PRs, in order
Each PR chains on the one before it. Sizes are the hello world app after that PR, raw and gzip at the default level, from each PR's description. The table above uses gzip level 9 and brotli quality 11.
Compiler output (135.1 / 43.3 kB to 123.2 / 39.6 kB)
encodeOpregisters it on first push, so a bundler can drop handlers nobody emits.precompileModule. It prints a template as JavaScript that references its opcodes by identifier, plus the list of imports the build tool must add.babel-plugin-ember-template-compilationto useprecompileModule. By PR 11 the compilers bind every import themselves throughjsutils.bindImport, so the patch is: callprecompileModule, wrap the expression in its factory.Runtime that loads on demand (to 75.5 / 23.9 kB)
{{each}}, class normalization, and the runloop only load when used.Trim the always-on VM (to 55.2 / 17.9 kB)
VITE_NO_DEBUG_RENDER_TREE=true.Ahead of time (to 50.6 / 16.4 kB)
mutand{{outlet}}bind as imports in the AST, with JavaScript shadowing, so the keyword lists and built-in tables go away. Opcode handlers, stdlib routines, and sexp ops bind through the samejsutils.bindImportcall. Loose templates and the runtime compilers bind the same way, so the built-in name tables load only for JSON blocks from addons published before this change.What does not change
template()compiler in the browser.Status
pnpm test: 9484 tests, 17 skipped, 0 failures.pnpm lintandpnpm type-check:internalspass. The classic, Vite, and strict resolver smoke apps pass. CI is green on the fork for every PR in the chain. The plan and the numbers per step: https://claude.ai/code/artifact/153fb9f5-67b3-44e3-b2d9-d6f2f618f25b🤖 Generated with Claude Code
https://claude.ai/code/session_017PQyBrSj4CZaqMcKc7aW81