842 lines
31 KiB
Rust
842 lines
31 KiB
Rust
use crate::android::bench_jni::PlatformHandle;
|
|
use crate::client::transcript_fold::{TranscriptItem, fold_event};
|
|
use crate::ui::{self, TranscriptScreen};
|
|
use android_view::jni::{JavaVM, objects::GlobalRef};
|
|
use event_model::SeqEvent;
|
|
use iris::android::{AndroidAppState, AndroidUiState};
|
|
use iris::prelude::*;
|
|
use std::{
|
|
fs, mem,
|
|
sync::{
|
|
Arc, Mutex,
|
|
atomic::{AtomicBool, Ordering},
|
|
mpsc,
|
|
},
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
const STREAM_EVENTS_PER_SEC: u64 = 20;
|
|
const STREAM_SECONDS: u64 = 20;
|
|
|
|
const LEGACY_CYCLES: usize = 6;
|
|
|
|
const FLING_VELOCITY_PX_S: f32 = 12_000.0;
|
|
const FLING_COUNT: usize = 8;
|
|
const FLING_SETTLE_CAP_MS: u64 = 3_000;
|
|
const FLING_PAUSE_MS: u64 = 300;
|
|
|
|
const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \
|
|
multisyllabic words so wrapping and reflow are properly exercised: internationalization, \
|
|
counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \
|
|
uncharacteristically, overenthusiastically, misunderstanding, straightforwardness, \
|
|
telecommunications, and interdisciplinary collaboration all push a narrow composer field to \
|
|
wrap across several lines while the transcript above is pushed upward by the growing \
|
|
keyboard-adjacent box, which is exactly what a real reader typing a long message sees \
|
|
happening now!!!";
|
|
const TYPE_CHAR_MS: u64 = 50;
|
|
|
|
const KEYBOARD_CYCLES: usize = 5;
|
|
const KEYBOARD_WAIT_MS: u64 = 1_000;
|
|
|
|
const POLL_MS: u64 = 16;
|
|
|
|
const REPORT_MAX_HEIGHT_DP: f32 = 260.0;
|
|
|
|
#[derive(AndroidUiState)]
|
|
pub struct BenchClient {
|
|
ui_state: AndroidUiState,
|
|
content: WeakWidget<WidgetPtr>,
|
|
report_display: WeakWidget<TextEdit>,
|
|
top_bar: WeakWidget<WidgetPtr>,
|
|
screen: Option<TranscriptScreen>,
|
|
items: Vec<TranscriptItem>,
|
|
stream_tail: Vec<SeqEvent>,
|
|
platform: Option<Arc<PlatformHandle>>,
|
|
last_report: Option<String>,
|
|
running: bool,
|
|
ime_state: Arc<Mutex<ImeState>>,
|
|
keyboard_was_visible: bool,
|
|
last_top_pad: f32,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ImeState {
|
|
visible: bool,
|
|
shown_events: u32,
|
|
hidden_events: u32,
|
|
}
|
|
|
|
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
|
wtext(message.to_string())
|
|
.color(PaintId::WHITE)
|
|
.overflow(TextOverflow::Wrap)
|
|
.pad(16)
|
|
.add_strong(rsc)
|
|
.any()
|
|
}
|
|
|
|
fn process_cpu_ms() -> Option<u64> {
|
|
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
|
|
// initialises on success; on failure it is never read.
|
|
unsafe {
|
|
let mut usage: libc::rusage = mem::zeroed();
|
|
if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 {
|
|
return None;
|
|
}
|
|
let user_ms = usage.ru_utime.tv_sec as u64 * 1000 + usage.ru_utime.tv_usec as u64 / 1000;
|
|
let sys_ms = usage.ru_stime.tv_sec as u64 * 1000 + usage.ru_stime.tv_usec as u64 / 1000;
|
|
Some(user_ms + sys_ms)
|
|
}
|
|
}
|
|
|
|
fn peak_rss_kb() -> Option<u64> {
|
|
fs::read_to_string("/proc/self/status")
|
|
.ok()?
|
|
.lines()
|
|
.find_map(|line| line.strip_prefix("VmHWM:"))
|
|
.and_then(|rest| rest.trim().strip_suffix("kB"))
|
|
.and_then(|n| n.trim().parse().ok())
|
|
}
|
|
|
|
fn battery_line(samples: &[i32]) -> String {
|
|
if samples.is_empty() {
|
|
return " battery current: unavailable on this device".to_string();
|
|
}
|
|
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
|
|
let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else {
|
|
unreachable!("samples is non-empty, checked above");
|
|
};
|
|
format!(
|
|
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
|
|
samples.len()
|
|
)
|
|
}
|
|
|
|
impl BenchClient {
|
|
pub(super) fn new(mut ui_state: AndroidUiState, rsc: &mut StdRsc<Self>) -> Self {
|
|
crate::ui::register_fonts(&mut rsc.ui);
|
|
let content = WidgetPtr::new().add(rsc);
|
|
let loading = placeholder(rsc, "Loading fixture...");
|
|
content(rsc).set(loading);
|
|
|
|
let report_display = wtext("")
|
|
.editable(EditMode::MultiLine)
|
|
.text_align(Align::LEFT)
|
|
.overflow(TextOverflow::Wrap)
|
|
.size(14)
|
|
.color(PaintId::WHITE)
|
|
.attr::<Selectable>(())
|
|
.label("Benchmark report")
|
|
.add(rsc);
|
|
|
|
let top_bar = WidgetPtr::new().add(rsc);
|
|
let controls = bench_controls(rsc, 0.0);
|
|
top_bar(rsc).set(controls);
|
|
let tree = (
|
|
top_bar,
|
|
report_display
|
|
.pad(dp(8))
|
|
.max_height(dp(REPORT_MAX_HEIGHT_DP)),
|
|
content.height(rest(1)),
|
|
)
|
|
.span(Dir::DOWN)
|
|
.add_strong(rsc)
|
|
.any();
|
|
ui_state.set_root(rsc, tree);
|
|
|
|
let font = rsc.ui.text.borrow_mut().font_diagnostics();
|
|
log::info!(
|
|
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
|
|
bold={:?} italic={:?} mono={:?}",
|
|
font.families_found,
|
|
font.default_family,
|
|
font.default_mono_family,
|
|
font.regular_resolved,
|
|
font.bold_resolved,
|
|
font.italic_resolved,
|
|
font.mono_resolved,
|
|
);
|
|
|
|
let mut client = Self {
|
|
ui_state,
|
|
content,
|
|
report_display,
|
|
top_bar,
|
|
screen: None,
|
|
items: Vec::new(),
|
|
stream_tail: Vec::new(),
|
|
platform: None,
|
|
last_report: None,
|
|
running: false,
|
|
ime_state: Arc::new(Mutex::new(ImeState::default())),
|
|
keyboard_was_visible: false,
|
|
last_top_pad: 0.0,
|
|
};
|
|
|
|
match ui::fixture::build_screen(rsc) {
|
|
Ok((opened, tree)) => {
|
|
client.items = opened.items;
|
|
client.stream_tail = opened.stream_tail;
|
|
(client.content)(rsc).set(tree);
|
|
client.screen = Some(opened.screen);
|
|
}
|
|
Err(message) => {
|
|
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
|
|
}
|
|
}
|
|
client
|
|
}
|
|
}
|
|
|
|
impl AndroidAppState for BenchClient {
|
|
type Resources = StdRsc<Self>;
|
|
|
|
fn platform_ready(&mut self, _rsc: &mut Self::Resources, vm: JavaVM, view: GlobalRef) {
|
|
self.platform = Some(Arc::new(PlatformHandle::new(vm, view)));
|
|
}
|
|
|
|
fn back_pressed(&mut self, _rsc: &mut Self::Resources) -> bool {
|
|
false
|
|
}
|
|
|
|
fn on_insets_changed(
|
|
&mut self,
|
|
rsc: &mut Self::Resources,
|
|
insets: iris::android::WindowInsets,
|
|
) {
|
|
if insets.top != self.last_top_pad {
|
|
self.last_top_pad = insets.top;
|
|
let controls = bench_controls(rsc, insets.top);
|
|
(self.top_bar)(rsc).set(controls);
|
|
}
|
|
|
|
if let Some(screen) = &self.screen {
|
|
screen
|
|
.composer
|
|
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
|
|
}
|
|
|
|
// The platform's own answer, not `ime_bottom > 0.0` -- see
|
|
// `iris::android::WindowInsets::ime_bottom`. The height is still
|
|
// climbing while the keyboard slides in, so a frame or two of a
|
|
// real opening reads as "closed" when the boolean is inferred from
|
|
// it, and `shown_events`/`hidden_events` below count transitions.
|
|
let ime_visible = insets.ime_visible;
|
|
|
|
let mut ime = self.ime_state.lock().unwrap();
|
|
if ime_visible && !ime.visible {
|
|
ime.shown_events += 1;
|
|
}
|
|
if !ime_visible && ime.visible {
|
|
ime.hidden_events += 1;
|
|
}
|
|
ime.visible = ime_visible;
|
|
drop(ime);
|
|
|
|
if ime_visible && !self.keyboard_was_visible {
|
|
self.keyboard_was_visible = true;
|
|
rsc.spawn_task(async move |mut ctx| {
|
|
tokio::time::sleep(Duration::from_millis(KEYBOARD_DIAGNOSTICS_DELAY_MS)).await;
|
|
ctx.update(|state: &mut BenchClient, rsc| {
|
|
state.capture_keyboard_diagnostics(rsc);
|
|
});
|
|
});
|
|
} else if !ime_visible {
|
|
self.keyboard_was_visible = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
|
|
|
|
type Rsc = StdRsc<BenchClient>;
|
|
|
|
/// What a report says about the `iris::input`/`iris::frame` trace, from
|
|
/// the flag read at the start of what is being reported and again at the
|
|
/// end.
|
|
///
|
|
/// Three answers rather than two. Those lines are default-off and the
|
|
/// switch that turns them on is on screen while a benchmark runs, so
|
|
/// "somebody moved it half way through" is a state that actually happens
|
|
/// -- and reported as either "on" or "off" it is a confident sentence
|
|
/// about a log that only covers part of the run. The "on" wording also
|
|
/// says what it costs, because a traced run fills the ring in seconds and
|
|
/// a reader looking at a log with nothing else in it should know why.
|
|
fn trace_line(at_start: bool, at_end: bool) -> String {
|
|
match (at_start, at_end) {
|
|
(true, true) => "input/frame trace: on (iris::input and iris::frame lines are in \
|
|
the app log, and a traced run fills the ring in seconds)"
|
|
.to_string(),
|
|
(false, false) => "input/frame trace: off".to_string(),
|
|
_ => "input/frame trace: switched during this run, so those lines cover only part \
|
|
of it"
|
|
.to_string(),
|
|
}
|
|
}
|
|
|
|
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
|
|
/// why it needs one at all. A dark neutral rather than pure black
|
|
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
|
|
/// instead of a hole in the background the buttons happen to float in.
|
|
const HEADER_SURFACE: Srgba8 = Srgba8::new(28, 28, 34, 255);
|
|
|
|
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
|
|
/// `on_insets_changed` has run once) -- folded in here, rather than
|
|
/// exposing the unadded builder for a caller to `.pad()` itself, because
|
|
/// naming that builder's type at each call site is more machinery than a
|
|
/// top-of-screen padding number is worth.
|
|
const HEADER_TEXT: f32 = 18.0;
|
|
|
|
const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
|
|
|
|
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
|
let run_rect = rect(Srgba8::rgb(40, 70, 40))
|
|
.on(
|
|
CursorSense::click(),
|
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
|
ctx.state.start_benchmark(rsc);
|
|
},
|
|
)
|
|
.label("Run benchmark");
|
|
let run = (
|
|
run_rect,
|
|
wtext("Run benchmark")
|
|
.size(HEADER_TEXT)
|
|
.text_align(Align::CENTER),
|
|
)
|
|
.stack()
|
|
.pad(dp(8))
|
|
.add(rsc);
|
|
|
|
let copy_rect = rect(Srgba8::rgb(50, 50, 60))
|
|
.on(
|
|
CursorSense::click(),
|
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
|
ctx.state.copy_report(rsc);
|
|
},
|
|
)
|
|
.label("Copy report");
|
|
let copy = (
|
|
copy_rect,
|
|
wtext("Copy report")
|
|
.size(HEADER_TEXT)
|
|
.text_align(Align::CENTER),
|
|
)
|
|
.stack()
|
|
.pad(dp(8))
|
|
.add(rsc);
|
|
|
|
let diag_rect = rect(Srgba8::rgb(60, 45, 70))
|
|
.on(
|
|
CursorSense::click(),
|
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
|
ctx.state.show_diagnostics(rsc);
|
|
},
|
|
)
|
|
.label("Diagnostics");
|
|
let diagnostics = (
|
|
diag_rect,
|
|
wtext("Diagnostics")
|
|
.size(HEADER_TEXT)
|
|
.text_align(Align::CENTER),
|
|
)
|
|
.stack()
|
|
.pad(dp(8))
|
|
.add(rsc);
|
|
|
|
// A switch rather than a button, so its own appearance says which
|
|
// state it is in: the two `iris::input`/`iris::frame` targets are
|
|
// default-off (`iris::diagnostics`'s module doc) because a 120Hz
|
|
// session fills the 2000-line ring in seconds, so "is it on right
|
|
// now" is the question somebody has while looking at a log that is
|
|
// either full of trace or has none.
|
|
let tracing = iris::diagnostics::trace_enabled();
|
|
let trace_rect = rect(if tracing {
|
|
Srgba8::rgb(90, 70, 30)
|
|
} else {
|
|
Srgba8::rgb(50, 50, 60)
|
|
})
|
|
.on(
|
|
CursorSense::click(),
|
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
|
ctx.state.toggle_trace(rsc);
|
|
},
|
|
)
|
|
.label("Trace input and frames");
|
|
let trace = (
|
|
trace_rect,
|
|
wtext(if tracing { "Trace on" } else { "Trace off" })
|
|
.size(HEADER_TEXT)
|
|
.text_align(Align::CENTER),
|
|
)
|
|
.stack()
|
|
.pad(dp(8))
|
|
.add(rsc);
|
|
|
|
let row1 = (run, copy).span(Dir::RIGHT).add(rsc);
|
|
let row2 = (diagnostics, trace).span(Dir::RIGHT).add(rsc);
|
|
let buttons = (row1, row2).span(Dir::DOWN).add(rsc);
|
|
|
|
(rect(HEADER_SURFACE), buttons)
|
|
.stack()
|
|
.height(dp(2.0 * HEADER_ROW_HEIGHT_DP))
|
|
.pad(Padding::top(top_pad))
|
|
.add_strong(rsc)
|
|
.any()
|
|
}
|
|
|
|
impl BenchClient {
|
|
fn show_message(&mut self, rsc: &mut Rsc, message: &str) {
|
|
let widget = placeholder(rsc, message);
|
|
(self.content)(rsc).set(widget);
|
|
self.screen = None;
|
|
}
|
|
|
|
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
|
|
let (screen, tree) = ui::build_tree(rsc, ui::fixture::rows(&self.items));
|
|
(self.content)(rsc).set(tree);
|
|
self.screen = Some(screen);
|
|
}
|
|
|
|
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
|
|
let report = self.diagnostics_text(rsc);
|
|
self.report_display(rsc).set(&report);
|
|
self.last_report = Some(report);
|
|
}
|
|
|
|
/// Turns the `iris::input`/`iris::frame` trace on or off, redraws the
|
|
/// switch that says so, and shows the pane that now reports it.
|
|
fn toggle_trace(&mut self, rsc: &mut Rsc) {
|
|
let on = !iris::diagnostics::trace_enabled();
|
|
iris::diagnostics::set_trace(on);
|
|
log::info!(
|
|
"iris diagnostics: input/frame trace {}",
|
|
if on { "on" } else { "off" }
|
|
);
|
|
let controls = bench_controls(rsc, self.last_top_pad);
|
|
(self.top_bar)(rsc).set(controls);
|
|
self.show_diagnostics(rsc);
|
|
}
|
|
|
|
/// The diagnostics report as text, with no side effect on what is on
|
|
/// screen -- shared by the `Diagnostics` button (which shows it) and
|
|
/// the keyboard-open capture (which only logs it), so the two can
|
|
/// never drift into reporting different things.
|
|
fn diagnostics_text(&self, rsc: &mut Rsc) -> String {
|
|
let font = rsc.ui.text.borrow_mut().font_diagnostics();
|
|
let frame_report = match self.android_state().frame_report.report() {
|
|
Some(stats) => format!("{stats}"),
|
|
None => "no frames recorded yet".to_string(),
|
|
};
|
|
let renderer = match &self.android_state().renderer {
|
|
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
|
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
|
};
|
|
// Insets must be visible without adb so a missing callback can be
|
|
// distinguished from a callback reporting zero IME height.
|
|
format!(
|
|
"{renderer}\n{}\n{}\n{}\n{}",
|
|
trace_line(
|
|
iris::diagnostics::trace_enabled(),
|
|
iris::diagnostics::trace_enabled()
|
|
),
|
|
self.android_state().insets_report(),
|
|
// Which server this build talks to, and what to do when the
|
|
// answer is "none" -- the bench itself opens a checked-in
|
|
// fixture and needs no server, so this pane is the only place
|
|
// an enrolment can be seen to have taken.
|
|
crate::android::enrollment::status_line(),
|
|
crate::android::app_log::diagnostics_line()
|
|
)
|
|
}
|
|
|
|
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
|
|
let report = self.diagnostics_text(rsc);
|
|
log::info!("iris keyboard diagnostics:\n{report}");
|
|
}
|
|
|
|
fn copy_report(&mut self, rsc: &mut Rsc) {
|
|
let Some(platform) = &self.platform else {
|
|
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
|
return;
|
|
};
|
|
let report = match self.last_report.clone() {
|
|
Some(report) => report,
|
|
None => format!(
|
|
"no benchmark has run yet -- these are the diagnostics instead:\n\n{}",
|
|
self.diagnostics_text(rsc)
|
|
),
|
|
};
|
|
if platform.copy_to_clipboard("iris bench report", &report) {
|
|
log::info!("iris bench report: copied to clipboard");
|
|
} else {
|
|
log::info!("iris bench report: clipboard copy failed");
|
|
}
|
|
}
|
|
|
|
fn start_benchmark(&mut self, rsc: &mut Rsc) {
|
|
if self.running {
|
|
log::info!("iris bench report: already running");
|
|
return;
|
|
}
|
|
self.running = true;
|
|
self.android_state_mut().frame_report.reset();
|
|
self.report_display(rsc).set("Running benchmark...");
|
|
|
|
let platform = self.platform.clone();
|
|
let stream_tail = self.stream_tail.clone();
|
|
let ime_state = self.ime_state.clone();
|
|
let platform_hz = platform.as_ref().and_then(|p| p.refresh_rate_hz());
|
|
let cpu_start = process_cpu_ms();
|
|
// Read at the start as well as the end, because the switch is on
|
|
// screen while a run is going: a report that only asked afterwards
|
|
// would say "on" about a run whose first half has no trace in it
|
|
// -- the inferred answer presented as the measured one.
|
|
let trace_at_start = iris::diagnostics::trace_enabled();
|
|
let run_started_at = Instant::now();
|
|
|
|
rsc.spawn_task(async move |mut ctx| {
|
|
// The battery sampler runs for the whole run, once a second,
|
|
// the benchmark's established battery-sampling cadence
|
|
// -- via its own JNI-attached thread, not `ctx.update`, since
|
|
// a sample needs no widget-tree access.
|
|
let sampler_done = Arc::new(AtomicBool::new(false));
|
|
let samples = Arc::new(Mutex::new(Vec::<i32>::new()));
|
|
let sampler = platform.clone().map(|platform| {
|
|
let done = sampler_done.clone();
|
|
let samples = samples.clone();
|
|
tokio::spawn(async move {
|
|
while !done.load(Ordering::Relaxed) {
|
|
if let Some(value) = platform.battery_current_ua() {
|
|
samples.lock().unwrap().push(value);
|
|
}
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
}
|
|
})
|
|
});
|
|
|
|
let travel = run_fling_phase(&mut ctx).await;
|
|
let (sent, total) = run_stream_phase(&mut ctx, stream_tail).await;
|
|
run_type_phase(&mut ctx, &platform).await;
|
|
let keyboard = run_keyboard_phase(&mut ctx, &platform, &ime_state).await;
|
|
|
|
sampler_done.store(true, Ordering::Relaxed);
|
|
if let Some(sampler) = sampler {
|
|
let _ = sampler.await;
|
|
}
|
|
let battery = battery_line(&samples.lock().unwrap());
|
|
let cpu_line = match (cpu_start, process_cpu_ms()) {
|
|
(Some(start), Some(end)) => {
|
|
format!(
|
|
" process CPU time over this run: {}ms",
|
|
end.saturating_sub(start)
|
|
)
|
|
}
|
|
_ => " process CPU time over this run: unavailable".to_string(),
|
|
};
|
|
let rss_line = match peak_rss_kb() {
|
|
Some(kb) => format!(" peak RSS: {kb}kB"),
|
|
None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(),
|
|
};
|
|
let total_seconds = run_started_at.elapsed().as_secs_f64();
|
|
|
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
|
state.running = false;
|
|
let now = Instant::now();
|
|
let drawn_hz = state.android_state().frame_report.sustained_frame_hz();
|
|
let refresh_hz = match (drawn_hz, platform_hz) {
|
|
(Some(d), Some(p)) => d.max(p),
|
|
(Some(d), None) => d,
|
|
(None, Some(p)) => p,
|
|
(None, None) => 60.0,
|
|
};
|
|
let hz_line = match (drawn_hz, platform_hz) {
|
|
(Some(d), Some(p)) if d > p + 5.0 => format!(
|
|
" (sustained {d:.0}fps, so at least that; the display reported {p:.0}Hz)"
|
|
),
|
|
(Some(d), Some(_)) => format!(" (as the display reports it; drew {d:.0}fps)"),
|
|
(Some(d), None) => {
|
|
format!(" (sustained {d:.0}fps; the display would not say)")
|
|
}
|
|
(None, Some(_)) => {
|
|
" (as the display reports it; too few frames to measure)".to_string()
|
|
}
|
|
(None, None) => " (assumed -- neither measured nor reported)".to_string(),
|
|
};
|
|
let phase_lines: String = state
|
|
.android_state()
|
|
.frame_report
|
|
.phase_stats(now, refresh_hz)
|
|
.iter()
|
|
.map(|p| format!("{p}\n"))
|
|
.collect();
|
|
let per_phase = if phase_lines.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("per phase:\n{phase_lines}\n")
|
|
};
|
|
let frames_block = match state.android_state().frame_report.report() {
|
|
Some(stats) => {
|
|
let (late, late_pct) =
|
|
state.android_state().frame_report.late_at_hz(refresh_hz);
|
|
format!(
|
|
"frames:\n {} frames over {:.1}s at {:.0}Hz{hz_line} ({:.1}ms \
|
|
budget)\n \
|
|
late: {late} ({late_pct:.1}%)\n total p50 {:.1}ms p90 {:.1}ms \
|
|
p99 {:.1}ms\n worst {:.1}ms\n build_p50 {:.1}ms acquire_p50 \
|
|
{:.1}ms submit_p50 {:.1}ms",
|
|
stats.total_frames,
|
|
total_seconds,
|
|
refresh_hz,
|
|
1000.0 / refresh_hz as f64,
|
|
stats.p50.as_secs_f64() * 1000.0,
|
|
stats.p90.as_secs_f64() * 1000.0,
|
|
stats.p99.as_secs_f64() * 1000.0,
|
|
stats.worst.as_secs_f64() * 1000.0,
|
|
stats.cpu_p50.as_secs_f64() * 1000.0,
|
|
stats.acquire_p50.as_secs_f64() * 1000.0,
|
|
stats.gpu_wait_p50.as_secs_f64() * 1000.0,
|
|
)
|
|
}
|
|
None => "frames:\n no frames recorded".to_string(),
|
|
};
|
|
let scroll_line = format!(
|
|
" scroll: {LEGACY_CYCLES} cycles ({} swipes, legacy tween), streamed \
|
|
{sent}/{total} fixture events",
|
|
LEGACY_CYCLES * 4
|
|
);
|
|
let fling_line = format!(
|
|
" fling: {FLING_COUNT} flings out + {FLING_COUNT} back at \
|
|
{FLING_VELOCITY_PX_S}px/s, travel {travel}"
|
|
);
|
|
let type_line = format!(
|
|
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
|
|
TYPE_TEXT.chars().count()
|
|
);
|
|
let traced = trace_line(trace_at_start, iris::diagnostics::trace_enabled());
|
|
let report = format!(
|
|
"iris bench report\n{traced}\n{per_phase}{frames_block}\n\nbench:\n\
|
|
{fling_line}\n{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n\
|
|
{rss_line}\n{battery}"
|
|
);
|
|
log::info!("iris bench report: {report}");
|
|
state.report_display(rsc).set(&report);
|
|
state.last_report = Some(report);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Runs `f` against the real `BenchClient`/`Rsc` on the main thread (the
|
|
/// same `ctx.update` every other mutation here goes through) and returns
|
|
/// its result to the caller's async task. `ctx.update` wakes the UI thread,
|
|
/// whose task callback drains Iris's update queue before checking whether the
|
|
/// retained widget tree needs another frame.
|
|
/// Polls rather than assuming one `POLL_MS` sleep is enough, since a
|
|
/// slow device's frame callback can lag further than that.
|
|
async fn read_from_state<T, F>(ctx: &mut iris::task::TaskCtx<Rsc>, f: F) -> T
|
|
where
|
|
T: Send + 'static,
|
|
F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
|
|
{
|
|
let (tx, rx) = mpsc::channel();
|
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
|
let _ = tx.send(f(state, rsc));
|
|
});
|
|
loop {
|
|
if let Ok(value) = rx.try_recv() {
|
|
return value;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
|
|
}
|
|
}
|
|
|
|
async fn run_fling_phase(ctx: &mut iris::task::TaskCtx<Rsc>) -> String {
|
|
ctx.update(|state: &mut BenchClient, _rsc| {
|
|
state.android_state_mut().frame_report.mark_phase("fling");
|
|
});
|
|
ctx.update(|state: &mut BenchClient, rsc| {
|
|
if let Some(screen) = &state.screen {
|
|
(screen.list)(rsc).jump_to_end();
|
|
}
|
|
});
|
|
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
|
|
// `anchor = None` into a real slot before `start` is read.
|
|
tokio::time::sleep(Duration::from_millis(POLL_MS * 2)).await;
|
|
let start = read_anchor_position(ctx).await;
|
|
|
|
for _ in 0..FLING_COUNT {
|
|
ctx.update(|state: &mut BenchClient, rsc| {
|
|
if let Some(screen) = &state.screen {
|
|
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
|
|
}
|
|
});
|
|
wait_for_fling_settle(ctx).await;
|
|
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
|
|
}
|
|
let outward = read_anchor_position(ctx).await;
|
|
|
|
for _ in 0..FLING_COUNT {
|
|
ctx.update(|state: &mut BenchClient, rsc| {
|
|
if let Some(screen) = &state.screen {
|
|
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
|
|
animate_scroll(screen.list, rsc);
|
|
}
|
|
});
|
|
wait_for_fling_settle(ctx).await;
|
|
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
|
|
}
|
|
let end = read_anchor_position(ctx).await;
|
|
|
|
format!("start={start} outward={outward} end={end} ticked=frame-loop")
|
|
}
|
|
|
|
async fn read_anchor_position(ctx: &mut iris::task::TaskCtx<Rsc>) -> String {
|
|
read_from_state(ctx, |state, rsc| match &state.screen {
|
|
Some(screen) => (screen.list)(rsc).anchor_position_display(),
|
|
None => "idx=none".to_string(),
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn wait_for_fling_settle(ctx: &mut iris::task::TaskCtx<Rsc>) {
|
|
let cap = Duration::from_millis(FLING_SETTLE_CAP_MS);
|
|
let started = Instant::now();
|
|
while started.elapsed() < cap {
|
|
let still_scrolling = read_from_state(ctx, |state, rsc| match &state.screen {
|
|
Some(screen) => (screen.list)(rsc).is_scrolling(),
|
|
None => false,
|
|
})
|
|
.await;
|
|
if !still_scrolling {
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
|
|
}
|
|
}
|
|
|
|
async fn run_stream_phase(
|
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
|
stream_tail: Vec<SeqEvent>,
|
|
) -> (usize, usize) {
|
|
ctx.update(|state: &mut BenchClient, _rsc| {
|
|
state.android_state_mut().frame_report.mark_phase("stream");
|
|
});
|
|
ctx.update(|state: &mut BenchClient, rsc| {
|
|
if let Some(screen) = &state.screen {
|
|
(screen.list)(rsc).jump_to_end();
|
|
}
|
|
});
|
|
|
|
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
|
|
let mut sent = 0usize;
|
|
for event in stream_tail.into_iter().take(total) {
|
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
|
let old_items = state.items.clone();
|
|
state.items = fold_event(&state.items, &event);
|
|
match state.screen.as_mut() {
|
|
Some(screen) => screen.apply(rsc, &old_items, &state.items),
|
|
None => state.rebuild_transcript(rsc),
|
|
}
|
|
});
|
|
sent += 1;
|
|
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
(sent, total)
|
|
}
|
|
|
|
async fn run_type_phase(
|
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
|
platform: &Option<Arc<PlatformHandle>>,
|
|
) {
|
|
ctx.update(|state: &mut BenchClient, _rsc| {
|
|
state.android_state_mut().frame_report.mark_phase("type");
|
|
});
|
|
ctx.update(|state: &mut BenchClient, rsc| {
|
|
if let Some(screen) = &state.screen {
|
|
(screen.list)(rsc).jump_to_end();
|
|
state.set_focus(Some(screen.composer.field));
|
|
}
|
|
});
|
|
if let Some(p) = platform {
|
|
p.show_ime();
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
|
|
let mut typed = String::new();
|
|
for ch in TYPE_TEXT.chars() {
|
|
typed.push(ch);
|
|
let text = typed.clone();
|
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
|
if let Some(screen) = &state.screen {
|
|
(screen.composer.field)(rsc).set(&text);
|
|
}
|
|
});
|
|
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
while !typed.is_empty() {
|
|
typed.pop();
|
|
let text = typed.clone();
|
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
|
if let Some(screen) = &state.screen {
|
|
(screen.composer.field)(rsc).set(&text);
|
|
}
|
|
});
|
|
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
|
|
}
|
|
}
|
|
|
|
async fn run_keyboard_phase(
|
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
|
platform: &Option<Arc<PlatformHandle>>,
|
|
ime_state: &Arc<Mutex<ImeState>>,
|
|
) -> String {
|
|
ctx.update(|state: &mut BenchClient, _rsc| {
|
|
state
|
|
.android_state_mut()
|
|
.frame_report
|
|
.mark_phase("keyboard");
|
|
});
|
|
let mut shown = 0;
|
|
let mut hidden = 0;
|
|
for _ in 0..KEYBOARD_CYCLES {
|
|
let before_shown = ime_state.lock().unwrap().shown_events;
|
|
if let Some(p) = platform {
|
|
p.show_ime();
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
|
|
if ime_state.lock().unwrap().shown_events > before_shown {
|
|
shown += 1;
|
|
}
|
|
|
|
let before_hidden = ime_state.lock().unwrap().hidden_events;
|
|
if let Some(p) = platform {
|
|
p.hide_ime();
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
|
|
if ime_state.lock().unwrap().hidden_events > before_hidden {
|
|
hidden += 1;
|
|
}
|
|
}
|
|
if shown == 0 {
|
|
format!(" keyboard: could not be shown ({KEYBOARD_CYCLES} attempts, 0 confirmed visible)")
|
|
} else {
|
|
format!(
|
|
" keyboard: shown {shown}/{KEYBOARD_CYCLES}, hidden {hidden}/{KEYBOARD_CYCLES} \
|
|
(confirmed via on_insets_changed)"
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::TYPE_TEXT;
|
|
|
|
#[test]
|
|
fn type_text_is_exactly_600_characters() {
|
|
assert_eq!(TYPE_TEXT.chars().count(), 600);
|
|
}
|
|
}
|