//! Profiling runs rather than tests: what a frame costs on the CPU, 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, on a clock this file owns. It exists so "the //! fling stutters" can be attributed rather than guessed at, and it is //! kept between investigations rather than rewritten each time (Iris, //! 2026-09-09: "please keep the profiling rig around for future use"). //! //! cargo test --release --test frame_profile -- --ignored --nocapture //! //! Two runs today: `what_a_fling_frame_costs` (scrolling over transcript //! that is already folded) and `what_a_streamed_event_costs` (a reply //! arriving into it). //! //! `#[ignore]`d because it asserts nothing -- it prints a distribution, //! so `run-tests.sh` neither runs it nor can fail on it. **Release, or //! the numbers mean nothing**: layout is dominated by text shaping, which //! is an order of magnitude slower unoptimised. //! //! What it cannot answer: anything about the GPU, the present queue, or //! the phone's own clock. It measures the CPU half of a frame, which is //! where `cpu_p50` in a phone bench report comes from. use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; use iris::harness::{Harness, TouchScript}; use iris::prelude::*; use std::time::{Duration, Instant}; /// Passes over the same content, alternating direction. More than two /// because the question the rig was built for is whether a frame's cost /// is first-time work (which the first pass pays and the rest do not) or /// work repeated every time a row comes back on screen. const PASSES: usize = 8; /// The velocity `bench_client.rs`'s fling phase uses, so a number here /// and a number in a phone report describe the same gesture. const VELOCITY: f32 = 12_000.0; /// A fling settles on the spline's own schedule (~2s at this velocity); /// this only stops a pass that somehow never settles from running away. const PASS_CAP_MS: u64 = 4_000; fn pct(sorted: &[Duration], p: f64) -> f64 { let i = ((sorted.len() as f64 - 1.0) * p).round() as usize; sorted[i].as_secs_f64() * 1000.0 } fn summarise(name: &str, samples: &[Duration]) { let mut v = samples.to_vec(); v.sort(); let over_budget = v .iter() .filter(|d| **d > Duration::from_micros(8_333)) .count(); println!( " {name:16} n={:<5} p50 {:.2}ms p90 {:.2}ms p99 {:.2}ms worst {:.2}ms \ over-8.3ms {over_budget}", v.len(), pct(&v, 0.5), pct(&v, 0.9), pct(&v, 0.99), pct(&v, 1.0), ); } #[test] #[ignore] fn what_a_fling_frame_costs() { 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); // The recorded flick first, so the velocity a real finger produces is // in the log beside the scripted passes below. let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch")).unwrap(); h.replay(&flick); println!( "recorded flick released at {:?}px/s; scripted passes run at {VELOCITY}px/s", (opened.screen.list)(&mut h.rsc).fling_velocity(), ); let mut t = flick.end_ms(); let mut all_frames = Vec::new(); let mut all_layouts = Vec::new(); for pass in 0..PASSES { // Away from the newest end on the even passes and back on the // odd ones, the same out-and-back the bench's fling phase drives. let velocity = if pass % 2 == 0 { VELOCITY } else { -VELOCITY }; let list = (opened.screen.list)(&mut h.rsc); list.fling(velocity); let id = opened.screen.list.id(); h.rsc.ui.animate(id); let mut frames = Vec::new(); let mut layouts = Vec::new(); let end = t + PASS_CAP_MS; while t <= end { let at = Instant::now(); h.frame(t); frames.push(at.elapsed()); layouts.push(h.render.last_layout_duration()); t += PHONE_FRAME_MS; if !(opened.screen.list)(&mut h.rsc).is_scrolling() { break; } } let laid_out = layouts .iter() .filter(|d| **d > Duration::from_micros(20)) .count(); println!( "pass {pass} ({}): {} frames, {laid_out} did layout", if velocity > 0.0 { "out " } else { "back" }, frames.len(), ); summarise("frame", &frames); all_frames.extend(frames); all_layouts.extend(layouts); // A moment at rest between passes, as a finger would leave. t += 200; } println!( "\nall {PASSES} passes, primitives={}", h.render.active_primitive_count() ); summarise("frame", &all_frames); summarise("layout", &all_layouts); } /// The other half of a bench run, and since 2026-09-09 the expensive one: /// what it costs to fold one arriving event into the transcript and show /// it. The bench's stream phase measured `build p50 9.5ms` on Iris's /// phone against a fling's 0.4ms, so this is where the frame time now is. /// /// Reports the fold and the widget-tree apply separately, because they /// are different problems with different fixes -- and reports how the /// cost moves as the transcript grows, which is the shape that says /// whether the work is per-event or per-event-times-transcript. #[test] #[ignore] fn what_a_streamed_event_costs() { 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 items = opened.items; println!( "backlog: {} items, then {} streamed events", items.len(), opened.stream_tail.len() ); let mut fold = Vec::new(); let mut apply = Vec::new(); let mut frame = Vec::new(); let mut t = PHONE_FRAME_MS; for (n, event) in opened.stream_tail.iter().enumerate() { let at = Instant::now(); let old = items.clone(); let folded = ai_app::client::transcript_fold::fold_event(&items, event); fold.push(at.elapsed()); items = folded; let at = Instant::now(); opened.screen.apply(&mut h.rsc, &old, &items); apply.push(at.elapsed()); t += PHONE_FRAME_MS; let at = Instant::now(); h.frame(t); frame.push(at.elapsed()); // Where the cost sits as the transcript grows -- one line early, // one late, is enough to see a per-event cost from a quadratic. if n == 0 || n == opened.stream_tail.len() - 1 { println!( " event {n:>3} of {}: items={} fold {:?} apply {:?}", opened.stream_tail.len(), items.len(), fold[n], apply[n], ); } } summarise("fold", &fold); summarise("apply", &apply); summarise("frame", &frame); }