You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Split out of #456, and a sibling of #461 and #462. Those cover the cost of the re-stage. This one is about what a camera frame still does after#459 removed re-staging from the camera path entirely.
#459's thesis is that a pan or a zoom is a uniform write plus a draw call, and its e2e gate proves the strong half of that: camera motion uploads zero bytes to the GPU. But zero uploads is not zero work. Two things still run on every camera frame, and neither depends on the camera.
1. A forced style recalc, per frame
renderPoints reads knockoutColor on every draw (webgl/renderer/webgl-renderer.ts:855), and the host supplies it as:
resolveColor itself is fine — it is memoised on the colour string (webgl/color-utils.ts:23) and returns the cached array on a hit, so nothing is parsed or allocated. The cost is getComputedStyle(...).backgroundColor itself, which is not cached at all.
The timing is what makes it expensive. The frame path immediately before this writes attr('transform', …) on three SVG groups (interaction/plot-interaction-controller.ts:115, :118, :121), so the style tree is dirty when the read lands and the browser must flush a recalculation to answer it. This is very likely the single largest remaining per-frame cost now that the cull is gone.
The background colour changes on theme switch, not per frame.
2. Both dirty-check signatures recomputed, per frame
render() recomputes computeDataSignature(pd) and computeStyleSignature(pd) on every frame (webgl-renderer.ts:430-431) purely to compare them equal.
After #459 these are invariant across camera motion by construction: _getPointsForRendering returns the same PlotData object every frame, and neither function reads the transform. Per frame that costs:
6 toFixed(2) plus 4 template concatenations (data signature)
an indices array literal, a .filter(), a .map(), 4 template strings with 3 toFixed each, and a .join() (style signature)
Roughly 20-30 short-lived allocations per frame, ~1,200-1,800/sec at 60fps, of pure GC churn on the frame path.
Note also that computeStyleSignature's .filter((i) => i < len) is dead: the indices are [0, floor(len/4), floor(len/2), len-1], all < len for any len >= 1.
#459 was scoped to deleting the cull, and it is already a large stack base (#458 -> #459 -> #460). Item 1 is pre-existing and needs theme-change invalidation wiring to fix properly. Item 2 is a memoisation whose correctness argument depends on#459's identity guarantee, so it could not have been made before that landed.
Suggested shape
Item 1 — resolve the knockout colour once and cache it on the host, invalidating from the existing theme/resize paths (or a matchMedia('(prefers-color-scheme: …)') listener). Pass the cached tuple through the existing getter so the renderer's contract is unchanged. Worth checking first with a profile that the recalc is as expensive as the ordering suggests.
Item 2 — memoise both signatures on pd object identity plus the existing styleSignature/stylesDirty state. This is legitimate because of #459's identity guarantee, and unlike the explicit dirty flags the #456 design doc rejected, it keeps the content sampling for every new PlotData. A zero-risk subset if the memo is judged too clever: delete the dead .filter and build the string with a fixed 4-iteration loop.
Fixing #461 and #462 does not subsume either item — both of those are about work that runs on a re-stage, and neither of these runs on a re-stage.
Acceptance
A measurable reduction in per-frame work for the zoomInOut and dragCanvas scenarios of pnpm perf at 573K, with before/after recorded in the PR. The camera-no-restage e2e gate must stay green — its uploadedBytes delta of 0 is unaffected by either change, which is the point: this is the work that gate cannot see.
Split out of #456, and a sibling of #461 and #462. Those cover the cost of the re-stage. This one is about what a camera frame still does after #459 removed re-staging from the camera path entirely.
#459's thesis is that a pan or a zoom is a uniform write plus a draw call, and its e2e gate proves the strong half of that: camera motion uploads zero bytes to the GPU. But zero uploads is not zero work. Two things still run on every camera frame, and neither depends on the camera.
1. A forced style recalc, per frame
renderPointsreadsknockoutColoron every draw (webgl/renderer/webgl-renderer.ts:855), and the host supplies it as:resolveColoritself is fine — it is memoised on the colour string (webgl/color-utils.ts:23) and returns the cached array on a hit, so nothing is parsed or allocated. The cost isgetComputedStyle(...).backgroundColoritself, which is not cached at all.The timing is what makes it expensive. The frame path immediately before this writes
attr('transform', …)on three SVG groups (interaction/plot-interaction-controller.ts:115,:118,:121), so the style tree is dirty when the read lands and the browser must flush a recalculation to answer it. This is very likely the single largest remaining per-frame cost now that the cull is gone.The background colour changes on theme switch, not per frame.
2. Both dirty-check signatures recomputed, per frame
render()recomputescomputeDataSignature(pd)andcomputeStyleSignature(pd)on every frame (webgl-renderer.ts:430-431) purely to compare them equal.After #459 these are invariant across camera motion by construction:
_getPointsForRenderingreturns the samePlotDataobject every frame, and neither function reads the transform. Per frame that costs:toFixed(2)plus 4 template concatenations (data signature)indicesarray literal, a.filter(), a.map(), 4 template strings with 3toFixedeach, and a.join()(style signature)getColors()calls — andgetColors(styling/style-getters.ts) is the allocation-heavy one perf: the re-stage costs ~757 ms per million points, and every recolour pays it #462 describes:getProteinAnnotationValuesreturns an array, then.map,.filter, anew Setand a spread. Only element[0]is used.Roughly 20-30 short-lived allocations per frame, ~1,200-1,800/sec at 60fps, of pure GC churn on the frame path.
Note also that
computeStyleSignature's.filter((i) => i < len)is dead: the indices are[0, floor(len/4), floor(len/2), len-1], all< lenfor anylen >= 1.Why not in #459
#459 was scoped to deleting the cull, and it is already a large stack base (#458 -> #459 -> #460). Item 1 is pre-existing and needs theme-change invalidation wiring to fix properly. Item 2 is a memoisation whose correctness argument depends on #459's identity guarantee, so it could not have been made before that landed.
Suggested shape
Item 1 — resolve the knockout colour once and cache it on the host, invalidating from the existing theme/resize paths (or a
matchMedia('(prefers-color-scheme: …)')listener). Pass the cached tuple through the existing getter so the renderer's contract is unchanged. Worth checking first with a profile that the recalc is as expensive as the ordering suggests.Item 2 — memoise both signatures on
pdobject identity plus the existingstyleSignature/stylesDirtystate. This is legitimate because of #459's identity guarantee, and unlike the explicit dirty flags the #456 design doc rejected, it keeps the content sampling for every newPlotData. A zero-risk subset if the memo is judged too clever: delete the dead.filterand build the string with a fixed 4-iteration loop.Fixing #461 and #462 does not subsume either item — both of those are about work that runs on a re-stage, and neither of these runs on a re-stage.
Acceptance
A measurable reduction in per-frame work for the
zoomInOutanddragCanvasscenarios ofpnpm perfat 573K, with before/after recorded in the PR. Thecamera-no-restagee2e gate must stay green — itsuploadedBytesdelta of 0 is unaffected by either change, which is the point: this is the work that gate cannot see.