Skip to content

[PERF] Simplify the VM's hot paths - #21564

Draft
NullVoxPopuli-ai-agent wants to merge 5 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:perf/simplify-vm-hot-paths
Draft

[PERF] Simplify the VM's hot paths#21564
NullVoxPopuli-ai-agent wants to merge 5 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:perf/simplify-vm-hot-paths

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Removes indirection and per-item allocation from the VM's hot paths, and fixes two bugs found along the way. Net -120 lines. No behaviour change.

Three benchmarks, three different answers. The short version: partial updates over a list get 9 to 22% faster, one bench is consistently 4% slower, and most of the rest sits under the noise floor.

Numbers

rere-benchmark

The closest benchmark to what this PR touches, since it measures rendering after boot rather than boot itself. Three independent pairs, --count=10, 8x CPU throttle. A bench only counts as moved when all three pairs agree. Positive means this branch is better.

bench pair 1 pair 2 pair 3
1k items, 1 update on 5% (random, async) +21.9% +14.5% +9.4% faster
1k items, 1 update on 5% (random) +2.1% +3.6% +7.5% faster
Incrementing Render Effect +1.8% +1.5% +2.4% faster
1k items, 1 update each (sequentially, async) -3.6% -4.1% -3.8% slower
DB Monitor w/ chat simulation +0.3% +0.2% -0.1% flat
1 value, 1k consumers (single burst) +1.6% -1.8% -0.6% flat
the other 9 benches unstable

Nine of fifteen benches flip sign between pairs, so I am not reporting numbers for them. The widest was 1 item, 1k updates: +22.1%, then +9.8%, then -12.6%. At --count=10 most of these benches move more between identical runs than this PR moves them.

The 1k items, 1 update each (sequentially, async) regression is the only consistent negative anywhere in this PR, and it is tight enough across pairs to be worth chasing.

VM work in isolation

Measured in Node against SimpleDOM so DOM cost does not mask it. Interleaved pairs, medians in ms:

phase main branch delta
create 30.25 19.00 -34.6%
update every 10th 16.91 8.81 -46.1%
select row 5.37 4.69 -8.8%
swap rows 5.24 4.81 -6.1%
remove row 4.80 4.23 -2.0%
append 1000 50.03 48.12 -5.1%
clear 13.68 13.12 -5.5%
total 128.40 107.80 -15.8%

pnpm bench

Fidelity 20, 8x CPU throttle. This one renders and clears large lists, where DOM cost dominates and VM cost is a minority of the run:

phase branch vs main
duration (total) no difference [-862ms to +763ms]
render1000Items1 -5.44%
append1000Items1 -7.63%
selectFirstRow1 -13.37%
clearItems2 +6.16%
clearManyItems2 +7.56%
the other 16 phases no difference

Two clear phases regress, and they regressed in an earlier run of this branch too, on the same two phases. That is reproducible rather than harness noise, and I have not found the cause. The other three clear phases show no difference, and the total is not significant either way.

What changed

