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 faa047efbd
commit 4fb369fdd0
8 files changed
+687 -122

No files matched your search

+107 -21
View File
@@ -94,6 +94,42 @@ pub struct UiRenderState {
last_input_at: Mutex<Option<Instant>>,
}
/// What a widget being redrawn keeps from the draw it is replacing.
///
/// These four always travel together -- they are read off one
/// `ActiveData` that was just taken out of `active` and handed straight
/// to the draw that replaces it -- and they were four positional
/// parameters of [`UiRenderState::draw_inner`] until 2026-09-09, next to
/// six others. [`Default`] is the "nothing to keep" case: a widget drawn
/// for the first time, and the root of a full relayout.
pub(super) struct Retained {
/// So children this draw does not draw again can be retired.
pub children: Vec<WidgetId>,
/// Reused in place with its delta reset, never reallocated: a
/// descendant that is not itself redrawn still points at it. See
/// LAYOUT.md section 2.
pub move_slot: Option<MoveIdx>,
pub own_mask: MaskIdx,
/// Slots the draw may write into instead of allocating -- see
/// `Painter::take_recycled`. Anything it does not claim is freed when
/// the draw ends.
pub primitives: Vec<PrimitiveHandle>,
}
impl Default for Retained {
/// Nothing kept: no children to retire, no move slot to reuse, no
/// mask of its own yet, nothing to recycle. Hand-written because
/// `MaskIdx`'s zero is a real slot rather than "none".
fn default() -> Self {
Self {
children: Vec::new(),
move_slot: None,
own_mask: MaskIdx::NONE,
primitives: Vec::new(),
}
}
}
/// The bound on the parent walk -- see `resolve_move` in shader.wgsl,
/// which walks the identical chain and must be kept in step with this
/// constant. It exists so a cyclic `parent` link cannot hang either walk,
@@ -386,9 +422,7 @@ impl UiRenderState {
None,
MoveOffset::NONE_PARENT,
MaskIdx::NONE,
None,
None,
MaskIdx::NONE,
Retained::default(),
rsc,
);
}
@@ -421,14 +455,15 @@ impl UiRenderState {
parent: Option<WidgetId>,
parent_move_slot: u32,
mask: MaskIdx,
old_children: Option<Vec<WidgetId>>,
old_move_slot: Option<MoveIdx>,
old_own_mask: MaskIdx,
retained: Retained,
rsc: &mut dyn UiRsc,
) {
let mut old_children = old_children.unwrap_or_default();
let mut old_move_slot = old_move_slot;
let mut own_mask = old_own_mask;
let Retained {
children: mut old_children,
move_slot: mut old_move_slot,
mut own_mask,
primitives: mut recycle,
} = retained;
// Consumed here, not merely read: this call *is* the redraw the mark
// asked for, and leaving the mark set is what stranded a widget's
// primitives. `Painter::draw_twice` calls this twice for the same id
@@ -483,19 +518,21 @@ impl UiRenderState {
return;
}
// if not, then maintain resize and track old children to remove unneeded
let active = self.remove(id, false, rsc).unwrap();
let active = self.remove(id, false, true, rsc).unwrap();
old_children = active.children;
old_move_slot = Some(active.move_slot);
own_mask = active.own_mask;
recycle = active.primitives;
} else if dirty && self.active.contains_key(&id) {
// Dirty and already drawn: none of the fast paths above may be
// taken (the widget's own content changed, so its old primitives
// say nothing about its new ones), but they are also the only
// thing that frees them. Same two lines, reached the other way.
let active = self.remove(id, false, rsc).unwrap();
let active = self.remove(id, false, true, rsc).unwrap();
old_children = active.children;
old_move_slot = Some(active.move_slot);
own_mask = active.own_mask;
recycle = active.primitives;
}
// draw widget
@@ -552,6 +589,7 @@ impl UiRenderState {
id,
textures: Vec::new(),
primitives: Vec::new(),
recycle: recycle.into_iter().peekable(),
children: Vec::new(),
rsc,
};
@@ -581,11 +619,21 @@ impl UiRenderState {
own_mask,
textures,
primitives,
recycle,
children,
layer,
id,
} = painter;
// Whatever the draw did not claim is genuinely gone: this draw
// wrote fewer primitives than the last one, or stopped matching
// part way. Freeing it here rather than in `remove` is what lets
// the draw in between reuse the slots -- see
// `Primitives::recycle`.
for h in recycle {
self.free_primitive(&h);
}
// add to active
let active = ActiveData {
id,
@@ -698,19 +746,42 @@ impl UiRenderState {
self.mov_count += 1;
}
/// Retires `id`'s primitives (unless `keep_primitives`, in which case
/// they come back in the returned `ActiveData` for the redraw about to
/// happen to recycle -- see `Painter::take_recycled`), drops the mask
/// refs they held, and takes the widget out of `active`.
///
/// The handles stay in the returned `ActiveData` either way, freed or
/// not: `remask_shape_users` below reads them, and so does the
/// caller. **A caller that passed `keep_primitives: false` must not
/// free them again** -- they name slots that may already have been
/// handed out.
///
/// The mask refs are dropped either way: a recycled slot is rewritten
/// with whatever mask the *new* draw is under, and that draw takes its
/// own ref (`Painter::write_primitive`).
///
/// NOTE: instance textures are cleared and self.textures freed
fn remove(&mut self, id: WidgetId, undraw: bool, rsc: &mut dyn UiRsc) -> Option<ActiveData> {
fn remove(
&mut self,
id: WidgetId,
undraw: bool,
keep_primitives: bool,
rsc: &mut dyn UiRsc,
) -> Option<ActiveData> {
let mut active = self.active.remove(&id);
if let Some(active) = &mut active {
for h in &active.primitives {
let mask = self.primitives.free(h);
if h.pos != NOT_DRAWN {
self.layers[h.layer].free(h.pos, h.is_image());
}
let mask = self.primitives.instance(h.slot).mask_idx;
if mask != MaskIdx::NONE {
rsc.ui_mut().masks.remove(mask);
}
}
if !keep_primitives {
for h in &active.primitives {
self.free_primitive(h);
}
}
Self::remask_shape_users(&self.active, id, active.own_mask, &active.primitives, rsc);
active.textures.clear();
rsc.ui_mut().textures.free();
@@ -753,6 +824,18 @@ impl UiRenderState {
active
}
/// Retires one primitive: its arena slot and, if a layer's draw order
/// names it, its position there. The two go together -- a slot handed
/// out again while its old order entry still names it would be drawn
/// twice -- which is why this is one function rather than two lines
/// repeated at each call site.
fn free_primitive(&mut self, h: &PrimitiveHandle) {
self.primitives.free(h);
if h.pos != NOT_DRAWN {
self.layers[h.layer].free(h.pos, h.is_image());
}
}
/// A mask whose shape primitive was just freed clips to a slot that
/// now holds something else, so the widget that owns it is marked for
/// redraw -- its own `set_mask` is the only thing that resolves the
@@ -802,7 +885,7 @@ impl UiRenderState {
}
fn remove_rec(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) -> Option<ActiveData> {
let inst = self.remove(id, true, rsc);
let inst = self.remove(id, true, false, rsc);
if let Some(inst) = &inst {
for c in &inst.children {
self.remove_rec(*c, rsc);
@@ -1117,7 +1200,7 @@ impl UiRenderState {
return;
}
let Some(active) = self.remove(id, false, rsc) else {
let Some(active) = self.remove(id, false, true, rsc) else {
return;
};
let old_size = active.size;
@@ -1134,9 +1217,12 @@ impl UiRenderState {
parent,
parent_move_slot,
active.mask,
Some(active.children),
Some(active.move_slot),
active.own_mask,
Retained {
children: active.children,
move_slot: Some(active.move_slot),
own_mask: active.own_mask,
primitives: active.primitives,
},
rsc,
);
// If this widget's own reported size changed, its parent's layout