Skip to main content

subms_hdr_histogram/features/
concurrent_writes.rs

1//! Lock-free concurrent histogram.
2//!
3//! Same log-linear bucketing as the base `HdrHistogram` but every
4//! counter is an `AtomicU64`. Producers call `record()` from any
5//! thread without external synchronisation - the only contention is
6//! the per-bucket `fetch_add`. Snapshot reads walk the array with
7//! relaxed loads; the value-at-percentile / max / count answers
8//! reflect a point in time that may interleave with concurrent
9//! writers, but each individual counter is intact.
10//!
11//! Trade-off vs the base: the array is fixed-size and pre-allocated
12//! at construction. A growable layout would need a mutex around the
13//! resize, which defeats the lock-free property. Pick the upper bound
14//! at construction (number of major buckets) - at 3 sig-digits the
15//! default 32 majors is 65536 counters tracking values past 4e12,
16//! which is well beyond sub-millisecond latency in nanosecond units.
17
18use crate::{index_of, value_from_index};
19use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
20
21/// Concurrent HDR histogram. All operations are lock-free.
22pub struct ConcurrentHdrHistogram {
23    sub_count: u32,
24    sub_count_bits: u32,
25    counters: Vec<AtomicU64>,
26    total: AtomicU64,
27    high_index: AtomicUsize,
28}
29
30impl ConcurrentHdrHistogram {
31    /// New histogram with the given significant-digit precision and
32    /// default major-bucket capacity (32, tracking values past 4e12 at
33    /// 3 sig-digits).
34    pub fn new(significant_digits: u32) -> Self {
35        Self::with_majors(significant_digits, 32)
36    }
37
38    /// Explicit major-bucket capacity. Counter array length is
39    /// `sub_count * majors`. Records that would land past the last
40    /// bucket are clamped into the final bucket (no resize possible
41    /// in a lock-free design).
42    pub fn with_majors(significant_digits: u32, majors: u32) -> Self {
43        let sig = significant_digits.clamp(1, 5);
44        let target = 2u32 * 10u32.pow(sig);
45        let sub_count_bits = (32 - target.leading_zeros()).max(1);
46        let sub_count = 1u32 << sub_count_bits;
47        let majors = majors.max(1);
48        let total_buckets = (sub_count as usize) * (majors as usize);
49        let mut counters = Vec::with_capacity(total_buckets);
50        for _ in 0..total_buckets {
51            counters.push(AtomicU64::new(0));
52        }
53        Self {
54            sub_count,
55            sub_count_bits,
56            counters,
57            total: AtomicU64::new(0),
58            high_index: AtomicUsize::new(0),
59        }
60    }
61
62    pub fn sub_count(&self) -> u32 {
63        self.sub_count
64    }
65
66    pub fn sub_count_bits(&self) -> u32 {
67        self.sub_count_bits
68    }
69
70    pub fn count(&self) -> u64 {
71        self.total.load(Ordering::Relaxed)
72    }
73
74    pub fn max(&self) -> u64 {
75        if self.count() == 0 {
76            return 0;
77        }
78        value_from_index(self.high_index.load(Ordering::Relaxed), self.sub_count_bits)
79    }
80
81    /// Record a value. Safe from any thread.
82    pub fn record(&self, value: u64) {
83        let raw = index_of(value, self.sub_count_bits) as usize;
84        let idx = raw.min(self.counters.len() - 1);
85        self.counters[idx].fetch_add(1, Ordering::Relaxed);
86        self.total.fetch_add(1, Ordering::Relaxed);
87        // Race on high_index is benign: any thread that wrote a
88        // higher index will eventually win the CAS. Worst case the
89        // snapshot reader sees a stale low high_index for a few ns.
90        let mut cur = self.high_index.load(Ordering::Relaxed);
91        while idx > cur {
92            match self.high_index.compare_exchange_weak(
93                cur,
94                idx,
95                Ordering::Relaxed,
96                Ordering::Relaxed,
97            ) {
98                Ok(_) => break,
99                Err(seen) => cur = seen,
100            }
101        }
102    }
103
104    /// Snapshot-style percentile read. Walks the array with relaxed
105    /// loads; not linearisable across all concurrent writers but
106    /// each individual counter read is intact.
107    pub fn value_at_percentile(&self, q: f64) -> u64 {
108        let total = self.count();
109        if total == 0 {
110            return 0;
111        }
112        let target = ((q.clamp(0.0, 1.0) * total as f64) as u64).max(1);
113        let high = self.high_index.load(Ordering::Relaxed);
114        let mut cum = 0u64;
115        let end = (high + 1).min(self.counters.len());
116        for i in 0..end {
117            cum += self.counters[i].load(Ordering::Relaxed);
118            if cum >= target {
119                return value_from_index(i, self.sub_count_bits);
120            }
121        }
122        value_from_index(high, self.sub_count_bits)
123    }
124
125    /// Atomically drain every counter and total into a `Snapshot`,
126    /// leaving the histogram empty. Used by `DualRecorder` to harvest
127    /// the inactive side. Each per-counter swap is independent, so
128    /// concurrent writers may land their increments in EITHER the
129    /// drained snapshot OR the now-zeroed live histogram - we never
130    /// double-count or lose a write.
131    pub fn drain_snapshot(&self) -> Snapshot {
132        let mut counts = Vec::with_capacity(self.counters.len());
133        let high = self.high_index.load(Ordering::Relaxed);
134        let len = (high + 1).min(self.counters.len());
135        let mut total = 0u64;
136        for i in 0..len {
137            let v = self.counters[i].swap(0, Ordering::AcqRel);
138            counts.push(v);
139            total += v;
140        }
141        // Subtract what we drained from `total`. Concurrent
142        // record()s that fired during the loop will keep `total`
143        // ahead of the per-counter sum; that's correct for the
144        // live side.
145        self.total.fetch_sub(total, Ordering::AcqRel);
146        self.high_index.store(0, Ordering::Relaxed);
147        Snapshot {
148            sub_count_bits: self.sub_count_bits,
149            counts,
150            total,
151        }
152    }
153}
154
155/// Frozen view of a histogram at the moment of `drain_snapshot()`.
156/// Has the same percentile / max APIs as the live histogram but is
157/// immutable.
158pub struct Snapshot {
159    sub_count_bits: u32,
160    counts: Vec<u64>,
161    total: u64,
162}
163
164impl Snapshot {
165    pub fn count(&self) -> u64 {
166        self.total
167    }
168
169    pub fn max(&self) -> u64 {
170        if self.total == 0 {
171            return 0;
172        }
173        // The drained snapshot truncates at high_index, so the last
174        // non-zero counter is at most counts.len()-1.
175        for i in (0..self.counts.len()).rev() {
176            if self.counts[i] > 0 {
177                return value_from_index(i, self.sub_count_bits);
178            }
179        }
180        0
181    }
182
183    pub fn value_at_percentile(&self, q: f64) -> u64 {
184        if self.total == 0 {
185            return 0;
186        }
187        let target = ((q.clamp(0.0, 1.0) * self.total as f64) as u64).max(1);
188        let mut cum = 0u64;
189        // `total` is the sum of `counts` by construction (drain_snapshot adds
190        // each swapped counter), and target <= total, so the loop always
191        // returns; the trailing 0 is only there to satisfy the compiler.
192        for (i, &c) in self.counts.iter().enumerate() {
193            cum += c;
194            if cum >= target {
195                return value_from_index(i, self.sub_count_bits);
196            }
197        }
198        0
199    }
200}
201
202#[cfg(test)]
203#[path = "concurrent_writes_tests.rs"]
204mod tests;