Five groups. The diff is small enough to read.

  1. Opcode dispatch. AppendOpcodes wrapped every handler in an object so it could dispatch machine opcodes, which nothing ever registered. RuntimeOpImpl decoded the same instruction header once per getter, so the VM read one heap word three or four times per instruction. LowLevelVM had four methods where two do.
  2. Per-VM allocation. A VM is built for every block that re-renders on its own, one per {{#each}} item. Its six bookkeeping stacks, the tree builder's two stacks, and the updating VM's frames were wrapper objects over arrays. They are arrays now.
  3. Per-item allocation. AppendingBlockImpl wrapped every appended node in two objects, and now keeps node edges and nested block edges in separate monomorphic fields. Tracker allocated a Set per tracking frame, and almost every frame holds zero or one tag. ArrayIterator carried a state object to remember whether it had yielded its first item. pushElement allocated a cursor per opened element, roughly eight per row.
  4. Per-call allocation. VM._execute allocated an iterator result per instruction. destroy allocated two closures per call. uniqueKeyFor went through a wrapper with lazy accessors for every item on every pass over a list. FunctionHelperManager.getValue allocated a key array to test whether named args exist.
  5. Dead code. ProgramHeapImpl.compact() and free() were unreachable, and compact() looped over the global length rather than the handle table. Three unused register tuple factories. A duplicate snapshot definition assigned in two places with different behaviour.

Two bugs found on the way

ListBlockOpcode.sync read children[i] past the end for every list that grew. That deoptimizes the load and leaves it generic for the rest of the process, and it runs once per item. Present on main today.

LowLevelRegisters was typed [$pc, $ra, $sp, $fp], but the constants are $fp = 2 and $sp = 3. Anyone who writes a register tuple positionally gets the last two backwards. That happened to me and cost about 700 test failures.

Needs a reviewer's eye

Three type-level changes. None alters runtime behaviour, but all three touch published surface:

  1. RuntimeOp gains seek(offset), and its fields become readonly.
  2. TreeBuilder.cursors goes from Stack<Cursor> to readonly Cursor[].
  3. ProgramHeapImpl loses two public methods that could not work.

Split out of this PR

Two earlier commits changed behaviour and are reverted here. Each is now its own PR with its own measurement:

Measuring them separately is what showed that the first was the entire destroyable win and the second was noise. Together they looked like one result.

Testing

9449 tests, 9432 pass, 17 skip, 0 fail. Identical to main on the same machine. tsc, eslint and prettier are clean.

On the benchmark harness

Worth knowing separately from this PR. I ran pnpm bench from a main worktree, comparing main against itself. It reported two significant phase improvements of about 7%, on render1000Items2 and updateEvery10thItem2. At fidelity 20 the harness has a real false positive rate on individual phases. The duration total behaved correctly.

That is also why an earlier render1000Items3 regression here was not real. A paired re-measurement over 26 runs put it at -8.6% [-17.3% to +3.2%], and the interval excludes the reported +11%.

Not done

  • The 1k items, 1 update each (sequentially, async) regression in rere-benchmark, consistent at about -4%.
  • The two pnpm bench clear phase regressions above.
  • UpdatingVM._execute has a megamorphic call site at opcode.evaluate(this), which is inherent to an opcode interpreter.
  • ArrayIterator.next calls this.keyFor polymorphically, because each key strategy is a different closure.

NullVoxPopuli and others added 3 commits August 16, 2026 09:39
Reduces work on the paths the js-framework-benchmark app exercises, by
deleting indirection rather than adding fast paths. Net -140 lines.

Dispatch:
- `AppendOpcodes` stored every handler in an `{ syscall, evaluate }` wrapper
  so it could dispatch machine opcodes too. Nothing ever registered a machine
  opcode (`LowLevelVM` switches on them inline), so the wrapper, its union
  type, the overloads and both asserts are gone. Dispatch is now one array
  load and a call.
- `LowLevelVM.evaluateOuter/evaluateInner/evaluateMachine/evaluateSyscall`
  collapse into `evaluateOuter` plus `evaluate`.
- `RuntimeOpImpl` decoded the instruction header separately in each of the
  `type`, `size` and `isMachine` getters, so every instruction read the same
  heap word three or four times. `seek(offset)` decodes it once into plain
  fields.
- `VM._execute` drove the loop through `next()`, allocating a
  `{ done, value }` result per instruction. It now loops directly;
  `next()` stays for `TemplateIterator` and returns a shared constant.

Allocations, mostly per `{{#each}}` item:
- `VM`'s six bookkeeping stacks and the tree builder's block/modifier stacks
  were `StackImpl` wrappers over arrays. They are arrays now. A VM is
  constructed per re-rendered block, so this is 8 fewer objects each.
- `UpdatingVM` allocated an `UpdatingVMFrame` per block per revalidation
  pass. Frames are three parallel arrays; the class is gone.
- `AppendingBlockImpl` wrapped every appended node in `First`/`Last` objects
  purely to give nodes a `Bounds`-shaped interface. It stores the node
  directly and both classes are gone.
- `Tracker` allocated a `Set` per tracking frame. Nearly every frame consumes
  zero or one distinct tag, so the `Set` is deferred until a second one shows
  up.
- `ArrayIterator` carried a three-state `{ kind }` object to remember whether
  it had yielded its first item. A `pos` counter starting at -1 says the same
  thing. Note that reading `length` in the constructor is load-bearing for
  tracked collections; see the comment.
- `FunctionHelperManager.getValue` called `Object.keys(named).length` to test
  for named args, allocating a key array on every helper call.

Dead code:
- `ProgramHeapImpl.compact()` and `free()` were never called. `compact()`
  also looped over the global `length` rather than the handle table, so it
  could not have worked. The `handleState` table it maintained goes with it.
- Three unused register-tuple factories in `low-level.ts`, and a duplicate
  `snapshot` definition in `stack.ts`.

Also fixes the `LowLevelRegisters` tuple labels, which claimed
`[$pc, $ra, $sp, $fp]` while the register constants are `$fp = 2, $sp = 3`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e builder

Follow-ups to the previous commit, found by profiling and by running
@e18e/deopt over a Node-hosted version of the benchmark workload.

@glimmer/destroyable — `getDestroyableMeta` was the hottest Glimmer function
in a CPU profile of the benchmark app at 1.7% self time:

- Meta now lives on the destroyable under a symbol rather than in a module
  `WeakMap`. A `WeakMap.get` also forces an identity hash onto the key the
  first time it is used as one.
- `iterate` takes the value it used to close over as an argument, so `destroy`
  passes module-level functions instead of allocating two closures per call.
  It also uses an index loop rather than `Array#forEach`.
- Finalizers (detach from parents, mark destroyed) drain in one shared pass.
  Every `destroy` still schedules a pass, so a dropped or cancelled queue
  can't strand anything, but whichever pass runs first drains everyone queued
  since. This replaces one closure per destroyable, of which clearing a large
  list produces tens of thousands.
- Destroyable tracking needs a real registry now that meta isn't enumerable.
  It's a `Set` populated only between `enableDestroyableTracking()` and
  `assertDestroyablesDestroyed()`.

@glimmer/reference — `uniqueKeyFor` runs for every item on every pass over a
list, purely so that duplicate keys can be disambiguated:

- `WeakMapWithPrimitives` is gone. Both it and `identityForNthOccurence` now
  hold a `WeakMap` and a `Map` directly rather than going through a wrapper
  object with lazy accessors, which cost two accessor calls and an
  `isIndexable` check on each of a get and a set, per item.

@glimmer/runtime — element builder:

- `pushElement` reuses cursor slots above the current depth instead of
  allocating a `CursorImpl` per opened element. That's roughly eight per row,
  so ~80k for a 10,000-row render. `cursors` is a plain array with an explicit
  `cursorDepth`; `RehydrateTree` still allocates its own richer cursors, which
  it pushes through `pushCursor`.

@glimmer/runtime — list diff:

- `sync` read `children[i]` past the end for every list that grew. An
  out-of-bounds element read deoptimizes the load and leaves it generic for
  the rest of the process, and this one runs once per item. The bounds are
  checked explicitly now. Pre-existing on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drops em dashes and multi-line restatements, per the repo prose guidance.
Comment-only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent changed the title [PERF] Flatten VM opcode dispatch and cut per-block allocations [PERF] Simplify the VM's hot paths Aug 16, 2026
Both were breaking, and perf work must not carry breaking changes.

Meta goes back into the module `WeakMap`. Storing it on the destroyable
under a symbol threw on frozen and sealed objects, which `registerDestructor`,
`destroy` and `associateDestroyableChild` accept today through
`@ember/destroyable`. A symbol property is also visible to `Reflect.ownKeys`.

Destroy finalizers go back to one scheduled callback each. Draining them in a
shared pass moved work within the runloop `destroy` queue, and queue ordering
needs an RFC.

What stays is internal only: `iterate` takes its context as an argument, so
`destroy` passes module-level functions rather than allocating two closures per
call, and it walks branded arrays with an index loop. Scheduling, ordering and
the captured `parents` list match `main` exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Reverted the two behaviour changes. Neither belongs in perf work.

  1. Destroyable meta is back in the module WeakMap. Storing it on the object under a symbol threw on frozen and sealed destroyables, which @ember/destroyable accepts today, and a symbol property is visible to Reflect.ownKeys.
  2. Destroy finalizers are back to one scheduled callback each. Batching them moved work inside the runloop destroy queue, and queue ordering needs an RFC.

What survives in that file is internal only: iterate takes its context as an argument so destroy passes module-level functions instead of allocating two closures per call. Scheduling, ordering and the captured parents list now match main exactly.

Cost of the revert, measured on the Node VM benchmark: total goes from -35.7% to about -15%. clear and append lose their gains, which is where destroyable work dominated.

The browser numbers currently in the description describe the reverted code. I am re-running pnpm bench and will replace them.

@NullVoxPopuli

Copy link
Copy Markdown
Contributor

@NullVoxPopuli-ai-agent open new separate prs for just the reverted changes

Replacing the `First` and `Last` wrapper objects with a `SimpleNode | Bounds`
union removed two allocations per appended node, but made `firstNode` and
`lastNode` polymorphic. @e18e/deopt reported a `wrong map` deoptimization on
`firstNode` that `main` does not have, and `clear` reads both for every bounds
it removes.

Nodes and nested blocks now live in separate fields. No allocation returns, and
`firstNode` is gone from the deopt report.

On the Node VM benchmark this moves `clear` from +0.2% to -5.5%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Ran rere-benchmark. It is the right benchmark for this PR, and it changes the picture: pnpm bench renders and clears large lists where DOM cost dominates, rere measures updates after boot, which is what this code touches.

Three independent pairs, --count=10, 8x throttle, ember only. Positive means this branch is better.

bench pair 1 pair 2 pair 3
1k items, 1 update on 5% (random, async) +21.9% +14.5% +9.4% faster
1k items, 1 update on 5% (random) +2.1% +3.6% +7.5% faster
Incrementing Render Effect +1.8% +1.5% +2.4% faster
1k items, 1 update each (sequentially, async) -3.6% -4.1% -3.8% slower
DB Monitor w/ chat simulation +0.3% +0.2% -0.1% flat
1 value, 1k consumers (single burst) +1.6% -1.8% -0.6% flat
1 item, 1k updates +22.1% +9.8% -12.6% unstable
1 item, 1k updates (async) +10.4% +3.6% +0.1% unstable
1 item, 100k updates +1.6% +1.4% -5.1% unstable
1 item, 100k updates (async) +0.7% -1.4% -2.3% unstable
1k items, 1 update each (sequentially) +7.2% -3.2% +9.9% unstable
1k items, 1 update on 25% (random) +9.1% -6.3% +14.0% unstable
1k items, 1 update on 25% (random, async) +4.0% -8.7% -11.0% unstable
1 value, 1k consumers (bursts of 100) -2.3% -12.7% -0.2% unstable
1 value, 1k consumers (bursts of 1000) -5.2% +12.3% +7.5% unstable

Two things for you, separate from this PR.

First, nine of fifteen benches flip sign between pairs at --count=10. 1 item, 1k updates went +22.1%, +9.8%, then -12.6%. Had I run one pair I would have reported a 22% win on a bench that comes back -12.6% on the next run. Whatever count the benches settle at, it looks higher than 10 for the fan-out and 25%-random ones.

Second, 1k items, 1 update each (sequentially, async) is -3.6/-4.1/-3.8%. That is the only consistent regression in this PR and I have not found its cause yet.

Method: both builds packed with build-for-publishing, installed with pnpm use-tar-for ember, alternating main and branch, same machine, same settings. p50 of :done minus :start, matching results/app/utils.ts.

Small note on the runner: it prompts for the save file and for the hardware confirmation, so it needs a TTY. On a headless box pnpm bench --framework=ember --bench=all exits 13 at the first prompt. A flag to accept both would make it scriptable in CI.

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