Skip to main content

oximedia_gpu/
gpu_timer.rs

1#![allow(dead_code)]
2//! GPU timing and profiling utilities.
3//!
4//! This module provides high-resolution timing infrastructure for measuring
5//! GPU operation latencies, frame times, and pipeline stage durations.
6//! It maintains a rolling history for statistical analysis.
7
8use std::collections::VecDeque;
9use std::time::{Duration, Instant};
10
11/// A named timing region for GPU profiling.
12#[derive(Debug, Clone)]
13pub struct TimerRegion {
14    /// Human-readable label for this region.
15    pub label: String,
16    /// Start timestamp.
17    pub start: Instant,
18    /// End timestamp (set when the region is stopped).
19    pub end: Option<Instant>,
20}
21
22impl TimerRegion {
23    /// Create a new timer region with the given label. Starts immediately.
24    #[must_use]
25    pub fn start(label: &str) -> Self {
26        Self {
27            label: label.to_string(),
28            start: Instant::now(),
29            end: None,
30        }
31    }
32
33    /// Stop the timer region.
34    pub fn stop(&mut self) {
35        self.end = Some(Instant::now());
36    }
37
38    /// Return the elapsed duration, or duration since start if still running.
39    #[must_use]
40    pub fn elapsed(&self) -> Duration {
41        match self.end {
42            Some(end) => end.duration_since(self.start),
43            None => self.start.elapsed(),
44        }
45    }
46
47    /// Check if the region has been stopped.
48    #[must_use]
49    pub fn is_stopped(&self) -> bool {
50        self.end.is_some()
51    }
52}
53
54/// A single timing sample with label and duration.
55#[derive(Debug, Clone)]
56pub struct TimingSample {
57    /// Label identifying what was timed.
58    pub label: String,
59    /// Measured duration.
60    pub duration: Duration,
61    /// Frame number when this sample was taken.
62    pub frame_number: u64,
63}
64
65/// Configuration for the GPU timer.
66#[derive(Debug, Clone)]
67pub struct GpuTimerConfig {
68    /// Maximum number of samples to keep in the rolling history.
69    pub max_history: usize,
70    /// Whether to enable timing collection.
71    pub enabled: bool,
72    /// Target frame time for performance budgeting.
73    pub target_frame_time: Duration,
74}
75
76impl Default for GpuTimerConfig {
77    fn default() -> Self {
78        Self {
79            max_history: 300,
80            enabled: true,
81            target_frame_time: Duration::from_micros(16_667), // ~60 FPS
82        }
83    }
84}
85
86/// Statistical summary of timing data.
87#[derive(Debug, Clone)]
88pub struct TimingStats {
89    /// Minimum duration in the sample window.
90    pub min: Duration,
91    /// Maximum duration in the sample window.
92    pub max: Duration,
93    /// Mean (average) duration.
94    pub mean: Duration,
95    /// Median duration.
96    pub median: Duration,
97    /// 95th percentile duration.
98    pub p95: Duration,
99    /// 99th percentile duration.
100    pub p99: Duration,
101    /// Standard deviation in microseconds.
102    pub std_dev_us: f64,
103    /// Number of samples.
104    pub sample_count: usize,
105}
106
107impl TimingStats {
108    /// Compute timing statistics from a slice of durations.
109    #[allow(clippy::cast_precision_loss)]
110    #[must_use]
111    pub fn from_durations(durations: &[Duration]) -> Option<Self> {
112        if durations.is_empty() {
113            return None;
114        }
115
116        let mut sorted: Vec<Duration> = durations.to_vec();
117        sorted.sort();
118
119        let count = sorted.len();
120        let min = sorted[0];
121        let max = sorted[count - 1];
122        let median = sorted[count / 2];
123
124        let sum_us: f64 = sorted.iter().map(|d| d.as_micros() as f64).sum();
125        let mean_us = sum_us / count as f64;
126        let mean = Duration::from_micros(mean_us as u64);
127
128        let p95_idx = ((count as f64) * 0.95).ceil() as usize;
129        let p95 = sorted[p95_idx.min(count - 1)];
130
131        let p99_idx = ((count as f64) * 0.99).ceil() as usize;
132        let p99 = sorted[p99_idx.min(count - 1)];
133
134        let variance: f64 = sorted
135            .iter()
136            .map(|d| {
137                let diff = d.as_micros() as f64 - mean_us;
138                diff * diff
139            })
140            .sum::<f64>()
141            / count as f64;
142        let std_dev_us = variance.sqrt();
143
144        Some(Self {
145            min,
146            max,
147            mean,
148            median,
149            p95,
150            p99,
151            std_dev_us,
152            sample_count: count,
153        })
154    }
155
156    /// Return mean as frames per second equivalent.
157    #[allow(clippy::cast_precision_loss)]
158    #[must_use]
159    pub fn mean_fps(&self) -> f64 {
160        let mean_secs = self.mean.as_secs_f64();
161        if mean_secs > 0.0 {
162            1.0 / mean_secs
163        } else {
164            0.0
165        }
166    }
167}
168
169/// Frame time tracker that measures per-frame GPU durations.
170#[derive(Debug, Clone)]
171pub struct FrameTimer {
172    /// Rolling history of frame durations.
173    history: VecDeque<Duration>,
174    /// Maximum history size.
175    max_history: usize,
176    /// Current frame start time.
177    frame_start: Option<Instant>,
178    /// Total frames measured.
179    total_frames: u64,
180}
181
182impl FrameTimer {
183    /// Create a new frame timer with the given history capacity.
184    #[must_use]
185    pub fn new(max_history: usize) -> Self {
186        Self {
187            history: VecDeque::with_capacity(max_history),
188            max_history,
189            frame_start: None,
190            total_frames: 0,
191        }
192    }
193
194    /// Mark the start of a frame.
195    pub fn begin_frame(&mut self) {
196        self.frame_start = Some(Instant::now());
197    }
198
199    /// Mark the end of a frame, recording the duration.
200    pub fn end_frame(&mut self) -> Option<Duration> {
201        let start = self.frame_start.take()?;
202        let duration = start.elapsed();
203        if self.history.len() >= self.max_history {
204            self.history.pop_front();
205        }
206        self.history.push_back(duration);
207        self.total_frames += 1;
208        Some(duration)
209    }
210
211    /// Return the latest frame duration.
212    #[must_use]
213    pub fn last_frame_time(&self) -> Option<Duration> {
214        self.history.back().copied()
215    }
216
217    /// Return the average frame time over the history window.
218    #[allow(clippy::cast_precision_loss)]
219    #[must_use]
220    pub fn average_frame_time(&self) -> Option<Duration> {
221        if self.history.is_empty() {
222            return None;
223        }
224        let sum: Duration = self.history.iter().sum();
225        Some(sum / self.history.len() as u32)
226    }
227
228    /// Return the current FPS based on average frame time.
229    #[must_use]
230    pub fn current_fps(&self) -> Option<f64> {
231        self.average_frame_time().map(|avg| 1.0 / avg.as_secs_f64())
232    }
233
234    /// Return the total number of frames measured.
235    #[must_use]
236    pub fn total_frames(&self) -> u64 {
237        self.total_frames
238    }
239
240    /// Return statistics over the history window.
241    #[must_use]
242    pub fn stats(&self) -> Option<TimingStats> {
243        let durations: Vec<Duration> = self.history.iter().copied().collect();
244        TimingStats::from_durations(&durations)
245    }
246
247    /// Clear the frame history.
248    pub fn clear(&mut self) {
249        self.history.clear();
250        self.frame_start = None;
251    }
252
253    /// Return the number of samples in the history.
254    #[must_use]
255    pub fn history_len(&self) -> usize {
256        self.history.len()
257    }
258}
259
260/// High-level GPU timer that manages multiple named timing regions.
261pub struct GpuTimer {
262    /// Active timing regions.
263    active_regions: Vec<TimerRegion>,
264    /// History of timing samples organized by label.
265    samples: VecDeque<TimingSample>,
266    /// Frame timer for per-frame tracking.
267    frame_timer: FrameTimer,
268    /// Configuration.
269    config: GpuTimerConfig,
270    /// Current frame number.
271    current_frame: u64,
272}
273
274impl GpuTimer {
275    /// Create a new GPU timer with default configuration.
276    #[must_use]
277    pub fn new() -> Self {
278        Self::with_config(GpuTimerConfig::default())
279    }
280
281    /// Create a new GPU timer with the given configuration.
282    #[must_use]
283    pub fn with_config(config: GpuTimerConfig) -> Self {
284        let max_history = config.max_history;
285        Self {
286            active_regions: Vec::new(),
287            samples: VecDeque::with_capacity(max_history),
288            frame_timer: FrameTimer::new(max_history),
289            config,
290            current_frame: 0,
291        }
292    }
293
294    /// Begin a named timing region. Returns the index for stopping it later.
295    pub fn begin_region(&mut self, label: &str) -> usize {
296        if !self.config.enabled {
297            return 0;
298        }
299        let region = TimerRegion::start(label);
300        self.active_regions.push(region);
301        self.active_regions.len() - 1
302    }
303
304    /// End a timing region by index, recording the sample.
305    pub fn end_region(&mut self, index: usize) -> Option<Duration> {
306        if !self.config.enabled || index >= self.active_regions.len() {
307            return None;
308        }
309        self.active_regions[index].stop();
310        let region = &self.active_regions[index];
311        let duration = region.elapsed();
312        let sample = TimingSample {
313            label: region.label.clone(),
314            duration,
315            frame_number: self.current_frame,
316        };
317        if self.samples.len() >= self.config.max_history {
318            self.samples.pop_front();
319        }
320        self.samples.push_back(sample);
321        Some(duration)
322    }
323
324    /// Begin a new frame for the frame timer.
325    pub fn begin_frame(&mut self) {
326        self.current_frame += 1;
327        self.frame_timer.begin_frame();
328        self.active_regions.clear();
329    }
330
331    /// End the current frame.
332    pub fn end_frame(&mut self) -> Option<Duration> {
333        self.frame_timer.end_frame()
334    }
335
336    /// Get timing statistics for a specific label.
337    #[must_use]
338    pub fn stats_for_label(&self, label: &str) -> Option<TimingStats> {
339        let durations: Vec<Duration> = self
340            .samples
341            .iter()
342            .filter(|s| s.label == label)
343            .map(|s| s.duration)
344            .collect();
345        TimingStats::from_durations(&durations)
346    }
347
348    /// Get the frame timer statistics.
349    #[must_use]
350    pub fn frame_stats(&self) -> Option<TimingStats> {
351        self.frame_timer.stats()
352    }
353
354    /// Get the current FPS.
355    #[must_use]
356    pub fn current_fps(&self) -> Option<f64> {
357        self.frame_timer.current_fps()
358    }
359
360    /// Check if the average frame time exceeds the target.
361    #[must_use]
362    pub fn is_over_budget(&self) -> bool {
363        self.frame_timer
364            .average_frame_time()
365            .is_some_and(|avg| avg > self.config.target_frame_time)
366    }
367
368    /// Return all unique labels that have been recorded.
369    #[must_use]
370    pub fn labels(&self) -> Vec<String> {
371        let mut labels: Vec<String> = self
372            .samples
373            .iter()
374            .map(|s| s.label.clone())
375            .collect::<std::collections::HashSet<_>>()
376            .into_iter()
377            .collect();
378        labels.sort();
379        labels
380    }
381
382    /// Return the total number of samples recorded.
383    #[must_use]
384    pub fn sample_count(&self) -> usize {
385        self.samples.len()
386    }
387
388    /// Return the current frame number.
389    #[must_use]
390    pub fn current_frame_number(&self) -> u64 {
391        self.current_frame
392    }
393
394    /// Check if timing collection is enabled.
395    #[must_use]
396    pub fn is_enabled(&self) -> bool {
397        self.config.enabled
398    }
399
400    /// Enable or disable timing collection.
401    pub fn set_enabled(&mut self, enabled: bool) {
402        self.config.enabled = enabled;
403    }
404
405    /// Clear all samples and reset the timer.
406    pub fn reset(&mut self) {
407        self.active_regions.clear();
408        self.samples.clear();
409        self.frame_timer.clear();
410        self.current_frame = 0;
411    }
412}
413
414impl Default for GpuTimer {
415    fn default() -> Self {
416        Self::new()
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn test_timer_region_start_stop() {
426        let mut region = TimerRegion::start("test");
427        assert!(!region.is_stopped());
428        region.stop();
429        assert!(region.is_stopped());
430        assert!(region.elapsed() < Duration::from_secs(1));
431    }
432
433    #[test]
434    fn test_timer_region_label() {
435        let region = TimerRegion::start("my_region");
436        assert_eq!(region.label, "my_region");
437    }
438
439    #[test]
440    fn test_timing_stats_basic() {
441        let durations = vec![
442            Duration::from_micros(100),
443            Duration::from_micros(200),
444            Duration::from_micros(300),
445            Duration::from_micros(400),
446            Duration::from_micros(500),
447        ];
448        let stats = TimingStats::from_durations(&durations)
449            .expect("from_durations should succeed with valid durations");
450        assert_eq!(stats.min, Duration::from_micros(100));
451        assert_eq!(stats.max, Duration::from_micros(500));
452        assert_eq!(stats.sample_count, 5);
453        assert_eq!(stats.median, Duration::from_micros(300));
454    }
455
456    #[test]
457    fn test_timing_stats_empty() {
458        let result = TimingStats::from_durations(&[]);
459        assert!(result.is_none());
460    }
461
462    #[test]
463    fn test_timing_stats_single() {
464        let durations = vec![Duration::from_millis(1)];
465        let stats = TimingStats::from_durations(&durations)
466            .expect("from_durations should succeed with valid durations");
467        assert_eq!(stats.min, stats.max);
468        assert_eq!(stats.sample_count, 1);
469        assert!((stats.std_dev_us - 0.0).abs() < 0.001);
470    }
471
472    #[test]
473    fn test_timing_stats_mean_fps() {
474        let durations = vec![Duration::from_millis(16), Duration::from_millis(17)];
475        let stats = TimingStats::from_durations(&durations)
476            .expect("from_durations should succeed with valid durations");
477        let fps = stats.mean_fps();
478        assert!(fps > 50.0 && fps < 70.0);
479    }
480
481    #[test]
482    fn test_frame_timer_basic() {
483        let mut timer = FrameTimer::new(100);
484        timer.begin_frame();
485        let dur = timer.end_frame();
486        assert!(dur.is_some());
487        assert_eq!(timer.total_frames(), 1);
488    }
489
490    #[test]
491    fn test_frame_timer_history_limit() {
492        let mut timer = FrameTimer::new(3);
493        for _ in 0..5 {
494            timer.begin_frame();
495            timer.end_frame();
496        }
497        assert_eq!(timer.history_len(), 3);
498        assert_eq!(timer.total_frames(), 5);
499    }
500
501    #[test]
502    fn test_frame_timer_clear() {
503        let mut timer = FrameTimer::new(100);
504        timer.begin_frame();
505        timer.end_frame();
506        timer.clear();
507        assert_eq!(timer.history_len(), 0);
508        assert!(timer.last_frame_time().is_none());
509    }
510
511    #[test]
512    fn test_frame_timer_no_begin() {
513        let mut timer = FrameTimer::new(100);
514        let dur = timer.end_frame();
515        assert!(dur.is_none());
516    }
517
518    #[test]
519    fn test_gpu_timer_create() {
520        let timer = GpuTimer::new();
521        assert!(timer.is_enabled());
522        assert_eq!(timer.sample_count(), 0);
523    }
524
525    #[test]
526    fn test_gpu_timer_region() {
527        let mut timer = GpuTimer::new();
528        let idx = timer.begin_region("vertex_shader");
529        let dur = timer.end_region(idx);
530        assert!(dur.is_some());
531        assert_eq!(timer.sample_count(), 1);
532    }
533
534    #[test]
535    fn test_gpu_timer_frame_cycle() {
536        let mut timer = GpuTimer::new();
537        timer.begin_frame();
538        let _idx = timer.begin_region("pass1");
539        timer.end_region(0);
540        let frame_dur = timer.end_frame();
541        assert!(frame_dur.is_some());
542        assert_eq!(timer.current_frame_number(), 1);
543    }
544
545    #[test]
546    fn test_gpu_timer_labels() {
547        let mut timer = GpuTimer::new();
548        let i1 = timer.begin_region("alpha");
549        timer.end_region(i1);
550        let i2 = timer.begin_region("beta");
551        timer.end_region(i2);
552        let labels = timer.labels();
553        assert_eq!(labels.len(), 2);
554        assert!(labels.contains(&"alpha".to_string()));
555        assert!(labels.contains(&"beta".to_string()));
556    }
557
558    #[test]
559    fn test_gpu_timer_disabled() {
560        let config = GpuTimerConfig {
561            enabled: false,
562            ..Default::default()
563        };
564        let mut timer = GpuTimer::with_config(config);
565        assert!(!timer.is_enabled());
566        let idx = timer.begin_region("test");
567        assert_eq!(idx, 0);
568        let dur = timer.end_region(idx);
569        assert!(dur.is_none());
570    }
571
572    #[test]
573    fn test_gpu_timer_reset() {
574        let mut timer = GpuTimer::new();
575        timer.begin_frame();
576        let idx = timer.begin_region("test");
577        timer.end_region(idx);
578        timer.end_frame();
579        timer.reset();
580        assert_eq!(timer.sample_count(), 0);
581        assert_eq!(timer.current_frame_number(), 0);
582    }
583
584    #[test]
585    fn test_gpu_timer_set_enabled() {
586        let mut timer = GpuTimer::new();
587        assert!(timer.is_enabled());
588        timer.set_enabled(false);
589        assert!(!timer.is_enabled());
590    }
591
592    #[test]
593    fn test_gpu_timer_stats_for_label() {
594        let mut timer = GpuTimer::new();
595        for _ in 0..5 {
596            let idx = timer.begin_region("compute");
597            timer.end_region(idx);
598        }
599        let stats = timer.stats_for_label("compute");
600        assert!(stats.is_some());
601        assert_eq!(stats.expect("stats should be available").sample_count, 5);
602    }
603
604    #[test]
605    fn test_gpu_timer_over_budget() {
606        let config = GpuTimerConfig {
607            target_frame_time: Duration::from_nanos(1), // impossibly small
608            ..Default::default()
609        };
610        let mut timer = GpuTimer::with_config(config);
611        timer.begin_frame();
612        // Spin briefly
613        let _x: u64 = (0..1000).sum();
614        timer.end_frame();
615        assert!(timer.is_over_budget());
616    }
617}