//! 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, changed: Vec, uploaded: Vec, whole: Vec, calls: Vec, } 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>) { 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::(); 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::()), glyphs: Tally::new("glyphs", size_of::()), } } /// 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 = 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 = bytemuck::cast_slice(entries).to_vec(); let ranges = dirty.ranges(entries.len(), 1024 / size_of::()); dirty.clear(); self.rects.frame(&bytes, ranges); let (entries, dirty) = GlyphPrimitive::vec(data).for_upload(); let bytes: Vec = bytemuck::cast_slice(entries).to_vec(); let ranges = dirty.ranges(entries.len(), 1024 / size_of::()); 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); }