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;