khora_infra/telemetry/
gpu_monitor.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! GPU performance monitoring.
16
17use std::borrow::Cow;
18use std::sync::Mutex;
19
20use khora_core::renderer::api::core::{GpuHook, RenderStats};
21use khora_core::telemetry::monitoring::{
22    GpuReport, MonitoredResourceType, ResourceMonitor, ResourceUsageReport,
23};
24
25/// GPU performance monitor that works with any RenderSystem implementation.
26#[derive(Debug)]
27pub struct GpuMonitor {
28    system_name: String,
29    last_frame_stats: Mutex<Option<GpuReport>>,
30}
31
32impl GpuMonitor {
33    /// Create a new GPU performance monitor
34    pub fn new(system_name: String) -> Self {
35        Self {
36            system_name,
37            last_frame_stats: Mutex::new(None),
38        }
39    }
40
41    /// Returns the latest detailed GPU performance report.
42    pub fn get_gpu_report(&self) -> Option<GpuReport> {
43        *self.last_frame_stats.lock().unwrap()
44    }
45
46    /// Update performance stats from frame timing data
47    pub fn update_from_frame_stats(&self, render_stats: &RenderStats) {
48        // Create hook timings based on render stats
49        // We simulate the timeline: FrameStart -> MainPassBegin -> MainPassEnd -> FrameEnd
50        let frame_start_us = 0u32; // Start of frame timeline
51        let frame_end_us = (render_stats.gpu_frame_total_time_ms * 1000.0) as u32;
52        let main_pass_duration_us = (render_stats.gpu_main_pass_time_ms * 1000.0) as u32;
53
54        // Place main pass in the middle of the frame for simplicity
55        let main_pass_begin_us = (frame_end_us - main_pass_duration_us) / 2;
56        let main_pass_end_us = main_pass_begin_us + main_pass_duration_us;
57
58        let mut hook_timings = [None; 4];
59        hook_timings[GpuHook::FrameStart as usize] = Some(frame_start_us);
60        hook_timings[GpuHook::MainPassBegin as usize] = Some(main_pass_begin_us);
61        hook_timings[GpuHook::MainPassEnd as usize] = Some(main_pass_end_us);
62        hook_timings[GpuHook::FrameEnd as usize] = Some(frame_end_us);
63
64        let report = GpuReport {
65            frame_number: render_stats.frame_number,
66            hook_timings_us: hook_timings,
67            // Convert milliseconds to microseconds
68            cpu_preparation_time_us: Some((render_stats.cpu_preparation_time_ms * 1000.0) as u32),
69            cpu_submission_time_us: Some(
70                (render_stats.cpu_render_submission_time_ms * 1000.0) as u32,
71            ),
72            draw_calls: render_stats.draw_calls,
73            triangles_rendered: render_stats.triangles_rendered,
74        };
75
76        let mut last_stats = self.last_frame_stats.lock().unwrap();
77        *last_stats = Some(report);
78    }
79}
80
81impl ResourceMonitor for GpuMonitor {
82    fn monitor_id(&self) -> Cow<'static, str> {
83        Cow::Owned(format!("Gpu_{}", self.system_name))
84    }
85
86    fn resource_type(&self) -> MonitoredResourceType {
87        MonitoredResourceType::Gpu
88    }
89
90    fn get_usage_report(&self) -> ResourceUsageReport {
91        // GPU performance doesn't have byte-based usage, so return default
92        ResourceUsageReport::default()
93    }
94
95    fn get_gpu_report(&self) -> Option<GpuReport> {
96        self.get_gpu_report()
97    }
98
99    fn get_metrics(
100        &self,
101    ) -> Vec<(
102        khora_core::telemetry::metrics::MetricId,
103        khora_core::telemetry::metrics::MetricValue,
104    )> {
105        use khora_core::telemetry::metrics::{MetricId, MetricValue};
106        let mut metrics = Vec::new();
107
108        if let Some(report) = self.get_gpu_report() {
109            metrics.push((
110                MetricId::new("renderer", "draw_calls"),
111                MetricValue::Gauge(report.draw_calls as f64),
112            ));
113            metrics.push((
114                MetricId::new("renderer", "triangles"),
115                MetricValue::Gauge(report.triangles_rendered as f64),
116            ));
117
118            if let Some(total_ms) = report.frame_total_duration_us() {
119                metrics.push((
120                    MetricId::new("renderer", "frame_time"),
121                    MetricValue::Gauge(total_ms as f64 / 1000.0),
122                ));
123            }
124
125            if let Some(main_ms) = report.main_pass_duration_us() {
126                metrics.push((
127                    MetricId::new("renderer", "gpu_time"),
128                    MetricValue::Gauge(main_ms as f64 / 1000.0),
129                ));
130            }
131        }
132
133        metrics
134    }
135
136    fn as_any(&self) -> &dyn std::any::Any {
137        self
138    }
139
140    fn update(&self) {
141        // GPU monitor updates are handled by update_from_frame_stats()
142        // when called from the render system, so no additional work needed here
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn gpu_monitor_creation() {
152        let monitor = GpuMonitor::new("TestGPU".to_string());
153        assert_eq!(monitor.monitor_id(), "Gpu_TestGPU");
154        assert_eq!(monitor.resource_type(), MonitoredResourceType::Gpu);
155    }
156
157    #[test]
158    fn gpu_monitor_update_stats() {
159        let monitor = GpuMonitor::new("TestGPU".to_string());
160
161        // Initially no performance report
162        assert!(monitor.get_gpu_report().is_none());
163
164        // Create sample render stats
165        let render_stats = RenderStats {
166            frame_number: 42,
167            cpu_preparation_time_ms: 1.0,
168            cpu_render_submission_time_ms: 0.5,
169            gpu_main_pass_time_ms: 16.67,
170            gpu_frame_total_time_ms: 16.67,
171            draw_calls: 100,
172            triangles_rendered: 1000,
173            vram_usage_estimate_mb: 256.0,
174        };
175
176        // Update stats
177        monitor.update_from_frame_stats(&render_stats);
178
179        // Should now have a performance report
180        let report = monitor.get_gpu_report();
181        assert!(report.is_some());
182
183        let report = report.unwrap();
184        assert_eq!(report.frame_number, 42);
185        assert_eq!(report.cpu_preparation_time_us, Some(1000)); // 1ms = 1000μs
186        assert_eq!(report.cpu_submission_time_us, Some(500)); // 0.5ms = 500μs
187    }
188
189    #[test]
190    fn gpu_report_hook_methods() {
191        let monitor = GpuMonitor::new("TestGPU".to_string());
192
193        let render_stats = RenderStats {
194            frame_number: 1,
195            cpu_preparation_time_ms: 0.1,
196            cpu_render_submission_time_ms: 0.05,
197            gpu_main_pass_time_ms: 16.67,
198            gpu_frame_total_time_ms: 17.0,
199            draw_calls: 50,
200            triangles_rendered: 500,
201            vram_usage_estimate_mb: 128.0,
202        };
203
204        monitor.update_from_frame_stats(&render_stats);
205        let report = monitor.get_gpu_report().unwrap();
206
207        // Test frame number
208        assert_eq!(report.frame_number, 1);
209
210        // With our RenderStats-based hook calculation, we now have hook timings
211        assert_eq!(report.frame_total_duration_us(), Some(17000)); // 17ms = 17000μs
212        assert_eq!(report.main_pass_duration_us(), Some(16670)); // 16.67ms = 16670μs
213    }
214
215    #[test]
216    fn gpu_report_missing_data() {
217        let monitor = GpuMonitor::new("TestGPU".to_string());
218
219        let render_stats = RenderStats {
220            frame_number: 1,
221            cpu_preparation_time_ms: 0.0,
222            cpu_render_submission_time_ms: 0.0,
223            gpu_main_pass_time_ms: 0.0,
224            gpu_frame_total_time_ms: 0.0,
225            draw_calls: 0,
226            triangles_rendered: 0,
227            vram_usage_estimate_mb: 0.0,
228        };
229
230        monitor.update_from_frame_stats(&render_stats);
231        let report = monitor.get_gpu_report().unwrap();
232
233        // With zero timing values, hook timings will still be calculated (starting at 0)
234        assert_eq!(report.frame_total_duration_us(), Some(0)); // 0ms = 0μs
235        assert_eq!(report.main_pass_duration_us(), Some(0)); // 0ms = 0μs
236    }
237}