feat(core): add opt-in per-phase timing to the post-mutation flush - #55
Merged
Conversation
`runPostMutation` runs as a microtask after a mutation batch, doing three things in sequence: the delete flush, the layout flush, and the focus apply. Its cost was not observable from outside, which made it impossible to say whether it was the fixed per-row cost a device investigation had cornered. That investigation, on a TV SoC running a virtualised row list, had measured a ~50ms cost to mount each new row, landing on the frame that handles the navigation keypress. A linear fit across item counts put roughly two thirds of it in cost that does NOT scale with the number of items in the row: halving items per row cut mount cost only 11% and moved frame rate not at all. The renderer's own share was ruled out by measurement (node creation is 0.054ms per node). A Chrome trace then caught `runPostMutation` at 36.7ms across five row mounts, roughly a third again on top of the keypress handler itself, which put it at the top of the list of suspects and left nothing to measure it with. Adds `Config.postMutationDebug` and a `postMutationTiming` counters object carrying call count, total and max, and the same pair for each of the three phases, plus `resetPostMutationTiming()` so a consumer can sample over an interval. `Config` is the established runtime debug seam here (`debug`, `focusDebug`, `keyDebug`, `focusHistoryDebug`) and is toggleable at runtime, which a benchmark page needs. Counters are mutated in place rather than passed to a callback so sampling allocates nothing. Off by default and free when off: `runPostMutation` reads the flag once and delegates to a separate `runPostMutationTimed`, so the default path never reaches a clock or takes a per-phase branch. Getting there meant extracting the three phase bodies into `flushDeletes` / `flushLayout` / `flushFocus` so both drivers share them; those bodies are unchanged. Worth knowing when reading the numbers: `schedulePostMutation` registers both `stage.reprocessUpdates(runPostMutation)` and `queueMicrotask(runPostMutation)`, and `runPostMutation` has no already-drained guard, so every mutation runs it twice. The second pass early-returns from all three phases on empty queues and costs nothing, but it halves any per-call mean. The trace above showed ten calls for five mounts, so the real per-call figure was 7.34ms rather than 3.67ms. The new `calls` counter makes this visible; the duplicate itself is left alone here since removing it is a behaviour change, not instrumentation. Tests: 4 new, covering silence when disabled, per-phase accumulation, that totals keep accumulating across flushes, and that reset zeroes everything. 113 pass, tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chiefcll
force-pushed
the
feat/post-mutation-timing
branch
from
August 29, 2026 03:25
418f9dc to
16227e3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
runPostMutationruns as a microtask after a mutation batch and does three things in sequence: the delete flush, the layout flush, and the focus apply. None of it was observable from outside.This adds
Config.postMutationDebugplus apostMutationTimingcounters object with call count, total and max, and the same pair for each phase, and aresetPostMutationTiming()so a consumer can sample over an interval.Why
From a performance investigation on a TV SoC running a virtualised row list. Mounting each new row costs ~50ms, and it lands on the frame handling the navigation keypress, dropping several vsyncs.
A linear fit across item counts put roughly two thirds of that in cost that does not scale with the number of items in the row: halving items per row cut mount cost only 11% and moved frame rate not at all. The renderer's own share was ruled out by direct measurement (node creation is 0.054ms per node, shader creation is a few percent).
A Chrome trace then caught
runPostMutationat 36.7ms across five row mounts, about a third again on top of the keypress handler itself. That made it the leading suspect for the fixed cost, and there was nothing to measure it with. Hence per-phase, not just a total: the point is to find which of the three dominates.Design
Configis this package's established runtime debug seam (debug,focusDebug,keyDebug,focusHistoryDebug) and is toggleable at runtime, which a benchmark page needs. An event was considered and rejected: this package has no emitter of its own, and adding one for a single consumer is new machinery. Counters are mutated in place rather than passed to a callback so sampling allocates nothing.Free when off.
runPostMutationreads the flag once and delegates to a separaterunPostMutationTimed, so the default path never reaches a clock or takes a per-phase branch. Getting there meant extracting the three phase bodies intoflushDeletes/flushLayout/flushFocusso both drivers share them. Those bodies are unchanged; please check that in review, it is the only part of this PR that touches existing behaviour.Reviewer notes
Every mutation runs
runPostMutationtwice.schedulePostMutationregisters bothstage.reprocessUpdates(runPostMutation)andqueueMicrotask(runPostMutation), andrunPostMutationhas no already-drained guard. The second pass early-returns from all three phases on empty queues and costs nothing, but it halves any per-call mean: the trace above showed ten calls for five mounts, so the real per-call figure was 7.34ms, not 3.67ms. The newcallscounter makes this visible. Not fixed here, because removing the duplicate is a behaviour change rather than instrumentation, and it deserves its own PR.Two other things noticed while reading this path, also left alone:
flushFocuson thedeferredFocusElementbranch callssetFocus(), which callsschedulePostMutation()again, so an autofocus costs a full second round trip through all three phases.flushLayoutallocates[...layoutQueue]once per outer iteration, and the outer loop runs once per ancestor level becauseupdateLayoutre-queues the parent on a size change.Verification gap, please read. This was written and verified against
v2, then rebased ontomainat your request. The cherry-pick was clean (the two branches differ inelementNode.tsonly in the import block, well away from these changes), but it has not been run againstmain.The reason is environmental rather than anything about this change: the checkout's
node_modulesis installed forv2(SolidJS 2.0) whilemainexpects SolidJS 1.x, so 10 of 18 test files fail to transform onmainregardless of this branch. Cleanmainand this branch produce byte-identical results with that tree installed (same 3tscerrors, same 2 failed tests, 137 passed), so this change demonstrably adds no new failures, but its own 4 tests are among the files that cannot collect.CI will be the first real run on
main. If it goes red, the likely causes are the SolidJS 1.x/2.0 API surface used bytests/setupor theVITE_USE_NEW_FLEXswitch that exists onmainbut notv2. The instrumentation itself timesflushLayout, which callsupdateLayoutregardless of which flex implementation is selected, so that switch should not matter.Tests
4 new: silent when disabled, per-phase accumulation with bounds checks, totals keep accumulating across flushes, reset zeroes everything.
pnpm run tscclean,pnpm test113 passed / 70 skipped,pnpm run lint0 errors, prettier clean.Not included
package.jsonandpnpm-lock.yamlare modified in the working tree, moving@solidtv/rendererfrom a dependency to a devDependency and bumping it to 1.7.0. That predates this work (node_modules has 1.7.0 installed from six days earlier) and is unrelated, so it is deliberately left out of this branch.🤖 Generated with Claude Code