[PERF] Simplify the VM's hot paths - #21564
Conversation
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>
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>
|
Reverted the two behaviour changes. Neither belongs in perf work.
What survives in that file is internal only: Cost of the revert, measured on the Node VM benchmark: total goes from -35.7% to about -15%. The browser numbers currently in the description describe the reverted code. I am re-running |
|
@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>
|
Ran rere-benchmark. It is the right benchmark for this PR, and it changes the picture: Three independent pairs,
Two things for you, separate from this PR. First, nine of fifteen benches flip sign between pairs at Second, Method: both builds packed with 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 |
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.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=10most 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:
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:
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.
AppendOpcodeswrapped every handler in an object so it could dispatch machine opcodes, which nothing ever registered.RuntimeOpImpldecoded the same instruction header once per getter, so the VM read one heap word three or four times per instruction.LowLevelVMhad four methods where two do.{{#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.AppendingBlockImplwrapped every appended node in two objects, and now keeps node edges and nested block edges in separate monomorphic fields.Trackerallocated aSetper tracking frame, and almost every frame holds zero or one tag.ArrayIteratorcarried a state object to remember whether it had yielded its first item.pushElementallocated a cursor per opened element, roughly eight per row.VM._executeallocated an iterator result per instruction.destroyallocated two closures per call.uniqueKeyForwent through a wrapper with lazy accessors for every item on every pass over a list.FunctionHelperManager.getValueallocated a key array to test whether named args exist.ProgramHeapImpl.compact()andfree()were unreachable, andcompact()looped over the globallengthrather than the handle table. Three unused register tuple factories. A duplicatesnapshotdefinition assigned in two places with different behaviour.Two bugs found on the way
ListBlockOpcode.syncreadchildren[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 onmaintoday.LowLevelRegisterswas typed[$pc, $ra, $sp, $fp], but the constants are$fp = 2and$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:
RuntimeOpgainsseek(offset), and its fields become readonly.TreeBuilder.cursorsgoes fromStack<Cursor>toreadonly Cursor[].ProgramHeapImplloses 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
mainon the same machine.tsc,eslintandprettierare clean.On the benchmark harness
Worth knowing separately from this PR. I ran
pnpm benchfrom amainworktree, comparingmainagainst itself. It reported two significant phase improvements of about 7%, onrender1000Items2andupdateEvery10thItem2. At fidelity 20 the harness has a real false positive rate on individual phases. Thedurationtotal behaved correctly.That is also why an earlier
render1000Items3regression 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
1k items, 1 update each (sequentially, async)regression in rere-benchmark, consistent at about -4%.pnpm benchclear phase regressions above.UpdatingVM._executehas a megamorphic call site atopcode.evaluate(this), which is inherent to an opcode interpreter.ArrayIterator.nextcallsthis.keyForpolymorphically, because each key strategy is a different closure.