Skip to content

perf(graph): experiment with prepare/publish/paint tree culling (alternative to #428) - #432

Draft
draedful wants to merge 16 commits into
v2from
claude/culling-redesign
Draft

draedful wants to merge 16 commits into
v2from
claude/culling-redesign

Conversation

@draedful

Copy link
Copy Markdown
Collaborator

Experiment. An alternative implementation of tree-native viewport culling, built on top of #428 to compare the two on the preview stand. It is not meant to be merged as is.

Only the difference from #428: experiment/tile-visibility-culling...claude/culling-redesign (two commits: 75455305 makes #428's visibility incremental, d25cb4ec is the redesign). The branch is not merged with the two newest v2 commits (#424, #425), so that this comparison stays clean.

Why

#428 is fast when zoomed in but slower than v2 when zoomed out (scales 0.01–0.03), most of all on connection-heavy scenes such as Nirvana Max Graph, and on the first frames after mounting. The costs that grew with the number of visible nodes:

  • a separate prepare pass for every visible clean node;
  • an O(N) selection and publication every frame;
  • a binary switch between sparse and dense traversal;
  • a Map and sort based painter;
  • updatePoints running three times per connection on mount.

Behavior and architecture

Each culled layer root has one TreeCulling coordinator that runs a frame in three steps:

  1. Prepare. The owner, new nodes and nodes that requested an update call prepareUpdate(), ancestors first. Clean nodes are not touched.
  2. Publish visibility. It is kept between frames. A camera move rechecks only nodes in the strips between the old and new viewports, plus nodes with changed bounds. A full selection runs only after a structural change; when the viewport covers the whole index, every eligible node is visible without a query. Bounds reach the TileIndex once per frame, right before publication.
  3. Paint. One flat pass over the cached local paint order calls iterate(prepared, visible) only for visible and already prepared nodes. A clean visible node applies its own pending work inside iterate(). Clean offscreen nodes get no call; their visible descendants are still reached through them.

A frame therefore costs the visible nodes plus the work that changed.

  • Contract. The tree decides spatial visibility and passes it to iterate(). A component can only veto painting through isRenderAllowed() (hidden blocks, connections without geometry). isVisible() combines that veto with isInViewport() for queries.
  • Bookkeeping. The coordinator's node sets are arrays indexed by slots stored on the nodes, with no hashing. The paint order also marks the eligible nodes, so publication does not call isEligible() per node. Tree.setChildren() with unchanged children no longer invalidates the scope. Before this, Block.updateChildren() returned a new anchor array on every update and rebuilt the whole scope's structure, including on every drag frame.
  • Connections. Batches keep v2's chunks: up to 100 items in registration order, so overlap blending is identical to v2. A chunk whose bounds lie outside the viewport is skipped. Camera moves never rebuild chunk paths. Connections cache their line and arrow paths, and labels skip themselves through isInViewport() in afterRender.

Internal API changes

Compatibility of Tree and Component internals was intentionally not preserved. What to restore, and how, is to be discussed separately.

  • ITree.iterate(prepared, visible); the tree and scheduler iterator is (node, prepared, visible).
  • Removed: onViewportChange, isSkippedByCulling, the getWalkToken option, preparesOnIterate, invalidateVisibility, getChildrenArray, activeInViewport, Tree.setVisible.
  • Components override isRenderAllowed() instead of isVisible() to veto painting.
  • Batch items return cached geometry from getPath(). New: BlockConnection.getArrowPath(), batch.markBoundsDirty(item). A chunk containing an item without getPathBounds() is never culled.

Results

GraphLayer scheduler.update time, ms, measured with scripts/bench/culling (headless Chromium, ANGLE Metal, Apple Silicon, DPR 2). Both versions run the same zoom history before measuring, and screenshots are compared after the camera moves.

Panning, near view (1600×1000):

Scale Nirvana v2 Nirvana this PR Playground v2 Playground this PR
0.01 13.3 11.4 22.8 21.5
0.02 12.9 10.6 19.4 16.1
0.05 10.4 5.0 13.2 7.1
0.1 8.6 2.3 10.3 2.3
0.2 8.5 1.9 9.6 1.5

Zooming and the full view (1600×1600) follow the same pattern.

First two frames after mounting, scale 0.01:

  • Nirvana: on par with v2 (±2–3% between runs).
  • Playground: still +5…+11% (for example 416 vs 374 ms); 0–5% at scales 0.1–0.2. The remaining cost is a few extra passes over ~38k nodes while the code is not yet optimized by the JIT: indexing, publication, paint-order build and registration.

Screenshots match v2 at every measured point. At scale 0.01, GPU rasterization varies by a few grey levels between page loads, even for the same version; the benchmark reports the largest per-channel difference to tell this noise from culling errors.

Benchmark

node scripts/bench/culling/run.mjs --variants base=origin/v2,current=. --scales 0.01,0.02,0.05,0.1
node scripts/bench/culling/run.mjs --variants pr428=origin/experiment/tile-visibility-culling,current=. --first --repeats 12

Scenes, options and caveats are described in scripts/bench/culling/README.md.

Not verified

  • Tests and typecheck of the other packages (graph-react, devtools and so on), and e2e. packages/graph typecheck, lint and all 569 tests pass.
  • Scenes with connection labels turned on.
  • Browsers and hardware other than Chromium with Metal on Apple Silicon.
  • Manual checks in Storybook.

🤖 Generated with Claude Code

draedful and others added 16 commits October 20, 2026 13:14
Blocks, connections and groups containers keep their children in a
world-space TileIndex. Each frame a container walks the children whose
bounds intersect the camera viewport (plus a margin), children that
requested a render, newly appended children and children that just
left the view, in the same zIndex/renderOrder order as a full walk.
It falls back to a full walk when most children are near the viewport.

- performRender() marks the branch pending in culling ancestors, so
  pending props/state, first iterate and anchor updates of off-screen
  components are still processed in the next frame.
- renderOrder is assigned to every child when the z-order is rebuilt,
  and isIterated() is true for children skipped by culling, so point
  hit tests keep today's results.
- EXP_VISIBILITY_CULLING setting (default true), VISIBILITY_TILE_SIZE
  and VISIBILITY_CULLING_MARGIN constants.
- Fix Tree.updateZIndex leaving the node in its previous zIndex group
  (a raised block was walked and drawn twice) and removeZIndex keeping
  removed nodes in the cached order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lets GraphStressTest and NirvanaMaxGraphTest be compared with
EXP_VISIBILITY_CULLING on and off on the preview stand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prepare only new and requested nodes before painting, then publish
viewport visibility incrementally: a camera move rechecks nodes in the
strips between the previous and current viewport plus nodes with new
bounds, and a structural change runs one complete selection. A single
flat pass over the cached paint order visits published and prepared
nodes; clean components update inside their own iterate().

This removes the per-frame preparation pass, linear selection and the
dense/sparse paint paths that made zoomed-out views slower than v2.
Connections compute geometry once per real input change instead of up
to three times on mount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A culled layer root now runs each frame in three steps owned by TreeCulling:
new and requested nodes prepare, viewport visibility is published
incrementally, and one flat pass over the cached paint order calls
iterate(prepared, visible) only for visible and prepared nodes.

- ITree.iterate(prepared, visible) replaces the per-node viewport callbacks;
  components veto painting through isRenderAllowed().
- Coordinator node sets are arrays with slots stored on the nodes, and the
  paint order marks eligible nodes for publication.
- Tree.setChildren() with unchanged children no longer invalidates the scope.
- Connection batches keep base-sized chunks in registration order and skip
  chunks outside the viewport; connections cache line and arrow paths.

Tree and Component internals change without compatibility shims:
ITree.iterate and the tree iterator take (prepared, visible); onViewportChange,
isSkippedByCulling, getWalkToken, preparesOnIterate, invalidateVisibility,
getChildrenArray, activeInViewport and Tree.setVisible are removed; override
isRenderAllowed() instead of isVisible(); batch items return cached geometry
from getPath() and report getPathBounds().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
node scripts/bench/culling/run.mjs builds several versions of packages/graph
(git refs or the working tree) and measures GraphLayer update time while
panning and zooming the Nirvana and Playground stress scenes, plus the first
two frames after mounting, and compares their screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gravity-ui-bot

Copy link
Copy Markdown
Contributor

Preview is ready.

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