1use 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
21pub(crate) const HISTORY: usize = 1800;
23pub(crate) const HEADLESS_HISTORY: usize = 50_000;
25const WORST_FRAMES: usize = 3;
27
28#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
32pub struct FrameTimes {
33 pub avg: f32,
35 pub p50: f32,
37 pub p95: f32,
39 pub p99: f32,
41 pub max: f32,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
47pub struct SystemReport {
48 pub name: String,
50 pub sim: bool,
52 pub avg_ms: f32,
54 pub max_ms: f32,
56 pub allocs_per_frame: f32,
58}
59
60#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
62pub struct ScopeReport {
63 pub name: String,
65 pub avg_ms: f32,
67 pub max_ms: f32,
69 pub calls_per_frame: f32,
71}
72
73#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
75pub struct AllocReport {
76 pub per_frame: f32,
78 pub bytes_per_frame: f64,
80 pub max_per_frame: u32,
82}
83
84#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
86pub struct Contributor {
87 pub name: String,
89 pub ms: f32,
91 pub scope: bool,
93}
94
95#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
97pub struct WorstFrame {
98 pub frame: u64,
100 pub ms: f32,
102 pub allocs: u32,
104 pub top: Vec<Contributor>,
106}
107
108#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
110pub struct PassReport {
111 pub name: String,
113 pub ms: f32,
115}
116
117#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
119pub struct RenderReport {
120 pub systems_ms: f32,
122 pub render_cpu_ms: f32,
124 pub display_wait_ms: f32,
126 pub gpu_ms: Option<f32>,
128 pub draw_calls: usize,
130 pub sprites: usize,
132 pub models: usize,
134 pub triangles: usize,
136 pub shadow_models: usize,
138 pub shadow_triangles: usize,
140 pub occluded: usize,
142 pub gpu_passes: Vec<PassReport>,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149#[non_exhaustive]
150pub enum WarningKind {
151 AllocatesEveryFrame,
153 Hotspot,
155 Spikes,
157 GpuBound,
159 CpuBound,
161 HeavyShadows,
163 ManyDrawCalls,
165}
166
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169pub struct PerfWarning {
170 pub kind: WarningKind,
172 pub message: String,
174 pub hint: String,
176}
177
178#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
198pub struct PerfReport {
199 pub frames: usize,
201 pub entities: usize,
203 pub frame_ms: FrameTimes,
205 pub systems: Vec<SystemReport>,
207 pub scopes: Vec<ScopeReport>,
210 pub allocs: Option<AllocReport>,
213 pub worst_frames: Vec<WorstFrame>,
215 pub render: Option<RenderReport>,
217 pub warnings: Vec<PerfWarning>,
219}
220
221impl PerfReport {
222 pub fn system(&self, name: &str) -> Option<&SystemReport> {
224 self.systems.iter().find(|s| name_matches(&s.name, name))
225 }
226
227 pub fn scope(&self, name: &str) -> Option<&ScopeReport> {
229 self.scopes.iter().find(|s| s.name == name)
230 }
231
232 pub fn to_json(&self) -> String {
234 serde_json::to_string_pretty(self).unwrap_or_default()
235 }
236
237 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 pub fn save(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
250 std::fs::write(path, self.to_json())
251 }
252
253 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 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 #[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 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
513#[serde(default)]
514pub struct Budget {
515 pub frame_avg_ms: Option<f32>,
517 pub frame_p99_ms: Option<f32>,
519 pub frame_max_ms: Option<f32>,
521 pub allocs_per_frame: Option<f32>,
523 pub systems: BTreeMap<String, f32>,
526 pub scopes: BTreeMap<String, f32>,
528}
529
530impl Budget {
531 pub fn new() -> Self {
533 Self::default()
534 }
535
536 pub fn frame_avg_ms(mut self, ms: f32) -> Self {
538 self.frame_avg_ms = Some(ms);
539 self
540 }
541
542 pub fn frame_p99_ms(mut self, ms: f32) -> Self {
544 self.frame_p99_ms = Some(ms);
545 self
546 }
547
548 pub fn frame_max_ms(mut self, ms: f32) -> Self {
550 self.frame_max_ms = Some(ms);
551 self
552 }
553
554 pub fn allocs_per_frame(mut self, n: f32) -> Self {
556 self.allocs_per_frame = Some(n);
557 self
558 }
559
560 pub fn system(mut self, name: &str, ms: f32) -> Self {
562 self.systems.insert(name.into(), ms);
563 self
564 }
565
566 pub fn scope(mut self, name: &str, ms: f32) -> Self {
568 self.scopes.insert(name.into(), ms);
569 self
570 }
571
572 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#[derive(Debug, Clone, PartialEq)]
583pub struct BudgetViolation {
584 pub what: String,
586 pub limit: f32,
588 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#[derive(Debug, Clone, PartialEq)]
604pub struct Regression {
605 pub what: String,
607 pub before: f32,
609 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
619struct Series {
623 name: &'static str,
624 sim: bool,
625 ms: Vec<f32>,
626 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
648pub(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 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 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
820pub(crate) struct PerfState {
823 pub on: bool,
825 pub explicit: bool,
827 pub scopes: ScopeState,
828 pub collector: Collector,
829 pub system_allocs: Vec<u32>,
831 mark: AllocCounts,
832 pub frame_allocs: AllocCounts,
833 pub frames: u64,
834 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 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 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 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 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 pub fn hud_due(&self) -> bool {
910 self.hud_extra.is_empty() || self.frames.is_multiple_of(30)
911 }
912
913 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
929pub(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
1056pub(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}