The frame report says what it measured: idle is not stutter, waiting is not late
Iris's phone came back "now THAT is smooth", and reading that run against the bench's own timings found three things the report was getting wrong -- two of them shipped yesterday in the fix for the last three. `missed vsyncs` counted idleness. Every gap between frames was treated as cadence, so the bench's own pauses read as stutter: 276 for sixteen 300ms rests between flings, 2410 for twelve hundred 50ms keystroke gaps, 821 for four hundred 50ms stream gaps -- each within a few percent of the arithmetic. A gap now measures anything only if the frame before it had asked for another one. `late` counted the swapchain wait as cost. A well-paced loop spends each frame blocked in the acquire, so its total sits at exactly one refresh period and every frame lands on the budget boundary -- 0.4ms of work and 5.7ms of waiting is not a late frame. It is judged on `FrameParts::work`. And the refresh rate is the larger of what the platform claims and what the run sustained, because each can only be wrong one way. `Display.getRefreshRate()` answered 60 for a run that drew 3405 frames in 33.1s, since a phone that varies its rate answers with whatever mode it is in when asked. The first attempt at measuring it instead took the fastest tenth of the gaps and reported 88Hz for this repo's 60Hz emulator, whose app manages 54 -- a budget no frame there could meet, invented out of the app's best moments, and caught only by running the corrected report on the emulator before shipping it. A sustained rate is a floor and cannot do that. Both are printed when they disagree. Also corrected in the docs: "103fps on a 120Hz screen" divided the fling phase by its whole duration, rests included. Both runs sustained ~120.3fps through the motion, so the callback ordering was never costing frames -- what changed is the clock, which moves no frame count at all, which is exactly why nothing in a report could show it. `fling_profile.rs` is `frame_profile.rs` and gained a stream run, which says where the frame time now is: folding an arriving event is 0.35ms and applying the diff 0.41ms, while the frame is 3.86ms here and 9.5ms on the phone. 401 events move the item count 652 -> 654, so nearly every one is a delta into the same row -- the cost is re-shaping one growing message, not `fold_event`'s per-event clone, which was the hypothesis and is what measuring it ruled out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
42d54eec95
commit
9bf714fa2e
6 files changed
+457
-57
No files matched your search
@@ -0,0 +1,190 @@
|
||||
//! 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);
|
||||
}
|
||||
Reference in new issue
Block a user