Skip to content

Commit e28d5f2

Browse files
committed
docs: record vue node large-graph performance work
Captures what was changed, what each change bought, how it was measured, and what is deliberately left - including the constraints that shaped the implementation, which are not obvious from the code alone.
1 parent 1c66c7f commit e28d5f2

1 file changed

Lines changed: 158 additions & 0 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# Vue nodes on large graphs
2+
3+
Vue node rendering was unusable on large workflows: every node in the graph
4+
was mounted as a DOM component tree at all times, at every zoom level. A
5+
3000-node workflow spent 310ms per frame while panning — roughly 3fps — where
6+
the canvas renderer managed 27ms on the same graph.
7+
8+
This document records what was changed, what it bought, and what is left.
9+
10+
## Result
11+
12+
Median frame time while panning. "litegraph" is the canvas renderer as it was
13+
before any of this work, and is the bar Vue nodes needed to clear.
14+
15+
| nodes | zoom | litegraph | vue before | vue after | vs litegraph | speedup |
16+
| ----: | ---- | --------: | ---------: | --------: | -----------: | ------: |
17+
| 1000 | 0.6 | 10.1 ms | 57.1 ms | 12.0 ms | 1.19x | 4.8x |
18+
| 1000 | min | 25.1 ms | 66.3 ms | 20.7 ms | 0.82x | 3.2x |
19+
| 1000 | fit | 37.2 ms | 81.4 ms | 34.2 ms | 0.92x | 2.4x |
20+
| 3000 | 0.6 | 27.3 ms | 310.5 ms | 23.1 ms | 0.85x | 13.4x |
21+
| 3000 | min | 41.6 ms | 280.5 ms | 34.3 ms | 0.82x | 8.2x |
22+
| 3000 | fit | 126.5 ms | 380.7 ms | 104.0 ms | 0.82x | 3.7x |
23+
24+
Vue nodes now match or beat the original canvas-renderer baseline in five of
25+
six configurations. DOM element counts on the 3000-node graph fell from 227,787
26+
to 5,355 at working zoom and to 418 (application chrome only) when zoomed out.
27+
28+
`min` is the lowest zoom reachable by scrolling (0.1). `fit` is the zoom a
29+
workflow opens at, which the loader picks to frame the whole graph and which
30+
can be far below `min` — 0.0255 for the 3000-node graph.
31+
32+
## What changed
33+
34+
Each of these is a separate commit and can be reverted on its own.
35+
36+
### Minimap change detection
37+
38+
The minimap re-derived the entire graph every animation frame, and profiling a
39+
pan showed roughly 62% of samples inside it — more than the node DOM. Its data
40+
source ran a linear `find` over every node inside a loop over every node
41+
(O(n²), ~9M comparisons per frame at 3000 nodes), serialised every link to
42+
JSON to detect rewires, and rebuilt itself several times per render pass.
43+
44+
Change detection now uses an allocation-free rolling digest, the data source is
45+
memoised per pass, node lookups go through the graph's own id index, and the
46+
loop polls at 100ms rather than every frame. Structural edits already force a
47+
redraw through event hooks; the loop only exists to catch drags and resizes,
48+
which emit no event.
49+
50+
This is renderer-independent, so it made the canvas renderer faster too.
51+
52+
### Viewport culling
53+
54+
Only nodes intersecting an expanded viewport rect are mounted. Nodes mount as
55+
soon as they enter and unmount after a short delay, so nodes oscillating on the
56+
viewport edge do not thrash. Because that delay only elapses once panning
57+
stops, a long continuous pan would otherwise accumulate everything it swept
58+
over; an eager prune bounds the mounted set once it outgrows what is visible.
59+
60+
Two constraints shaped this:
61+
62+
- **The mounted set must not be a reactive dependency of the camera.**
63+
`TransformPane` deliberately writes its transform by direct DOM mutation to
64+
avoid re-diffing every node each frame; binding the node list to the camera
65+
would reintroduce exactly that cost. Camera state is sampled through a
66+
throttle instead.
67+
- **Culling must unmount, never `display: none`.** The shared `ResizeObserver`
68+
has no zero-size guard, so a hidden node writes 0x0 bounds back through
69+
`layoutStore` into `liteNode.size` and corrupts the saved workflow.
70+
71+
Selected nodes are never culled, so unmounting cannot interrupt a drag or
72+
resize in progress.
73+
74+
### Culling margin
75+
76+
The margin was a fraction of the viewport, which is screen-space, so dividing
77+
it by the zoom made it cover more and more of the graph as the user zoomed out
78+
— at minimum zoom it more than doubled the queried area and mounted 2012 of
79+
3003 nodes. It is now capped in graph units, which leaves working zoom
80+
untouched and only binds below roughly 0.35 zoom.
81+
82+
### Node culling index
83+
84+
A QuadTree of node bounds, rebuilt only when `layoutStore.layoutVersion`
85+
changes, so panning over a static graph costs a tree query rather than a scan.
86+
87+
It deliberately does not reuse the shared `SpatialIndexManager`: that index is
88+
built on `QUADTREE_CONFIG.DEFAULT_BOUNDS`, a fixed ±10000 box, and
89+
`QuadTree.insert` silently drops anything not fully contained — its return
90+
value is discarded, and `update()` cannot recover an item that was never
91+
inserted. Real workflows outgrow that box, so most nodes would be missing from
92+
query results. This index sizes its root to the graph's own extent.
93+
94+
### Level of detail
95+
96+
Below the zoom at which node text is legible, nodes are drawn as plain filled
97+
rectangles on a canvas and no Vue components are mounted at all. This mirrors
98+
what the canvas renderer has always done for its own low-quality pass, and uses
99+
the same threshold (`min_font_size_for_lod`, adjusted for DPR) so both
100+
renderers simplify at the same zoom.
101+
102+
The drawing lives in `renderer/core/canvas/nodeBoxRenderer.ts` — a pure
103+
function over a 2D context and a set of bounds, with no graph or canvas
104+
renderer dependencies — alongside `CanvasPathRenderer`, which already owns link
105+
drawing. An earlier attempt drew these through the canvas renderer's own node
106+
pass; that was reverted, because it left the DOM renderer unable to draw a
107+
zoomed-out graph without litegraph present.
108+
109+
Link rendering waits for nodes to report slot positions measured from the DOM.
110+
Simplified nodes have no slot elements, so on a graph large enough to open
111+
below the threshold that measurement never arrived and links stayed hidden.
112+
Crossing into the simplified mode now releases that wait, and links fall back
113+
to positions derived from node bounds.
114+
115+
## Method
116+
117+
Graphs are built by stamping the default workflow across empty space in a grid,
118+
which is how these workflows arise in practice. Each measurement loads a
119+
workflow, moves to a fixed zoom, and records frame deltas from a rAF loop
120+
driving a continuous pan, reporting the median and p95 of the stable portion.
121+
Both renderers are measured in the same build, on the same workflow, with a
122+
fresh page each.
123+
124+
Scripts live under `temp/scripts/` (untracked):
125+
126+
```bash
127+
node temp/scripts/gen-big-graph.mjs 3000 # build a workflow
128+
LABEL=my-run node temp/scripts/sweep.mjs # measure both renderers
129+
node temp/scripts/build-report.mjs # render temp/perf-report.html
130+
SCALE=0.0255 node temp/scripts/profile-pan.mjs temp/big-graph-3000.json
131+
```
132+
133+
Caveat: one sample per configuration, with run-to-run variance measured at
134+
roughly 10%. The large differences above are well clear of that; anything
135+
inside ~15% should be treated as a tie.
136+
137+
## Known follow-ups
138+
139+
- **`renderLink` rebuilds its render context per link, per frame**, re-walking
140+
the link-type colour map and reallocating the highlighted-id set. Roughly 15%
141+
of frame time at fit zoom, and invisible at working zoom because culling
142+
means few links draw. It is in the shared link path, so fixing it helps both
143+
renderers. The fix is hoisting the context out of the per-link loop rather
144+
than caching it, since `LGraphCanvas.link_type_colors` is a static dictionary
145+
that extensions mutate in place.
146+
- **The LOD threshold still reads the canvas renderer** for
147+
`min_font_size_for_lod` and `NODE_TEXT_SIZE`, and asks it to repaint when
148+
crossing. That keeps both renderers switching at the same zoom today; it can
149+
become a plain setting once the DOM renderer owns link drawing. The node
150+
boxes themselves have no such dependency.
151+
- **Links are still drawn by the canvas renderer.** `CanvasPathRenderer` owns
152+
the drawing, but `drawConnections` decides which links to draw and computes
153+
their endpoints. This predates this work and is the remaining obstacle to the
154+
DOM renderer standing alone.
155+
- **A fully zoomed-out large graph is slow in both renderers** — 104ms and
156+
113ms respectively at 3000 nodes. Most of that is browser layout, paint and
157+
compositing for what is on screen, not JavaScript, so it is not obviously
158+
fixable by either renderer.

0 commit comments

Comments
 (0)