Skip to main content

ravenrs_core/
diagnostics.rs

1//! What a running app can say about its GPU and its frames, as plain data: the
2//! renderer and the platform fill it in, and widgets show it without either
3//! knowing about the other.
4
5use std::collections::VecDeque;
6use std::fmt;
7use std::time::Duration;
8
9/// Frames a [`FrameLog`] keeps.
10const FRAME_LOG_SIZE: usize = 240;
11/// The longest URL [`new_issue_url`] builds. Servers answer longer ones with an
12/// error instead of the page, and GitHub's limit is not documented, so this stays
13/// well under the 8 KB many servers allow.
14pub const MAX_ISSUE_URL: usize = 6000;
15/// Where a path starts to name a person's own folder.
16const USER_FOLDERS: [&str; 4] = ["/home/", "/Users/", "\\Users\\", "\\users\\"];
17
18/// One GPU as the graphics API describes it.
19#[derive(Clone, Debug, Default, PartialEq, Eq)]
20#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
21pub struct AdapterReport {
22    /// The GPU's name.
23    pub name: String,
24    /// The graphics API: Vulkan, Metal, DX12, GL or WebGPU.
25    pub backend: String,
26    /// Integrated, discrete, virtual or CPU.
27    pub device_type: String,
28    /// The driver's name.
29    pub driver: String,
30    /// The driver's version and details.
31    pub driver_info: String,
32    /// It draws on the CPU, with no GPU behind it.
33    pub software: bool,
34}
35
36impl fmt::Display for AdapterReport {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        write!(f, "{} ({}, {}", self.name, self.backend, self.device_type)?;
39        if !self.driver.is_empty() {
40            write!(f, ", {}", self.driver)?;
41        }
42        if !self.driver_info.is_empty() {
43            write!(f, " {}", self.driver_info)?;
44        }
45        f.write_str(")")
46    }
47}
48
49/// How frames reach the window.
50#[derive(Clone, Debug, Default, PartialEq, Eq)]
51#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
52pub struct SurfaceReport {
53    /// The pixel format in use.
54    pub format: String,
55    /// The present mode in use.
56    pub present_mode: String,
57    /// The present modes the surface supports.
58    pub supported_present_modes: Vec<String>,
59}
60
61/// The GPU an app ended up on and the ones it could have used.
62#[derive(Clone, Debug, Default, PartialEq, Eq)]
63#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
64pub struct GpuReport {
65    /// The adapter in use.
66    pub active: AdapterReport,
67    /// Every adapter the graphics API offers.
68    pub available: Vec<AdapterReport>,
69    /// Adapters that exist but are left out because their driver is not
70    /// conformant, which an old GPU's driver may not be.
71    pub hidden: Vec<AdapterReport>,
72    /// The window's surface; `None` when no window could be opened.
73    pub surface: Option<SurfaceReport>,
74}
75
76/// The machine and the build around the GPU.
77#[derive(Clone, Debug, Default, PartialEq, Eq)]
78#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
79pub struct SystemReport {
80    /// The RavenRS version.
81    pub version: String,
82    /// The operating system.
83    pub os: String,
84    /// The window system session, such as `wayland` or `x11`, when known.
85    pub session: Option<String>,
86    /// The desktop, such as `GNOME`, when known.
87    pub desktop: Option<String>,
88    /// Vulkan drivers installed on the system, on Linux.
89    pub vulkan_drivers: Vec<String>,
90    /// Environment variables set that change which GPU is used, as `NAME=value`.
91    pub overrides: Vec<String>,
92    /// Built with optimizations. Without them everything is many times slower.
93    pub optimized: bool,
94}
95
96/// Something in a [`DiagnosticReport`] that explains a slow or broken app.
97#[derive(Clone, Debug, PartialEq, Eq)]
98#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
99pub enum DiagnosticFinding {
100    /// Frames are drawn on the CPU.
101    SoftwareRendering,
102    /// The app was built without optimizations.
103    UnoptimizedBuild,
104    /// These adapters exist but their drivers are not conformant, so they are not used.
105    HiddenAdapters(Vec<String>),
106    /// No GPU could be used at all, for this reason.
107    NoGpu(String),
108}
109
110impl fmt::Display for DiagnosticFinding {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            Self::SoftwareRendering => f.write_str("frames are drawn on the CPU"),
114            Self::UnoptimizedBuild => f.write_str("built without optimizations"),
115            Self::HiddenAdapters(names) => write!(f, "non-conformant drivers left out: {}", names.join(", ")),
116            Self::NoGpu(reason) => write!(f, "no usable GPU: {reason}"),
117        }
118    }
119}
120
121/// Everything known about where an app runs, to read or to paste into a bug report.
122#[derive(Clone, Debug, PartialEq, Eq)]
123#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
124pub struct DiagnosticReport {
125    /// The GPU, or why there is none.
126    pub gpu: Result<GpuReport, String>,
127    /// The machine and the build.
128    pub system: SystemReport,
129}
130
131impl Default for DiagnosticReport {
132    /// No GPU was looked for yet.
133    fn default() -> Self {
134        Self { gpu: Err("not checked".into()), system: SystemReport::default() }
135    }
136}
137
138#[cfg_attr(feature = "live", ravenrs_live::expose)]
139impl DiagnosticReport {
140    /// What in this report explains a slow or broken app, most serious first.
141    ///
142    /// ```
143    /// use ravenrs_core::{DiagnosticFinding, DiagnosticReport};
144    ///
145    /// let report = DiagnosticReport { gpu: Err("no driver".into()), ..Default::default() };
146    /// assert_eq!(report.findings()[0], DiagnosticFinding::NoGpu("no driver".into()));
147    /// ```
148    pub fn findings(&self) -> Vec<DiagnosticFinding> {
149        let mut findings = Vec::new();
150        match &self.gpu {
151            Err(reason) => findings.push(DiagnosticFinding::NoGpu(reason.clone())),
152            Ok(gpu) => {
153                if gpu.active.software {
154                    findings.push(DiagnosticFinding::SoftwareRendering);
155                }
156                if !gpu.hidden.is_empty() {
157                    let names = gpu.hidden.iter().map(ToString::to_string).collect();
158                    findings.push(DiagnosticFinding::HiddenAdapters(names));
159                }
160            }
161        }
162        if !self.system.optimized {
163            findings.push(DiagnosticFinding::UnoptimizedBuild);
164        }
165        findings
166    }
167}
168
169impl fmt::Display for DiagnosticReport {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        let system = &self.system;
172        writeln!(f, "RavenRS: {}", system.version)?;
173        writeln!(f, "Build: {}", if system.optimized { "optimized" } else { "NOT optimized" })?;
174        writeln!(f, "System: {}", system.os)?;
175        if let Some(session) = &system.session {
176            writeln!(f, "Session: {session}")?;
177        }
178        if let Some(desktop) = &system.desktop {
179            writeln!(f, "Desktop: {desktop}")?;
180        }
181        match &self.gpu {
182            Err(reason) => writeln!(f, "GPU: none ({reason})")?,
183            Ok(gpu) => {
184                writeln!(f, "GPU in use: {}", gpu.active)?;
185                for adapter in &gpu.available {
186                    writeln!(f, "Available: {adapter}")?;
187                }
188                for adapter in &gpu.hidden {
189                    writeln!(f, "Hidden, non-conformant: {adapter}")?;
190                }
191                match &gpu.surface {
192                    Some(surface) => writeln!(
193                        f,
194                        "Surface: {}, {} (supports {})",
195                        surface.format,
196                        surface.present_mode,
197                        surface.supported_present_modes.join(", ")
198                    )?,
199                    None => writeln!(f, "Surface: none, no window could be opened")?,
200                }
201            }
202        }
203        if !system.vulkan_drivers.is_empty() {
204            writeln!(f, "Vulkan drivers installed: {}", system.vulkan_drivers.join(", "))?;
205        }
206        if !system.overrides.is_empty() {
207            writeln!(f, "Overrides: {}", system.overrides.join(" "))?;
208        }
209        for finding in self.findings() {
210            writeln!(f, "Warning: {finding}")?;
211        }
212        Ok(())
213    }
214}
215
216/// `text` with every path inside a person's own folder replaced, since those
217/// paths usually carry the person's name. A path can have spaces, so what follows
218/// goes up to the end of the line or a quote or list separator: too much hidden is
219/// better than a surname left behind.
220///
221/// ```
222/// use ravenrs_core::redact_user_paths;
223///
224/// let text = "Overrides: VK_DRIVER_FILES=/home/ana/icd.json\nGPU: Intel";
225/// assert_eq!(redact_user_paths(text), "Overrides: VK_DRIVER_FILES=<user folder>\nGPU: Intel");
226/// ```
227pub fn redact_user_paths(text: &str) -> String {
228    let mut redacted = String::with_capacity(text.len());
229    let mut rest = text;
230    while let Some(at) = USER_FOLDERS.iter().filter_map(|folder| rest.find(folder)).min() {
231        redacted.push_str(&rest[..at]);
232        redacted.push_str("<user folder>");
233        rest = &rest[at..];
234        let end = rest.find(['\n', '"', '\'', ',', ';']).unwrap_or(rest.len());
235        rest = &rest[end..];
236    }
237    redacted.push_str(rest);
238    redacted
239}
240
241/// Whether a byte stands for itself in a URL's query.
242fn is_unreserved(byte: u8) -> bool {
243    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
244}
245
246fn percent_encode(text: &str) -> String {
247    let mut encoded = String::with_capacity(text.len());
248    for byte in text.bytes() {
249        if is_unreserved(byte) {
250            encoded.push(char::from(byte));
251        } else {
252            encoded.push_str(&format!("%{byte:02X}"));
253        }
254    }
255    encoded
256}
257
258/// The URL of a new issue with `intro` and then `report` in a code block as its
259/// body, for the person to review and send on the page it opens. It never passes
260/// [`MAX_ISSUE_URL`]: a report that would make it longer loses its last lines, whole
261/// where possible, and `truncated_note` says so after the block.
262///
263/// `new_issue` is the page for new issues and may already have a query, such as
264/// `https://github.com/owner/repo/issues/new`.
265///
266/// ```
267/// use ravenrs_core::new_issue_url;
268///
269/// let url = new_issue_url("https://example.com/new", "Steps:", "GPU: Intel\n", "");
270/// assert_eq!(url, "https://example.com/new?body=Steps%3A%0A%0A%60%60%60text%0AGPU%3A%20Intel%0A%60%60%60%0A");
271/// ```
272pub fn new_issue_url(new_issue: &str, intro: &str, report: &str, truncated_note: &str) -> String {
273    let joiner = if new_issue.contains('?') { '&' } else { '?' };
274    let url = |report: &str, note: &str| {
275        let body = format!("{intro}\n\n```text\n{report}\n```\n{note}");
276        format!("{new_issue}{joiner}body={}", percent_encode(&body))
277    };
278    let report = report.trim_end();
279    let whole = url(report, "");
280    if whole.len() <= MAX_ISSUE_URL {
281        return whole;
282    }
283    let room = MAX_ISSUE_URL.saturating_sub(url("", truncated_note).len());
284    let mut used = 0;
285    let mut end = 0;
286    for (at, c) in report.char_indices() {
287        used += percent_encode(c.encode_utf8(&mut [0; 4])).len();
288        if used > room {
289            break;
290        }
291        end = at + c.len_utf8();
292    }
293    let kept = &report[..end];
294    url(kept.rfind('\n').map_or(kept, |line| &kept[..line]), truncated_note)
295}
296
297/// Time spent in each phase of the UI runtime, once a clock was given to it.
298#[derive(Clone, Copy, Debug, Default, PartialEq)]
299#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
300pub struct PhaseTimes {
301    /// Running the views.
302    pub build: Duration,
303    /// Solving sizes and positions.
304    pub layout: Duration,
305    /// Giving every element its place on the screen.
306    pub place: Duration,
307    /// Turning elements into the scene.
308    pub paint: Duration,
309}
310
311#[cfg_attr(feature = "live", ravenrs_live::expose)]
312impl PhaseTimes {
313    /// All the phases together.
314    pub fn total(&self) -> Duration {
315        self.build + self.layout + self.place + self.paint
316    }
317}
318
319/// Why the views ran again. It is the first question about a slow screen: every
320/// rebuild costs in proportion to the whole screen.
321#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
322#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
323pub enum BuildCause {
324    /// The first build.
325    #[default]
326    First,
327    /// A `State` that the views read changed.
328    Data,
329    /// The pointer moved onto something else.
330    Hover,
331    /// Something was pressed or released.
332    Press,
333    /// Focus moved.
334    Focus,
335    /// Content that depends on its scroll position moved.
336    Scroll,
337    /// The window changed size or scale.
338    Viewport,
339    /// A view asked to run again at a certain time.
340    Clock,
341    /// Another thread asked through a waker.
342    Remote,
343    /// A platform service was installed or a system setting changed.
344    System,
345}
346
347#[cfg_attr(feature = "live", ravenrs_live::expose)]
348impl BuildCause {
349    /// Every cause, in the order [`BuildCauses`] counts them.
350    pub const ALL: [Self; 10] = [
351        Self::First,
352        Self::Data,
353        Self::Hover,
354        Self::Press,
355        Self::Focus,
356        Self::Scroll,
357        Self::Viewport,
358        Self::Clock,
359        Self::Remote,
360        Self::System,
361    ];
362}
363
364/// How many builds each [`BuildCause`] caused.
365#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
366#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
367pub struct BuildCauses {
368    counts: [u64; BuildCause::ALL.len()],
369}
370
371#[cfg_attr(feature = "live", ravenrs_live::expose)]
372impl BuildCauses {
373    /// Counts one build.
374    pub fn add(&mut self, cause: BuildCause) {
375        self.counts[cause as usize] += 1;
376    }
377
378    /// The builds `cause` caused.
379    pub fn count(&self, cause: BuildCause) -> u64 {
380        self.counts[cause as usize]
381    }
382}
383
384/// What the UI runtime did for one frame.
385#[derive(Clone, Copy, Debug, Default, PartialEq)]
386#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
387pub struct FrameWork {
388    /// Time in each phase; zero unless a clock was given to the runtime.
389    pub phases: PhaseTimes,
390    /// Times the views ran.
391    pub builds: u32,
392    /// Why they ran, the first time this frame.
393    pub cause: Option<BuildCause>,
394    /// Times the scene was painted.
395    pub paints: u32,
396    /// Times text was shaped.
397    pub shapes: u32,
398    /// Elements on screen.
399    pub elements: u32,
400}
401
402/// What a type of view cost in the latest build, not counting the views inside it.
403#[derive(Clone, Debug, Default, PartialEq)]
404#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
405pub struct ViewCost {
406    /// The view's type, as the compiler names it.
407    pub name: &'static str,
408    /// How many of them ran.
409    pub count: u32,
410    /// Time they took.
411    pub time: Duration,
412    /// Elements they made.
413    pub elements: u32,
414    /// Text they shaped.
415    pub shapes: u32,
416}
417
418#[cfg_attr(feature = "live", ravenrs_live::expose)]
419impl ViewCost {
420    /// The type's name without its path or generics: `VStack`, not
421    /// `ravenrs_widgets::stack::VStack<(..)>`.
422    pub fn short_name(&self) -> &'static str {
423        let name = self.name.split('<').next().unwrap_or(self.name);
424        name.rsplit("::").next().unwrap_or(name)
425    }
426}
427
428/// What the renderer's resources looked like after a frame.
429#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
430#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
431pub struct RenderWork {
432    /// Draw calls split because the kind of primitive changed.
433    pub breaks_kind: u32,
434    /// Draw calls split because the image changed.
435    pub breaks_image: u32,
436    /// Draw calls split because the clip changed.
437    pub breaks_clip: u32,
438    /// Bytes of instances sent to the GPU.
439    pub uploaded_bytes: u64,
440    /// Glyphs rasterized since the renderer was created.
441    pub glyphs_rasterized: u64,
442    /// Times the glyph atlas lost everything.
443    pub atlas_resets: u64,
444    /// How full the glyph atlas is, in thousandths of its height.
445    pub atlas_used: u32,
446    /// Side of the glyph atlas in pixels.
447    pub atlas_size: u32,
448    /// Image textures on the GPU.
449    pub cached_images: u32,
450    /// The GPU's own time on a recent frame, when timing is on and it can tell.
451    pub gpu: Option<Duration>,
452}
453
454/// What one frame cost. All durations are time on the CPU: waiting for a busy
455/// GPU shows up in [`Self::acquire`] and [`Self::present`], not in [`Self::render`].
456#[derive(Clone, Copy, Debug, Default, PartialEq)]
457#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
458pub struct FrameSample {
459    /// Rebuilding, laying out and painting the UI.
460    pub update: Duration,
461    /// Waiting for a texture to draw on, which vsync and a busy GPU make longer.
462    pub acquire: Duration,
463    /// Recording and submitting the GPU's work.
464    pub render: Duration,
465    /// Handing the texture to the window system.
466    pub present: Duration,
467    /// Quads drawn.
468    pub quads: usize,
469    /// Glyphs drawn.
470    pub glyphs: usize,
471    /// Draw calls issued.
472    pub draw_calls: usize,
473    /// The drawing surface in physical pixels.
474    pub size: (u32, u32),
475    /// Physical pixels per logical pixel.
476    pub scale: f32,
477    /// What the UI runtime did.
478    pub work: FrameWork,
479    /// What the renderer did.
480    pub render_work: RenderWork,
481}
482
483#[cfg_attr(feature = "live", ravenrs_live::expose)]
484impl FrameSample {
485    /// The whole frame.
486    pub fn total(&self) -> Duration {
487        self.update + self.acquire + self.render + self.present
488    }
489}
490
491/// The median and the 95th percentile of a duration.
492#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
493#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
494pub struct Percentiles {
495    /// Half of the frames took less than this.
496    pub median: Duration,
497    /// 95% of the frames took less than this.
498    pub p95: Duration,
499}
500
501#[cfg_attr(feature = "live", ravenrs_live::expose)]
502impl Percentiles {
503    fn of(mut values: Vec<Duration>) -> Self {
504        values.sort_unstable();
505        let at = |fraction: f64| {
506            let last = values.len().saturating_sub(1);
507            values.get((last as f64 * fraction).round() as usize).copied().unwrap_or_default()
508        };
509        Self { median: at(0.5), p95: at(0.95) }
510    }
511}
512
513/// The recent frames of a [`FrameLog`], summed up.
514#[derive(Clone, Copy, Debug, Default, PartialEq)]
515#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
516pub struct FrameSummary {
517    /// Frames summed up.
518    pub frames: usize,
519    /// See [`FrameSample::update`].
520    pub update: Percentiles,
521    /// See [`FrameSample::acquire`].
522    pub acquire: Percentiles,
523    /// See [`FrameSample::render`].
524    pub render: Percentiles,
525    /// See [`FrameSample::present`].
526    pub present: Percentiles,
527    /// See [`FrameSample::total`].
528    pub total: Percentiles,
529    /// The latest frame, for its counts and its size.
530    pub latest: FrameSample,
531}
532
533impl fmt::Display for FrameSummary {
534    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
535        let latest = &self.latest;
536        writeln!(f, "Frames: {} (median / p95)", self.frames)?;
537        let stages = [
538            ("Total", self.total),
539            ("Update", self.update),
540            ("Acquire", self.acquire),
541            ("Render", self.render),
542            ("Present", self.present),
543        ];
544        for (name, stage) in stages {
545            writeln!(f, "  {name}: {:.1?} / {:.1?}", stage.median, stage.p95)?;
546        }
547        writeln!(
548            f,
549            "Latest frame: {}x{} at scale {}, {} quads, {} glyphs, {} draw calls",
550            latest.size.0, latest.size.1, latest.scale, latest.quads, latest.glyphs, latest.draw_calls
551        )
552    }
553}
554
555/// Frames per second from which a window feels fluid.
556const GOOD_FPS: f64 = 45.0;
557/// Frames per second under which a window feels slow.
558const FAIR_FPS: f64 = 24.0;
559
560/// How a [`PerformanceResult`] feels to the person using the app.
561#[derive(Clone, Copy, Debug, PartialEq, Eq)]
562#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
563pub enum PerformanceVerdict {
564    /// Fluid.
565    Good,
566    /// Usable, with visible stutter.
567    Fair,
568    /// Slow enough to get in the way.
569    Poor,
570}
571
572/// Where the time of a slow frame goes.
573#[derive(Clone, Copy, Debug, PartialEq, Eq)]
574#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
575pub enum Bottleneck {
576    /// Drawing: the GPU cannot fill the window in time, or there is none.
577    Gpu,
578    /// Rebuilding and laying out the UI, on the processor.
579    Interface,
580}
581
582/// What redrawing the window nonstop for a few seconds showed.
583#[derive(Clone, Copy, Debug, PartialEq)]
584#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
585pub struct PerformanceResult {
586    /// Frames drawn per second. Unlike the durations of a [`FrameSummary`], this
587    /// does count all the GPU's work: a busy GPU holds back the next frame.
588    pub fps: f64,
589    /// Frames drawn.
590    pub frames: u64,
591    /// How long it ran.
592    pub seconds: f64,
593    /// How that feels.
594    pub verdict: PerformanceVerdict,
595    /// Where the time goes; `None` when the verdict is good.
596    pub bottleneck: Option<Bottleneck>,
597}
598
599impl fmt::Display for PerformanceResult {
600    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
601        write!(
602            f,
603            "Performance test: {:.0} frames per second, {} frames in {:.1} s, {:?}",
604            self.fps, self.frames, self.seconds, self.verdict
605        )?;
606        match self.bottleneck {
607            Some(bottleneck) => writeln!(f, ", limited by {bottleneck:?}"),
608            None => writeln!(f),
609        }
610    }
611}
612
613/// The latest frames an app drew. Recording one is a push into a fixed buffer,
614/// so it can stay on while nobody is looking.
615///
616/// ```
617/// use std::time::Duration;
618/// use ravenrs_core::{FrameLog, FrameSample};
619///
620/// let mut log = FrameLog::default();
621/// log.record(FrameSample { update: Duration::from_millis(4), ..Default::default() });
622/// assert_eq!(log.summary().total.median, Duration::from_millis(4));
623/// ```
624#[derive(Clone, Debug, Default)]
625#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
626pub struct FrameLog {
627    samples: VecDeque<FrameSample>,
628    recorded: u64,
629}
630
631#[cfg_attr(feature = "live", ravenrs_live::expose)]
632impl FrameLog {
633    /// Adds a frame, forgetting the oldest once the log is full.
634    pub fn record(&mut self, sample: FrameSample) {
635        if self.samples.len() == FRAME_LOG_SIZE {
636            self.samples.pop_front();
637        }
638        self.samples.push_back(sample);
639        self.recorded += 1;
640    }
641
642    /// Forgets every frame, to measure from here on.
643    pub fn clear(&mut self) {
644        self.samples.clear();
645        self.recorded = 0;
646    }
647
648    /// Judges the frames recorded since the log was cleared, `seconds` ago.
649    pub fn measure(&self, seconds: f64) -> PerformanceResult {
650        let fps = if seconds > 0.0 { self.recorded as f64 / seconds } else { 0.0 };
651        let verdict = if fps >= GOOD_FPS {
652            PerformanceVerdict::Good
653        } else if fps >= FAIR_FPS {
654            PerformanceVerdict::Fair
655        } else {
656            PerformanceVerdict::Poor
657        };
658        let summary = self.summary();
659        let drawing = summary.acquire.median + summary.render.median + summary.present.median;
660        let bottleneck = (verdict != PerformanceVerdict::Good)
661            .then_some(if summary.update.median > drawing { Bottleneck::Interface } else { Bottleneck::Gpu });
662        PerformanceResult { fps, frames: self.recorded, seconds, verdict, bottleneck }
663    }
664
665    /// The frames in the log, oldest first.
666    pub fn samples(&self) -> impl Iterator<Item = &FrameSample> {
667        self.samples.iter()
668    }
669
670    /// The frames in the log in the Trace Event format, which Perfetto
671    /// (<https://ui.perfetto.dev>) and `chrome://tracing` open.
672    pub fn trace_json(&self) -> String {
673        let mut events = Vec::new();
674        let mut at = 0.0;
675        for (number, sample) in self.samples.iter().enumerate() {
676            let phases = sample.work.phases;
677            let mut start = at;
678            let mut slice = |name: &str, length: Duration| {
679                let micros = length.as_secs_f64() * 1e6;
680                if micros > 0.0 {
681                    events.push(format!(
682                        "{{\"name\":\"{name}\",\"ph\":\"X\",\"pid\":1,\"tid\":1,\"ts\":{start:.1},\"dur\":{micros:.1},\"args\":{{\"frame\":{number}}}}}"
683                    ));
684                }
685                start += micros;
686            };
687            slice("build", phases.build);
688            slice("layout", phases.layout);
689            slice("place", phases.place);
690            slice("paint", phases.paint);
691            slice("other update", sample.update.saturating_sub(phases.total()));
692            slice("acquire", sample.acquire);
693            slice("render", sample.render);
694            slice("present", sample.present);
695            at += sample.total().as_secs_f64() * 1e6;
696        }
697        format!("{{\"traceEvents\":[{}]}}", events.join(","))
698    }
699
700    /// The frames in the log, summed up.
701    pub fn summary(&self) -> FrameSummary {
702        let of =
703            |stage: fn(&FrameSample) -> Duration| Percentiles::of(self.samples.iter().map(stage).collect());
704        FrameSummary {
705            frames: self.samples.len(),
706            update: of(|s| s.update),
707            acquire: of(|s| s.acquire),
708            render: of(|s| s.render),
709            present: of(|s| s.present),
710            total: of(FrameSample::total),
711            latest: self.samples.back().copied().unwrap_or_default(),
712        }
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719
720    fn gpu(software: bool) -> GpuReport {
721        GpuReport {
722            active: AdapterReport { name: "llvmpipe".into(), software, ..Default::default() },
723            ..Default::default()
724        }
725    }
726
727    #[test]
728    fn a_healthy_machine_has_nothing_to_report() {
729        let report = DiagnosticReport {
730            gpu: Ok(gpu(false)),
731            system: SystemReport { optimized: true, ..Default::default() },
732        };
733        assert!(report.findings().is_empty());
734        assert!(!report.to_string().contains("Warning"));
735    }
736
737    #[test]
738    fn drawing_on_the_cpu_comes_first() {
739        let report = DiagnosticReport { gpu: Ok(gpu(true)), system: SystemReport::default() };
740        assert_eq!(
741            report.findings(),
742            vec![DiagnosticFinding::SoftwareRendering, DiagnosticFinding::UnoptimizedBuild]
743        );
744        assert!(report.to_string().contains("Warning: frames are drawn on the CPU"));
745    }
746
747    #[test]
748    fn an_adapter_hidden_for_its_driver_is_named() {
749        let mut gpu = gpu(true);
750        gpu.hidden.push(AdapterReport { name: "Intel Haswell".into(), ..Default::default() });
751        let report = DiagnosticReport { gpu: Ok(gpu), ..Default::default() };
752        assert!(report.to_string().contains("Hidden, non-conformant: Intel Haswell"));
753        assert!(
754            matches!(&report.findings()[1], DiagnosticFinding::HiddenAdapters(names) if names.len() == 1)
755        );
756    }
757
758    #[test]
759    fn percentiles_ignore_a_lone_slow_frame_in_the_median() {
760        let mut log = FrameLog::default();
761        for ms in 1..=99 {
762            log.record(FrameSample { update: Duration::from_millis(ms), ..Default::default() });
763        }
764        log.record(FrameSample { update: Duration::from_secs(5), ..Default::default() });
765        let summary = log.summary();
766        assert_eq!(summary.frames, 100);
767        assert_eq!(summary.update.median, Duration::from_millis(51));
768        assert_eq!(summary.update.p95, Duration::from_millis(95));
769    }
770
771    #[test]
772    fn the_log_forgets_the_oldest_frames() {
773        let mut log = FrameLog::default();
774        for _ in 0..FRAME_LOG_SIZE + 10 {
775            log.record(FrameSample::default());
776        }
777        assert_eq!(log.summary().frames, FRAME_LOG_SIZE);
778    }
779
780    fn log_of(frames: u64, sample: FrameSample) -> FrameLog {
781        let mut log = FrameLog::default();
782        for _ in 0..frames {
783            log.record(sample);
784        }
785        log
786    }
787
788    #[test]
789    fn a_fluid_window_has_nothing_to_blame() {
790        let result = log_of(300, FrameSample::default()).measure(5.0);
791        assert_eq!(result.fps, 60.0);
792        assert_eq!(result.verdict, PerformanceVerdict::Good);
793        assert_eq!(result.bottleneck, None);
794    }
795
796    #[test]
797    fn the_rate_counts_more_frames_than_the_log_keeps() {
798        let log = log_of(FRAME_LOG_SIZE as u64 + 60, FrameSample::default());
799        assert_eq!(log.measure(5.0).frames, FRAME_LOG_SIZE as u64 + 60);
800    }
801
802    #[test]
803    fn a_slow_window_blames_where_the_time_goes() {
804        let drawing = FrameSample { render: Duration::from_millis(40), ..Default::default() };
805        let result = log_of(50, drawing).measure(5.0);
806        assert_eq!(result.verdict, PerformanceVerdict::Poor);
807        assert_eq!(result.bottleneck, Some(Bottleneck::Gpu));
808
809        let building = FrameSample { update: Duration::from_millis(40), ..Default::default() };
810        let result = log_of(150, building).measure(5.0);
811        assert_eq!(result.verdict, PerformanceVerdict::Fair);
812        assert_eq!(result.bottleneck, Some(Bottleneck::Interface));
813    }
814
815    #[test]
816    fn nothing_drawn_is_a_poor_result_not_a_division_by_zero() {
817        assert_eq!(FrameLog::default().measure(0.0).verdict, PerformanceVerdict::Poor);
818    }
819
820    #[test]
821    fn clearing_starts_the_count_over() {
822        let mut log = log_of(10, FrameSample::default());
823        log.clear();
824        assert_eq!(log.measure(1.0).frames, 0);
825    }
826
827    #[test]
828    fn an_empty_log_sums_up_to_nothing() {
829        assert_eq!(FrameLog::default().summary(), FrameSummary::default());
830    }
831}