Skip to main content

proof_engine/debug/
profiler.rs

1//! Frame-level CPU timing profiler.
2//!
3//! Records named timing spans across a rolling window of frames.
4//! Use `FrameProfiler::begin("name")` / `FrameProfiler::end("name")` around
5//! sections you want to profile.
6
7use std::collections::HashMap;
8use std::time::Instant;
9
10// ── TimingSpan ────────────────────────────────────────────────────────────────
11
12/// A single timing measurement.
13#[derive(Clone, Debug)]
14pub struct TimingSpan {
15    pub name:           String,
16    pub duration_us:    u64,   // microseconds
17}
18
19// ── SpanAccumulator ───────────────────────────────────────────────────────────
20
21struct SpanAccumulator {
22    start:     Instant,
23    samples:   Vec<u64>,
24    capacity:  usize,
25    head:      usize,
26    filled:    bool,
27}
28
29impl SpanAccumulator {
30    fn new(capacity: usize) -> Self {
31        Self {
32            start:    Instant::now(),
33            samples:  vec![0; capacity],
34            capacity,
35            head:     0,
36            filled:   false,
37        }
38    }
39
40    fn begin(&mut self) { self.start = Instant::now(); }
41
42    fn end(&mut self) {
43        let dur = self.start.elapsed().as_micros() as u64;
44        self.samples[self.head] = dur;
45        self.head = (self.head + 1) % self.capacity;
46        if self.head == 0 { self.filled = true; }
47    }
48
49    fn avg_us(&self) -> u64 {
50        let count = if self.filled { self.capacity } else { self.head.max(1) };
51        let sum: u64 = self.samples[..count].iter().sum();
52        sum / count as u64
53    }
54
55    fn max_us(&self) -> u64 {
56        let count = if self.filled { self.capacity } else { self.head.max(1) };
57        self.samples[..count].iter().cloned().max().unwrap_or(0)
58    }
59
60    fn min_us(&self) -> u64 {
61        let count = if self.filled { self.capacity } else { self.head.max(1) };
62        self.samples[..count].iter().cloned().min().unwrap_or(0)
63    }
64
65    fn last_us(&self) -> u64 {
66        let idx = if self.head == 0 { self.capacity - 1 } else { self.head - 1 };
67        self.samples[idx]
68    }
69}
70
71// ── FrameProfiler ─────────────────────────────────────────────────────────────
72
73/// Rolling-window CPU frame profiler.
74///
75/// Spans are identified by string name. Begin/end calls must be paired.
76/// Stats are available as averages, min, max, and last value.
77pub struct FrameProfiler {
78    spans:         HashMap<String, SpanAccumulator>,
79    window_frames: usize,
80    /// Total frame time (span "frame").
81    frame_start:   Instant,
82    frame_count:   u64,
83}
84
85impl FrameProfiler {
86    /// Create a profiler with a rolling window of `window_frames` frames.
87    pub fn new(window_frames: usize) -> Self {
88        Self {
89            spans:         HashMap::new(),
90            window_frames: window_frames.max(1),
91            frame_start:   Instant::now(),
92            frame_count:   0,
93        }
94    }
95
96    /// Begin timing a named span.
97    pub fn begin(&mut self, name: &str) {
98        let cap = self.window_frames;
99        self.spans.entry(name.to_string())
100            .or_insert_with(|| SpanAccumulator::new(cap))
101            .begin();
102    }
103
104    /// End timing a named span.
105    pub fn end(&mut self, name: &str) {
106        if let Some(acc) = self.spans.get_mut(name) {
107            acc.end();
108        }
109    }
110
111    /// Mark the start of a new frame (measures total frame time under "frame").
112    pub fn begin_frame(&mut self) {
113        self.frame_start = Instant::now();
114        self.begin("frame");
115        self.frame_count += 1;
116    }
117
118    /// Mark the end of a frame.
119    pub fn end_frame(&mut self) {
120        self.end("frame");
121    }
122
123    /// Average duration of a span in microseconds.
124    pub fn avg_us(&self, name: &str) -> u64 {
125        self.spans.get(name).map(|s| s.avg_us()).unwrap_or(0)
126    }
127
128    /// Maximum duration of a span in microseconds.
129    pub fn max_us(&self, name: &str) -> u64 {
130        self.spans.get(name).map(|s| s.max_us()).unwrap_or(0)
131    }
132
133    /// Minimum duration of a span in microseconds.
134    pub fn min_us(&self, name: &str) -> u64 {
135        self.spans.get(name).map(|s| s.min_us()).unwrap_or(0)
136    }
137
138    /// Last recorded duration of a span in microseconds.
139    pub fn last_us(&self, name: &str) -> u64 {
140        self.spans.get(name).map(|s| s.last_us()).unwrap_or(0)
141    }
142
143    /// Average FPS derived from the "frame" span.
144    pub fn fps(&self) -> f32 {
145        let avg_us = self.avg_us("frame");
146        if avg_us == 0 { return 0.0; }
147        1_000_000.0 / avg_us as f32
148    }
149
150    /// Total frame count since creation.
151    pub fn frame_count(&self) -> u64 { self.frame_count }
152
153    /// Sorted list of (name, avg_us) for all tracked spans.
154    pub fn report(&self) -> Vec<(&str, u64)> {
155        let mut out: Vec<(&str, u64)> = self.spans.iter()
156            .map(|(name, acc)| (name.as_str(), acc.avg_us()))
157            .collect();
158        out.sort_by(|a, b| b.1.cmp(&a.1));
159        out
160    }
161
162    /// Format the profiler report as a multi-line string.
163    pub fn format_report(&self) -> String {
164        let mut lines = Vec::new();
165        lines.push(format!("=== Frame Profiler ({}f window) ===", self.window_frames));
166        lines.push(format!("Frame #{} — FPS: {:.1}", self.frame_count, self.fps()));
167        for (name, avg_us) in self.report() {
168            let max_us = self.max_us(name);
169            let last   = self.last_us(name);
170            lines.push(format!("  {:20} avg={:>6}µs  max={:>6}µs  last={:>6}µs",
171                               name, avg_us, max_us, last));
172        }
173        lines.join("\n")
174    }
175
176    /// Reset all span accumulators.
177    pub fn reset(&mut self) {
178        self.spans.clear();
179        self.frame_count = 0;
180    }
181
182    /// Returns true if the "frame" span average exceeds `budget_ms` milliseconds.
183    pub fn over_budget(&self, budget_ms: f32) -> bool {
184        self.avg_us("frame") > (budget_ms * 1000.0) as u64
185    }
186}
187
188// ── ScopedSpan ────────────────────────────────────────────────────────────────
189
190/// RAII guard that calls `begin` on construction and `end` on drop.
191///
192/// ```rust,no_run
193/// use proof_engine::debug::profiler::{FrameProfiler, ScopedSpan};
194/// let mut profiler = FrameProfiler::new(60);
195/// {
196///     let _span = ScopedSpan::new(&mut profiler, "my_section");
197///     // ... work ...
198/// } // end() called automatically
199/// ```
200pub struct ScopedSpan<'a> {
201    profiler: &'a mut FrameProfiler,
202    name:     String,
203}
204
205impl<'a> ScopedSpan<'a> {
206    pub fn new(profiler: &'a mut FrameProfiler, name: &str) -> Self {
207        profiler.begin(name);
208        Self { profiler, name: name.to_string() }
209    }
210}
211
212impl<'a> Drop for ScopedSpan<'a> {
213    fn drop(&mut self) {
214        self.profiler.end(&self.name);
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use std::thread;
222    use std::time::Duration;
223
224    #[test]
225    fn profiler_measures_sleep() {
226        let mut p = FrameProfiler::new(10);
227        p.begin("sleep");
228        thread::sleep(Duration::from_millis(5));
229        p.end("sleep");
230        let avg = p.avg_us("sleep");
231        assert!(avg >= 3000, "expected >= 3ms sleep, got {}µs", avg);
232    }
233
234    #[test]
235    fn profiler_fps() {
236        let mut p = FrameProfiler::new(5);
237        for _ in 0..5 {
238            p.begin("frame");
239            thread::sleep(Duration::from_millis(16));
240            p.end("frame");
241        }
242        let fps = p.fps();
243        assert!(fps > 40.0 && fps < 100.0, "FPS should be ~60 ±40, got {}", fps);
244    }
245
246    #[test]
247    fn report_sorted_by_duration() {
248        let mut p = FrameProfiler::new(4);
249        p.begin("slow"); thread::sleep(Duration::from_millis(10)); p.end("slow");
250        p.begin("fast"); thread::sleep(Duration::from_millis(1));  p.end("fast");
251        let report = p.report();
252        assert_eq!(report[0].0, "slow");
253    }
254
255    #[test]
256    fn scoped_span_ends_on_drop() {
257        let mut p = FrameProfiler::new(4);
258        {
259            let _s = ScopedSpan::new(&mut p, "scoped");
260            thread::sleep(Duration::from_millis(2));
261        }
262        assert!(p.last_us("scoped") >= 1000, "scoped span should have measured time");
263    }
264}