Skip to content

Fix performance issues at most levels - #1

Open
foxnne wants to merge 10 commits into
mainfrom
push-qtpmrmwrorlv
Open

Fix performance issues at most levels#1
foxnne wants to merge 10 commits into
mainfrom
push-qtpmrmwrorlv

Conversation

@foxnne

@foxnne foxnne commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

No description provided.

foxnne added 10 commits August 23, 2026 14:38
Add `--pan` to the `--world` sweep: at every zoom, 120 parked frames then 120
with the camera moving 13 screen px a frame, reporting mean/p50/p95/max frame ms
and how many of those frames rebuilt the lift. The parked sweep could not see the
pan cost by construction — parked is 0 lift rebuilds and ~0.4 ms, panning is
119/120 rebuilds and 3-4 ms.

Split `liftLinks` into focus/scan/build/sort/fade counters (no-op unless the
bench sets `prof_io`), and print the world step/sync/lift and misc buckets in the
debug HUD, which were timed every frame and never shown.
Three mechanical changes to `liftLinks`, all of which leave the lifted set
bit-identical — the sweep's marks and links columns are unchanged at every zoom.

- The five hash maps and two lists it used were `.empty` on entry and freed on
  exit, so a pan frame (119 of 120 miss the lift cache) grew a 17k-29k entry table
  from nothing and rehashed all the way up, sixty times a second. They are `World`
  fields now, cleared rather than freed.
- The per-cut-cell neighbour accumulation is a scatter-add over cell ids, so it is
  a dense epoch-stamped array instead of a hash map — the same trick `cut_of` uses
  to avoid a per-frame memset of one entry per cell.
- The budget truncation wants the *set* of the heaviest `keep` links, and a full
  sort also ordered the ~20,000 it was about to throw away: 2.0 ms a frame at 300k
  notes, the largest single item in a moving frame. `selectTopK` is an introselect
  to the same set. `heavier` is a strict total order, so that set is unique.

300k notes, budget 4000, camera panning, at the zoom where every note has just
exploded: 4.40 -> 2.27 ms mean, p95 8.37 -> 4.80, max 10.55 -> 6.19.
`updateLabels` ended with a pass over every node in the vault to step `label_vis`,
and allocated and memset one bool per note to drive it; `drawLabels` then walked
the vault again to find the names to draw. Both ran on every frame of a pan.
`GraphNode` is 160 bytes, so this is the same whole-vault stride
`syncNodesFromWorld` was already rewritten to refuse — about 1 ms a pass at 286k
notes, twice a frame, to move at most 96 non-zero values.

Nothing but `updateLabels` ever raises a `label_vis`, so the set of non-zero ones
is exactly (placed this frame) + (was showing last frame), both bounded by
`max_labels`. `Panel.label_live` tracks it per cloud, `GraphNode.label_epoch`
separates the two cases without a per-node array, and `rewarmLabels` re-seeds it
after a rebuild renumbers the nodes, exactly as `rewarmPointer` does.

Same names in the same places: the only behavioural difference is that a fade
snaps to zero at the 0.004 settle threshold instead of decaying below it forever,
which is four times under what `drawLabel` draws at.
…hash map

`fadeLinks` built a whole `live` set each frame — one insert per lifted link, so
~22,000 of them at the top of the quality slider — purely to decide which entries
of `link_fade` were no longer present. `link_fade` can answer that itself: its
value carries the epoch of the frame that last touched it. Same crossfades, same
links; the append loop also reserves once instead of testing capacity 22,000 times.

300k notes, budget 4000, at the zoom where every note has just exploded: parked
0.74 -> 0.37 ms/frame, panning 2.27 -> 1.98 mean and p95 4.80 -> 4.26.

The pan probe now also breaks out `step` from `liftLinks`, which is how we know
the remaining spikes are in the lift and not in lazy cell placement (step is
0.13 ms of a 1.98 ms moving frame).
… vault

Three whole-vault walks in the draw path, all per frame:

- `worldMarkHoldsOpen` scanned a cell's entire note range for an open document,
  once per mark. The marks partition what is on screen, so at overview the sum of
  those ranges *is* the vault — 300,000 reads at a 160-byte stride every frame to
  find the two or three notes that are open. Asked from the other end it is the
  union of the open notes' ancestor chains: a handful of tabs times seven levels,
  built once in `updateHoldsOpen` and read as a set. Identical answers.
- `world_draw` and `updateLabels` each built their own
  `AutoHashMapUnmanaged(cell, point)` from the same mark list, to serve two
  lookups per link endpoint — 40,000 hash lookups a frame between them at the top
  of the quality slider. `World.present` now publishes cell -> mark index in a
  dense stamped array (`World.markIndex`) and both read that. `markWorldPos`, which
  was a linear scan of the marks, reads it too.

The sweep's marks and links columns are unchanged at every zoom.

Also report cut churn per pan frame, which is the number that decides whether an
incremental lift is worth building: 10.8% at the worst zoom, 0.0% parked.
Placement is lazy, so a fast pan at the coalesce boundary opens thousands of cells
in a single frame — and each one ran an exhaustive walk over every arrangement of
its ring children, evaluating the full cost at each of up to (arity-1)! = 720
leaves. That was a 5 ms hitch inside `World.present`, right where the reader is
already asking the most of the frame.

The walk is the same walk, with branches that cannot win cut. The bound is the
part of the cost the assignments so far already fix, plus the terms no arrangement
can change: every ring slot is the same distance from the centre, so the `mass_k`
pull and any sibling pair with the centre child at one end are constant.

