Skip to main content

marx/
lib.rs

1//! A zero-config simple histogram collector
2//! for use in instrumented optimization.
3//! Uses logarithmic bucketing rather than sampling,
4//! and has bounded (generally <0.5%) error on percentiles.
5//! Performs no allocations after initial creation.
6//! Uses Relaxed atomics during collection.
7//!
8//! When you create it, it allocates 65k AtomicUsize's
9//! that it uses for incrementing. Generating reports
10//! after running workloads on dozens of `Histo`'s
11//! does not result in a perceptible delay, but it
12//! might not be acceptable for use in low-latency
13//! reporting paths.
14//!
15//! The trade-offs taken in this are to minimize latency
16//! during collection, while initial allocation and
17//! postprocessing delays are acceptable.
18//!
19//! Future work to further reduce collection latency
20//! may include using thread-local caches that perform
21//! no atomic operations until they are dropped, when
22//! they may atomically aggregate their measurements
23//! into the shared collector that will be used for
24//! reporting.
25#![deny(missing_docs)]
26#![cfg_attr(test, deny(warnings))]
27
28use std::fmt::{self, Debug};
29use std::sync::atomic::{AtomicUsize, Ordering};
30
31const PRECISION: f64 = 100.;
32const BUCKETS: usize = 1 << 16;
33
34/// A histogram collector that uses zero-configuration logarithmic buckets.
35pub struct Histo {
36    vals: Vec<AtomicUsize>,
37    sum: AtomicUsize,
38    count: AtomicUsize,
39}
40
41impl Default for Histo {
42    fn default() -> Histo {
43        let mut vals = Vec::with_capacity(BUCKETS);
44        vals.resize_with(BUCKETS, Default::default);
45
46        Histo {
47            vals,
48            sum: AtomicUsize::new(0),
49            count: AtomicUsize::new(0),
50        }
51    }
52}
53
54unsafe impl Send for Histo {}
55
56impl Debug for Histo {
57    fn fmt(&self, f: &mut fmt::Formatter) -> std::result::Result<(), fmt::Error> {
58        const PS: [f64; 10] = [0., 50., 75., 90., 95., 97.5, 99., 99.9, 99.99, 100.];
59        f.write_str("Histogram[")?;
60
61        for p in &PS {
62            let res = self.percentile(*p).round();
63            let line = format!("({} -> {}) ", p, res);
64            f.write_str(&*line)?;
65        }
66
67        f.write_str("]")
68    }
69}
70
71impl Histo {
72    /// Record a value.
73    #[inline]
74    pub fn measure<T: Into<f64>>(&self, raw_value: T) -> usize {
75        #[cfg(not(feature = "disable"))]
76        {
77            let value_float: f64 = raw_value.into();
78            self.sum
79                .fetch_add(value_float.round() as usize, Ordering::Relaxed);
80
81            self.count.fetch_add(1, Ordering::Relaxed);
82
83            // compress the value to one of 2**16 values
84            // using logarithmic bucketing
85            let compressed: u16 = compress(value_float);
86
87            // increment the counter for this compressed value
88            self.vals[compressed as usize].fetch_add(1, Ordering::Relaxed) + 1
89        }
90
91        #[cfg(feature = "disable")]
92        {
93            0
94        }
95    }
96
97    /// Retrieve a percentile [0-100]. Returns NAN if no metrics have been
98    /// collected yet.
99    pub fn percentile(&self, p: f64) -> f64 {
100        #[cfg(not(feature = "disable"))]
101        {
102            assert!(p <= 100., "percentiles must not exceed 100.0");
103
104            let count = self.count.load(Ordering::Acquire);
105
106            if count == 0 {
107                return std::f64::NAN;
108            }
109
110            let mut target = count as f64 * (p / 100.);
111            if target == 0. {
112                target = 1.;
113            }
114
115            let mut sum = 0.;
116
117            for (idx, val) in self.vals.iter().enumerate() {
118                let count = val.load(Ordering::Acquire);
119                sum += count as f64;
120
121                if sum >= target {
122                    return decompress(idx as u16);
123                }
124            }
125        }
126
127        std::f64::NAN
128    }
129
130    /// Dump out some common percentiles.
131    pub fn print_percentiles(&self) {
132        println!("{:?}", self);
133    }
134
135    /// Return the sum of all observations in this histogram.
136    pub fn sum(&self) -> usize {
137        self.sum.load(Ordering::Acquire)
138    }
139
140    /// Return the count of observations in this histogram.
141    pub fn count(&self) -> usize {
142        self.count.load(Ordering::Acquire)
143    }
144}
145
146// compress takes a value and lossily shrinks it to an u16 to facilitate
147// bucketing of histogram values, staying roughly within 1% of the true
148// value. This fails for large values of 1e142 and above, and is
149// inaccurate for values closer to 0 than +/- 0.51 or +/- math.Inf.
150#[inline]
151fn compress<T: Into<f64>>(value: T) -> u16 {
152    let value: f64 = value.into();
153    let abs = value.abs();
154    let boosted = 1. + abs;
155    let ln = boosted.ln();
156    let compressed = PRECISION * ln + 0.5;
157    assert!(compressed <= std::u16::MAX as f64);
158    compressed as u16
159}
160
161// decompress takes a lossily shrunken u16 and returns an f64 within 1% of
162// the original passed to compress.
163#[inline]
164fn decompress(compressed: u16) -> f64 {
165    let unboosted = compressed as f64 / PRECISION;
166    (unboosted.exp() - 1.)
167}
168
169#[test]
170fn it_works() {
171    let c = Histo::default();
172    assert_eq!(c.measure(2), 1);
173    assert_eq!(c.measure(2), 2);
174    assert_eq!(c.measure(3), 1);
175    assert_eq!(c.measure(3), 2);
176    assert_eq!(c.measure(4), 1);
177    assert_eq!(c.percentile(0.).round() as usize, 2);
178    assert_eq!(c.percentile(40.).round() as usize, 2);
179    assert_eq!(c.percentile(40.1).round() as usize, 3);
180    assert_eq!(c.percentile(80.).round() as usize, 3);
181    assert_eq!(c.percentile(80.1).round() as usize, 4);
182    assert_eq!(c.percentile(100.).round() as usize, 4);
183    c.print_percentiles();
184}
185
186#[test]
187fn high_percentiles() {
188    let c = Histo::default();
189    for _ in 0..9000 {
190        c.measure(10);
191    }
192    for _ in 0..900 {
193        c.measure(25);
194    }
195    for _ in 0..90 {
196        c.measure(33);
197    }
198    for _ in 0..9 {
199        c.measure(47);
200    }
201    c.measure(500);
202    assert_eq!(c.percentile(0.).round() as usize, 10);
203    assert_eq!(c.percentile(99.).round() as usize, 25);
204    assert_eq!(c.percentile(99.89).round() as usize, 33);
205    assert_eq!(c.percentile(99.91).round() as usize, 47);
206    assert_eq!(c.percentile(99.99).round() as usize, 47);
207    assert_eq!(c.percentile(100.).round() as usize, 502);
208}
209
210#[test]
211fn multithreaded() {
212    use std::sync::Arc;
213    use std::thread;
214
215    let h = Arc::new(Histo::default());
216    let mut threads = vec![];
217
218    for _ in 0..10 {
219        let h = h.clone();
220        threads.push(thread::spawn(move || {
221            h.measure(20);
222        }));
223    }
224
225    for t in threads.into_iter() {
226        t.join().unwrap();
227    }
228
229    assert_eq!(h.percentile(50.).round() as usize, 20);
230}