Skip to main content

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}