metrics_util/storage/summary.rs
1use sketches_ddsketch::{Config, DDSketch};
2use std::fmt;
3
4/// A quantile sketch with relative-error guarantees.
5///
6/// Based on [DDSketch][ddsketch], `Summary` provides quantiles over an arbitrary distribution of
7/// floating-point numbers, including for negative numbers, using a space-efficient sketch that
8/// provides relative-error guarantees, regardless of the absolute range between the smallest and
9/// larger values.
10///
11/// `Summary` is similar to [HDRHistogram][hdrhistogram] in practice, but supports an arbitrary
12/// range of values, and supports floating-point numbers.
13///
14/// Numbers with an absolute value smaller than given `min_value` will be recognized as zeroes.
15///
16/// Memory usage for `Summary` should be nearly identical to `DDSketch`.
17/// [`Summary::estimated_size`] provides a rough estimate of summary size based on the current
18/// values that have been added to it.
19///
20/// As mentioned above, this sketch provides relative-error guarantees across quantiles falling
21/// within 0 <= q <= 1, but trades some accuracy at the lowest quantiles as part of the collapsing
22/// scheme that allows for automatically handling arbitrary ranges of values, even when the
23/// maximum number of bins has been allocated. Typically, q=0.05 and below is where this error will
24/// be noticed, if present.
25///
26/// For cases when all values are positive, you can simply use [`Summary::min`] in lieu of checking
27/// these quantiles, as the minimum value will be closer to the true value. For cases when values
28/// range from negative to positive, the aforementioned collapsing will perturb the estimated true
29/// value for quantiles that conceptually fall within this collapsed band.
30///
31/// For example, for a distribution that spans from -25 to 75, we would intuitively expect q=0 to be
32/// -25, q=0.25 to be 0, q=0.5 to be 25, and so on. Internally, negative numbers and positive
33/// numbers are handled in two separate containers. Based on this example, one container would
34/// handle -25 to 0, and another would handle the 0 to 75 range. As the containers are mapped "back
35/// to back", q=0.25 for this hypothetical summary would actually be q=0 within the negative
36/// container, which may return an estimated true value that exceeds the relative error guarantees.
37///
38/// Of course, as these problems are related to the estimation aspect of this data structure, users
39/// can allow the summary to allocate more bins to compensate for these edge cases, if desired.
40///
41/// [ddsketch]: https://arxiv.org/abs/1908.10693
42/// [hdrhistogram]: https://docs.rs/hdrhistogram
43#[derive(Clone)]
44pub struct Summary {
45 sketch: DDSketch,
46}
47
48impl fmt::Debug for Summary {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 // manual implementation because DDSketch does not implement Debug
51 f.debug_struct("Summary").finish_non_exhaustive()
52 }
53}
54
55impl Summary {
56 /// Creates a new [`Summary`].
57 ///
58 /// `alpha` represents the desired relative error for this summary. If `alpha` was 0.0001, that
59 /// would represent a desired relative error of 0.01%. For example, if the true value at
60 /// quantile q0 was 1, the estimated value at that quantile would be a value within 0.01% of the
61 /// true value, or a value between 0.9999 and 1.0001.
62 ///
63 /// `max_buckets` controls how many subbuckets are created, which directly influences memory usage.
64 /// Each bucket "costs" eight bytes, so a summary with 2048 buckets would consume a maximum of
65 /// around 16 KiB. Depending on how many samples have been added to the summary, the number of
66 /// subbuckets allocated may be far below `max_buckets`, and the summary will allocate more as
67 /// needed to fulfill the relative error guarantee.
68 ///
69 /// `min_value` controls the smallest value that will be recognized distinctly from zero. Said
70 /// another way, any value between `-min_value` and `min_value` will be counted as zero.
71 pub fn new(alpha: f64, max_buckets: u32, min_value: f64) -> Summary {
72 let config = Config::new(alpha, max_buckets, min_value.abs());
73
74 Summary { sketch: DDSketch::new(config) }
75 }
76
77 /// Creates a new [`Summary`] with default values.
78 ///
79 /// `alpha` is 0.0001, `max_buckets` is 32,768, and `min_value` is 1.0e-9.
80 ///
81 /// This will yield a summary that is roughly equivalent in memory usage to an HDRHistogram with
82 /// 3 significant digits, and will support values down to a single nanosecond.
83 ///
84 /// In practice, when using only positive values, maximum memory usage can be expected to hover
85 /// around 200KiB, while usage of negative values can lead to an average maximum size of around
86 /// 400KiB.
87 pub fn with_defaults() -> Summary {
88 Summary::new(0.0001, 32_768, 1.0e-9)
89 }
90
91 /// Adds a sample to the summary.
92 ///
93 /// If the absolute value of `value` is smaller than given `min_value`, it will be added as a zero.
94 pub fn add(&mut self, value: f64) {
95 if value.is_infinite() {
96 return;
97 }
98
99 self.sketch.add(value);
100 }
101
102 /// Gets the estimated value at the given quantile.
103 ///
104 /// If the sketch is empty, or if the quantile is less than 0.0 or greater than 1.0, then the
105 /// result will be `None`.
106 ///
107 /// If the 0.0 or 1.0 quantile is requested, this function will return self.min() or self.max()
108 /// instead of the estimated value.
109 pub fn quantile(&self, q: f64) -> Option<f64> {
110 if !(0.0..=1.0).contains(&q) || self.count() == 0 {
111 return None;
112 }
113
114 self.sketch.quantile(q).expect("quantile should be valid at this point")
115 }
116
117 /// Merge another Summary into this one.
118 ///
119 /// # Errors
120 ///
121 /// This function will return an error if the other Summary was not created with the same
122 /// parameters.
123 pub fn merge(&mut self, other: &Summary) -> Result<(), MergeError> {
124 self.sketch.merge(&other.sketch).map_err(|_| MergeError {})?;
125 Ok(())
126 }
127
128 /// Gets the minimum value this summary has seen so far.
129 pub fn min(&self) -> f64 {
130 self.sketch.min().unwrap_or(f64::INFINITY)
131 }
132
133 /// Gets the maximum value this summary has seen so far.
134 pub fn max(&self) -> f64 {
135 self.sketch.max().unwrap_or(f64::NEG_INFINITY)
136 }
137
138 /// Whether or not this summary is empty.
139 pub fn is_empty(&self) -> bool {
140 self.count() == 0
141 }
142
143 /// Gets the number of samples in this summary.
144 pub fn count(&self) -> usize {
145 self.sketch.count()
146 }
147
148 /// Gets the estimized size of this summary, in bytes.
149 ///
150 /// In practice, this value should be very close to the actual size, but will not be entirely
151 /// precise.
152 pub fn estimated_size(&self) -> usize {
153 std::mem::size_of::<Self>() + (self.sketch.length() * 8)
154 }
155}
156
157#[derive(Copy, Clone, Debug)]
158pub struct MergeError {}
159
160impl fmt::Display for MergeError {
161 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
162 write!(f, "merge error")
163 }
164}
165
166impl std::error::Error for MergeError {}
167
168#[cfg(test)]
169mod tests {
170 use super::Summary;
171
172 use quickcheck_macros::quickcheck;
173
174 // Need this, because without the relative_eq/abs_diff_eq imports, we get weird IDE errors.
175 #[allow(unused_imports)]
176 use approx::{abs_diff_eq, assert_abs_diff_eq, assert_relative_eq, relative_eq};
177
178 use ndarray::{Array1, Axis};
179 use ndarray_stats::{interpolate::Linear, QuantileExt};
180 use noisy_float::types::n64;
181 use ordered_float::NotNan;
182 use rand::{distributions::Distribution, thread_rng};
183 use rand_distr::Uniform;
184
185 #[test]
186 fn test_basics() {
187 let alpha = 0.0001;
188 let max_bins = 32_768;
189 let min_value = 1.0e-9;
190 let mut summary = Summary::new(alpha, max_bins, min_value);
191 assert!(summary.is_empty());
192
193 // Stretch the legs with a single value.
194 summary.add(-420.42);
195 assert_eq!(summary.count(), 1);
196 assert_relative_eq!(summary.min(), -420.42);
197 assert_relative_eq!(summary.max(), -420.42);
198
199 let test_cases = vec![(0.1, -420.42), (0.5, -420.42), (0.9, -420.42)];
200 for (q, val) in test_cases {
201 assert_relative_eq!(
202 summary.quantile(q).expect("value should exist"),
203 val,
204 max_relative = alpha
205 );
206 }
207
208 summary.add(420.42);
209
210 assert_eq!(summary.count(), 2);
211 assert_relative_eq!(summary.min(), -420.42);
212 assert_relative_eq!(summary.max(), 420.42);
213 assert_relative_eq!(
214 summary.quantile(0.5).expect("value should exist"),
215 -420.42,
216 max_relative = alpha
217 );
218 assert_relative_eq!(
219 summary.quantile(0.51).expect("value should exist"),
220 -420.42,
221 max_relative = alpha
222 );
223
224 summary.add(42.42);
225 assert_eq!(summary.count(), 3);
226 assert_relative_eq!(summary.min(), -420.42);
227 assert_relative_eq!(summary.max(), 420.42);
228
229 let test_cases = vec![
230 (0.333333, -420.42),
231 (0.333334, -420.42),
232 (0.666666, 42.42),
233 (0.666667, 42.42),
234 (0.999999, 42.42),
235 ];
236 for (q, val) in test_cases {
237 assert_relative_eq!(
238 summary.quantile(q).expect("value should exist"),
239 val,
240 max_relative = alpha
241 );
242 }
243 }
244
245 #[test]
246 fn test_positive_uniform() {
247 let alpha = 0.0001;
248 let max_bins = 32_768;
249 let min_value = 1.0e-9;
250
251 let mut rng = thread_rng();
252 let dist = Uniform::new(0.0, 100.0);
253
254 let mut summary = Summary::new(alpha, max_bins, min_value);
255 let mut uniform = Vec::new();
256 for _ in 0..100_000 {
257 let value = dist.sample(&mut rng);
258 uniform.push(NotNan::new(value).unwrap());
259 summary.add(value);
260 }
261
262 uniform.sort();
263 let mut true_histogram = Array1::from(uniform);
264
265 let quantiles = &[0.25, 0.5, 0.75, 0.99];
266 for quantile in quantiles {
267 let aval_raw = true_histogram
268 .quantile_axis_mut(Axis(0), n64(*quantile), &Linear)
269 .expect("quantile should be in range");
270 let aval = aval_raw.get(()).expect("quantile value should be present").into_inner();
271 let sval = summary.quantile(*quantile).expect("quantile value should be present");
272
273 // Multiply the true value by α, and double it to account from the -α/α swing.
274 let distance = (aval * alpha) * 2.0;
275
276 assert_relative_eq!(aval, sval, max_relative = distance);
277 }
278 }
279
280 #[test]
281 fn test_negative_positive_uniform() {
282 let alpha = 0.0001;
283 let max_bins = 65_536;
284 let min_value = 1.0e-9;
285
286 let mut rng = thread_rng();
287 let dist = Uniform::new(-100.0, 100.0);
288
289 let mut summary = Summary::new(alpha, max_bins, min_value);
290 let mut uniform = Vec::new();
291 for _ in 0..100_000 {
292 let value = dist.sample(&mut rng);
293 uniform.push(NotNan::new(value).unwrap());
294 summary.add(value);
295 }
296
297 uniform.sort();
298 let mut true_histogram = Array1::from(uniform);
299
300 // We explicitly skirt q=0.5 here to avoid the edge case quantiles as best as possible
301 // while asserting tightly to our relative error bound for everything else.
302 let quantiles = &[0.25, 0.47, 0.75, 0.99];
303 for quantile in quantiles {
304 let aval_raw = true_histogram
305 .quantile_axis_mut(Axis(0), n64(*quantile), &Linear)
306 .expect("quantile should be in range");
307 let aval = aval_raw.get(()).expect("quantile value should be present").into_inner();
308 let sval = summary.quantile(*quantile).expect("quantile value should be present");
309
310 // Multiply the true value by α, and quadruple it to account from the -α/α swing,
311 // but also to account for the values sitting at the edge case quantiles.
312 let distance = (aval.abs() * alpha) * 2.0;
313
314 assert_relative_eq!(aval, sval, max_relative = distance);
315 }
316 }
317
318 #[test]
319 fn test_zeroes() {
320 let mut summary = Summary::with_defaults();
321 summary.add(0.0);
322 assert_eq!(summary.quantile(0.5), Some(0.0));
323 }
324
325 #[test]
326 fn test_infinities() {
327 let mut summary = Summary::with_defaults();
328 summary.add(f64::INFINITY);
329 assert_eq!(summary.quantile(0.5), None);
330 summary.add(f64::NEG_INFINITY);
331 assert_eq!(summary.quantile(0.5), None);
332 }
333
334 #[quickcheck]
335 fn quantile_validity(inputs: Vec<f64>) -> bool {
336 let mut had_non_inf = false;
337
338 let mut summary = Summary::with_defaults();
339 for input in &inputs {
340 if !input.is_infinite() {
341 had_non_inf = true;
342 }
343 summary.add(*input);
344 }
345
346 let qs = &[0.0, 0.5, 0.9, 0.95, 0.99, 0.999, 1.0];
347 for q in qs {
348 let result = summary.quantile(*q);
349 if had_non_inf {
350 assert!(result.is_some());
351 } else {
352 assert!(result.is_none());
353 }
354 }
355
356 true
357 }
358}