Skip to content

perf(ml): P2-2 spatial index for hit-test and paint culling - #227

Merged
tiensonqin merged 8 commits into
feat/ml-backendfrom
perf/ml-p2-2-spatial-index
Oct 13, 2026
Merged

tiensonqin merged 8 commits into
feat/ml-backendfrom
perf/ml-p2-2-spatial-index

Conversation

@tiensonqin

Copy link
Copy Markdown
Collaborator

Summary

P2-2 from the ml-backend plan: a spatial index shared by the native
paint pass's viewport cull and hit-testing.

  • Lui_spatial (new platform/native/lui_spatial library): a
    snapshot index over resolved rects. Per node it records the shown
    subtree's bound b, its clip-escaping part eb (promoted popups,
    which paint and hit-test outside ancestor clips), and a zero-area
    flag z (degenerate nodes still run the emission path). Parents
    with ≥32 children get a bound-top-sorted child array with
    prefix-max bottoms — the interval index the plan calls for.
  • Lui_paint.walk/paint take an optional (index, query).
    Region (viewport) prunes subtrees that cannot intersect;
    Point (hit-test) prunes subtrees that cannot contain the point.
    Roots come from the index snapshot, avoiding the O(n)
    root_ids fold per walk. Pruning is a strict filter on the
    unindexed visit set — same order, same clip contexts, and the
    unindexed path is unchanged when no index is supplied.
  • Lui_query.hit_path/hit take ?spatial.
  • Lui_host tracks a geometry invalidation counter and accepts a
    spatial provider consulted inside repaint.
  • Lui_window.Assembly lazily builds the index over the
    populate-time hit mirror and stamps each mirror with a geometry key
    (store revision, host invalidations, scroll epoch, live context
    anchor, scale, viewport). Paint gets the index only when the key
    proves the mirror still matches the frame; hit paths get it
    whenever it matches the current mirror. Any staleness falls back
    to the unindexed walk — the index can never answer wrong.

Benchmarks (platform/native/perf, 100k-row virtual list)

metric before after
hit_path p50 (3 probes) 319.7 ms ~0.002 ms
static-frame paint p50 ~120 ms 0.06 ms (warm index)
index build — 247 ms (once per geometry epoch)

Walk visits for a point hit at 100k rows: 3 nodes vs ~100k.

Test plan

  • lui_paint tests: indexed == unindexed op streams over a tree with
    scroll clip, promoted popup, hidden subtree, display:contents,
    spacer and z-order; visit bound on a 300-row column; zero-area
    children stay reachable.
  • lui_input tests: hit_path indexed == unindexed across a point
    grid over clip/popup/contents/hidden/z-overlap cases; promoted
    popover escape; wide-list row hits.
  • All existing suites pass (dune build @runtest).

Generated with Devin

tiensonqin and others added 5 commits October 13, 2026 07:55
New lui_spatial library: a snapshot index over resolved node rects
that both the paint pass's viewport cull and hit-testing prune with.
Per node it records the shown subtree's bound [b], its clip-escaping
part [eb] (promoted popups), and a zero-area flag [z]; parents with
>= 32 children get a bound-top-sorted array with prefix-max bottoms
so a query touches O(log n + candidates) siblings instead of every
child. The env is caller-supplied so the module stays free of
Lui_paint dependencies.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Lui_paint.walk takes an optional (index, query) pair: roots come from
the index's snapshot (avoiding the O(n) root_ids fold per walk),
wide parents enumerate candidates through the interval arrays, and
small parents fall back to a per-child keep test that preserves the
walk's exact clip/popup/z-order semantics. Lui_paint.paint gains
?spatial supplying a Region query over the viewport; without it the
walk is byte-for-byte the old path. spatial_env publishes the
paint-semantics env index builders share.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Lui_query.hit_path/hit take ?spatial and serve point queries
  through it — same walk, same path order.
- Lui_host gains an invalidation counter (bumped by resize,
  set_hidden, set_ctx_anchor, invalidate_layout) plus a spatial
  provider thunk consulted inside repaint.
- Lui_window.Assembly builds the index lazily over the populate-time
  hit mirror, stamps each mirror with a geometry key (store revision,
  host invalidations, scroll epoch, live context anchor, scale,
  viewport) and hands the paint pass an index only when the key
  proves the mirror still matches the frame being resolved; hit
  paths get the index whenever it matches the current mirror.
  Stale or absent keys fall back to the unindexed walk, so the index
  can never serve a wrong answer.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- lui_paint tests: indexed-vs-unindexed paint produces identical op
  streams (z-order, clip, promoted popup, contents, hidden, spacer),
  a 300-row column visits <= 12 nodes under a 100px viewport vs 302
  unindexed, and zero-area children stay reachable.
- lui_input tests: a point-grid sweep asserts hit_path indexed ==
  unindexed on a tree covering clip escape, display:contents, hidden
  subtrees, spacers and z-overlap; plus promoted-popover and
  wide-list hits.
- perf scenario hit-*k: hit_path p50 unindexed vs indexed, indexed
  static-frame paint, and index build cost. At 100k rows: hit_path
  320ms -> ~0.002ms; indexed static paint 0.06ms vs ~120ms; index
  build ~250ms once per geometry epoch.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Picks up #224 (retained scene fragments), #217 (dead-clip ghost
walk), #213, #209, #223. Resolution:

- walk keeps both features: the ghost pass takes clip so the spatial
  keep test can prune its popup search, and both body and ghost
  enumerate children through the shared candidates helper.
- paint stays ?spatial — the retained fragment pass is the
  production path and owns its own cull, so the host's spatial
  provider is dropped; the index still serves hit-testing via
  Assembly.handle and Lui_paint.paint callers.
- Lui_host keeps only the invalidation counter the hit-index key
  needs.
- Restores Lui_paint.resolve_in, lost from #224's squash merge
  (same fix as 21b58d7 on perf/ml-p2-3-scene-batch-cache).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@tiensonqin tiensonqin closed this Oct 13, 2026
@tiensonqin tiensonqin reopened this Oct 13, 2026
- host: keep inv_count and vport
- paint walk: ?mounted + ?spatial; mounted vlist slices bypass the
  spatial index, other parents use candidates
- window: keep spatial key/index and vport feed + drift actions +
  vlist hook; return spatial_hit
- perf/scenarios: mount_vlist now takes unit
body: adopt frame_of; keep mounted/spatial child dispatch.
@tiensonqin
tiensonqin merged commit 133870c into feat/ml-backend Oct 13, 2026
8 checks passed
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