Skip to main content

ponko/
perf_report.rs

1//! The performance report: where a game's time and allocations go, the
2//! worst frames, warnings with advice, budgets for tests and CI, and
3//! comparing two runs.
4//!
5//! A [`PerfReport`] comes from [`Ctx::perf_report`](crate::Ctx::perf_report)
6//! in a running game and from [`Game::profile_headless`](crate::Game::profile_headless)
7//! for a headless run; `CRABBY_BENCH` prints one, and the control endpoint's
8//! `perf.get` returns one as JSON.
9
10use std::collections::BTreeMap;
11use std::fmt::{self, Write as _};
12use std::path::Path;
13
14use serde::{Deserialize, Serialize};
15
16use crate::alloc_count::{self, AllocCounts};
17use crate::ctx::SystemTiming;
18use crate::profiler::percentile;
19use crate::scope::{self, ScopeState, ScopeTiming};
20
21/// Frames of history a running game keeps (about half a minute at 60 fps).
22pub(crate) const HISTORY: usize = 1800;
23/// The most a headless run keeps; a longer run reports its last frames.
24pub(crate) const HEADLESS_HISTORY: usize = 50_000;
25/// How many of the slowest frames a report lists.
26const WORST_FRAMES: usize = 3;
27
28// ------------------------------------------------------------------ report
29
30/// Frame times, in milliseconds. In a headless run a frame is one tick.
31#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
32pub struct FrameTimes {
33    /// Average.
34    pub avg: f32,
35    /// Median.
36    pub p50: f32,
37    /// 95th percentile.
38    pub p95: f32,
39    /// 99th percentile: the stutters.
40    pub p99: f32,
41    /// The slowest frame.
42    pub max: f32,
43}
44
45/// One system's cost, from [`PerfReport::systems`].
46#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
47pub struct SystemReport {
48    /// The system's function name, e.g. `my_game::movement`.
49    pub name: String,
50    /// A simulation system (it may run several times per frame).
51    pub sim: bool,
52    /// Average time per frame, in ms.
53    pub avg_ms: f32,
54    /// Its slowest frame, in ms.
55    pub max_ms: f32,
56    /// Average heap allocations per frame (0 unless allocations are counted).
57    pub allocs_per_frame: f32,
58}
59
60/// One profiling scope's cost, from [`PerfReport::scopes`].
61#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
62pub struct ScopeReport {
63    /// The scope's name.
64    pub name: String,
65    /// Average time per frame, in ms (all threads added together).
66    pub avg_ms: f32,
67    /// Its slowest frame, in ms.
68    pub max_ms: f32,
69    /// How often it ran per frame, on average.
70    pub calls_per_frame: f32,
71}
72
73/// Heap allocations per frame, from [`PerfReport::allocs`].
74#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
75pub struct AllocReport {
76    /// Allocations per frame, on average, over the whole process.
77    pub per_frame: f32,
78    /// Bytes requested per frame, on average.
79    pub bytes_per_frame: f64,
80    /// The most allocations in one frame.
81    pub max_per_frame: u32,
82}
83
84/// Something that cost time in a [`WorstFrame`].
85#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
86pub struct Contributor {
87    /// A system or scope name.
88    pub name: String,
89    /// Its time in that frame, in ms.
90    pub ms: f32,
91    /// A scope (part of a system) rather than a system.
92    pub scope: bool,
93}
94
95/// One of the slowest frames, from [`PerfReport::worst_frames`].
96#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
97pub struct WorstFrame {
98    /// The frame (or tick) number.
99    pub frame: u64,
100    /// How long it took, in ms.
101    pub ms: f32,
102    /// Allocations in it (0 unless allocations are counted).
103    pub allocs: u32,
104    /// What took the most time in it, slowest first.
105    pub top: Vec<Contributor>,
106}
107
108/// One GPU pass, in [`RenderReport::gpu_passes`].
109#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
110pub struct PassReport {
111    /// The pass's name ("shadows 0", "opaque", "bloom", ...).
112    pub name: String,
113    /// Its GPU time in ms.
114    pub ms: f32,
115}
116
117/// What drawing cost, in a windowed game's [`PerfReport`].
118#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
119pub struct RenderReport {
120    /// Time in systems per frame, in ms.
121    pub systems_ms: f32,
122    /// CPU time drawing (preparing, recording, submitting), in ms.
123    pub render_cpu_ms: f32,
124    /// Time waiting for the display, in ms (idle, not work).
125    pub display_wait_ms: f32,
126    /// GPU time per frame, in ms, where the GPU can measure it.
127    pub gpu_ms: Option<f32>,
128    /// GPU draw calls last frame.
129    pub draw_calls: usize,
130    /// Sprites drawn last frame.
131    pub sprites: usize,
132    /// 3D models drawn for the camera.
133    pub models: usize,
134    /// Triangles drawn for the camera.
135    pub triangles: usize,
136    /// 3D models drawn into shadow maps.
137    pub shadow_models: usize,
138    /// Triangles drawn into shadow maps.
139    pub shadow_triangles: usize,
140    /// 3D models skipped because something hid them.
141    pub occluded: usize,
142    /// GPU time per pass, slowest first.
143    pub gpu_passes: Vec<PassReport>,
144}
145
146/// What a [`PerfWarning`] is about.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149#[non_exhaustive]
150pub enum WarningKind {
151    /// A system allocates on the heap every frame.
152    AllocatesEveryFrame,
153    /// One system takes most of the CPU time.
154    Hotspot,
155    /// A few frames are much slower than the rest.
156    Spikes,
157    /// The GPU takes most of the frame.
158    GpuBound,
159    /// The CPU takes most of the frame.
160    CpuBound,
161    /// Shadow maps draw far more triangles than the camera.
162    HeavyShadows,
163    /// Very many draw calls.
164    ManyDrawCalls,
165}
166
167/// A problem the report found, and what to do about it.
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169pub struct PerfWarning {
170    /// What kind of problem.
171    pub kind: WarningKind,
172    /// What was found, with numbers.
173    pub message: String,
174    /// What to try.
175    pub hint: String,
176}
177
178/// Where a game's time and allocations go, over its last frames. It comes
179/// from [`Ctx::perf_report`](crate::Ctx::perf_report) in a running game and
180/// [`Game::profile_headless`](crate::Game::profile_headless) in a headless run.
181///
182/// ```
183/// use ponko::*;
184///
185/// fn spin(ctx: &mut Ctx) {
186///     profile_scope!("spin/work");
187///     let mut x = 0.0f32;
188///     for i in 0..2000 { x += (i as f32).sin(); }
189///     std::hint::black_box(x);
190/// }
191///
192/// let (_world, report) = Game::new("t", 1, 1).sim_system(spin).profile_headless(2, 20, |_| {});
193/// assert_eq!(report.frames, 20);
194/// assert!(report.system("spin").is_some() && report.scope("spin/work").is_some());
195/// println!("{report}");
196/// ```
197#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
198pub struct PerfReport {
199    /// Frames (or ticks, in a headless run) the numbers cover.
200    pub frames: usize,
201    /// Entities in the world now.
202    pub entities: usize,
203    /// Frame times.
204    pub frame_ms: FrameTimes,
205    /// Systems, slowest first.
206    pub systems: Vec<SystemReport>,
207    /// Profiling scopes, slowest first. Scopes sit inside systems, so don't
208    /// add them to the systems' times.
209    pub scopes: Vec<ScopeReport>,
210    /// Heap allocations, if they're counted
211    /// ([`CountingAllocator`](crate::CountingAllocator) is installed).
212    pub allocs: Option<AllocReport>,
213    /// The slowest frames and what was in them.
214    pub worst_frames: Vec<WorstFrame>,
215    /// Drawing (windowed games only).
216    pub render: Option<RenderReport>,
217    /// Problems found, worst first.
218    pub warnings: Vec<PerfWarning>,
219}
220
221impl PerfReport {
222    /// The system whose name is `name` or ends with `::name`.
223    pub fn system(&self, name: &str) -> Option<&SystemReport> {
224        self.systems.iter().find(|s| name_matches(&s.name, name))
225    }
226
227    /// The scope called `name`.
228    pub fn scope(&self, name: &str) -> Option<&ScopeReport> {
229        self.scopes.iter().find(|s| s.name == name)
230    }
231
232    /// The report as pretty JSON, to keep and [compare](Self::regressions) later.
233    pub fn to_json(&self) -> String {
234        serde_json::to_string_pretty(self).unwrap_or_default()
235    }
236
237    /// Reads a report written by [`to_json`](Self::to_json).
238    ///
239    /// # Errors
240    /// If the text isn't a report.
241    pub fn from_json(json: &str) -> Result<Self, String> {
242        serde_json::from_str(json).map_err(|e| format!("not a performance report: {e}"))
243    }
244
245    /// Writes [`to_json`](Self::to_json) to `path`.
246    ///
247    /// # Errors
248    /// If the file can't be written.
249    pub fn save(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
250        std::fs::write(path, self.to_json())
251    }
252
253    /// Reads a report saved with [`save`](Self::save).
254    ///
255    /// # Errors
256    /// If the file can't be read or isn't a report.
257    pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
258        let path = path.as_ref();
259        let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
260        Self::from_json(&text)
261    }
262
263    /// Where `budget` is broken. Empty means every limit holds.
264    pub fn check(&self, budget: &Budget) -> Vec<BudgetViolation> {
265        let mut out = Vec::new();
266        let over = |what: String, limit: Option<f32>, actual: f32| {
267            limit.filter(|&l| actual > l).map(|limit| BudgetViolation {
268                what,
269                limit,
270                actual: Some(actual),
271            })
272        };
273        let missing = |what: String, limit: f32| BudgetViolation {
274            what,
275            limit,
276            actual: None,
277        };
278        out.extend(over(
279            "frame time, average (ms)".into(),
280            budget.frame_avg_ms,
281            self.frame_ms.avg,
282        ));
283        out.extend(over(
284            "frame time, p99 (ms)".into(),
285            budget.frame_p99_ms,
286            self.frame_ms.p99,
287        ));
288        out.extend(over(
289            "frame time, slowest (ms)".into(),
290            budget.frame_max_ms,
291            self.frame_ms.max,
292        ));
293        if let Some(max) = budget.allocs_per_frame {
294            match &self.allocs {
295                Some(a) => out.extend(over("allocations per frame".into(), Some(max), a.per_frame)),
296                None => out.push(missing(
297                    "allocations per frame (not counted: install CountingAllocator)".into(),
298                    max,
299                )),
300            }
301        }
302        for (name, max) in &budget.systems {
303            match self.system(name) {
304                Some(s) => out.extend(over(format!("system `{}` (ms)", s.name), Some(*max), s.avg_ms)),
305                None => out.push(missing(format!("system `{name}` (no such system)"), *max)),
306            }
307        }
308        for (name, max) in &budget.scopes {
309            match self.scope(name) {
310                Some(s) => out.extend(over(format!("scope `{name}` (ms)"), Some(*max), s.avg_ms)),
311                None => out.push(missing(format!("scope `{name}` (never ran)"), *max)),
312            }
313        }
314        out
315    }
316
317    /// Panics, listing what's over budget and what the report suggests, if
318    /// any limit in `budget` is broken. For tests and CI.
319    ///
320    /// ```
321    /// use ponko::*;
322    ///
323    /// let (_w, report) = Game::new("t", 1, 1).sim_system(|_: &mut Ctx| {}).profile_headless(0, 10, |_| {});
324    /// report.assert_within(&Budget::new().frame_p99_ms(50.0));
325    /// ```
326    ///
327    /// # Panics
328    /// When a limit is broken.
329    #[track_caller]
330    pub fn assert_within(&self, budget: &Budget) {
331        let broken = self.check(budget);
332        if broken.is_empty() {
333            return;
334        }
335        let mut msg = String::from("performance budget broken:\n");
336        for v in &broken {
337            let _ = writeln!(msg, "  - {v}");
338        }
339        let _ = write!(msg, "\n{self}");
340        panic!("{msg}");
341    }
342
343    /// What got slower than in `baseline`, a report from an earlier run:
344    /// frame times, allocations, and each system and scope by name. A value
345    /// counts when it grew by more than `tolerance` (0.2 is 20%) and by more
346    /// than 0.05 ms (or one allocation), so noise in tiny numbers is ignored.
347    ///
348    /// ```
349    /// use ponko::*;
350    /// # let (_w, report) = Game::new("t", 1, 1).sim_system(|_: &mut Ctx| {}).profile_headless(0, 5, |_| {});
351    /// # let baseline = report.clone();
352    /// assert!(report.regressions(&baseline, 0.25).is_empty());
353    /// ```
354    pub fn regressions(&self, baseline: &PerfReport, tolerance: f32) -> Vec<Regression> {
355        let mut out = Vec::new();
356        let mut cmp = |what: String, before: f32, after: f32, floor: f32| {
357            if after > before * (1.0 + tolerance) && after - before > floor {
358                out.push(Regression { what, before, after });
359            }
360        };
361        cmp(
362            "frame time, average (ms)".into(),
363            baseline.frame_ms.avg,
364            self.frame_ms.avg,
365            0.05,
366        );
367        cmp(
368            "frame time, p99 (ms)".into(),
369            baseline.frame_ms.p99,
370            self.frame_ms.p99,
371            0.05,
372        );
373        if let (Some(b), Some(a)) = (&baseline.allocs, &self.allocs) {
374            cmp("allocations per frame".into(), b.per_frame, a.per_frame, 1.0);
375        }
376        for s in &self.systems {
377            if let Some(b) = baseline.systems.iter().find(|b| b.name == s.name) {
378                cmp(format!("system `{}` (ms)", s.name), b.avg_ms, s.avg_ms, 0.05);
379                cmp(
380                    format!("system `{}` allocations", s.name),
381                    b.allocs_per_frame,
382                    s.allocs_per_frame,
383                    1.0,
384                );
385            }
386        }
387        for s in &self.scopes {
388            if let Some(b) = baseline.scopes.iter().find(|b| b.name == s.name) {
389                cmp(format!("scope `{}` (ms)", s.name), b.avg_ms, s.avg_ms, 0.05);
390            }
391        }
392        out
393    }
394}
395
396fn name_matches(full: &str, wanted: &str) -> bool {
397    full == wanted || full.strip_suffix(wanted).is_some_and(|rest| rest.ends_with("::"))
398}
399
400impl fmt::Display for PerfReport {
401    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
402        let t = &self.frame_ms;
403        writeln!(
404            f,
405            "== performance report: {} frames, {} entities ==",
406            self.frames, self.entities
407        )?;
408        writeln!(
409            f,
410            "frame ms: avg {:.3}  p50 {:.3}  p95 {:.3}  p99 {:.3}  max {:.3}",
411            t.avg, t.p50, t.p95, t.p99, t.max
412        )?;
413        if let Some(a) = &self.allocs {
414            writeln!(
415                f,
416                "allocations per frame: {:.1} ({:.1} KB), at most {}",
417                a.per_frame,
418                a.bytes_per_frame / 1024.0,
419                a.max_per_frame
420            )?;
421        }
422        if let Some(r) = &self.render {
423            writeln!(
424                f,
425                "cpu: systems {:.2}  render {:.2}  display wait {:.2}   gpu {}",
426                r.systems_ms,
427                r.render_cpu_ms,
428                r.display_wait_ms,
429                r.gpu_ms.map_or("n/a".into(), |g| format!("{g:.2} ms"))
430            )?;
431            writeln!(
432                f,
433                "draw calls {}  sprites {}  models {} ({:.2}M tris)  shadows {} models ({:.2}M tris)  occluded {}",
434                r.draw_calls,
435                r.sprites,
436                r.models,
437                r.triangles as f64 / 1e6,
438                r.shadow_models,
439                r.shadow_triangles as f64 / 1e6,
440                r.occluded
441            )?;
442            if !r.gpu_passes.is_empty() {
443                writeln!(f, "-- gpu passes (ms) --")?;
444                for p in r.gpu_passes.iter().take(10) {
445                    writeln!(f, "{:>8.3}  {}", p.ms, p.name)?;
446                }
447            }
448        }
449        if !self.systems.is_empty() {
450            writeln!(f, "-- systems (avg ms, max ms, allocs/frame) --")?;
451            for s in self.systems.iter().take(15) {
452                writeln!(
453                    f,
454                    "{:>8.3} {:>8.3} {:>8.1}  {}",
455                    s.avg_ms, s.max_ms, s.allocs_per_frame, s.name
456                )?;
457            }
458        }
459        if !self.scopes.is_empty() {
460            writeln!(f, "-- scopes (avg ms, max ms, calls/frame) --")?;
461            for s in self.scopes.iter().take(15) {
462                writeln!(
463                    f,
464                    "{:>8.3} {:>8.3} {:>8.1}  {}",
465                    s.avg_ms, s.max_ms, s.calls_per_frame, s.name
466                )?;
467            }
468        }
469        if !self.worst_frames.is_empty() {
470            writeln!(f, "-- slowest frames --")?;
471            for w in &self.worst_frames {
472                write!(f, "frame {}: {:.2} ms", w.frame, w.ms)?;
473                if w.allocs > 0 {
474                    write!(f, ", {} allocations", w.allocs)?;
475                }
476                let top: Vec<_> = w.top.iter().map(|c| format!("{} {:.2}", c.name, c.ms)).collect();
477                if !top.is_empty() {
478                    write!(f, "  [{}]", top.join(", "))?;
479                }
480                writeln!(f)?;
481            }
482        }
483        if !self.warnings.is_empty() {
484            writeln!(f, "-- warnings --")?;
485            for w in &self.warnings {
486                writeln!(f, "! {}\n    -> {}", w.message, w.hint)?;
487            }
488        }
489        Ok(())
490    }
491}
492
493// ------------------------------------------------------------------ budget
494
495/// Limits for a [`PerfReport`], checked with
496/// [`PerfReport::assert_within`] in tests and CI. Everything starts unlimited.
497///
498/// Timings vary between machines; allocation counts don't, so an
499/// `allocs_per_frame(0.0)` budget is the one to rely on in CI, with generous
500/// time limits next to it. Budgets are also JSON (`{"frame_p99_ms": 2,
501/// "systems": {"movement": 0.5}}`), which is how C and C++ pass them.
502///
503/// ```
504/// use ponko::Budget;
505/// let budget = Budget::new()
506///     .frame_p99_ms(2.0)
507///     .allocs_per_frame(0.0)
508///     .system("movement", 0.5)
509///     .scope("ai/pathing", 1.0);
510/// assert_eq!(budget.systems["movement"], 0.5);
511/// ```
512#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
513#[serde(default)]
514pub struct Budget {
515    /// Most average frame time, in ms.
516    pub frame_avg_ms: Option<f32>,
517    /// Most 99th percentile frame time, in ms.
518    pub frame_p99_ms: Option<f32>,
519    /// Most time for the slowest frame, in ms.
520    pub frame_max_ms: Option<f32>,
521    /// Most heap allocations per frame. Needs the counting allocator.
522    pub allocs_per_frame: Option<f32>,
523    /// Most average ms per frame for systems. Names match the full name or
524    /// its last part (`movement` matches `my_game::movement`).
525    pub systems: BTreeMap<String, f32>,
526    /// Most average ms per frame for scopes.
527    pub scopes: BTreeMap<String, f32>,
528}
529
530impl Budget {
531    /// No limits yet.
532    pub fn new() -> Self {
533        Self::default()
534    }
535
536    /// Limits the average frame time.
537    pub fn frame_avg_ms(mut self, ms: f32) -> Self {
538        self.frame_avg_ms = Some(ms);
539        self
540    }
541
542    /// Limits the 99th percentile frame time.
543    pub fn frame_p99_ms(mut self, ms: f32) -> Self {
544        self.frame_p99_ms = Some(ms);
545        self
546    }
547
548    /// Limits the slowest frame.
549    pub fn frame_max_ms(mut self, ms: f32) -> Self {
550        self.frame_max_ms = Some(ms);
551        self
552    }
553
554    /// Limits heap allocations per frame (0.0: none).
555    pub fn allocs_per_frame(mut self, n: f32) -> Self {
556        self.allocs_per_frame = Some(n);
557        self
558    }
559
560    /// Limits a system's average time per frame.
561    pub fn system(mut self, name: &str, ms: f32) -> Self {
562        self.systems.insert(name.into(), ms);
563        self
564    }
565
566    /// Limits a scope's average time per frame.
567    pub fn scope(mut self, name: &str, ms: f32) -> Self {
568        self.scopes.insert(name.into(), ms);
569        self
570    }
571
572    /// Reads a budget from JSON.
573    ///
574    /// # Errors
575    /// If the text isn't a budget.
576    pub fn from_json(json: &str) -> Result<Self, String> {
577        serde_json::from_str(json).map_err(|e| format!("not a budget: {e}"))
578    }
579}
580
581/// A limit in a [`Budget`] that the report went over.
582#[derive(Debug, Clone, PartialEq)]
583pub struct BudgetViolation {
584    /// What was limited.
585    pub what: String,
586    /// The limit.
587    pub limit: f32,
588    /// What was measured; `None` if it couldn't be (no such system).
589    pub actual: Option<f32>,
590}
591
592impl fmt::Display for BudgetViolation {
593    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
594        match self.actual {
595            Some(a) => write!(f, "{}: {a:.3}, limit {:.3}", self.what, self.limit),
596            None => write!(f, "{}: nothing measured, limit {:.3}", self.what, self.limit),
597        }
598    }
599}
600
601/// Something that got slower between two reports, from
602/// [`PerfReport::regressions`].
603#[derive(Debug, Clone, PartialEq)]
604pub struct Regression {
605    /// What got slower.
606    pub what: String,
607    /// Its value in the baseline.
608    pub before: f32,
609    /// Its value now.
610    pub after: f32,
611}
612
613impl fmt::Display for Regression {
614    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
615        write!(f, "{}: {:.3} -> {:.3}", self.what, self.before, self.after)
616    }
617}
618
619// --------------------------------------------------------------- collection
620
621/// A system's or scope's per-frame history.
622struct Series {
623    name: &'static str,
624    sim: bool,
625    ms: Vec<f32>,
626    /// Allocations (systems) or calls (scopes).
627    n: Vec<u32>,
628}
629
630impl Series {
631    fn new(name: &'static str, sim: bool, len: usize, cap: usize) -> Self {
632        let mut ms = Vec::with_capacity(cap);
633        ms.resize(len, 0.0);
634        let mut n = Vec::with_capacity(cap);
635        n.resize(len, 0);
636        Self { name, sim, ms, n }
637    }
638}
639
640fn set<T>(v: &mut Vec<T>, i: usize, value: T) {
641    if i == v.len() {
642        v.push(value);
643    } else {
644        v[i] = value;
645    }
646}
647
648/// The last frames' timings, kept in rings so recording a frame allocates
649/// nothing once every system and scope has run.
650pub(crate) struct Collector {
651    cap: usize,
652    next: usize,
653    frames: Vec<u64>,
654    frame_ms: Vec<f32>,
655    allocs: Vec<u32>,
656    alloc_bytes: Vec<u64>,
657    systems: Vec<Series>,
658    scopes: Vec<Series>,
659}
660
661impl Collector {
662    pub fn new(cap: usize) -> Self {
663        Self {
664            cap: cap.max(1),
665            next: 0,
666            frames: Vec::new(),
667            frame_ms: Vec::new(),
668            allocs: Vec::new(),
669            alloc_bytes: Vec::new(),
670            systems: Vec::new(),
671            scopes: Vec::new(),
672        }
673    }
674
675    pub fn clear(&mut self) {
676        *self = Self::new(self.cap);
677    }
678
679    #[cfg(test)]
680    pub fn len(&self) -> usize {
681        self.frame_ms.len()
682    }
683
684    /// Records one frame.
685    pub fn push(
686        &mut self,
687        frame: u64,
688        frame_ms: f32,
689        frame_allocs: AllocCounts,
690        systems: &[SystemTiming],
691        system_allocs: &[u32],
692        scopes: &[ScopeTiming],
693    ) {
694        let same_systems = self.systems.len() == systems.len()
695            && self
696                .systems
697                .iter()
698                .zip(systems)
699                .all(|(a, b)| std::ptr::eq(a.name, b.name) || a.name == b.name);
700        if !same_systems {
701            self.clear();
702            self.systems = systems
703                .iter()
704                .map(|t| Series::new(t.name, t.sim, 0, self.cap))
705                .collect();
706        }
707        let len = self.frame_ms.len();
708        while self.scopes.len() < scopes.len() {
709            let t = &scopes[self.scopes.len()];
710            self.scopes.push(Series::new(t.name, false, len, self.cap));
711        }
712        let i = if len >= self.cap {
713            let i = self.next;
714            self.next = (i + 1) % self.cap;
715            i
716        } else {
717            len
718        };
719        set(&mut self.frames, i, frame);
720        set(&mut self.frame_ms, i, frame_ms);
721        set(&mut self.allocs, i, frame_allocs.count.min(u64::from(u32::MAX)) as u32);
722        set(&mut self.alloc_bytes, i, frame_allocs.bytes);
723        for (k, (s, t)) in self.systems.iter_mut().zip(systems).enumerate() {
724            set(&mut s.ms, i, t.last_ms);
725            set(&mut s.n, i, system_allocs.get(k).copied().unwrap_or(0));
726        }
727        for (k, s) in self.scopes.iter_mut().enumerate() {
728            let t = scopes.get(k);
729            set(&mut s.ms, i, t.map_or(0.0, |t| t.last_ms));
730            set(&mut s.n, i, t.map_or(0, |t| t.calls));
731        }
732    }
733
734    /// The report for the frames kept. `entities`, `render` and the warnings
735    /// are added by the caller.
736    pub fn report(&self) -> PerfReport {
737        let n = self.frame_ms.len();
738        if n == 0 {
739            return PerfReport::default();
740        }
741        let nf = n as f32;
742        let mut sorted = self.frame_ms.clone();
743        sorted.sort_unstable_by(f32::total_cmp);
744        let frame_ms = FrameTimes {
745            avg: self.frame_ms.iter().sum::<f32>() / nf,
746            p50: percentile(&sorted, 0.50),
747            p95: percentile(&sorted, 0.95),
748            p99: percentile(&sorted, 0.99),
749            max: sorted[n - 1],
750        };
751        let max = |v: &[f32]| v.iter().copied().fold(0.0, f32::max);
752        let mut systems: Vec<_> = self
753            .systems
754            .iter()
755            .map(|s| SystemReport {
756                name: s.name.into(),
757                sim: s.sim,
758                avg_ms: s.ms.iter().sum::<f32>() / nf,
759                max_ms: max(&s.ms),
760                allocs_per_frame: s.n.iter().map(|&a| a as f32).sum::<f32>() / nf,
761            })
762            .collect();
763        systems.sort_by(|a, b| b.avg_ms.total_cmp(&a.avg_ms));
764        let mut scopes: Vec<_> = self
765            .scopes
766            .iter()
767            .map(|s| ScopeReport {
768                name: s.name.into(),
769                avg_ms: s.ms.iter().sum::<f32>() / nf,
770                max_ms: max(&s.ms),
771                calls_per_frame: s.n.iter().map(|&c| c as f32).sum::<f32>() / nf,
772            })
773            .collect();
774        scopes.sort_by(|a, b| b.avg_ms.total_cmp(&a.avg_ms));
775        let allocs = alloc_count::counting().then(|| AllocReport {
776            per_frame: self.allocs.iter().map(|&a| a as f32).sum::<f32>() / nf,
777            bytes_per_frame: self.alloc_bytes.iter().sum::<u64>() as f64 / n as f64,
778            max_per_frame: self.allocs.iter().copied().max().unwrap_or(0),
779        });
780        let mut order: Vec<usize> = (0..n).collect();
781        order.sort_by(|&a, &b| self.frame_ms[b].total_cmp(&self.frame_ms[a]));
782        let worst_frames = order
783            .into_iter()
784            .take(WORST_FRAMES)
785            .map(|i| {
786                let mut top: Vec<Contributor> = self
787                    .systems
788                    .iter()
789                    .map(|s| (s, false))
790                    .chain(self.scopes.iter().map(|s| (s, true)))
791                    .filter(|(s, _)| s.ms[i] > 0.0)
792                    .map(|(s, scope)| Contributor {
793                        name: s.name.into(),
794                        ms: s.ms[i],
795                        scope,
796                    })
797                    .collect();
798                top.sort_by(|a, b| b.ms.total_cmp(&a.ms));
799                top.truncate(3);
800                WorstFrame {
801                    frame: self.frames[i],
802                    ms: self.frame_ms[i],
803                    allocs: self.allocs[i],
804                    top,
805                }
806            })
807            .collect();
808        PerfReport {
809            frames: n,
810            frame_ms,
811            systems,
812            scopes,
813            allocs,
814            worst_frames,
815            ..Default::default()
816        }
817    }
818}
819
820/// A game's profiling state: scopes, allocation counts and the history
821/// behind [`PerfReport`].
822pub(crate) struct PerfState {
823    /// Profiling is running (scopes and allocation counting count).
824    pub on: bool,
825    /// `Ctx::set_profiling(true)` was called.
826    pub explicit: bool,
827    pub scopes: ScopeState,
828    pub collector: Collector,
829    /// Allocations per system in this frame, by system index.
830    pub system_allocs: Vec<u32>,
831    mark: AllocCounts,
832    pub frame_allocs: AllocCounts,
833    pub frames: u64,
834    /// The overlay's extra lines (scopes, allocations, warnings).
835    pub hud_extra: String,
836}
837
838impl PerfState {
839    pub fn new() -> Self {
840        Self {
841            on: false,
842            explicit: false,
843            scopes: ScopeState::default(),
844            collector: Collector::new(HISTORY),
845            system_allocs: Vec::new(),
846            mark: AllocCounts::default(),
847            frame_allocs: AllocCounts::default(),
848            frames: 0,
849            hud_extra: String::new(),
850        }
851    }
852
853    /// Turns profiling on or off as wanted.
854    pub fn sync(&mut self, wanted: bool) {
855        if wanted == self.on {
856            return;
857        }
858        self.on = wanted;
859        if wanted {
860            scope::enable();
861            self.scopes.rebase();
862            self.mark = alloc_count::alloc_counts();
863        } else {
864            scope::disable();
865            self.hud_extra.clear();
866        }
867    }
868
869    /// Starts a frame with `n` systems.
870    pub fn begin_frame(&mut self, n: usize) {
871        self.system_allocs.clear();
872        self.system_allocs.resize(n, 0);
873        if self.on {
874            self.mark = alloc_count::alloc_counts();
875        }
876    }
877
878    /// Charges `n` allocations to system `i`.
879    pub fn add_system_allocs(&mut self, i: usize, n: u64) {
880        if let Some(slot) = self.system_allocs.get_mut(i) {
881            *slot = slot.saturating_add(n.min(u64::from(u32::MAX)) as u32);
882        }
883    }
884
885    /// Ends a frame that took `frame_ms`.
886    pub fn end_frame(&mut self, frame_ms: f32, timings: &[SystemTiming]) {
887        if !self.on {
888            return;
889        }
890        self.frames += 1;
891        self.scopes.end_frame();
892        let now = alloc_count::alloc_counts();
893        self.frame_allocs = AllocCounts {
894            count: now.count.saturating_sub(self.mark.count),
895            bytes: now.bytes.saturating_sub(self.mark.bytes),
896        };
897        self.collector.push(
898            self.frames,
899            frame_ms,
900            self.frame_allocs,
901            timings,
902            &self.system_allocs,
903            &self.scopes.timings,
904        );
905    }
906
907    /// `true` about twice a second while the overlay is up: time to
908    /// rebuild its extra lines.
909    pub fn hud_due(&self) -> bool {
910        self.hud_extra.is_empty() || self.frames.is_multiple_of(30)
911    }
912
913    /// Leaves out of this frame's count what was allocated since `before`.
914    pub fn forgive_allocs(&mut self, before: AllocCounts) {
915        let now = alloc_count::alloc_counts();
916        self.mark.count += now.count.saturating_sub(before.count);
917        self.mark.bytes += now.bytes.saturating_sub(before.bytes);
918    }
919}
920
921impl Drop for PerfState {
922    fn drop(&mut self) {
923        if self.on {
924            scope::disable();
925        }
926    }
927}
928
929// ------------------------------------------------------------------ advice
930
931/// Looks at `report` for known problems and adds the warnings.
932pub(crate) fn analyze(report: &mut PerfReport) {
933    let mut w = Vec::new();
934    let avg = report.frame_ms.avg;
935    let cpu_ms: f32 = report.systems.iter().map(|s| s.avg_ms).sum();
936
937    if report.allocs.is_some() {
938        for s in report.systems.iter().filter(|s| s.allocs_per_frame >= 1.0) {
939            w.push(PerfWarning {
940                kind: WarningKind::AllocatesEveryFrame,
941                message: format!("`{}` allocates {:.0} times per frame", s.name, s.allocs_per_frame),
942                hint: "Keep the buffer between frames instead: a Vec in a resource or a captured variable, cleared \
943                       and refilled. Avoid format!, to_string, collect and Box in per-frame code. Wrap suspect lines \
944                       in profile_scope! and watch the allocation count to find the one."
945                    .into(),
946            });
947        }
948    }
949    if let Some(s) = report.systems.first()
950        && s.avg_ms >= 1.0
951        && cpu_ms > 0.0
952        && s.avg_ms / cpu_ms > 0.5
953    {
954        w.push(PerfWarning {
955            kind: WarningKind::Hotspot,
956            message: format!(
957                "`{}` takes {:.0}% of the time spent in systems ({:.2} of {:.2} ms)",
958                s.name,
959                100.0 * s.avg_ms / cpu_ms,
960                s.avg_ms,
961                cpu_ms
962            ),
963            hint: "Wrap parts of it in profile_scope! to see where the time goes. Then: iterate with for_each / \
964                   par_for_each, spread per-entity work over frames with ctx.turns(n), or run it less often with \
965                   sim_system_every."
966                .into(),
967        });
968    }
969    let t = &report.frame_ms;
970    if report.frames >= 30 && t.p50 > 0.0 && t.max >= 4.0 * t.p50 && t.max - t.p50 >= 2.0 {
971        let worst = report.worst_frames.first();
972        let culprit = worst
973            .and_then(|f| f.top.first())
974            .map(|c| format!("; its biggest cost was `{}` ({:.2} ms)", c.name, c.ms))
975            .unwrap_or_default();
976        w.push(PerfWarning {
977            kind: WarningKind::Spikes,
978            message: format!(
979                "frame spikes: the slowest frame ({}) took {:.2} ms, {:.1}x the median ({:.2} ms){culprit}",
980                worst.map_or(0, |f| f.frame),
981                t.max,
982                t.max / t.p50,
983                t.p50
984            ),
985            hint: "Spikes come from allocation bursts, loading assets, spawning many entities at once, or a system \
986                   that sometimes does a lot (path searches, rebuilds). Spread such work over frames with \
987                   ctx.turns(n), or do it ahead of time. ctx.record_trace shows a slow frame on a timeline."
988                .into(),
989        });
990    }
991    if let Some(r) = &report.render {
992        if let Some(gpu) = r.gpu_ms
993            && avg > 0.0
994            && gpu >= 0.8 * avg
995        {
996            let (pass, hint) = match r.gpu_passes.first() {
997                Some(p) if p.name.contains("shadow") => (
998                    format!("; the slowest pass is `{}` ({:.2} ms)", p.name, p.ms),
999                    "Fewer or smaller shadow casters, a ShadowMesh for detailed models, set_mesh_shadows(false) for \
1000                     small things, or fewer cascades.",
1001                ),
1002                Some(p) => (
1003                    format!("; the slowest pass is `{}` ({:.2} ms)", p.name, p.ms),
1004                    "Draw fewer or smaller things, lower Game::msaa, or use cheaper shaders. gpu_passes says which \
1005                     pass; render_stats says whether it's triangles or pixels.",
1006                ),
1007                None => (
1008                    String::new(),
1009                    "Draw fewer or smaller things, or lower Game::msaa. Compare render_stats triangles with pixels.",
1010                ),
1011            };
1012            w.push(PerfWarning {
1013                kind: WarningKind::GpuBound,
1014                message: format!("GPU-bound: the GPU needs {gpu:.2} ms of the {avg:.2} ms frame{pass}"),
1015                hint: hint.into(),
1016            });
1017        } else if avg > 0.0 && r.systems_ms + r.render_cpu_ms >= 0.8 * avg {
1018            w.push(PerfWarning {
1019                kind: WarningKind::CpuBound,
1020                message: format!(
1021                    "CPU-bound: systems {:.2} ms + drawing {:.2} ms of the {avg:.2} ms frame",
1022                    r.systems_ms, r.render_cpu_ms
1023                ),
1024                hint: "Look at the slowest systems and scopes. If drawing dominates, check draw calls and models \
1025                       drawn (render_stats), and cull or merge what you can."
1026                    .into(),
1027            });
1028        }
1029        if r.shadow_triangles >= 200_000 && r.shadow_triangles >= 2 * r.triangles.max(1) {
1030            w.push(PerfWarning {
1031                kind: WarningKind::HeavyShadows,
1032                message: format!(
1033                    "shadow maps draw {:.1}M triangles, {:.1}x what the camera draws",
1034                    r.shadow_triangles as f64 / 1e6,
1035                    r.shadow_triangles as f64 / r.triangles.max(1) as f64
1036                ),
1037                hint: "Every caster is drawn again for each cascade it falls in. Give detailed models a simpler \
1038                       ShadowMesh, turn shadows off for small things (set_mesh_shadows), or shorten the shadow distance."
1039                    .into(),
1040            });
1041        }
1042        if r.draw_calls >= 1000 {
1043            w.push(PerfWarning {
1044                kind: WarningKind::ManyDrawCalls,
1045                message: format!("{} draw calls per frame", r.draw_calls),
1046                hint: "Each run of sprites sharing a texture, and each mesh part with its material, is one call. \
1047                       Pack sprites into an atlas, share meshes and materials between objects, and merge static \
1048                       geometry. ctx.mesh_costs lists the meshes."
1049                    .into(),
1050            });
1051        }
1052    }
1053    report.warnings = w;
1054}
1055
1056/// The overlay's extra lines: scopes, allocations and the worst warnings.
1057pub(crate) fn hud_lines(out: &mut String, report: &PerfReport) {
1058    out.clear();
1059    if let Some(a) = &report.allocs {
1060        let _ = writeln!(
1061            out,
1062            "allocs/frame {:.1} ({:.1} KB)",
1063            a.per_frame,
1064            a.bytes_per_frame / 1024.0
1065        );
1066    }
1067    if !report.scopes.is_empty() {
1068        out.push_str("-- scopes\n");
1069        for s in report.scopes.iter().take(6) {
1070            let _ = writeln!(out, "{:>6.2}  {} (x{:.0})", s.avg_ms, s.name, s.calls_per_frame);
1071        }
1072    }
1073    for w in report.warnings.iter().take(3) {
1074        let mut msg: String = w.message.chars().take(60).collect();
1075        if msg.len() < w.message.len() {
1076            msg.push('…');
1077        }
1078        let _ = writeln!(out, "! {msg}");
1079    }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085
1086    fn timing(name: &'static str, ms: f32) -> SystemTiming {
1087        SystemTiming {
1088            name,
1089            sim: true,
1090            last_ms: ms,
1091            avg_ms: ms,
1092        }
1093    }
1094
1095    fn collector_with(frames: &[(f32, f32, f32)]) -> Collector {
1096        let mut c = Collector::new(100);
1097        for (i, &(total, a, b)) in frames.iter().enumerate() {
1098            c.push(
1099                i as u64 + 1,
1100                total,
1101                AllocCounts::default(),
1102                &[timing("g::a", a), timing("g::b", b)],
1103                &[0, 0],
1104                &[],
1105            );
1106        }
1107        c
1108    }
1109
1110    #[test]
1111    fn reports_sort_systems_and_find_the_worst_frame() {
1112        let mut frames = vec![(2.0, 1.0, 0.5); 99];
1113        frames.push((30.0, 1.0, 25.0));
1114        let r = collector_with(&frames).report();
1115        assert_eq!(r.frames, 100);
1116        assert_eq!(r.systems[0].name, "g::a".to_string().as_str().to_owned().as_str());
1117        assert!((r.frame_ms.avg - 2.28).abs() < 1e-3);
1118        assert_eq!((r.frame_ms.p50, r.frame_ms.max), (2.0, 30.0));
1119        let worst = &r.worst_frames[0];
1120        assert_eq!((worst.frame, worst.ms), (100, 30.0));
1121        assert_eq!(worst.top[0].name, "g::b");
1122        assert!(r.system("b").is_some() && r.system("g::b").is_some() && r.system("::b").is_none());
1123    }
1124
1125    #[test]
1126    fn the_ring_keeps_the_latest_frames() {
1127        let mut c = Collector::new(10);
1128        for i in 0..25u64 {
1129            c.push(
1130                i + 1,
1131                i as f32,
1132                AllocCounts::default(),
1133                &[timing("s", i as f32)],
1134                &[0],
1135                &[],
1136            );
1137        }
1138        let r = c.report();
1139        assert_eq!(r.frames, 10);
1140        assert_eq!(r.frame_ms.max, 24.0);
1141        assert!((r.systems[0].avg_ms - 19.5).abs() < 1e-4);
1142        assert_eq!(r.worst_frames[0].frame, 25);
1143    }
1144
1145    #[test]
1146    fn scopes_that_appear_later_are_zero_before() {
1147        let mut c = Collector::new(10);
1148        let sys = [timing("s", 1.0)];
1149        c.push(1, 1.0, AllocCounts::default(), &sys, &[0], &[]);
1150        let scope = [ScopeTiming {
1151            name: "late",
1152            last_ms: 2.0,
1153            avg_ms: 2.0,
1154            calls: 4,
1155        }];
1156        c.push(2, 1.0, AllocCounts::default(), &sys, &[0], &scope);
1157        let r = c.report();
1158        assert_eq!(r.scope("late").map(|s| (s.avg_ms, s.calls_per_frame)), Some((1.0, 2.0)));
1159    }
1160
1161    #[test]
1162    fn changing_the_systems_starts_the_history_over() {
1163        let mut c = collector_with(&[(1.0, 1.0, 1.0); 5]);
1164        c.push(6, 1.0, AllocCounts::default(), &[timing("only", 1.0)], &[0], &[]);
1165        assert_eq!(c.len(), 1);
1166    }
1167
1168    #[test]
1169    fn budgets_name_what_broke() {
1170        let r = collector_with(&[(2.0, 1.0, 0.5); 40]).report();
1171        assert!(r.check(&Budget::new().frame_p99_ms(3.0).system("a", 1.5)).is_empty());
1172        let broken = r.check(
1173            &Budget::new()
1174                .frame_p99_ms(1.0)
1175                .system("b", 0.1)
1176                .system("nope", 1.0)
1177                .scope("x", 1.0),
1178        );
1179        let text: Vec<_> = broken.iter().map(ToString::to_string).collect();
1180        assert_eq!(broken.len(), 4, "{text:?}");
1181        assert!(text.iter().any(|t| t.contains("`g::b`")));
1182        assert!(text.iter().any(|t| t.contains("no such system")));
1183        assert!(text.iter().any(|t| t.contains("never ran")));
1184    }
1185
1186    #[test]
1187    #[should_panic(expected = "performance budget broken")]
1188    fn assert_within_panics_with_the_report() {
1189        collector_with(&[(2.0, 1.0, 0.5); 10])
1190            .report()
1191            .assert_within(&Budget::new().frame_avg_ms(1.0));
1192    }
1193
1194    #[test]
1195    fn budgets_come_from_json() {
1196        let b = Budget::from_json(r#"{"frame_p99_ms": 2.5, "systems": {"movement": 0.5}}"#).unwrap();
1197        assert_eq!(b, Budget::new().frame_p99_ms(2.5).system("movement", 0.5));
1198        assert!(Budget::from_json("3").is_err());
1199    }
1200
1201    #[test]
1202    fn reports_round_trip_and_compare() {
1203        let before = collector_with(&[(2.0, 1.0, 0.5); 40]).report();
1204        let same = PerfReport::from_json(&before.to_json()).unwrap();
1205        assert_eq!(same, before);
1206        assert!(same.regressions(&before, 0.1).is_empty());
1207        let after = collector_with(&[(3.0, 2.0, 0.5); 40]).report();
1208        let slower = after.regressions(&before, 0.1);
1209        let text: Vec<_> = slower.iter().map(ToString::to_string).collect();
1210        assert!(text.iter().any(|t| t.contains("system `g::a`")), "{text:?}");
1211        assert!(!text.iter().any(|t| t.contains("g::b")), "{text:?}");
1212        assert!(PerfReport::from_json("nope").is_err());
1213    }
1214
1215    #[test]
1216    fn warnings_explain_spikes_hotspots_and_allocations() {
1217        let mut frames = vec![(2.0, 1.5, 0.1); 99];
1218        frames.push((40.0, 1.5, 30.0));
1219        let mut r = collector_with(&frames).report();
1220        r.allocs = Some(AllocReport::default());
1221        r.systems[0].allocs_per_frame = 3.0;
1222        analyze(&mut r);
1223        let kinds: Vec<_> = r.warnings.iter().map(|w| w.kind).collect();
1224        assert!(kinds.contains(&WarningKind::AllocatesEveryFrame), "{r}");
1225        assert!(kinds.contains(&WarningKind::Hotspot), "{r}");
1226        assert!(kinds.contains(&WarningKind::Spikes), "{r}");
1227        let spikes = r.warnings.iter().find(|w| w.kind == WarningKind::Spikes).unwrap();
1228        assert!(
1229            spikes.message.contains("`g::b`"),
1230            "names the culprit: {}",
1231            spikes.message
1232        );
1233        assert!(r.to_string().contains("-> "), "the text report carries the hints");
1234    }
1235
1236    #[test]
1237    fn render_warnings_name_the_slow_pass() {
1238        let mut r = collector_with(&[(16.0, 1.0, 1.0); 40]).report();
1239        r.render = Some(RenderReport {
1240            gpu_ms: Some(15.0),
1241            gpu_passes: vec![PassReport {
1242                name: "shadows 0".into(),
1243                ms: 9.0,
1244            }],
1245            shadow_triangles: 5_000_000,
1246            triangles: 1_000_000,
1247            draw_calls: 4000,
1248            ..Default::default()
1249        });
1250        analyze(&mut r);
1251        let kinds: Vec<_> = r.warnings.iter().map(|w| w.kind).collect();
1252        assert_eq!(
1253            kinds,
1254            [
1255                WarningKind::GpuBound,
1256                WarningKind::HeavyShadows,
1257                WarningKind::ManyDrawCalls
1258            ]
1259        );
1260        assert!(r.warnings[0].message.contains("shadows 0"));
1261    }
1262
1263    #[test]
1264    fn quiet_games_get_no_warnings() {
1265        let mut r = collector_with(&[(2.0, 0.5, 0.5); 60]).report();
1266        analyze(&mut r);
1267        assert!(r.warnings.is_empty(), "{r}");
1268    }
1269}