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:
iris committed 2026-09-09 02:14:51 -04:00
1 parent 77cee6a8fa
commit 3c7d3db370
19 files changed
+6290 -155

No files matched your search

@@ -0,0 +1,228 @@
//! What each GPU arena costs to upload per frame, at layer 1 (docs/RUST.md's
//! "Three test layers") -- the real transcript screen over the real bench
//! fixture, with no window, no compositor and no GPU.
//!
//! cargo test --release --test arena_churn -- --ignored --nocapture
//!
//! from `scripts/rigs/ui-profile/`.
//!
//! It exists because the upload is the one part of a frame that layer 1
//! *builds* and never performs, so `frame_profile.rs` cannot see it at
//! all: the emulator's `stream: build p50` stayed at 10.5ms across a
//! change that nearly halved layer 1's CPU frame, and nothing could say
//! why until this could count bytes.
//!
//! Three numbers per array per frame, which is the point of the rig --
//! any two of them alone are misleading:
//!
//! - **changed** is the floor: entries whose bytes actually differ from
//! the previous frame, found by diffing. Nothing correct can upload
//! less.
//! - **uploaded** is what `iris` really writes, read from the same
//! `Dirty` sets `UiRenderNode::update` consumes and cleared here the
//! way an upload would clear them. Above `changed` by whatever the
//! marking over-marks plus whatever range coalescing pulls in.
//! - **whole** is what the old code wrote every time anything changed.
//!
//! `#[ignore]`d and assertion-free: it prints distributions, so
//! `run-tests.sh` neither runs it nor can fail on it.
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
use iris::harness::Harness;
use iris::prelude::{GlyphPrimitive, Primitive, RectPrimitive};
use iris::widget::Scrollable;
use ui_profile::stats::pct_u64;
/// Passes over the same content, alternating direction -- the same
/// out-and-back `frame_profile.rs`'s fling drives, so the two rigs
/// describe the same gesture.
const PASSES: usize = 8;
const VELOCITY: f32 = 12_000.0;
const PASS_CAP_MS: u64 = 4_000;
/// One array's per-frame totals.
#[derive(Default)]
struct Tally {
name: &'static str,
stride: usize,
/// The previous frame's bytes, for the diff that finds the floor.
prev: Vec<u8>,
changed: Vec<u64>,
uploaded: Vec<u64>,
whole: Vec<u64>,
calls: Vec<u64>,
}
impl Tally {
fn new(name: &'static str, stride: usize) -> Self {
Self {
name,
stride,
..Default::default()
}
}
/// Records one frame, and clears the dirty set as an upload would.
fn frame(&mut self, bytes: &[u8], ranges: Vec<std::ops::Range<usize>>) {
let n = self.prev.len().min(bytes.len()) / self.stride;
let mut changed = (0..n)
.filter(|i| {
let r = i * self.stride..(i + 1) * self.stride;
self.prev[r.clone()] != bytes[r]
})
.count();
// Everything past the old end is new, and so is dirty by
// definition.
changed += bytes.len() / self.stride - n;
self.changed.push((changed * self.stride) as u64);
self.uploaded
.push(ranges.iter().map(|r| (r.len() * self.stride) as u64).sum());
self.calls.push(ranges.len() as u64);
self.whole.push(bytes.len() as u64);
self.prev.clear();
self.prev.extend_from_slice(bytes);
}
fn report(&mut self) {
let sum = |v: &[u64]| v.iter().sum::<u64>();
let (changed, uploaded, whole) = (
sum(&self.changed),
sum(&self.uploaded),
sum(&self.whole),
);
println!(
" {:<10} whole {:>7.1} MB | uploaded {:>7.1} MB ({:>5.1}%) | floor {:>7.1} MB ({:>5.1}%)",
self.name,
whole as f64 / 1e6,
uploaded as f64 / 1e6,
100.0 * uploaded as f64 / whole.max(1) as f64,
changed as f64 / 1e6,
100.0 * changed as f64 / whole.max(1) as f64,
);
println!(
" {:<10} uploaded per frame p50 {:>7} p90 {:>7} p99 {:>7} max {:>7} B | \
writes p50 {} p90 {} max {}",
"",
pct_u64(&mut self.uploaded.clone(), 0.5),
pct_u64(&mut self.uploaded.clone(), 0.9),
pct_u64(&mut self.uploaded.clone(), 0.99),
pct_u64(&mut self.uploaded.clone(), 1.0),
pct_u64(&mut self.calls.clone(), 0.5),
pct_u64(&mut self.calls.clone(), 0.9),
pct_u64(&mut self.calls.clone(), 1.0),
);
}
}
/// The three arenas a transcript frame writes. Masks and move offsets are
/// left out deliberately: they are a hundred-odd entries, so their whole
/// buffer is smaller than one range of any of these.
struct Arenas {
instances: Tally,
rects: Tally,
glyphs: Tally,
}
impl Arenas {
fn new() -> Self {
Self {
instances: Tally::new("instances", 0),
rects: Tally::new("rects", size_of::<RectPrimitive>()),
glyphs: Tally::new("glyphs", size_of::<GlyphPrimitive>()),
}
}
/// Reads this frame's dirty ranges out of the render state and clears
/// them, exactly as `UiRenderNode::update` would on a real backend.
fn frame(&mut self, h: &mut Harness) {
let count = h.render.primitives.instances().len();
let (entries, dirty) = h.render.primitives.instances_for_upload();
// `PrimitiveInstance` is not exported, so the stride comes from
// the slice rather than from `size_of`.
let bytes: Vec<u8> = bytemuck::cast_slice(entries).to_vec();
self.instances.stride = if count == 0 { 48 } else { bytes.len() / count };
let ranges = dirty.ranges(count, 1024 / self.instances.stride);
dirty.clear();
self.instances.frame(&bytes, ranges);
let data = h.render.primitives.data_mut();
let (entries, dirty) = RectPrimitive::vec(data).for_upload();
let bytes: Vec<u8> = bytemuck::cast_slice(entries).to_vec();
let ranges = dirty.ranges(entries.len(), 1024 / size_of::<RectPrimitive>());
dirty.clear();
self.rects.frame(&bytes, ranges);
let (entries, dirty) = GlyphPrimitive::vec(data).for_upload();
let bytes: Vec<u8> = bytemuck::cast_slice(entries).to_vec();
let ranges = dirty.ranges(entries.len(), 1024 / size_of::<GlyphPrimitive>());
dirty.clear();
self.glyphs.frame(&bytes, ranges);
}
fn report(&mut self, h: &Harness) {
println!(
" arena {} slots for {} live primitives",
h.render.primitives.instances().len(),
h.render.primitives.live_count(),
);
self.instances.report();
self.rects.report();
self.glyphs.report();
}
}
#[test]
#[ignore]
fn what_a_fling_uploads() {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
let mut arenas = Arenas::new();
arenas.frame(&mut h);
let mut t = PHONE_FRAME_MS;
for pass in 0..PASSES {
let velocity = if pass % 2 == 0 { VELOCITY } else { -VELOCITY };
(opened.screen.list)(&mut h.rsc).fling(velocity);
let id = opened.screen.list.id();
h.rsc.ui.animate(id);
let end = t + PASS_CAP_MS;
while t <= end {
t += PHONE_FRAME_MS;
h.frame(t);
arenas.frame(&mut h);
if !(opened.screen.list)(&mut h.rsc).is_scrolling() {
break;
}
}
// A moment at rest between passes, as a finger would leave.
t += 200;
}
println!("\na fling, {PASSES} passes:");
arenas.report(&h);
}
#[test]
#[ignore]
fn what_a_streamed_reply_uploads() {
let mut h = Harness::new(phone_size(), PHONE_SCALE);
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
h.frame(0);
h.frame(PHONE_FRAME_MS);
let mut arenas = Arenas::new();
arenas.frame(&mut h);
let mut items = opened.items;
let mut t = PHONE_FRAME_MS;
for event in opened.stream_tail.iter() {
let old = items.clone();
items = ai_app::client::transcript_fold::fold_event(&items, event);
opened.screen.apply(&mut h.rsc, &old, &items);
t += PHONE_FRAME_MS;
h.frame(t);
arenas.frame(&mut h);
}
println!("\na reply streaming in, one event per frame:");
arenas.report(&h);
}