Skip to main content

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;