Skip to content

perf: the re-stage costs ~757 ms per million points, and every recolour pays it #462

Description

@tsenoner

Split out of #456. That issue removed camera motion from the set of things that trigger a re-stage; this one is about the cost of the re-stage itself, which is unchanged and is now the dominant interactive cost at every dataset size.

Measured

~757 ms per million points for a full rebuild (reported in #456, MacBook Pro M4 Pro / Chrome 151). It runs on any change to the data or the styling: selecting an annotation, changing the colour mapping, toggling a legend value in a way that moves depth, switching projection.

Where it goes

Per point, per rebuild, in populateBuffers (webgl/renderer/webgl-renderer.ts) and stagePoint/stagePointStyle (webgl/renderer/stage-point.ts):

  • A depth pre-pass calling getDepth, getOpacity and isPredicted.
  • An O(N log N) comparator sort (buildPaintOrder).
  • A staging callback calling getOpacity again, two d3 scale functions, then getColors, resolveColor, getPointSize, getShape/getShapeIndex and isPredicted.

That is roughly eight style-getter calls per point per rebuild, and getColors (styling/style-getters.ts) is itself allocation-heavy — getProteinAnnotationValues returns an array, then .map, .filter, a new Set and a spread. Five short-lived allocations per point, 573K times.

Then seven bufferSubData uploads (~44 MB at 1M points) and, for multi-label annotations, a full-surface atlas upload.

Directions worth evaluating, roughly in order of payoff-to-risk

  1. Hoist the invariant work out of the per-point loop. Most style getters resolve a per-value answer (a colour, a shape) via a per-point lookup. Precomputing value-indexed lookup tables once per rebuild and indexing them per point removes most of the allocation.
  2. Split the channels that actually changed. A colour-mapping change does not move positions, sizes, shapes or depths — the renderer already has a colour-only fast path, but a depth change (see perf: selection is part of the depth sort key, so the first click re-sorts the whole dataset #461) knocks it out. Fixing perf: selection is part of the depth sort key, so the first click re-sorts the whole dataset #461 makes this path reachable far more often.
  3. Radix or counting sort on the composed depth. composePaintDepth produces four tiers plus a bounded within-tier fraction, which is a much weaker requirement than a general comparator sort.
  4. Sort once and keep it. The order only has to change when depths change; today the reorder is entangled with the position update.

Not in scope

Reducing the per-point channel count (protspace stages seven arrays plus the atlas, against three for comparable renderers) would be a much larger change and costs expressiveness — per-point shapes and multi-label colour pies are real features. Worth a separate discussion if the above is not enough.

Acceptance

An annotation switch at 573K measurably faster, verified through pnpm perf's annotationChange scenario, with the before/after recorded in the PR. No change to rendered output — the existing screenshot and glyph tests are the guard.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions