iris: the arenas upload deltas, and stop being 11x bigger than the tree
Changing any primitive re-uploaded every primitive. Measured over the bench fixture by the new arena_churn rig: 758 MB across a fling and 1.2 GB across 401 streamed deltas, p50 3.0 MB per streamed frame. Three separate things were wrong, and only the first is what it looked like from the outside. ArrBuf reallocated on every length change. A fresh Buffer's contents are undefined, so adding one glyph -- which a streamed reply does constantly -- forced a full rewrite, and no partial upload could have been correct in the first place. It has a capacity now, growing geometrically and never shrinking, and update() answers whether the Buffer identity moved so a caller can rebuild its bind group and force the whole range dirty. That alone took the glyph array from 95% re-uploaded to 3%, and stopped primitive_group being rebuilt on every frame the arena changed. A redraw freed its primitives and pushed new ones. Freed slots are not reusable until the end of the frame -- a layer's draw order still names them -- and Painter::draw_twice is how a container learns a child's size, so with containers nested the arena's high-water was the transient push count rather than the live one: 17 million pushes across 401 deltas, 127,443 slots for 11,569 live primitives, growing linearly with the transcript. A redraw now gets its old handles back as a recycle pool (Painter::take_recycled, Primitives::recycle) and writes into the slots it already holds; the pool is consumed in order and whatever the draw does not claim is freed when it ends. The arena is exactly the live count now. The CPU frame improved with it, from p50 2.20ms to 1.39ms on the stream run, because the freeing and the draw-order renumbering went away. Nothing tracked which entries changed. util::Dirty is a bitset per uploaded array, coalesced into ranges at a 1 KiB gap. Marking is O(1) and allocation-free; reading it back is one word per 64 entries. Both alternatives were measured and rejected: a min..max span is nearly the whole buffer, since a frame's changes land in 5-20 scattered runs, and a Vec of indices would mean an allocation and a sort per frame at several thousand marks. It replaces Primitives::updated -- one bool that covered the instances and the per-primitive data together, so rewriting a rect's region re-uploaded every glyph -- and TrackedArena::changed. The trap only the rig could catch: writing an entry is not changing it. Recycling rewrote every glyph of every moved row with identical bytes, marking 73% of the glyph array against 0.6% genuinely changed, because what moves is the instance's region and not the glyph. PrimitiveVec::set and Primitives::set_instance compare before marking. Every array now uploads within a hair of its floor: fling instances 3.4% against 3.3%, fling glyphs 0.9% against 0.8%, stream glyphs 0.6% against 0.6%. Stream instances are at 72.7%, which *is* the floor and is a layout question rather than an upload one -- the list is pinned to the newest end, so a growing reply moves every row, and that should be one move_offsets write rather than a redraw. Noted in RUST.md as the next thing. Also: draw_inner's four old_* parameters become one Retained struct, so the recycle pool is a field rather than an eleventh positional argument next to three others of the same shape; and free_primitive is the one place a slot and its draw-order position are retired together. The rigs move to scripts/rigs/ui-profile, a crate of their own so a rig's dependencies stay out of the app's -- arena_churn needs bytemuck, which nothing in ai-app does. arena_churn prints floor, uploaded and whole side by side per array, because any two of those alone are misleading and the 122x over-marking above was invisible until all three were on screen together.
This commit is contained in:
1 parent
77cee6a8fa
commit
3c7d3db370
19 files changed
+6290
-155
No files matched your search
@@ -75,7 +75,8 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
|
||||
- `scripts/` — everything at the root that was neither a program nor a
|
||||
document: the three repo-wide shell scripts (`run-tests.sh`,
|
||||
`test-wg-tunnel.sh`, `wg-setup-host.sh`), `rigs/` (the `gpu-probe` and
|
||||
`virtgpu-probe` device probes), and `xtask/`. **A project's own scripts
|
||||
`virtgpu-probe` device probes, and `ui-profile`'s two layer-1
|
||||
profiling rigs), and `xtask/`. **A project's own scripts
|
||||
stay with the project** — `app/*.sh`, `app-rust/*.sh`, `iris/*.sh` and
|
||||
`server/enroll-link.sh` did not move (Iris, 2026-09-09: "I only meant
|
||||
top level sh files").
|
||||
@@ -389,14 +390,20 @@ Each exists because something was invisible without it.
|
||||
since the rig lives in iris and the app's examples do not). The emulator is for JNI, the IME, insets, the surface
|
||||
lifecycle and one verification run before a build goes to the phone --
|
||||
not for iterating on layout.
|
||||
- **`app-rust/tests/frame_profile.rs`** is what a frame costs on the CPU,
|
||||
- **`scripts/rigs/ui-profile/`** holds the two layer-1 profiling rigs, in
|
||||
a crate of their own so a rig's dependencies stay out of the app's
|
||||
(Iris, 2026-09-09: *"Rigs should probably all be in their own crate so
|
||||
dependencies and such don't get mixed"*). Run either from that
|
||||
directory; both are `#[ignore]`d and assertion-free, so `run-tests.sh`
|
||||
neither runs them nor can fail on them, and both need **release or the
|
||||
numbers mean nothing**.
|
||||
- **`tests/frame_profile.rs`** is what a frame costs on the CPU,
|
||||
at layer 1 -- `cargo test --release --test frame_profile -- --ignored
|
||||
--nocapture`, from `app-rust/`. Two runs: a fling over the bench
|
||||
--nocapture`. Two runs: a fling over the bench
|
||||
fixture eight times out and back, and a reply streaming into it one
|
||||
event at a time. `#[ignore]`d and assertion-free, so `run-tests.sh`
|
||||
neither runs it nor can fail on it; **release or the numbers mean
|
||||
nothing**, since text shaping dominates. It cannot answer anything
|
||||
about the GPU, the swapchain or the phone's own clock.
|
||||
event at a time. Text shaping dominates, which is why the profile is
|
||||
meaningless unoptimised. It cannot answer anything about the GPU, the
|
||||
swapchain or the phone's own clock.
|
||||
|
||||
What it established on 2026-09-09, worth not re-deriving. A **fling**
|
||||
is not CPU-bound: only about one frame in six lays anything out (the
|
||||
@@ -418,6 +425,37 @@ Each exists because something was invisible without it.
|
||||
`what_the_fixture_streams`) exist to keep that answerable: what a delta
|
||||
costs to re-split and re-compare, and what the fixture actually
|
||||
streams.
|
||||
- **`tests/arena_churn.rs`** is what a frame costs to *upload* -- the half
|
||||
of a frame layer 1 builds and never performs, and so the half
|
||||
`frame_profile.rs` cannot see at all. It prints three numbers per GPU
|
||||
array per frame, and the point of the rig is that no two of them alone
|
||||
are honest: **floor** (entries whose bytes actually differ, found by
|
||||
diffing), **uploaded** (what iris really writes, read from the same
|
||||
`Dirty` sets `UiRenderNode::update` consumes), and **whole** (what the
|
||||
old code wrote whenever anything changed). A gap between the first two
|
||||
is over-marking; one was 122x and invisible until both were printed
|
||||
side by side.
|
||||
|
||||
What it established on 2026-09-09, and what the three optimisations it
|
||||
drove were. Uploading the whole arena on any change cost **758 MB over
|
||||
a fling and 1.2 GB over 401 streamed deltas**, p50 3.0 MB per streamed
|
||||
frame. Three things were wrong and each is now guarded by this rig:
|
||||
`ArrBuf` reallocated on every length change, so adding one glyph made
|
||||
the buffer's contents undefined and forced a full rewrite; a redraw
|
||||
freed its primitives and pushed new ones, which -- since freed slots
|
||||
are only reusable next frame and `Painter::draw_twice` nests -- grew
|
||||
the arena to **127,443 slots for 11,569 live primitives**; and nothing
|
||||
tracked *which* entries changed. Now: the stream arena is 11,569 slots
|
||||
for 11,569 live, and every array uploads within a hair of its floor
|
||||
(fling instances 3.4% against a 3.3% floor, stream glyphs 0.6% against
|
||||
0.6%). The CPU half improved with it, since the freeing and renumbering
|
||||
went away: a streamed frame is p50 1.39ms, from 2.20ms.
|
||||
|
||||
**Stream instances sit at 72.7%, which is the floor and not a defect
|
||||
here.** The list is pinned to the newest end, so a growing reply moves
|
||||
every row, and a row's instances carry an absolute region. That is a
|
||||
`move_offsets` write the layout is not making -- the next thing to look
|
||||
at, and a layout question rather than an upload one.
|
||||
- **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08;
|
||||
docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader
|
||||
in software -- while its GLES *is* the host's real GPU through virgl at
|
||||
|
||||
Reference in new issue
Block a user