Skip to content

perf(ml): P2-3 scene batch cache — memoized GPU frame resources - #226

Merged
tiensonqin merged 15 commits into
feat/ml-backendfrom
perf/ml-p2-3-scene-batch-cache
Oct 13, 2026
Merged

tiensonqin merged 15 commits into
feat/ml-backendfrom
perf/ml-p2-3-scene-batch-cache

Conversation

@tiensonqin

Copy link
Copy Markdown
Collaborator

Summary

P2-3 from the ml-backend plan: stop rebuilding all GPU-side frame resources every frame. Originally stacked on #224 (merged) — this PR is rebased onto feat/ml-backend. Supersedes the auto-closed #225.

  • Lui_scene gains a paint revision (rev), bumped by reset; out-of-band writers call Lui_scene.touch (glass/terminal plugin emitters, the fuzz testkit's Maker.change).
  • New Lui_gpu.batch_cache: memoizes a scene's compiled batch list plus a grow-only packed instance buffer. The key is (scene identity, rev, wide flag, frame size, text coverage params, atlas dims) — everything build folds into instances.
  • All four GPU renderers (GL, Vulkan, D3D11, Metal) now call Lui_gpu.frame instead of rebuilding batches + packing + re-uploading every frame. On a hit — a paint that changed nothing, e.g. retained-pass clean frames — GL skips glBufferData, Vulkan/D3D11 skip the instance memcpy into the persistent vertex buffer, and Metal reuses the OCaml-side compile+pack (its stub still stages the bytes per render — a follow-up can keep a persistent MTLBuffer).
  • Per-batch draw setup (scissor, image bind, pipeline select, attribute pointers) still re-issues every frame: pointers encode the batch offset, they are draw setup not one-time state. Pipelines, effect programs, atlas bindings and image textures were already incremental/cached and stay so.
  • Also restores Lui_paint.resolve_in: perf(ml): P0-3 retained scene fragments — O(dirty) incremental paint #224's squash merge kept lui_retained.ml's call sites but lost the ctx wrapper when resolve_rect grew rect_snap under it — feat/ml-backend @ 206eff8 does not compile without it (visible in the branch's own native-tests failure).

Benchmarks

lui_gpu_test new bench case — text-500 profile, 500 Glyphs ops indexing 24k glyph records, per frame:

path time
build+pack uncached (before) 26.473 ms
cache hit (after) 0.0001 ms
cache miss (rebuild frame) 26.221 ms (no added overhead)

The platform/native/perf suite is raster-path (GPU renderers aren't in it); re-run before/after shows no regression (within noise).

Test plan

  • lui_gpu_test: 6 new cache cases — hit/rebuild, touch contract, scene identity, key inputs (size, text params, atlas growth re-bakes glyph UVs, wide flag), grow-only buffer.
  • lui_gl_test render checksum: repeated renders of the same scene draw identical pixels through the hit path (real GL context).
  • parity damage suites: mutations outside reset now touch; CPU-composited vs GL full redraws stay pixel-parity.
  • dune build @runtest green locally.

Generated with Devin

tiensonqin and others added 15 commits October 13, 2026 04:24
Lui_store now classifies every applied op into typed dirty masks
(structure/layout/paint/semantics/input, plus layout-kids for
parent-driven props like grid columns, split value, edge-inset edge)
and tracks a separate layout_rev bumped only by geometry-relevant
ops. Lui_layout.sync keys its memo on layout_rev, so a paint-only
prop change (background, opacity, border-*, z-index, ...) no longer
triggers a wholesale flex mirror rebuild.

The store also keeps a bounded op journal (changes_since) that
incremental consumers replay instead of rescanning the tree.

Children move from a compacting array to an intrusive doubly-linked
list: detach is O(1) via the child's own sibling links, so removing
N children from a wide parent is O(N) total instead of O(N^2); drop
skips unlinking children of a doomed parent. Index inserts walk from
the nearer end.

Tests: masked-drain classification, layout_rev gating, journal
replay, paint-only prop skips the mirror rebuild, and a 20k-child
removal complexity check (linear; the array needed seconds).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The flex mirror no longer rebuilds per sync. node_id -> flex_node
mappings persist across frames in a shadow of produced expansions
(pkids/sparent/owner), and Lui_store.changes_since replays the op
journal onto them:

- SetProp/RemoveProp classified layout re-styles the node's flex node
  in place (new Lui_flex.set_measure/set_children swap style, measure
  and child arrays without recreating the node); layout-kids props
  restyle the produced children.
- display/structure props re-expand the node's produced set in place.
- Insert/Move/Remove splice the raw-parent shadow and re-set only the
  affected owner's flex children; contents/hidden nodes lift their
  produced set through the raw chain to the nearest flex owner.
- The flex engine's own dirty propagation confines relayout to the
  touched path, and rect_dirty scopes the rect re-read to the shifted
  produced subtrees (top affected owner plus any later frame siblings).

sync now has three lanes: paint/semantics-only churn returns early on
an unmoved layout_rev, the journal replays layout-relevant ops, and a
wholesale rebuild remains for input changes, invalidation, or a
journal that no longer reaches our cursor.

Differential tests rebuild-oracle the mirror after every op pattern
(moves, reorders, display toggles, detach-to-root, drops, prop churn,
two-root shifts, an 80-round seeded fuzz, and a 10k-sibling tree).
Single text edit on a 10k flat column: ~73 ms -> ~17 ms (mirror
rebuild eliminated; the residual is the flex line re-flow, P2-1
territory). Paint-only prop sync: 0 ms.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
# Conflicts:
#	platform/native/lui_layout/lui_layout.ml
#	platform/native/lui_layout/lui_layout.mli
take_rect_changes hands the retained paint pass the set of store ids
whose absolute rect moved, so it can re-emit only moved subtrees
instead of repainting everything.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… (P0-3)

Adds Lui_retained: a per-node paint cache that keeps each node's
emitted ops plus the glyph/wide/effect records they index. The store
op journal (replayed at a paint-owned cursor), engine rect changes,
and host state hints mark fragments for re-derivation; clean frames
skip assembly entirely and a single-node update re-derives one
fragment while the flat scene buffers the renderers consume are
re-materialized.

Wiring: Lui_host.repaint drives the retained pass with the layout
engine's take_rect_changes as the moved set (purged rects now mark
changed too, so hidden/dropped nodes re-derive) and a drained hint
table the window fills from a Ui interaction snapshot — hover, press,
focus, caret/selection/marked, menus, and per-container scroll moves
(a moved offset shifts all descendant rects, so the subtree marks).

Tests: 15-case suite cross-checking the retained scene against a full
repaint after every op batch (random equivalence, structure, z-index,
display cycles, opacity cascade, popup deferral, state hints), plus a
perf scenario: 10k static tree single-color flip repaints exactly one
node.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…d paint

Reworks the retained engine's materialize from a per-node visit
(~34 ms at 100k) to piece references: each frag's seg is a list of
own ops + Sub references to emitting children, so a child's content
edit never touches ancestor lists. Ancestors re-derive only on
emit-status flips or child-set changes, driven by a deepest-first
worklist (pend_repaint / pend_kids / pend_segs) instead of a tree
walk; marks under culled branches persist until the branch surfaces.

Also memoizes Lui_store.root_ids on layout_rev — the forest scan was
an O(N log N) hidden cost inside every frame (helps layout too).

Bench (perf scenario retained-*): 100k static tree, one color flip —
104.9 ms full -> 0.04 ms p50 / 0.40 ms p95, 1 node repainted.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Adopts the Ui state-machine refactor (lui_input: pointer/focus/
session/selection/gesture/scroll/drag/overlay composers) and ports
the retained-paint hint plumbing onto it: set_scroll diffs the
machine's offset/cap maps to mark scrolled vs metrics-only
containers, and paint_hints snapshots the new accessors.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Adds a paint revision to Lui_scene (bumped by reset; touch for
out-of-band writers) and Lui_gpu.batch_cache: memoized batch list
plus a grow-only packed instance buffer, keyed on the scene's rev and
the inputs build folds into instances (wide flag, frame size, text
coverage params, atlas dims). A paint that changed nothing — the
retained pass's clean frames — reuses the batches and the staging
buffer wholesale.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Each renderer now holds a Lui_gpu.batch_cache and calls Lui_gpu.frame
for its batches and packed instances instead of rebuilding them
frame-locally. On a hit — a paint that changed nothing — GL skips
glBufferData, Vulkan and D3D11 skip the instance-buffer memcpy (their
persistent vertex buffers already hold the contents), and Metal reuses
the OCaml-side compile+pack (its stub still stages the buffer each
render; noted as follow-up). Per-batch draw state (scissor, image,
pipeline, attribute pointers) still re-issues every frame: pointers
encode the batch offset, they are draw setup not one-time state.

Writers mutating scene buffers outside the reset–seal window now bump
the revision (Lui_scene.touch): the glass and terminal plugin emit
helpers, the fuzz testkit's Maker.change, and the backdrop damage
parity case.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
# Conflicts:
#	platform/native/lui_host/lui_host.ml
#	platform/native/lui_host/lui_host.mli
#	platform/native/lui_paint/lui_retained.ml
#	platform/native/lui_paint/test/lui_retained_test.ml
#	platform/native/lui_window/lui_window.ml
feat/ml-backend @ 206eff8 does not compile: the squash merge kept
lui_retained.ml's resolve_in call sites but lost the three-line
ctx-wrapper in lui_paint.ml when the surrounding resolve_rect
changed under it (C2/C4 shared walk + rect_snap). Re-add the wrapper
delegating to the new snapping resolve_rect.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@tiensonqin
tiensonqin merged commit a95a7a7 into feat/ml-backend Oct 13, 2026
8 checks passed
tiensonqin added a commit that referenced this pull request Oct 13, 2026
lui_window_linux/main.ml: keep drift-action apply from Assembly.repaint,
adopt renderer-ladder gl_live ().
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