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
faa047efbd
commit
4fb369fdd0
8 files changed
+687
-122
No files matched your search
@@ -0,0 +1,150 @@
|
||||
//! Which entries of a GPU-bound array changed since the last upload.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
/// A bitset of dirty entries, coalesced into a handful of ranges when it
|
||||
/// is time to upload.
|
||||
///
|
||||
/// **Why a bitset** rather than the two obvious alternatives, both of
|
||||
/// which were measured against the bench fixture before this was written
|
||||
/// (`scripts/rigs/ui-profile`'s `arena_churn`). A `min..max` span is far
|
||||
/// too coarse: a frame's changes land in 5-20 runs scattered across the
|
||||
/// whole arena, so the span is very nearly the whole buffer. A `Vec` of
|
||||
/// touched indices is too expensive to *write*: a streaming frame marks
|
||||
/// several thousand entries, which would mean an allocation and a sort
|
||||
/// per frame. Marking a bit is O(1), allocation-free and idempotent, and
|
||||
/// the scan that reads it back is one word per 64 entries.
|
||||
#[derive(Default)]
|
||||
pub struct Dirty {
|
||||
words: Vec<u64>,
|
||||
/// Everything is dirty regardless of the bits -- the state after a
|
||||
/// buffer reallocation, whose contents are undefined, and the state a
|
||||
/// freshly built arena starts in. Kept as a flag rather than by
|
||||
/// setting every bit so that it costs nothing to say and cannot be
|
||||
/// half-applied as the array grows.
|
||||
all: bool,
|
||||
}
|
||||
|
||||
impl Dirty {
|
||||
/// Nothing uploaded yet, so nothing may be assumed about the buffer.
|
||||
pub fn new_all() -> Self {
|
||||
Self {
|
||||
words: Vec::new(),
|
||||
all: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark(&mut self, i: usize) {
|
||||
if self.all {
|
||||
return;
|
||||
}
|
||||
let word = i / 64;
|
||||
if word >= self.words.len() {
|
||||
self.words.resize(word + 1, 0);
|
||||
}
|
||||
self.words[word] |= 1 << (i % 64);
|
||||
}
|
||||
|
||||
/// Everything must be written: the buffer was reallocated (its
|
||||
/// contents are undefined), or the array was cleared.
|
||||
pub fn mark_all(&mut self) {
|
||||
self.all = true;
|
||||
self.words.clear();
|
||||
}
|
||||
|
||||
pub fn is_clean(&self) -> bool {
|
||||
!self.all && self.words.iter().all(|w| *w == 0)
|
||||
}
|
||||
|
||||
/// The ranges to upload, in ascending order, merging two runs
|
||||
/// separated by a gap of fewer than `gap` entries.
|
||||
///
|
||||
/// Merging trades bytes for `write_buffer` calls, and the fixture
|
||||
/// says the trade is very cheap in one direction: over a fling, a
|
||||
/// 1 KiB gap costs 0.1% more bytes than merging nothing at all and
|
||||
/// halves the worst-case call count (23 to 13). Past that it stops
|
||||
/// paying -- 4 KiB is +2% bytes for two fewer calls.
|
||||
pub fn ranges(&self, len: usize, gap: usize) -> Vec<Range<usize>> {
|
||||
if self.all {
|
||||
return Vec::from_iter((len > 0).then_some(0..len));
|
||||
}
|
||||
let mut ranges: Vec<Range<usize>> = Vec::new();
|
||||
for (w, word) in self.words.iter().enumerate() {
|
||||
let mut bits = *word;
|
||||
while bits != 0 {
|
||||
let start = w * 64 + bits.trailing_zeros() as usize;
|
||||
// The run of set bits starting here, within this word.
|
||||
let run = (bits >> (start - w * 64)).trailing_ones() as usize;
|
||||
let end = (start + run).min(len);
|
||||
if start >= len {
|
||||
break;
|
||||
}
|
||||
match ranges.last_mut() {
|
||||
// `start - last.end` is the gap; equal ends means
|
||||
// adjacent, which always merges.
|
||||
Some(last) if start - last.end <= gap => last.end = end,
|
||||
_ => ranges.push(start..end),
|
||||
}
|
||||
bits &= !(((1u128 << run) - 1) as u64) << (start - w * 64);
|
||||
}
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.all = false;
|
||||
self.words.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn marked(indices: &[usize], len: usize, gap: usize) -> Vec<Range<usize>> {
|
||||
let mut d = Dirty::default();
|
||||
for &i in indices {
|
||||
d.mark(i);
|
||||
}
|
||||
d.ranges(len, gap)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_entries_are_one_range() {
|
||||
assert_eq!(marked(&[3, 4, 5], 64, 0), vec![3..6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_that_crosses_a_word_boundary_is_one_range() {
|
||||
assert_eq!(marked(&[62, 63, 64, 65], 128, 0), vec![62..66]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_wider_than_the_threshold_stays_two_ranges() {
|
||||
assert_eq!(marked(&[0, 10], 64, 4), vec![0..1, 10..11]);
|
||||
assert_eq!(marked(&[0, 10], 64, 16), vec![0..11]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_stop_at_the_length() {
|
||||
// Entries marked and then dropped by a shrink must not be
|
||||
// uploaded past the end of what the caller is writing.
|
||||
assert_eq!(marked(&[1, 2, 40], 3, 0), vec![1..3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_all_covers_everything_and_survives_later_marks() {
|
||||
let mut d = Dirty::new_all();
|
||||
d.mark(2);
|
||||
assert_eq!(d.ranges(9, 0), vec![0..9]);
|
||||
assert!(!d.is_clean());
|
||||
d.clear();
|
||||
assert!(d.is_clean());
|
||||
assert!(d.ranges(9, 0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_array_has_nothing_to_upload_even_when_all_is_set() {
|
||||
assert!(Dirty::new_all().ranges(0, 0).is_empty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user