ferravex_engine/
profile.rs1use 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 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 #[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}