ferravex_core/
telemetry.rs

1//! Opt-in local recordings. GPU durations come only from tagged driver queries.
2use std::cell::Cell;
3
4#[derive(Clone, Copy, Debug, Default)]
5pub struct PhysicsWork {
6    pub steps: u64,
7    pub candidate_pairs: u64,
8    pub contact_points: u64,
9    pub ccd_impacts: u64,
10}
11thread_local! {static PHYSICS: Cell<PhysicsWork> = Cell::new(PhysicsWork::default());}
12/// Accumulate actual solver work for the current thread/frame, including catch-up ticks.
13pub fn record_physics(pairs: usize, contacts: usize, ccd: usize) {
14    PHYSICS.with(|cell| {
15        let mut v = cell.get();
16        v.steps += 1;
17        v.candidate_pairs += pairs as u64;
18        v.contact_points += contacts as u64;
19        v.ccd_impacts += ccd as u64;
20        cell.set(v);
21    });
22}
23pub fn take_physics() -> PhysicsWork {
24    PHYSICS.with(|cell| cell.replace(PhysicsWork::default()))
25}
26
27#[cfg(not(target_arch = "wasm32"))]
28mod recording {
29    use super::*;
30    use std::{
31        alloc::{GlobalAlloc, Layout, System},
32        path::PathBuf,
33        sync::atomic::{AtomicBool, AtomicU64, Ordering},
34        time::Instant,
35    };
36    static ENABLED: AtomicBool = AtomicBool::new(false);
37    static CALLS: AtomicU64 = AtomicU64::new(0);
38    static BYTES: AtomicU64 = AtomicU64::new(0);
39    /// Install once in an executable. Counts successful Rust allocations/reallocations,
40    /// not C/driver allocations or live heap size. Counting is enabled only during recordings.
41    pub struct TrackingAllocator;
42    fn allocated(ptr: *mut u8, bytes: usize) {
43        if !ptr.is_null() && ENABLED.load(Ordering::Relaxed) {
44            CALLS.fetch_add(1, Ordering::Relaxed);
45            BYTES.fetch_add(bytes as u64, Ordering::Relaxed);
46        }
47    }
48    // SAFETY: Every operation forwards its unchanged allocator contract to System.
49    unsafe impl GlobalAlloc for TrackingAllocator {
50        unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
51            let p = unsafe { System.alloc(layout) };
52            allocated(p, layout.size());
53            p
54        }
55        unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
56            let p = unsafe { System.alloc_zeroed(layout) };
57            allocated(p, layout.size());
58            p
59        }
60        unsafe fn realloc(&self, p: *mut u8, layout: Layout, size: usize) -> *mut u8 {
61            let p = unsafe { System.realloc(p, layout, size) };
62            allocated(p, size);
63            p
64        }
65        unsafe fn dealloc(&self, p: *mut u8, layout: Layout) {
66            unsafe { System.dealloc(p, layout) };
67        }
68    }
69    #[derive(Clone, Debug, Default)]
70    pub struct FrameSample {
71        pub cpu_us: f64,
72        /// CPU time issuing driver work and swapping; includes possible presentation blocking.
73        pub submit_us: Option<f64>,
74        pub software_render_us: Option<f64>,
75        pub gpu_us: Option<f64>,
76        pub draw_calls: Option<u64>,
77        pub vertices: Option<u64>,
78        pub upload_bytes: Option<u64>,
79        pub allocation_calls: u64,
80        pub allocation_bytes: u64,
81        pub physics: PhysicsWork,
82    }
83    pub struct FrameToken {
84        start: Instant,
85        calls: u64,
86        bytes: u64,
87    }
88    impl FrameToken {
89        pub fn begin() -> Self {
90            take_physics();
91            Self {
92                start: Instant::now(),
93                calls: CALLS.load(Ordering::Relaxed),
94                bytes: BYTES.load(Ordering::Relaxed),
95            }
96        }
97        pub fn elapsed_us(&self) -> f64 {
98            self.start.elapsed().as_secs_f64() * 1e6
99        }
100        pub fn finish(self, mut sample: FrameSample) -> FrameSample {
101            sample.allocation_calls = CALLS.load(Ordering::Relaxed).saturating_sub(self.calls);
102            sample.allocation_bytes = BYTES.load(Ordering::Relaxed).saturating_sub(self.bytes);
103            sample.physics = take_physics();
104            sample
105        }
106    }
107    /// A bounded recording. Reporting and screenshot encoding happen outside recorded frames.
108    pub struct Session {
109        output: PathBuf,
110        warmup: usize,
111        seen: usize,
112        dropped: usize,
113        samples: Vec<FrameSample>,
114        gpu_source: Option<&'static str>,
115        render_quality: Option<u32>,
116    }
117    impl Session {
118        pub fn from_env() -> Option<Self> {
119            let output = std::env::var_os("FERRAVEX_PERF_OUTPUT")?;
120            let warmup = std::env::var("FERRAVEX_PERF_WARMUP")
121                .map_or(60, |s| {
122                    s.parse::<usize>().expect("invalid FERRAVEX_PERF_WARMUP")
123                })
124                .min(100_000);
125            let session = Self {
126                output: output.into(),
127                warmup,
128                seen: 0,
129                dropped: 0,
130                samples: Vec::with_capacity(8192),
131                gpu_source: None,
132                render_quality: None,
133            };
134            ENABLED.store(true, Ordering::Relaxed);
135            Some(session)
136        }
137        pub fn record(&mut self, sample: FrameSample) {
138            self.seen += 1;
139            if self.seen <= self.warmup {
140                return;
141            }
142            if self.samples.len() < self.samples.capacity() {
143                self.samples.push(sample);
144            } else {
145                self.dropped += 1;
146            }
147        }
148        pub fn render_quality(&mut self, quality: u32) {
149            self.render_quality = Some(quality);
150        }
151        pub fn gpu_source(&mut self, source: &'static str) {
152            self.gpu_source = Some(source);
153        }
154        /// Results are tagged with the 1-based presented frame, including discarded warmup.
155        pub fn gpu_result(&mut self, frame: u64, microseconds: f64) {
156            if !microseconds.is_finite() || microseconds < 0. || frame <= self.warmup as u64 {
157                return;
158            }
159            let index = frame - self.warmup as u64 - 1;
160            if let Some(sample) = self.samples.get_mut(index as usize) {
161                sample.gpu_us = Some(microseconds);
162            }
163        }
164        pub fn write(&self, backend: &str, width: u32, height: u32) -> std::io::Result<()> {
165            let mut out = format!(
166                "{{\"schema\":2,\"backend\":{},\"os\":{},\"arch\":{},\"width\":{width},\"height\":{height},\"warmup\":{},\"dropped\":{},\"gpu_source\":{},\"render_quality\":{},\"frames\":[",
167                json_string(backend),
168                json_string(std::env::consts::OS),
169                json_string(std::env::consts::ARCH),
170                self.warmup,
171                self.dropped,
172                self.gpu_source.map_or_else(|| "null".into(), json_string),
173                optional(self.render_quality)
174            );
175            for (i, s) in self.samples.iter().enumerate() {
176                if i > 0 {
177                    out.push(',');
178                }
179                out.push_str(&format!("{{\"cpu_us\":{},\"submit_us\":{},\"software_render_us\":{},\"draw_calls\":{},\"vertices\":{},\"upload_bytes\":{},\"allocation_calls\":{},\"allocation_bytes\":{},\"physics_steps\":{},\"physics_pairs\":{},\"contact_points\":{},\"ccd_impacts\":{},\"gpu_us\":{}}}",s.cpu_us,optional(s.submit_us),optional(s.software_render_us),optional(s.draw_calls),optional(s.vertices),optional(s.upload_bytes),s.allocation_calls,s.allocation_bytes,s.physics.steps,s.physics.candidate_pairs,s.physics.contact_points,s.physics.ccd_impacts,optional(s.gpu_us)));
180            }
181            out.push_str("]}\n");
182            std::fs::write(&self.output, out)
183        }
184    }
185    fn optional<T: std::fmt::Display>(v: Option<T>) -> String {
186        v.map_or_else(|| "null".into(), |n| n.to_string())
187    }
188    pub fn json_string(value: &str) -> String {
189        let mut out = String::from("\"");
190        for c in value.chars() {
191            match c {
192                '"' => out.push_str("\\\""),
193                '\\' => out.push_str("\\\\"),
194                c if c < ' ' => out.push_str(&format!("\\u{:04x}", c as u32)),
195                c => out.push(c),
196            }
197        }
198        out.push('"');
199        out
200    }
201    #[cfg(test)]
202    mod tests {
203        use super::*;
204        #[test]
205        fn counters_sum_catchup_ticks_and_reset() {
206            take_physics();
207            record_physics(3, 4, 1);
208            record_physics(2, 5, 0);
209            let v = take_physics();
210            assert_eq!(
211                (v.steps, v.candidate_pairs, v.contact_points, v.ccd_impacts),
212                (2, 5, 9, 1)
213            );
214            assert_eq!(take_physics().steps, 0);
215        }
216        #[test]
217        fn strings_are_json_safe() {
218            assert_eq!(json_string("a\n\"\\\0"), "\"a\\u000a\\\"\\\\\\u0000\"");
219        }
220        #[test]
221        fn warmup_and_capacity_are_explicit() {
222            let mut s = Session {
223                output: PathBuf::new(),
224                warmup: 2,
225                seen: 0,
226                dropped: 0,
227                samples: Vec::with_capacity(2),
228                gpu_source: None,
229                render_quality: None,
230            };
231            for _ in 0..5 {
232                s.record(FrameSample::default());
233            }
234            assert_eq!((s.samples.len(), s.dropped), (2, 1));
235            s.gpu_result(2, 999.); // Warmup result arriving late.
236            s.gpu_result(4, 17.);
237            s.gpu_result(3, 12.);
238            s.gpu_result(3, f64::NAN);
239            s.gpu_result(4, -1.);
240            s.gpu_result(8, 4.); // Unrecorded overflow.
241            assert_eq!(s.samples[0].gpu_us, Some(12.));
242            assert_eq!(s.samples[1].gpu_us, Some(17.));
243        }
244    }
245}
246#[cfg(not(target_arch = "wasm32"))]
247pub use recording::*;