ferravex_engine/
profile.rs

1//! Bounded telemetry. Register counters once; recording does not allocate.
2//! Summary construction clones a bounded name; ids belong to their originating profiler.
3use std::time::Duration;
4#[cfg(not(target_arch = "wasm32"))]
5use std::time::Instant;
6const HISTORY: usize = 120;
7const MAX_COUNTERS: usize = 128;
8const MAX_NAME_BYTES: usize = 128;
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub struct CounterId(usize);
11#[derive(Clone, Debug)]
12pub struct Summary {
13    pub name: String,
14    pub mean_us: f64,
15    pub p99_us: u64,
16    pub max_us: u64,
17    pub over_budget: bool,
18    pub samples: usize,
19}
20struct Counter {
21    name: String,
22    values: [u64; HISTORY],
23    next: usize,
24    len: usize,
25    budget_us: u64,
26}
27#[derive(Default)]
28pub struct Profiler {
29    counters: Vec<Counter>,
30}
31impl Profiler {
32    pub fn register(&mut self, name: &str, budget: Duration) -> CounterId {
33        self.try_register(name, budget)
34            .expect("invalid profiler name or counter capacity exceeded")
35    }
36    /// Existing names retain their initial budget. Names are 1..=128 UTF-8 bytes.
37    pub fn try_register(&mut self, name: &str, budget: Duration) -> Option<CounterId> {
38        if name.is_empty() || name.len() > MAX_NAME_BYTES {
39            return None;
40        }
41        if let Some(i) = self
42            .counters
43            .iter()
44            .position(|counter| counter.name == name)
45        {
46            return Some(CounterId(i));
47        }
48        if self.counters.len() >= MAX_COUNTERS {
49            return None;
50        }
51        let i = self.counters.len();
52        self.counters.push(Counter {
53            name: name.into(),
54            values: [0; HISTORY],
55            next: 0,
56            len: 0,
57            budget_us: micros(budget),
58        });
59        Some(CounterId(i))
60    }
61    pub fn record(&mut self, id: CounterId, duration: Duration) {
62        assert!(
63            self.try_record(id, duration),
64            "counter id does not belong to this profiler"
65        );
66    }
67    pub fn try_record(&mut self, id: CounterId, duration: Duration) -> bool {
68        let Some(counter) = self.counters.get_mut(id.0) else {
69            return false;
70        };
71        counter.values[counter.next] = micros(duration);
72        counter.next = (counter.next + 1) % HISTORY;
73        counter.len = (counter.len + 1).min(HISTORY);
74        true
75    }
76    /// Native convenience. Browser callers supply performance.now through measure_with_clock.
77    #[cfg(not(target_arch = "wasm32"))]
78    pub fn measure<T>(&mut self, id: CounterId, f: impl FnOnce() -> T) -> T {
79        let start = Instant::now();
80        let result = f();
81        self.record(id, start.elapsed());
82        result
83    }
84    pub fn measure_with_clock<T>(
85        &mut self,
86        id: CounterId,
87        mut now: impl FnMut() -> Duration,
88        f: impl FnOnce() -> T,
89    ) -> T {
90        let start = now();
91        let result = f();
92        self.record(id, now().checked_sub(start).unwrap_or_default());
93        result
94    }
95    pub fn summary(&self, id: CounterId) -> Summary {
96        self.try_summary(id)
97            .expect("counter id does not belong to this profiler")
98    }
99    pub fn try_summary(&self, id: CounterId) -> Option<Summary> {
100        let counter = self.counters.get(id.0)?;
101        let mut sorted = counter.values;
102        sorted[..counter.len].sort_unstable();
103        let max = sorted[..counter.len].last().copied().unwrap_or(0);
104        let mean = if counter.len == 0 {
105            0.0
106        } else {
107            sorted[..counter.len].iter().map(|&n| n as f64).sum::<f64>() / counter.len as f64
108        };
109        let percentile = if counter.len == 0 {
110            0
111        } else {
112            sorted[(counter.len * 99).div_ceil(100) - 1]
113        };
114        Some(Summary {
115            name: counter.name.clone(),
116            mean_us: mean,
117            p99_us: percentile,
118            max_us: max,
119            over_budget: max > counter.budget_us,
120            samples: counter.len,
121        })
122    }
123}
124fn micros(duration: Duration) -> u64 {
125    duration.as_micros().min(u128::from(u64::MAX)) as u64
126}
127#[cfg(test)]
128mod tests {
129    use super::*;
130    #[test]
131    fn rolling_budget_and_quantile() {
132        let mut p = Profiler::default();
133        let id = p.register("physics", Duration::from_micros(100));
134        for _ in 0..130 {
135            p.record(id, Duration::from_micros(5));
136        }
137        p.record(id, Duration::from_micros(250));
138        let s = p.summary(id);
139        assert_eq!(s.samples, 120);
140        assert!(s.over_budget);
141        assert_eq!(s.max_us, 250);
142    }
143    #[test]
144    fn p99_uses_nearest_rank_without_off_by_one() {
145        let mut p = Profiler::default();
146        let id = p.register("rank", Duration::MAX);
147        for n in 1..=100 {
148            p.record(id, Duration::from_micros(n));
149        }
150        assert_eq!(p.summary(id).p99_us, 99);
151        assert_eq!(p.summary(id).mean_us, 50.5);
152    }
153    #[test]
154    fn bounded_names_and_external_clock() {
155        let mut p = Profiler::default();
156        assert!(p.try_register(&"x".repeat(129), Duration::ZERO).is_none());
157        let id = p.register("clock", Duration::ZERO);
158        let mut stamps = [Duration::from_micros(8), Duration::from_micros(3)].into_iter();
159        assert_eq!(
160            p.measure_with_clock(id, || stamps.next().unwrap(), || 42),
161            42
162        );
163        assert_eq!(p.summary(id).max_us, 0);
164        p.record(id, Duration::MAX);
165        assert_eq!(p.summary(id).max_us, u64::MAX);
166        assert!(!p.try_record(CounterId(10), Duration::ZERO));
167    }
168}