use std::time::Duration;
use web_time::Instant;
const PAUSE: Duration = Duration::from_millis(250);
const WINDOW: Duration = Duration::from_secs(2);
const LATE_MS: f32 = 25.0;
#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Why(u8);
impl Why {
pub(crate) const NONE: Why = Why(0);
pub(crate) const APP: Why = Why(1);
pub(crate) const RUNTIME: Why = Why(2);
pub(crate) const WIDGET: Why = Why(4);
pub(crate) const LOADING: Why = Why(8);
pub(crate) const POINTER: Why = Why(16);
const NAMES: [(Why, &'static str); 5] = [
(Why::APP, "app"),
(Why::RUNTIME, "runtime"),
(Why::WIDGET, "widget"),
(Why::LOADING, "loading"),
(Why::POINTER, "pointer"),
];
fn has(self, other: Why) -> bool {
self.0 & other.0 != 0
}
}
impl std::ops::BitOr for Why {
type Output = Why;
fn bitor(self, other: Why) -> Why {
Why(self.0 | other.0)
}
}
#[derive(Clone, Copy, Default)]
pub(crate) struct FrameCosts {
pub(crate) build: Option<f32>,
pub(crate) layout_paint: f32,
pub(crate) render: f32,
pub(crate) acquire: f32,
pub(crate) draw: f32,
pub(crate) gpu: Option<f32>,
pub(crate) layers: u32,
pub(crate) why: Why,
}
pub(crate) struct FrameStats {
enabled: bool,
run_start: Option<Instant>,
last: Option<Instant>,
intervals: Vec<f32>,
costs: Vec<FrameCosts>,
moving: Vec<(&'static str, usize)>,
}
impl FrameStats {
pub(crate) fn new() -> Self {
Self::with(switched_on())
}
pub(crate) fn with(enabled: bool) -> Self {
Self {
enabled,
run_start: None,
last: None,
intervals: Vec::new(),
costs: Vec::new(),
moving: Vec::new(),
}
}
pub(crate) fn note_moving(&mut self, families: &[(&'static str, bool)]) {
if !self.enabled {
return;
}
for (name, on) in families.iter().filter(|(_, on)| *on) {
let _ = on;
match self.moving.iter_mut().find(|(n, _)| n == name) {
Some((_, count)) => *count += 1,
None => self.moving.push((name, 1)),
}
}
}
pub(crate) fn enabled(&self) -> bool {
self.enabled
}
pub(crate) fn frame(&mut self, start: Instant, costs: FrameCosts) -> Option<String> {
if !self.enabled {
return None;
}
let mut report = None;
if let Some(last) = self.last {
let gap = start.saturating_duration_since(last);
if gap > PAUSE {
report = self.flush(last);
} else {
self.intervals.push(gap.as_secs_f32() * 1000.0);
}
}
if self.run_start.is_none() {
self.run_start = Some(start);
}
self.costs.push(costs);
self.last = Some(start);
if self
.run_start
.is_some_and(|run| start.saturating_duration_since(run) >= WINDOW)
{
report = self.flush(start).or(report);
}
if let Some(line) = &report {
log::info!(target: "frus::frames", "{line}");
}
report
}
fn flush(&mut self, end: Instant) -> Option<String> {
let run = self.run_start.take()?;
let costs = std::mem::take(&mut self.costs);
let intervals = std::mem::take(&mut self.intervals);
let mut moving = std::mem::take(&mut self.moving);
if intervals.len() < 2 {
return None;
}
let seconds = end.saturating_duration_since(run).as_secs_f32().max(1e-3);
let late = intervals.iter().filter(|&&ms| ms > LATE_MS).count();
let builds: Vec<f32> = costs.iter().filter_map(|c| c.build).collect();
let layout: Vec<f32> = costs.iter().map(|c| c.layout_paint).collect();
let render: Vec<f32> = costs.iter().map(|c| c.render).collect();
let (i50, i95, imax) = spread(&intervals);
let (l50, l95, _) = spread(&layout);
let (r50, r95, _) = spread(&render);
let (a50, a95, _) = spread(&costs.iter().map(|c| c.acquire).collect::<Vec<_>>());
let (d50, d95, _) = spread(&costs.iter().map(|c| c.draw).collect::<Vec<_>>());
let gpu: Vec<f32> = costs.iter().filter_map(|c| c.gpu).collect();
let gpu = if gpu.is_empty() {
String::new()
} else {
let (g50, g95, gmax) = spread(&gpu);
format!(", on the GPU {g50:.1}/{g95:.1}/{gmax:.1}")
};
let (y50, _, ymax) = spread(&costs.iter().map(|c| c.layers as f32).collect::<Vec<_>>());
let asked: Vec<String> = Why::NAMES
.iter()
.filter_map(|(why, name)| {
let n = costs.iter().filter(|c| c.why.has(*why)).count();
(n > 0).then(|| format!("{name} {n}"))
})
.collect();
let mut asked = if asked.is_empty() {
"nothing".to_string()
} else {
asked.join(", ")
};
if !moving.is_empty() {
moving.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
let named: Vec<String> = moving.iter().map(|(n, c)| format!("{n} {c}")).collect();
asked = format!("{asked} (moving: {})", named.join(", "));
}
let build = if builds.is_empty() {
"no rebuild".to_string()
} else {
let (b50, b95, _) = spread(&builds);
format!(
"build {b50:.1}/{b95:.1} ms ({} of {})",
builds.len(),
costs.len()
)
};
Some(format!(
"{} frames in {seconds:.1} s ({:.0} fps): interval {i50:.1}/{i95:.1}/{imax:.1} ms, \
{late} over budget; {build}, layout+paint {l50:.1}/{l95:.1} ms, render \
{r50:.1}/{r95:.1} ms (waiting for an image {a50:.1}/{a95:.1}, drawing \
{d50:.1}/{d95:.1}{gpu}) (median/95th or max); {y50:.0} layers, up to {ymax:.0}; next \
frame asked by {asked}",
costs.len(),
intervals.len() as f32 / seconds,
))
}
}
fn spread(values: &[f32]) -> (f32, f32, f32) {
if values.is_empty() {
return (0.0, 0.0, 0.0);
}
let mut sorted = values.to_vec();
sorted.sort_by(f32::total_cmp);
let at = |p: f32| sorted[((sorted.len() - 1) as f32 * p).round() as usize];
(at(0.5), at(0.95), sorted[sorted.len() - 1])
}
fn switched_on() -> bool {
if std::env::var("FRUS_FRAME_STATS").is_ok_and(|v| !v.is_empty() && v != "0") {
return true;
}
#[cfg(target_os = "android")]
{
let read = std::process::Command::new("/system/bin/getprop")
.arg("debug.frus.frames")
.output();
if let Ok(out) = read {
let value = String::from_utf8_lossy(&out.stdout);
let value = value.trim();
return !value.is_empty() && value != "0";
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn costs() -> FrameCosts {
FrameCosts {
build: Some(1.0),
layout_paint: 2.0,
render: 3.0,
acquire: 0.5,
draw: 2.0,
gpu: None,
layers: 0,
why: Why::WIDGET,
}
}
#[test]
fn a_run_of_frames_is_summarised_every_two_seconds() {
let mut stats = FrameStats::with(true);
let t0 = Instant::now();
let mut line = None;
let mut at = t0;
for i in 0..121 {
let step = if i == 60 { 50.0 } else { 1000.0 / 60.0 };
at += Duration::from_secs_f32(step / 1000.0);
if let Some(l) = stats.frame(at, costs()) {
line = Some(l);
}
}
let line = line.expect("a line after two seconds");
assert!(line.contains("1 over budget"), "{line}");
assert!(line.contains("build 1.0/1.0 ms"), "{line}");
assert!(line.contains("render 3.0/3.0 ms"), "{line}");
assert!(line.contains("interval 16.7/"), "{line}");
assert!(line.ends_with("asked by widget 120"), "{line}");
}
#[test]
fn a_pause_ends_a_run() {
let mut stats = FrameStats::with(true);
let t0 = Instant::now();
for i in 0..10 {
stats.frame(t0 + Duration::from_millis(16 * i), costs());
}
let line = stats
.frame(t0 + Duration::from_secs(5), costs())
.expect("the pause closes the run");
assert!(line.starts_with("10 frames"), "{line}");
assert!(line.contains("0 over budget"), "{line}");
}
#[test]
fn the_families_still_moving_are_named() {
let mut stats = FrameStats::with(true);
let t0 = Instant::now();
let mut line = None;
for i in 0..130u64 {
stats.note_moving(&[("scroll", i % 2 == 0), ("anims", true), ("ink", false)]);
if let Some(l) = stats.frame(t0 + Duration::from_millis(16 * i), costs()) {
line = Some(l);
}
}
let line = line.expect("a line");
assert!(line.contains("(moving: anims 1"), "{line}");
assert!(line.contains(", scroll "), "{line}");
assert!(!line.contains("ink"), "{line}");
}
#[test]
fn off_it_records_nothing() {
let mut stats = FrameStats::with(false);
let t0 = Instant::now();
for i in 0..200 {
assert!(stats
.frame(t0 + Duration::from_millis(16 * i), costs())
.is_none());
}
assert!(stats.costs.is_empty());
}
}