Fix performance issues at most levels - #1
Open
foxnne wants to merge 10 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.