decdn_cache/policy/sketch.rs
1//! Count-min frequency sketch keyed directly on BLAKE3 hashes (ADR 040).
2//!
3//! Keys are already uniform 32-byte hashes, so each row's column index is a
4//! disjoint 4-byte slice of the key reduced mod the sketch's width — no hash
5//! functions. Counters are saturating `u8` — classic `TinyLFU` uses 4-bit
6//! counters, and the extra headroom is free at one byte each. Halving ages them.
7//!
8//! [`ShardedCountMinSketch`] is the form the estimator uses. It splits the
9//! counter array into [`SHARDS`] independently locked [`CountMinSketch`]es of
10//! `cols / SHARDS` columns each, routed by a 4-byte slice of the key that no
11//! row index reads. An `increment` or an `estimate` locks exactly one shard.
12//!
13//! Two properties of one wide sketch of `cols` columns are load-bearing for the
14//! policies that read the estimates, and sharding keeps both:
15//!
16//! - **Aging cadence is global, not per shard.** Both readers compare estimates
17//! against a shared scale: [`super::tinylfu::ProbationAdmission`] tests one
18//! estimate against a node-wide `promotion_threshold`, and
19//! [`super::tinylfu::TinyLfuEviction`] ranks candidates from *different*
20//! shards least-frequent-first. Counters are only comparable if every counter
21//! is caught up to the same clock when it is read. So the halving clock is one
22//! node-wide observation counter, and a shard halves lazily — on its next
23//! touch — by however many windows have elapsed since it last caught up. The
24//! *work* stays sharded (`1 / SHARDS` of the array under one shard's lock,
25//! landing on one observer), while the *cadence* stays global. A shard clocked
26//! on its own traffic would age at a rate set by key skew, which is exactly
27//! what a frequency sketch must not do.
28//! - **Per-row collision rate.** Two distinct keys share a row counter only if
29//! they share a shard (probability `1 / SHARDS`) and then a column within it
30//! (`SHARDS / cols`) — the same `1 / cols` a single wide sketch gives, because
31//! the shard slice and the row slices are disjoint bytes of a uniform hash.
32//!
33//! The over-report rate carries over as exactly as the per-row rate does. The
34//! sketch reads a key hotter than it is when every one of its `ROWS` counters
35//! also holds some other key's count. The polluting keys need not be the same
36//! one across rows, so "two keys collide in all `ROWS` rows" is a far rarer
37//! event that does not bound the error — do not size the sketch from it. For
38//! `N` live keys the rate is `(1 - e^(-N / cols))^ROWS`, and [`SHARDS`] cancels
39//! out: a shard divides the columns and the keys in the same proportion. So
40//! sharding costs nothing in accuracy and the width alone sets it. The rate
41//! turns on `N / cols`, which holds the shipped `cols = 65536` under one
42//! percent out to roughly 25000 live keys. See
43//! [`super::tinylfu::TinyLfuEstimator::new`] for the sizing and its floor.
44use crate::Hash;
45use std::sync::atomic::{AtomicU64, Ordering};
46use std::sync::{Mutex, PoisonError};
47
48const ROWS: usize = 4;
49
50/// Number of independently locked shards in a [`ShardedCountMinSketch`].
51///
52/// A concurrency knob: the total counter budget, the per-row collision rate, and
53/// the aging cadence are all held constant across it (see the module docs), so
54/// raising or lowering it moves contention and halving granularity without
55/// moving any admission or eviction decision. The one quantity it does move is
56/// the whole-sketch over-report rate, by `SHARDS^(ROWS-1)` — negligible at the
57/// shipped sizing, and bounded in the module docs.
58pub const SHARDS: usize = 16;
59
60/// Bytes of key each index consumes: one `u32` per row, and one for the shard.
61const SLICE_BYTES: usize = 4;
62
63/// Byte offset of the shard-routing slice. It is derived from the bytes the row
64/// indices consume, so shard choice and column choice stay independent functions
65/// of the key however many rows the sketch has. The module docs' collision-rate
66/// argument rests on that independence, and a stride or row count that put a row
67/// index on the shard slice would correlate the two while leaving every test in
68/// this file green — hence the derivation rather than a literal offset.
69const SHARD_SLICE_START: usize = ROWS * SLICE_BYTES;
70
71// The derivation keeps the slices disjoint; this bounds them to the key. Past
72// `ROWS = 7` the shard slice runs off the end of a hash, `word_at` reads zero
73// for every key, and the sharding collapses to one shard.
74const _: () = assert!(
75 SHARD_SLICE_START + SLICE_BYTES <= size_of::<Hash>(),
76 "the shard slice must fit inside the key"
77);
78
79/// Observations per node-wide aging window, per column of total width. A sketch
80/// `cols` wide halves every `cols * AGING_WINDOW_PER_COL` observations.
81const AGING_WINDOW_PER_COL: u64 = 10;
82
83/// Read the little-endian word of [`SLICE_BYTES`] at `start` in `key`, or `0` if
84/// the key is shorter than the slice (unreachable for a 32-byte BLAKE3 hash, but expressed
85/// as a total function rather than an index that could panic).
86fn word_at(key: &Hash, start: usize) -> u32 {
87 let bytes = key.as_bytes();
88 let slice = bytes
89 .get(start..start.saturating_add(SLICE_BYTES))
90 .unwrap_or(&[]);
91 u32::from_le_bytes([
92 *slice.first().unwrap_or(&0),
93 *slice.get(1).unwrap_or(&0),
94 *slice.get(2).unwrap_or(&0),
95 *slice.get(3).unwrap_or(&0),
96 ])
97}
98
99/// A plain count-min counter array. It carries no aging clock of its own:
100/// [`ShardedCountMinSketch`] owns the node-wide clock and calls [`Self::halve`],
101/// which is what keeps every shard on one cadence.
102#[derive(Debug)]
103pub struct CountMinSketch {
104 cols: usize,
105 counters: Vec<u8>, // ROWS * cols, row-major
106}
107
108impl CountMinSketch {
109 /// A zeroed sketch `cols` counters wide per row. A `cols` of `0` is
110 /// raised to `1` so the modulo is always defined.
111 #[must_use]
112 pub fn new(cols: usize) -> Self {
113 let cols = cols.max(1);
114 Self {
115 cols,
116 counters: vec![0u8; ROWS * cols],
117 }
118 }
119
120 /// Record one access to `key`, saturating each row's counter rather
121 /// than wrapping.
122 pub fn increment(&mut self, key: &Hash) {
123 for row in 0..ROWS {
124 let word = word_at(key, row.saturating_mul(SLICE_BYTES));
125 let col = (word as usize) % self.cols;
126 if let Some(c) = self.counters.get_mut(row * self.cols + col) {
127 *c = c.saturating_add(1);
128 }
129 }
130 }
131
132 /// Age every counter by `times` halvings. A `times` at or past the counter
133 /// width drains the counter to zero — a shard cold for that many windows has
134 /// nothing left to age — rather than overflowing the shift.
135 pub fn halve(&mut self, times: u32) {
136 if times == 0 {
137 return;
138 }
139 for c in &mut self.counters {
140 *c = c.checked_shr(times).unwrap_or(0);
141 }
142 }
143
144 /// The count-min estimate for `key`: the smallest of its per-row
145 /// counters, which bounds the collision overcount.
146 #[must_use]
147 pub fn estimate(&self, key: &Hash) -> u8 {
148 let mut min = u8::MAX;
149 for row in 0..ROWS {
150 let word = word_at(key, row.saturating_mul(SLICE_BYTES));
151 let col = (word as usize) % self.cols;
152 min = min.min(*self.counters.get(row * self.cols + col).unwrap_or(&0));
153 }
154 min
155 }
156}
157
158/// One shard's counters plus the aging epoch they have already been caught up
159/// to. `epoch` trails the sketch's node-wide epoch until the shard is next
160/// touched; [`ShardedCountMinSketch::catch_up`] closes the gap.
161#[derive(Debug)]
162struct Shard {
163 sketch: CountMinSketch,
164 epoch: u64,
165}
166
167/// A [`CountMinSketch`] split into [`SHARDS`] independently locked pieces on one
168/// node-wide aging clock.
169///
170/// Every counter a given key touches lives in one shard, so an `increment` or an
171/// `estimate` takes exactly one shard lock and reads or writes `ROWS` counters.
172/// Halving is per shard and lazy: the shard is aged on its next touch by the
173/// number of node-wide windows that have elapsed since it last caught up. So the
174/// work is `1 / SHARDS` of the array under one lock, while every counter is
175/// caught up to the node-wide clock at the moment it is read. A ranking pass
176/// reads each candidate separately, so it carries one halving of skew for every
177/// window boundary it crosses. A halving applied evenly across a pass cannot
178/// invert the ranking — it only creates ties — but candidates read on opposite
179/// sides of a boundary can misorder, and two whose estimates are within a
180/// factor of two can swap. The window is `cols * 10` observations, so a sweep
181/// crossing even one boundary is already the uncommon case, and the next sweep
182/// re-reads both on one side of it.
183///
184/// The interior mutability is deliberate — the frequency estimator is shared by
185/// every serve completion and every fill-path admission read, so both take
186/// `&self` and neither can serialize on a single process-wide lock.
187#[derive(Debug)]
188pub struct ShardedCountMinSketch {
189 shards: Vec<Mutex<Shard>>,
190 /// Node-wide observation count. Divided by `window` it gives the current
191 /// aging epoch, so the epoch needs no separate atomic and no reset — there
192 /// is no read-modify-write for two observers to race on.
193 observations: AtomicU64,
194 /// Observations per aging window, node-wide. Equals `total_cols * 10`, the
195 /// window a single sketch of the same total width would halve on.
196 window: u64,
197}
198
199impl ShardedCountMinSketch {
200 /// Build a sketch of `cols` total columns per row, spread over [`SHARDS`]
201 /// shards. The per-shard width rounds up, so the realized total is at least
202 /// `cols`.
203 ///
204 /// A `cols` below [`SHARDS`] yields one shard per column, which is a
205 /// degenerate sketch rather than a smaller one: at `cols <= SHARDS` every
206 /// shard is one column wide, so all `ROWS` rows of a shard index the same
207 /// counter and the min-over-rows stops discriminating between keys that
208 /// share a shard. Callers wanting a small sketch should floor `cols` well
209 /// above [`SHARDS`], as [`super::tinylfu::TinyLfuEstimator::new`] does.
210 #[must_use]
211 pub fn new(cols: usize) -> Self {
212 let cols = cols.max(1);
213 let shard_count = SHARDS.min(cols);
214 let per_shard = cols.div_ceil(shard_count);
215 let total_cols = per_shard.saturating_mul(shard_count) as u64;
216 Self {
217 shards: (0..shard_count)
218 .map(|_| {
219 Mutex::new(Shard {
220 sketch: CountMinSketch::new(per_shard),
221 epoch: 0,
222 })
223 })
224 .collect(),
225 observations: AtomicU64::new(0),
226 window: total_cols.saturating_mul(AGING_WINDOW_PER_COL).max(1),
227 }
228 }
229
230 /// The shard owning `key`. Derived from a byte slice the row indices do not
231 /// read, so a key's shard and its per-row columns are independent.
232 fn shard_of(&self, key: &Hash) -> Option<&Mutex<Shard>> {
233 let count = self.shards.len();
234 if count == 0 {
235 return None;
236 }
237 let idx = (word_at(key, SHARD_SLICE_START) as usize) % count;
238 self.shards.get(idx)
239 }
240
241 /// Age `shard` up to `epoch` before it is read or written, so a counter is
242 /// caught up to the node-wide clock at the moment it is observed. `epoch`
243 /// never regresses a shard: a caller that computed a stale one finds
244 /// `elapsed` at zero and leaves the counters alone.
245 fn catch_up(shard: &mut Shard, epoch: u64) {
246 let elapsed = epoch.saturating_sub(shard.epoch);
247 if elapsed == 0 {
248 return;
249 }
250 shard
251 .sketch
252 .halve(u32::try_from(elapsed).unwrap_or(u32::MAX));
253 shard.epoch = epoch;
254 }
255
256 /// Count one sighting of `key`, locking only its shard.
257 ///
258 /// A poisoned shard recovers its inner sketch: the counters are approximate
259 /// popularity evidence, and refusing to count into a shard would silently
260 /// freeze the frequency of every key routed to it.
261 pub fn increment(&self, key: &Hash) {
262 // Claim this observation's slot on the node-wide clock before writing,
263 // so at any instant the clock already counts every sighting committed to
264 // a shard. An estimator loads the clock outside the shard lock, so the
265 // epoch it applies is stale by whatever lands between its load and its
266 // acquisition: it under-ages, never over-ages. Nothing bounds that gap
267 // in principle; a window is `cols * 10` observations, so in practice it
268 // is zero.
269 let observed = self
270 .observations
271 .fetch_add(1, Ordering::Relaxed)
272 .saturating_add(1);
273 let epoch = observed / self.window;
274 if let Some(shard) = self.shard_of(key) {
275 let mut guard = shard.lock().unwrap_or_else(PoisonError::into_inner);
276 Self::catch_up(&mut guard, epoch);
277 guard.sketch.increment(key);
278 }
279 }
280
281 /// The count-min estimate for `key`, locking only its shard. An absent shard
282 /// (only reachable from an empty sketch) reads as never seen.
283 ///
284 /// Reading ages the shard too: a key in a shard that has seen no traffic for
285 /// several windows must not read back at the value it held before those
286 /// windows elapsed, or a cold key would outrank a warm one.
287 #[must_use]
288 pub fn estimate(&self, key: &Hash) -> u8 {
289 let epoch = self.observations.load(Ordering::Relaxed) / self.window;
290 self.shard_of(key).map_or(0, |shard| {
291 let mut guard = shard.lock().unwrap_or_else(PoisonError::into_inner);
292 Self::catch_up(&mut guard, epoch);
293 guard.sketch.estimate(key)
294 })
295 }
296}
297
298#[cfg(test)]
299mod tests;