The arrangement is bit-identical to the unpruned walk's, and it has to be: every
surviving complete arrangement is still scored by `arrangementCost` itself, in the
same enumeration order, on the same strict `<`. Pruning decides only which
branches are reached. A tolerance keeps near-ties out of the bound's reach so they
resolve exactly as they did before — without it, arrangements that tie
mathematically but differ in the last float bit resolve the other way, which moves
children between slots and was caught by the `ext_k` test.

`ring_pruning` exists so 'pruning changes no layout' is asserted directly, over
real ladders, by placing each twice and requiring bit-identical positions.

300k, budget 4000, 40 px/frame at the boundary: worst-frame `present` 4.86 -> 3.12
ms, pan p95 5.05 -> 4.53.
Two costs at the coalesce boundary — the zoom where every note has just resolved,
the web is at its densest, and a fast pan hitches.

The label placer's collision test walked *every* link segment for every slot of
every candidate. At the boundary that is ~20,000 segments against a couple of
hundred candidates and their slots, in both the keep_in and bounds passes, plus
the trimmed and relaxed retries — tens of millions of segment/rect tests per
frame, on every frame of a pan, and none of it visible to the headless sweep.
`labels.SegGrid` buckets the segments by the cells they pass through, so a
candidate only tests the handful near it. `segmentHitsRect` still decides; the
index only decides who it is asked about. The walk is per column and widened
outward, because a missed cell is a name sitting on a link — checked by a test
that runs 20,000 random queries against 900 segments through both paths and
requires identical answers. Below 256 segments the linear walk is cheaper and the
index is skipped.

`cut_of`/`cut_stamp`, `side_w`/`side_stamp` and `mark_of`/`mark_stamp` were
parallel arrays that are never read apart, so every lookup was two cache misses
into two megabyte-sized arrays instead of one — and the lift does one per
neighbour, 52,000 times on a hard pan frame. One struct each.

The sweep's marks and links columns are unchanged at every zoom.
Three reasons a click's connections could go missing, all on the path between
'the note is drawn as itself' and 'it has coalesced'.

`lift_hold` bypassed the whole lift fingerprint, focus included. It exists so the
ambient web can lag a few frames while the camera flies — invisible, and it saves
the entire lift on the frames that can least afford it. But clicking a node is
what *starts* the flight, so the frames where the hold engages are exactly the
frames where the reader is waiting to see what they just clicked connect to.
Held, the lift answers with the previous note's links, and releases only once the
camera slows: connections that vanish on the way out and return on arrival. The
fingerprint is now two keys — the cut may lag, the focus and open set never may.

`world_draw` nested the focused note's pass inside `if (w.links.items.len > 0)`,
so a frame whose lift produced no cell-to-cell web took the highlight down with
it. The two sets exist for different reasons; the gate admits either.

`clearFrame` left `mark_epoch` alone, so the cell -> mark index outlived the marks
it points into. Harmless today because every reader runs after `present`, but on a
frame `step` returns early from it is a read past the end of `marks`.

The regression test fails without the first fix. Sweep columns unchanged.

`bench --world` also grows `--focus=N` (sweep with a note focused and centred, and
flag any zoom where the highlight would not be drawn) and `--zoom-mul=F` (finer
than doubling, which can step clean over a narrow band).
… cap

This is why the focused note's connections vanish at one zoom band and come back
if you zoom either way.

`LineBatch.max_lines` is 16,383 — dvui's `Vertex.Index` being a u16, which is
permanent app-wide since the web, raylib and dx11 backends all reject
`-Dvertex-index=u32`. It is a *batching* limit, and `SpriteBatch` has always
treated it as one, flushing and continuing. `LineBatch.add` just returned.

`world_draw` adds the ambient web first and the focused note's own links last. At
the zoom where a large vault has finished exploding, the drawn ambient web alone
is past 16,383 lines — 20,453 at 300k on the bench — so the batch was already full
before a single highlight line was offered to it, and every one was discarded in
silence. Zoom either way and fewer links survive the viewport clip, the count
falls under the cap, and the highlight reappears. The node itself stayed lit
throughout because marks are a separate sprite pass.

Overflowing into another draw call also preserves the ordering the highlight
depends on: later calls paint over earlier ones, so the focused links still sit on
top of the web.

Costs one extra `renderTriangles` per 16,383 lines, at a zoom that was already
drawing that many.

Note: `galaxy` builds its mark `SpriteBatch` without an `auto_tex`, which is the
same silent drop for marks. Not reachable at the 4000 mark ceiling, so left alone.
Atlas: hit where we draw, name what is hovered, calm the highlight

Three bits of polish, and one correction.

The overview draws its marks from the world's own positions, but the proximity
pass shoved `n.pos` — which is what hit-testing and the label placer read. So near
the cursor the ring you can see and the target you can click drifted apart: the
node lights up, the cursor never becomes a hand, and the click lands on nothing.
Worse at some zooms than others, because the shove reach is a world distance
derived from a screen radius. The shove is not drawn on the overview at all, so it
is now skipped there; drawing it instead would pull nodes away from their own link
endpoints, which is a failure this graph has already paid for once. The interior,
which does draw from `n.pos`, keeps it.

The hovered note's name takes the highlight colour. At overview density a swell is
easy to lose among neighbours, and the name is the part that actually says which
one you are on.

The focused note's links drop to half opacity. A note with many links drew each at
near-opaque highlight and the starburst buried the neighbour names it was pointing
at. The ambient web already thins as it thickens; the highlight needed the same.

Correction: `drawLabels`' overview branch returns before the `p.label_live` loop
added earlier, so that loop was unreachable — the live path was already walking
`p.visible.items`, which is bounded by the mark budget. Removed. The `label_live`
set is still what `updateLabels` fades over, which was the real cost.
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.

1 participant