crcbl_core/stats.rs
1//! Nearest-rank percentiles, and the sample count below which one is a lie.
2//!
3//! Topic 40 decides that a benchmark and a debug row alike
4//! report p50, p95, p99 and max and never a mean, because "frame time is a tail
5//! problem — a mean hides exactly the stutter a player notices". This module is
6//! the arithmetic behind that decision, in one place: `crcbl_ui::budget`'s
7//! rolling window of frame costs and `crcbl bench`'s per-iteration samples ask
8//! the same question of different numbers, and a second copy of the rank
9//! calculation is a second chance for the off-by-one it exists to avoid.
10//!
11//! It lives here for the reason [`trace`](mod@crate::trace) does — the facility
12//! every other crate reaches for and none of them owns.
13//!
14//! **There is deliberately no mean here.** Adding one would be adding the number
15//! the plan decided not to report, and a helper that offers it is an invitation
16//! to print it.
17
18/// Samples a window needs before a nearest-rank p95 is a percentile rather than
19/// the maximum under another name.
20///
21/// Nearest-rank p95 picks element `ceil(0.95 * n)` of `n` sorted samples, which
22/// is `n` itself — the maximum — for every `n` below this. Twenty is the first
23/// count at which the rank is `19` and the answer is therefore a percentile
24/// rather than the worst sample in the window under another name.
25pub const MIN_PERCENTILE_SAMPLES: usize = 20;
26
27/// The percentile of a **sorted** slice, by nearest rank.
28///
29/// The rank is `ceil(pct * n / 100)`, clamped to at least one, and the answer is
30/// the sample at that rank — so it is always a sample that actually happened
31/// rather than an interpolation between two that did. `pct` is a whole percent
32/// in `1..=100`; `percentile_of(xs, 100)` is the maximum.
33///
34/// Computed in integer arithmetic: `(pct * n)` for a window this size is nowhere
35/// near overflowing, and a float rank rounds `0.95 * 20` to something that is
36/// either side of 19 depending on the value, which is exactly the off-by-one this
37/// avoids.
38#[must_use]
39pub fn percentile_of(sorted: &[u64], pct: usize) -> Option<u64> {
40 let rank = (pct * sorted.len()).div_ceil(100).max(1);
41 sorted.get(rank - 1).copied()
42}
43
44/// A rolling window of the newest `N` samples, and the percentiles read off it.
45///
46/// The samples are nanoseconds by convention rather than by type: every caller
47/// so far is timing something, and a `Duration` here would push the conversion
48/// into the percentile arithmetic instead of leaving it at the edges where the
49/// unit is known.
50///
51/// **`N` is a const parameter and not a field** so that
52/// [`percentiles`](Self::percentiles) can sort into a stack array. This runs
53/// once a frame while a debug panel is up, and a fresh `Vec` per call would be
54/// an allocation on that path for a buffer whose size the window already bounds.
55#[derive(Clone, Debug)]
56pub struct Window<const N: usize> {
57 /// Newest last, at most `N` of them.
58 samples: std::collections::VecDeque<u64>,
59}
60
61impl<const N: usize> Default for Window<N> {
62 fn default() -> Self {
63 Self {
64 samples: std::collections::VecDeque::with_capacity(N),
65 }
66 }
67}
68
69impl<const N: usize> Window<N> {
70 /// An empty window.
71 #[must_use]
72 pub fn new() -> Self {
73 Self::default()
74 }
75
76 /// Records one sample, evicting the oldest when the window is full.
77 pub fn record(&mut self, sample: u64) {
78 if self.samples.len() >= N {
79 self.samples.pop_front();
80 }
81 if N > 0 {
82 self.samples.push_back(sample);
83 }
84 }
85
86 /// How many samples the window holds.
87 #[must_use]
88 pub fn len(&self) -> usize {
89 self.samples.len()
90 }
91
92 /// Whether it holds none.
93 #[must_use]
94 pub fn is_empty(&self) -> bool {
95 self.samples.is_empty()
96 }
97
98 /// The window's p50 and p95, or `None` below [`MIN_PERCENTILE_SAMPLES`].
99 ///
100 /// The floor is the whole reason this is one type rather than a `sort` at
101 /// each call site: a p95 over fewer samples is the maximum wearing a
102 /// percentile's name, and a caller that forgets the check reports it anyway.
103 #[must_use]
104 pub fn percentiles(&self) -> Option<(u64, u64)> {
105 if self.samples.len() < MIN_PERCENTILE_SAMPLES {
106 return None;
107 }
108 let mut sorted = [0u64; N];
109 let sorted = &mut sorted[..self.samples.len()];
110 for (slot, sample) in sorted.iter_mut().zip(&self.samples) {
111 *slot = *sample;
112 }
113 sorted.sort_unstable();
114 Some((percentile_of(sorted, 50)?, percentile_of(sorted, 95)?))
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 /// **The arithmetic, against a sequence whose answers are hand-computed.**
123 ///
124 /// Twenty samples of 1..=20. Nearest rank puts p50 at `ceil(0.50 * 20) = 10`
125 /// — the tenth smallest, which is 10 — and p95 at `ceil(0.95 * 20) = 19`,
126 /// which is 19 and is *not* the maximum. An off-by-one in either direction
127 /// lands on 9, 11, 18 or 20, all of which this catches.
128 #[test]
129 fn the_percentile_is_the_sample_at_the_nearest_rank() {
130 let sorted: Vec<u64> = (1..=20).collect();
131 assert_eq!(percentile_of(&sorted, 50), Some(10));
132 assert_eq!(percentile_of(&sorted, 95), Some(19));
133 assert_eq!(percentile_of(&sorted, 99), Some(20));
134 assert_eq!(percentile_of(&sorted, 100), Some(20));
135 // The first rank, and the clamp that keeps a small percentile of a small
136 // window from indexing at minus one.
137 assert_eq!(percentile_of(&sorted, 1), Some(1));
138 assert_eq!(percentile_of(&[7], 50), Some(7));
139 assert_eq!(percentile_of(&[7], 1), Some(7));
140 assert_eq!(percentile_of(&[], 50), None);
141 }
142
143 /// **[`MIN_PERCENTILE_SAMPLES`] is derived, and this is the derivation.**
144 ///
145 /// Its doc comment claims p95 and the maximum are the same sample for every
146 /// count below it, which a spot check at one or two counts does not
147 /// establish. This walks all of them, so a change to the constant or to the
148 /// rank arithmetic fails here rather than leaving a threshold that quietly
149 /// no longer means what it says.
150 /// **The order samples arrive in must not change the answer**, which is
151 /// what the sort inside [`Window::percentiles`] is for.
152 #[test]
153 fn the_window_sorts_before_it_picks() {
154 let mut ascending = Window::<120>::new();
155 let mut descending = Window::<120>::new();
156 for value in 1..=MIN_PERCENTILE_SAMPLES as u64 {
157 ascending.record(value);
158 }
159 for value in (1..=MIN_PERCENTILE_SAMPLES as u64).rev() {
160 descending.record(value);
161 }
162 assert_eq!(ascending.percentiles(), Some((10, 19)));
163 assert_eq!(descending.percentiles(), ascending.percentiles());
164 }
165
166 /// **The floor is the window's, not the caller's.** One sample short of
167 /// [`MIN_PERCENTILE_SAMPLES`] there is no percentile at all, and the sample
168 /// that completes it is the one that produces a number.
169 #[test]
170 fn a_short_window_has_no_percentile() {
171 let mut window = Window::<120>::new();
172 assert!(window.is_empty());
173 assert_eq!(window.percentiles(), None);
174 for _ in 0..MIN_PERCENTILE_SAMPLES - 1 {
175 window.record(7);
176 }
177 assert_eq!(window.len(), MIN_PERCENTILE_SAMPLES - 1);
178 assert_eq!(window.percentiles(), None, "one short is still short");
179 window.record(7);
180 assert_eq!(window.percentiles(), Some((7, 7)));
181 }
182
183 /// **The oldest sample leaves when the window is full**, so a window that
184 /// has seen a slow patch and then a fast one reports the fast one.
185 #[test]
186 fn the_window_evicts_the_oldest() {
187 const N: usize = 24;
188 let mut window = Window::<N>::new();
189 for _ in 0..N {
190 window.record(1_000);
191 }
192 assert_eq!(window.len(), N);
193 assert_eq!(window.percentiles(), Some((1_000, 1_000)));
194 for _ in 0..N {
195 window.record(1);
196 }
197 assert_eq!(window.len(), N, "the window does not grow past its bound");
198 assert_eq!(window.percentiles(), Some((1, 1)));
199 }
200
201 #[test]
202 fn the_minimum_is_the_first_count_at_which_p95_stops_being_the_maximum() {
203 for count in 1..MIN_PERCENTILE_SAMPLES {
204 let sorted: Vec<u64> = (1..=count as u64).collect();
205 assert_eq!(
206 percentile_of(&sorted, 95),
207 percentile_of(&sorted, 100),
208 "at {count} samples a p95 is still the maximum"
209 );
210 }
211 let sorted: Vec<u64> = (1..=MIN_PERCENTILE_SAMPLES as u64).collect();
212 assert_ne!(
213 percentile_of(&sorted, 95),
214 percentile_of(&sorted, 100),
215 "at {MIN_PERCENTILE_SAMPLES} samples they must part company"
216 );
217 }
218}