1use std::collections::{HashMap, VecDeque};
5use std::fmt;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
11pub struct Timestamp(pub u64);
12
13impl Timestamp {
14 pub fn elapsed_since(&self, earlier: Timestamp) -> Duration {
15 Duration(self.0.saturating_sub(earlier.0))
16 }
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
21pub struct Duration(pub u64);
22
23impl Duration {
24 pub const ZERO: Self = Self(0);
25
26 pub fn from_us(us: u64) -> Self { Self(us) }
27 pub fn from_ms(ms: f64) -> Self { Self((ms * 1000.0) as u64) }
28 pub fn from_secs(s: f64) -> Self { Self((s * 1_000_000.0) as u64) }
29
30 pub fn as_us(&self) -> u64 { self.0 }
31 pub fn as_ms(&self) -> f64 { self.0 as f64 / 1000.0 }
32 pub fn as_secs(&self) -> f64 { self.0 as f64 / 1_000_000.0 }
33
34 pub fn add(self, other: Self) -> Self { Self(self.0 + other.0) }
35 pub fn saturating_sub(self, other: Self) -> Self { Self(self.0.saturating_sub(other.0)) }
36}
37
38impl fmt::Display for Duration {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 if self.0 < 1000 {
41 write!(f, "{}µs", self.0)
42 } else if self.0 < 1_000_000 {
43 write!(f, "{:.2}ms", self.as_ms())
44 } else {
45 write!(f, "{:.3}s", self.as_secs())
46 }
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub struct SpanId(pub u32);
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SpanKind {
57 Cpu,
58 Gpu,
59 Memory,
60 Io,
61 Script,
62 Physics,
63 Render,
64 Audio,
65 Custom,
66}
67
68impl SpanKind {
69 pub fn color_rgb(&self) -> [u8; 3] {
70 match self {
71 Self::Cpu => [70, 130, 200],
72 Self::Gpu => [200, 100, 50],
73 Self::Memory => [100, 180, 80],
74 Self::Io => [180, 60, 180],
75 Self::Script => [220, 180, 40],
76 Self::Physics => [60, 180, 180],
77 Self::Render => [220, 80, 80],
78 Self::Audio => [80, 200, 140],
79 Self::Custom => [160, 160, 160],
80 }
81 }
82}
83
84#[derive(Debug, Clone)]
85pub struct ProfileSpan {
86 pub id: SpanId,
87 pub name: String,
88 pub kind: SpanKind,
89 pub start: Timestamp,
90 pub end: Option<Timestamp>,
91 pub parent: Option<SpanId>,
92 pub depth: u32,
93 pub thread: u32,
94 pub extra: Option<String>,
95}
96
97impl ProfileSpan {
98 pub fn duration(&self) -> Option<Duration> {
99 self.end.map(|e| e.elapsed_since(self.start))
100 }
101
102 pub fn is_open(&self) -> bool { self.end.is_none() }
103
104 pub fn contains(&self, other: &ProfileSpan) -> bool {
105 if let (Some(self_end), Some(other_end)) = (self.end, other.end) {
106 self.start <= other.start && self_end >= other_end
107 } else {
108 false
109 }
110 }
111}
112
113#[derive(Debug, Clone)]
116pub struct FrameProfile {
117 pub frame_number: u64,
118 pub frame_start: Timestamp,
119 pub frame_end: Option<Timestamp>,
120 pub spans: Vec<ProfileSpan>,
121 pub counters: HashMap<String, CounterSample>,
122 pub memory: MemorySnapshot,
123 pub gpu_timestamp_start: Timestamp,
124 pub gpu_timestamp_end: Option<Timestamp>,
125}
126
127impl FrameProfile {
128 pub fn new(frame_number: u64, start: Timestamp) -> Self {
129 Self {
130 frame_number,
131 frame_start: start,
132 frame_end: None,
133 spans: Vec::new(),
134 counters: HashMap::new(),
135 memory: MemorySnapshot::default(),
136 gpu_timestamp_start: Timestamp::default(),
137 gpu_timestamp_end: None,
138 }
139 }
140
141 pub fn cpu_duration(&self) -> Option<Duration> {
142 self.frame_end.map(|e| e.elapsed_since(self.frame_start))
143 }
144
145 pub fn gpu_duration(&self) -> Option<Duration> {
146 self.gpu_timestamp_end.map(|e| e.elapsed_since(self.gpu_timestamp_start))
147 }
148
149 pub fn total_span_time(&self, kind: SpanKind) -> Duration {
150 self.spans.iter()
151 .filter(|s| s.kind == kind && s.parent.is_none())
152 .filter_map(|s| s.duration())
153 .fold(Duration::ZERO, |acc, d| acc.add(d))
154 }
155
156 pub fn slowest_span(&self) -> Option<&ProfileSpan> {
157 self.spans.iter()
158 .filter_map(|s| s.duration().map(|d| (s, d)))
159 .max_by_key(|(_, d)| d.0)
160 .map(|(s, _)| s)
161 }
162
163 pub fn spans_sorted_by_duration(&self) -> Vec<&ProfileSpan> {
164 let mut s: Vec<&ProfileSpan> = self.spans.iter().collect();
165 s.sort_by(|a, b| {
166 let da = a.duration().unwrap_or(Duration::ZERO);
167 let db = b.duration().unwrap_or(Duration::ZERO);
168 db.cmp(&da)
169 });
170 s
171 }
172
173 pub fn build_flamegraph(&self) -> FlamegraphNode {
174 let root_spans: Vec<&ProfileSpan> = self.spans.iter()
175 .filter(|s| s.parent.is_none())
176 .collect();
177 FlamegraphNode {
178 name: "Frame".into(),
179 kind: SpanKind::Cpu,
180 duration: self.cpu_duration().unwrap_or(Duration::ZERO),
181 children: root_spans.iter().map(|s| self.build_flame_node(s)).collect(),
182 depth: 0,
183 }
184 }
185
186 fn build_flame_node(&self, span: &ProfileSpan) -> FlamegraphNode {
187 let children: Vec<&ProfileSpan> = self.spans.iter()
188 .filter(|s| s.parent == Some(span.id))
189 .collect();
190 FlamegraphNode {
191 name: span.name.clone(),
192 kind: span.kind,
193 duration: span.duration().unwrap_or(Duration::ZERO),
194 children: children.iter().map(|s| self.build_flame_node(s)).collect(),
195 depth: span.depth,
196 }
197 }
198
199 pub fn top_n_spans(&self, n: usize) -> Vec<(&ProfileSpan, Duration)> {
200 let mut v: Vec<(&ProfileSpan, Duration)> = self.spans.iter()
201 .filter_map(|s| s.duration().map(|d| (s, d)))
202 .collect();
203 v.sort_by(|a, b| b.1.cmp(&a.1));
204 v.truncate(n);
205 v
206 }
207}
208
209#[derive(Debug, Clone)]
212pub struct FlamegraphNode {
213 pub name: String,
214 pub kind: SpanKind,
215 pub duration: Duration,
216 pub children: Vec<FlamegraphNode>,
217 pub depth: u32,
218}
219
220impl FlamegraphNode {
221 pub fn self_time(&self) -> Duration {
222 let children_total: u64 = self.children.iter()
223 .map(|c| c.duration.0)
224 .sum();
225 Duration(self.duration.0.saturating_sub(children_total))
226 }
227
228 pub fn max_depth(&self) -> u32 {
229 self.children.iter()
230 .map(|c| c.max_depth())
231 .max()
232 .unwrap_or(0)
233 + 1
234 }
235
236 pub fn total_span_count(&self) -> usize {
237 1 + self.children.iter().map(|c| c.total_span_count()).sum::<usize>()
238 }
239
240 pub fn visit<F: FnMut(&FlamegraphNode, u32)>(&self, depth: u32, f: &mut F) {
241 f(self, depth);
242 for child in &self.children {
243 child.visit(depth + 1, f);
244 }
245 }
246
247 pub fn ascii_tree(&self, indent: usize) -> String {
248 let mut out = format!(
249 "{}{} ({})\n",
250 " ".repeat(indent),
251 self.name,
252 self.duration,
253 );
254 for child in &self.children {
255 out.push_str(&child.ascii_tree(indent + 1));
256 }
257 out
258 }
259}
260
261#[derive(Debug, Clone)]
264pub struct CounterSample {
265 pub name: String,
266 pub value: f64,
267 pub unit: CounterUnit,
268 pub min: f64,
269 pub max: f64,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum CounterUnit {
274 Count,
275 Bytes,
276 Milliseconds,
277 Microseconds,
278 Percentage,
279 Hertz,
280 Triangles,
281 DrawCalls,
282 Custom,
283}
284
285impl CounterUnit {
286 pub fn format(&self, v: f64) -> String {
287 match self {
288 Self::Count => format!("{:.0}", v),
289 Self::Bytes => format_bytes(v as u64),
290 Self::Milliseconds => format!("{:.2}ms", v),
291 Self::Microseconds => format!("{:.1}µs", v),
292 Self::Percentage => format!("{:.1}%", v),
293 Self::Hertz => format!("{:.0}Hz", v),
294 Self::Triangles => format!("{}K tri", (v / 1000.0) as u64),
295 Self::DrawCalls => format!("{:.0} DC", v),
296 Self::Custom => format!("{:.3}", v),
297 }
298 }
299}
300
301fn format_bytes(b: u64) -> String {
302 const KB: u64 = 1024;
303 const MB: u64 = KB * 1024;
304 const GB: u64 = MB * 1024;
305 if b >= GB { format!("{:.2} GB", b as f64 / GB as f64) }
306 else if b >= MB { format!("{:.1} MB", b as f64 / MB as f64) }
307 else if b >= KB { format!("{:.0} KB", b as f64 / KB as f64) }
308 else { format!("{} B", b) }
309}
310
311#[derive(Debug, Clone, Default)]
314pub struct MemorySnapshot {
315 pub heap_bytes: u64,
316 pub stack_bytes: u64,
317 pub gpu_vram_bytes: u64,
318 pub texture_bytes: u64,
319 pub vertex_buffer_bytes: u64,
320 pub index_buffer_bytes: u64,
321 pub uniform_buffer_bytes: u64,
322 pub render_target_bytes: u64,
323 pub audio_bytes: u64,
324 pub script_bytes: u64,
325 pub asset_cache_bytes: u64,
326}
327
328impl MemorySnapshot {
329 pub fn total_cpu(&self) -> u64 {
330 self.heap_bytes + self.stack_bytes + self.audio_bytes + self.script_bytes
331 }
332
333 pub fn total_gpu(&self) -> u64 {
334 self.gpu_vram_bytes
335 }
336
337 pub fn total_gpu_breakdown(&self) -> u64 {
338 self.texture_bytes + self.vertex_buffer_bytes + self.index_buffer_bytes
339 + self.uniform_buffer_bytes + self.render_target_bytes
340 }
341
342 pub fn format_summary(&self) -> String {
343 format!(
344 "CPU: {} | GPU: {} (Tex:{} VB:{} RT:{})",
345 format_bytes(self.total_cpu()),
346 format_bytes(self.total_gpu()),
347 format_bytes(self.texture_bytes),
348 format_bytes(self.vertex_buffer_bytes),
349 format_bytes(self.render_target_bytes),
350 )
351 }
352}
353
354#[derive(Debug, Clone)]
357pub struct PerformanceBudget {
358 pub name: String,
359 pub frame_time_ms: f64,
360 pub cpu_ms: f64,
361 pub gpu_ms: f64,
362 pub draw_calls: u32,
363 pub triangle_count: u32,
364 pub vram_bytes: u64,
365 pub texture_bytes: u64,
366}
367
368impl Default for PerformanceBudget {
369 fn default() -> Self {
370 Self {
371 name: "60 FPS".into(),
372 frame_time_ms: 16.67,
373 cpu_ms: 8.0,
374 gpu_ms: 8.0,
375 draw_calls: 500,
376 triangle_count: 500_000,
377 vram_bytes: 512 * 1024 * 1024,
378 texture_bytes: 256 * 1024 * 1024,
379 }
380 }
381}
382
383impl PerformanceBudget {
384 pub fn mobile() -> Self {
385 Self {
386 name: "30 FPS Mobile".into(),
387 frame_time_ms: 33.33,
388 cpu_ms: 12.0,
389 gpu_ms: 12.0,
390 draw_calls: 100,
391 triangle_count: 100_000,
392 vram_bytes: 128 * 1024 * 1024,
393 texture_bytes: 64 * 1024 * 1024,
394 }
395 }
396
397 pub fn hi_end() -> Self {
398 Self {
399 name: "120 FPS High-End".into(),
400 frame_time_ms: 8.33,
401 cpu_ms: 4.0,
402 gpu_ms: 4.0,
403 draw_calls: 2000,
404 triangle_count: 2_000_000,
405 vram_bytes: 4 * 1024 * 1024 * 1024,
406 texture_bytes: 2 * 1024 * 1024 * 1024,
407 }
408 }
409
410 pub fn check(&self, frame: &FrameProfile) -> BudgetReport {
411 let mut violations = Vec::new();
412 let frame_ms = frame.cpu_duration().unwrap_or(Duration::ZERO).as_ms();
413 if frame_ms > self.frame_time_ms {
414 violations.push(BudgetViolation {
415 item: "Frame Time".into(),
416 actual: frame_ms,
417 budget: self.frame_time_ms,
418 unit: CounterUnit::Milliseconds,
419 });
420 }
421 BudgetReport {
422 budget_name: self.name.clone(),
423 frame_number: frame.frame_number,
424 violations,
425 within_budget: true,
426 }
427 }
428}
429
430#[derive(Debug, Clone)]
431pub struct BudgetViolation {
432 pub item: String,
433 pub actual: f64,
434 pub budget: f64,
435 pub unit: CounterUnit,
436}
437
438impl BudgetViolation {
439 pub fn overage_pct(&self) -> f64 {
440 if self.budget > 0.0 {
441 (self.actual - self.budget) / self.budget * 100.0
442 } else {
443 0.0
444 }
445 }
446}
447
448impl fmt::Display for BudgetViolation {
449 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
450 write!(f, "{}: {} / {} (+{:.1}%)",
451 self.item,
452 self.unit.format(self.actual),
453 self.unit.format(self.budget),
454 self.overage_pct(),
455 )
456 }
457}
458
459#[derive(Debug, Clone)]
460pub struct BudgetReport {
461 pub budget_name: String,
462 pub frame_number: u64,
463 pub violations: Vec<BudgetViolation>,
464 pub within_budget: bool,
465}
466
467impl BudgetReport {
468 pub fn is_clean(&self) -> bool { self.violations.is_empty() }
469}
470
471#[derive(Debug, Clone)]
474pub struct StatRingBuffer {
475 pub name: String,
476 pub unit: CounterUnit,
477 data: VecDeque<f64>,
478 capacity: usize,
479}
480
481impl StatRingBuffer {
482 pub fn new(name: &str, unit: CounterUnit, capacity: usize) -> Self {
483 Self {
484 name: name.to_string(),
485 unit,
486 data: VecDeque::with_capacity(capacity),
487 capacity,
488 }
489 }
490
491 pub fn push(&mut self, value: f64) {
492 if self.data.len() >= self.capacity {
493 self.data.pop_front();
494 }
495 self.data.push_back(value);
496 }
497
498 pub fn mean(&self) -> f64 {
499 if self.data.is_empty() { return 0.0; }
500 self.data.iter().sum::<f64>() / self.data.len() as f64
501 }
502
503 pub fn min(&self) -> f64 {
504 self.data.iter().cloned().fold(f64::MAX, f64::min)
505 }
506
507 pub fn max(&self) -> f64 {
508 self.data.iter().cloned().fold(f64::MIN, f64::max)
509 }
510
511 pub fn percentile(&self, pct: f64) -> f64 {
512 if self.data.is_empty() { return 0.0; }
513 let mut sorted: Vec<f64> = self.data.iter().cloned().collect();
514 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
515 let idx = ((pct / 100.0) * (sorted.len() - 1) as f64) as usize;
516 sorted[idx.min(sorted.len() - 1)]
517 }
518
519 pub fn latest(&self) -> f64 {
520 self.data.back().copied().unwrap_or(0.0)
521 }
522
523 pub fn sparkline(&self, width: usize, height: usize) -> Vec<Vec<bool>> {
524 let mut grid = vec![vec![false; width]; height];
525 if self.data.is_empty() { return grid; }
526 let max_val = self.max().max(1e-10);
527 let min_val = self.min();
528 let range = (max_val - min_val).max(1e-10);
529 let n = self.data.len();
530 for (i, &v) in self.data.iter().enumerate() {
531 let x = i * width / n.max(1);
532 let y_norm = (v - min_val) / range;
533 let y = ((1.0 - y_norm) * (height - 1) as f64) as usize;
534 if x < width && y < height {
535 grid[y][x] = true;
536 }
537 }
538 grid
539 }
540
541 pub fn ascii_sparkline(&self, width: usize) -> String {
542 const BARS: &[char] = &[' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
543 if self.data.is_empty() { return " ".repeat(width); }
544 let max_val = self.max().max(1e-10);
545 let n = self.data.len().min(width);
546 let skip = self.data.len().saturating_sub(width);
547 self.data.iter().skip(skip).take(n).map(|&v| {
548 let norm = (v / max_val).clamp(0.0, 1.0);
549 BARS[(norm * (BARS.len() - 1) as f64) as usize]
550 }).collect()
551 }
552
553 pub fn len(&self) -> usize { self.data.len() }
554 pub fn is_empty(&self) -> bool { self.data.is_empty() }
555}
556
557pub struct Profiler {
560 pub enabled: bool,
561 pub frame_history: VecDeque<FrameProfile>,
562 pub max_history: usize,
563 pub current_frame: Option<FrameProfile>,
564 pub frame_counter: u64,
565 next_span_id: u32,
566 open_spans: Vec<SpanId>,
567 span_stack: Vec<SpanId>,
568 pub budget: PerformanceBudget,
569 pub budget_violations: Vec<BudgetReport>,
570 pub stats: HashMap<String, StatRingBuffer>,
571 pub capture_flamegraph: bool,
572 pub flamegraph_history: VecDeque<FlamegraphNode>,
573 max_flamegraph_history: usize,
574 sim_time_us: u64, }
576
577impl Profiler {
578 pub fn new() -> Self {
579 let mut p = Self {
580 enabled: true,
581 frame_history: VecDeque::new(),
582 max_history: 120,
583 current_frame: None,
584 frame_counter: 0,
585 next_span_id: 1,
586 open_spans: Vec::new(),
587 span_stack: Vec::new(),
588 budget: PerformanceBudget::default(),
589 budget_violations: Vec::new(),
590 stats: HashMap::new(),
591 capture_flamegraph: true,
592 flamegraph_history: VecDeque::new(),
593 max_flamegraph_history: 30,
594 sim_time_us: 0,
595 };
596 p.register_stat("fps", CounterUnit::Hertz, 128);
597 p.register_stat("frame_ms", CounterUnit::Milliseconds, 128);
598 p.register_stat("cpu_ms", CounterUnit::Milliseconds, 128);
599 p.register_stat("gpu_ms", CounterUnit::Milliseconds, 128);
600 p.register_stat("draw_calls", CounterUnit::DrawCalls, 128);
601 p.register_stat("triangles", CounterUnit::Triangles, 128);
602 p.register_stat("vram", CounterUnit::Bytes, 128);
603 p.register_stat("heap", CounterUnit::Bytes, 128);
604 p.register_stat("particles", CounterUnit::Count, 128);
605 p
606 }
607
608 pub fn register_stat(&mut self, name: &str, unit: CounterUnit, capacity: usize) {
609 self.stats.insert(name.to_string(), StatRingBuffer::new(name, unit, capacity));
610 }
611
612 fn now(&self) -> Timestamp {
613 Timestamp(self.sim_time_us)
614 }
615
616 pub fn advance_sim_time(&mut self, us: u64) {
617 self.sim_time_us += us;
618 }
619
620 pub fn begin_frame(&mut self) {
621 if !self.enabled { return; }
622 let ts = self.now();
623 self.current_frame = Some(FrameProfile::new(self.frame_counter, ts));
624 self.frame_counter += 1;
625 self.open_spans.clear();
626 self.span_stack.clear();
627 }
628
629 pub fn end_frame(&mut self, frame_ms: f64) {
630 if !self.enabled { return; }
631 self.advance_sim_time((frame_ms * 1000.0) as u64);
632 let ts = self.now();
633 if let Some(frame) = &mut self.current_frame {
634 frame.frame_end = Some(ts);
635 }
636 if let Some(frame) = self.current_frame.take() {
637 let report = self.budget.check(&frame);
639 if !report.is_clean() {
640 self.budget_violations.push(report);
641 if self.budget_violations.len() > 100 {
642 self.budget_violations.remove(0);
643 }
644 }
645 if self.capture_flamegraph {
647 let fg = frame.build_flamegraph();
648 if self.flamegraph_history.len() >= self.max_flamegraph_history {
649 self.flamegraph_history.pop_front();
650 }
651 self.flamegraph_history.push_back(fg);
652 }
653 if let Some(cpu_dur) = frame.cpu_duration() {
655 let cpu_ms = cpu_dur.as_ms();
656 if let Some(s) = self.stats.get_mut("frame_ms") { s.push(cpu_ms); }
657 if let Some(s) = self.stats.get_mut("cpu_ms") { s.push(cpu_ms); }
658 if cpu_ms > 0.0 {
659 if let Some(s) = self.stats.get_mut("fps") { s.push(1000.0 / cpu_ms); }
660 }
661 }
662 if self.frame_history.len() >= self.max_history {
664 self.frame_history.pop_front();
665 }
666 self.frame_history.push_back(frame);
667 }
668 }
669
670 pub fn begin_span(&mut self, name: &str, kind: SpanKind) -> SpanId {
671 if !self.enabled { return SpanId(0); }
672 let id = SpanId(self.next_span_id);
673 self.next_span_id += 1;
674 let ts = self.now();
675 let parent = self.span_stack.last().copied();
676 let depth = self.span_stack.len() as u32;
677 let span = ProfileSpan {
678 id, name: name.to_string(), kind,
679 start: ts, end: None,
680 parent, depth, thread: 0, extra: None,
681 };
682 if let Some(frame) = &mut self.current_frame {
683 frame.spans.push(span);
684 }
685 self.span_stack.push(id);
686 self.open_spans.push(id);
687 id
688 }
689
690 pub fn end_span(&mut self, id: SpanId) {
691 if !self.enabled { return; }
692 let ts = self.now();
693 if let Some(frame) = &mut self.current_frame {
694 if let Some(span) = frame.spans.iter_mut().find(|s| s.id == id) {
695 span.end = Some(ts);
696 }
697 }
698 self.span_stack.retain(|&s| s != id);
699 self.open_spans.retain(|&s| s != id);
700 }
701
702 pub fn record_counter(&mut self, name: &str, value: f64, unit: CounterUnit) {
703 if let Some(s) = self.stats.get_mut(name) {
704 s.push(value);
705 } else {
706 self.register_stat(name, unit, 128);
707 if let Some(s) = self.stats.get_mut(name) {
708 s.push(value);
709 }
710 }
711 if let Some(frame) = &mut self.current_frame {
712 frame.counters.insert(name.to_string(), CounterSample {
713 name: name.to_string(),
714 value,
715 unit,
716 min: value,
717 max: value,
718 });
719 }
720 }
721
722 pub fn record_memory(&mut self, mem: MemorySnapshot) {
723 if let Some(s) = self.stats.get_mut("vram") {
724 s.push(mem.gpu_vram_bytes as f64);
725 }
726 if let Some(s) = self.stats.get_mut("heap") {
727 s.push(mem.heap_bytes as f64);
728 }
729 if let Some(frame) = &mut self.current_frame {
730 frame.memory = mem;
731 }
732 }
733
734 pub fn fps(&self) -> f64 {
735 self.stats.get("fps").map(|s| s.latest()).unwrap_or(0.0)
736 }
737
738 pub fn frame_ms(&self) -> f64 {
739 self.stats.get("frame_ms").map(|s| s.latest()).unwrap_or(0.0)
740 }
741
742 pub fn mean_fps(&self) -> f64 {
743 self.stats.get("fps").map(|s| s.mean()).unwrap_or(0.0)
744 }
745
746 pub fn p95_frame_ms(&self) -> f64 {
747 self.stats.get("frame_ms").map(|s| s.percentile(95.0)).unwrap_or(0.0)
748 }
749
750 pub fn latest_frame(&self) -> Option<&FrameProfile> {
751 self.frame_history.back()
752 }
753
754 pub fn latest_flamegraph(&self) -> Option<&FlamegraphNode> {
755 self.flamegraph_history.back()
756 }
757
758 pub fn stat(&self, name: &str) -> Option<&StatRingBuffer> {
759 self.stats.get(name)
760 }
761
762 pub fn summary_line(&self) -> String {
763 let fps = self.fps();
764 let ms = self.frame_ms();
765 let p95 = self.p95_frame_ms();
766 format!("FPS: {:.0} avg: {:.2}ms p95: {:.2}ms", fps, ms, p95)
767 }
768
769 pub fn detailed_summary(&self) -> String {
770 let mut out = String::new();
771 out.push_str(&format!("=== Profiler ({} frames) ===\n", self.frame_history.len()));
772 out.push_str(&format!("FPS: {:.1} Frame: {} p95: {}\n",
773 self.fps(),
774 Duration::from_ms(self.frame_ms()),
775 Duration::from_ms(self.p95_frame_ms()),
776 ));
777 for (name, stat) in &self.stats {
778 if stat.is_empty() { continue; }
779 out.push_str(&format!(
780 " {:<20} latest={:<12} mean={:<12} p95={:<12} {}\n",
781 name,
782 stat.unit.format(stat.latest()),
783 stat.unit.format(stat.mean()),
784 stat.unit.format(stat.percentile(95.0)),
785 stat.ascii_sparkline(20),
786 ));
787 }
788 if !self.budget_violations.is_empty() {
789 let recent = &self.budget_violations[self.budget_violations.len().saturating_sub(3)..];
790 out.push_str(&format!("⚠ {} budget violations (last 3 frames):\n",
791 self.budget_violations.len()));
792 for report in recent {
793 for v in &report.violations {
794 out.push_str(&format!(" frame {}: {}\n", report.frame_number, v));
795 }
796 }
797 }
798 if let Some(fg) = self.latest_flamegraph() {
799 out.push_str("--- Last Flamegraph ---\n");
800 out.push_str(&fg.ascii_tree(0));
801 }
802 out
803 }
804
805 pub fn reset_stats(&mut self) {
806 for stat in self.stats.values_mut() {
807 stat.data.clear();
808 }
809 self.budget_violations.clear();
810 self.frame_history.clear();
811 }
812}
813
814impl Default for Profiler {
815 fn default() -> Self { Self::new() }
816}
817
818pub struct SpanGuard<'a> {
823 profiler: &'a mut Profiler,
824 id: SpanId,
825}
826
827impl<'a> SpanGuard<'a> {
828 pub fn new(profiler: &'a mut Profiler, name: &str, kind: SpanKind) -> Self {
829 let id = profiler.begin_span(name, kind);
830 Self { profiler, id }
831 }
832}
833
834impl<'a> Drop for SpanGuard<'a> {
835 fn drop(&mut self) {
836 self.profiler.end_span(self.id);
837 }
838}
839
840#[cfg(test)]
843mod tests {
844 use super::*;
845
846 #[test]
847 fn duration_display() {
848 assert_eq!(Duration::from_us(500).to_string(), "500µs");
849 assert_eq!(Duration::from_ms(2.5).to_string(), "2.50ms");
850 }
851
852 #[test]
853 fn stat_ring_mean() {
854 let mut buf = StatRingBuffer::new("test", CounterUnit::Count, 4);
855 buf.push(1.0); buf.push(2.0); buf.push(3.0); buf.push(4.0);
856 assert!((buf.mean() - 2.5).abs() < 1e-9);
857 }
858
859 #[test]
860 fn stat_ring_capacity() {
861 let mut buf = StatRingBuffer::new("test", CounterUnit::Count, 3);
862 buf.push(1.0); buf.push(2.0); buf.push(3.0); buf.push(4.0);
863 assert_eq!(buf.len(), 3);
864 assert_eq!(buf.latest(), 4.0);
865 }
866
867 #[test]
868 fn stat_percentile() {
869 let mut buf = StatRingBuffer::new("p", CounterUnit::Count, 100);
870 for i in 0..100 { buf.push(i as f64); }
871 let p95 = buf.percentile(95.0);
872 assert!(p95 >= 90.0 && p95 <= 100.0);
873 }
874
875 #[test]
876 fn profiler_frame_cycle() {
877 let mut p = Profiler::new();
878 p.begin_frame();
879 let id = p.begin_span("RenderScene", SpanKind::Cpu);
880 p.advance_sim_time(8000); p.end_span(id);
882 p.end_frame(8.0);
883 assert_eq!(p.frame_history.len(), 1);
884 let frame = p.frame_history.back().unwrap();
885 let dur = frame.cpu_duration().unwrap();
886 assert!(dur.as_ms() > 0.0);
887 }
888
889 #[test]
890 fn flamegraph_tree() {
891 let mut p = Profiler::new();
892 p.begin_frame();
893 let root = p.begin_span("Root", SpanKind::Cpu);
894 p.advance_sim_time(1000);
895 let child = p.begin_span("Child", SpanKind::Cpu);
896 p.advance_sim_time(500);
897 p.end_span(child);
898 p.end_span(root);
899 p.end_frame(1.5);
900 let fg = p.latest_flamegraph().unwrap();
901 assert!(!fg.children.is_empty());
902 }
903
904 #[test]
905 fn budget_violation_overage() {
906 let budget = PerformanceBudget {
907 frame_time_ms: 16.0,
908 ..Default::default()
909 };
910 let mut frame = FrameProfile::new(0, Timestamp(0));
911 frame.frame_end = Some(Timestamp(20_000)); let report = budget.check(&frame);
913 assert!(!report.violations.is_empty());
914 assert!(report.violations[0].overage_pct() > 0.0);
915 }
916
917 #[test]
918 fn format_bytes() {
919 assert_eq!(super::format_bytes(1024), "1 KB");
920 assert_eq!(super::format_bytes(1024*1024), "1.0 MB");
921 assert_eq!(super::format_bytes(512), "512 B");
922 }
923}