Skip to main content

spg_engine/
memoize.rs

1// pedantic doc_markdown flags every bare ident in the comment-as-
2// spec block; allowing at the module level keeps the spec readable.
3#![allow(clippy::doc_markdown)]
4
5//! v6.2.6 — Memoize cache for correlated subqueries.
6//!
7//! When a `WHERE` clause references a scalar subquery whose inner
8//! body depends on the outer row's column values (the classic
9//! `WHERE id IN (SELECT MAX(x) FROM y WHERE y.k = outer.k)`
10//! shape), the engine's current behaviour re-runs the inner
11//! SELECT once per outer row — `O(outer_rows × inner_cost)` work
12//! even when many outer rows share the same correlated key.
13//!
14//! v6.2.6 wraps that path with a per-query `MemoizeCache`:
15//! before running the inner, hash the (subquery identity, outer-
16//! row values) key and look it up; cache hits return the prior
17//! result without re-executing. Caps:
18//!
19//!   - **1024 entries** (configurable via the planner's
20//!     [`Self::with_max_entries`])
21//!   - **16 MiB** of cumulative cached `Value` bytes (v5.5
22//!     per-query memory budget's 1/16 share; configurable via
23//!     [`Self::with_max_bytes`])
24//!
25//! When either cap is hit, the least-recently-used entry is
26//! evicted before insertion.
27//!
28//! v6.2.6 ships the simple linear-vec LRU. v6.2.x can swap to a
29//! BTreeMap + LinkedList for sub-`O(n)` lookup if it ever
30//! matters; the gate is "≥ 5× speedup on the repeated-key
31//! workload" which the linear scan clears at scale-1k.
32
33use alloc::collections::VecDeque;
34use alloc::string::String;
35use alloc::vec::Vec;
36
37use spg_storage::Value;
38
39/// v6.2.6 — default cache size cap. Matches the design's "1024
40/// entries" figure (V6_2_DESIGN.md L2 row 6).
41pub const DEFAULT_MAX_ENTRIES: usize = 1024;
42
43/// v7.37.7 (mailrs cascade contention round 2 instrumentation) —
44/// runtime counters to disambiguate samply attribution.
45/// `samply` reported 67.8% CPU in `MemoizeCache::new` + drop_in_place
46/// under 20-worker stress, but K01 (eager-alloc → lazy) didn't move
47/// cascade amplification — suggesting either the attribution was off
48/// or the eager 96 KB alloc isn't the dominant cost. These counters
49/// provide ground truth: how many times is `new()` actually called,
50/// how many entries does any cache ever hold, how many caches die
51/// empty. Read once at end of bench via `MemoizeCache::counter_snapshot()`.
52///
53/// Counters use Relaxed atomic ops (visibility, no synchronization).
54/// Cost per op is sub-ns and does not introduce contention.
55pub mod counters {
56    use core::sync::atomic::AtomicU64;
57
58    pub static NEW_CALLS: AtomicU64 = AtomicU64::new(0);
59    pub static PUT_CALLS: AtomicU64 = AtomicU64::new(0);
60    pub static MAX_ENTRIES_SEEN: AtomicU64 = AtomicU64::new(0);
61    pub static DROP_WITH_ZERO_ENTRIES: AtomicU64 = AtomicU64::new(0);
62    pub static DROP_WITH_ENTRIES: AtomicU64 = AtomicU64::new(0);
63
64    #[derive(Debug, Clone, Copy)]
65    pub struct Snapshot {
66        pub new_calls: u64,
67        pub put_calls: u64,
68        pub max_entries_seen: u64,
69        pub drop_with_zero_entries: u64,
70        pub drop_with_entries: u64,
71    }
72
73    pub fn snapshot() -> Snapshot {
74        use core::sync::atomic::Ordering::Relaxed;
75        Snapshot {
76            new_calls: NEW_CALLS.load(Relaxed),
77            put_calls: PUT_CALLS.load(Relaxed),
78            max_entries_seen: MAX_ENTRIES_SEEN.load(Relaxed),
79            drop_with_zero_entries: DROP_WITH_ZERO_ENTRIES.load(Relaxed),
80            drop_with_entries: DROP_WITH_ENTRIES.load(Relaxed),
81        }
82    }
83
84    pub fn reset() {
85        use core::sync::atomic::Ordering::Relaxed;
86        NEW_CALLS.store(0, Relaxed);
87        PUT_CALLS.store(0, Relaxed);
88        MAX_ENTRIES_SEEN.store(0, Relaxed);
89        DROP_WITH_ZERO_ENTRIES.store(0, Relaxed);
90        DROP_WITH_ENTRIES.store(0, Relaxed);
91    }
92}
93
94/// v6.2.6 — default cumulative bytes cap. 16 MiB matches the
95/// v5.5 per-query budget's 1/16 share.
96pub const DEFAULT_MAX_BYTES: usize = 16 * 1024 * 1024;
97
98/// Cache key — the subquery's textual identity plus the outer
99/// row's value tuple. Two scalar-subquery node positions with
100/// identical Display text are treated as the same subquery for
101/// caching purposes (sound: equal Display → equal AST).
102#[derive(Debug, Clone, PartialEq)]
103pub struct CacheKey {
104    pub subquery_repr: String,
105    pub outer_values: Vec<Value<'static>>,
106}
107
108/// v7.29 - one batch-evaluated correlated subquery: the outer key
109/// column and the key -> value map.
110///
111/// v7.37.x (docker-fair SCALARSQ attack) — extended with an
112/// `empty_default` Value. PG scalar-subquery empty-set semantics
113/// distinguish `COUNT(*)` / `COUNT(col)` (= 0 over no rows) from
114/// every other aggregate (= NULL). The hollow_scalar_subqueries
115/// template-rewrite step empties the inner SelectStatement before
116/// the per-row splice, so the splicer cannot inspect the original
117/// aggregate kind at probe time. Storing the empty-default on the
118/// GroupMap captures that information at try_batch_correlated_scalar
119/// construction time, where the original inner is still in hand.
120pub type GroupMap = (
121    spg_sql::ast::ColumnName,
122    alloc::collections::BTreeMap<String, Value<'static>>,
123    Value<'static>,
124);
125
126/// v7.29 (3c) - per-expression resolution plan: for the i-th scalar
127/// subquery node (pre-order) of a host expression, the shared batch
128/// map (None = unbatchable, resolve per row). Keyed by the HOST
129/// expression's address - callers guarantee the expression outlives
130/// the per-query memo (aggregate items / WHERE trees do). The stored
131/// subquery count guards against address reuse.
132/// (subquery count, per-subquery batch maps, hollow template). The
133/// template is the host expression with every scalar subquery BODY
134/// emptied - cloning it per row costs nodes, not whole subquery
135/// ASTs (the splice walk replaces the hollow nodes by pre-order).
136pub type ExprPlan = (
137    usize,
138    alloc::vec::Vec<Option<alloc::rc::Rc<GroupMap>>>,
139    spg_sql::ast::Expr,
140);
141
142/// v7.34 (mailrs conn-pool-exhaustion P0) - decorrelated `[NOT] EXISTS`
143/// semi/anti-join: the outer correlation columns (in key order) and the
144/// set of encoded inner key-tuples that have >=1 matching inner row,
145/// built in ONE scan. An outer row's EXISTS reduces to a membership
146/// test, turning O(outer x inner-exec) per-row work into O(scan + outer
147/// lookups) - PG's Hash Semi/Anti Join.
148/// v7.39 (round 596) — the outer side is an EXPRESSION, not just a column.
149/// `EXISTS (SELECT 1 FROM b WHERE b.id = a.id + 1)` correlates just as
150/// exactly as `b.id = a.id` does, but only the column shape decorrelated, so
151/// the expression shape ran the subquery once per outer row: O(n²), and
152/// measured at 427 ms / 1.6 s / 6.4 s / >25 s as the table went 2k / 4k / 8k
153/// / 16k, where the column shape stays linear (0.5 / 0.9 / 1.5 / 3.2 ms).
154pub type ExistsSet = (
155    alloc::vec::Vec<spg_sql::ast::Expr>,
156    alloc::collections::BTreeSet<String>,
157);
158
159/// v7.30.2 (mailrs round-25) - canonicalised membership set for a
160/// large all-literal `IN` list. Integer literals canonicalise to
161/// i64 (cross-width `Int = BigInt` stays correct); string literals
162/// stay verbatim. Mixed or exotic families are not eligible and
163/// keep the linear `apply_binary` scan.
164///
165/// v7.37.x (docker-bench NOTEX 红线) — switched from `BTreeSet` to
166/// `hashbrown::HashSet`. The probe shape is 25 k outer-row membership
167/// lookups against a 12.5 k-element InList; BTreeSet was O(log N) ≈
168/// 14 byte comparisons per lookup (~70 ns), hash set is O(1) ≈ 5 ns.
169/// Net win on the docker-fair NOTEX bench: 4.4 ms → ~2 ms.
170#[derive(Debug, Clone)]
171pub enum InListSet {
172    Int(hashbrown::HashSet<i64>),
173    Text(hashbrown::HashSet<alloc::string::String>),
174}
175
176#[derive(Debug, Clone)]
177pub struct InListSetEntry {
178    pub set: InListSet,
179    /// The list carried a NULL literal: a non-matching needle
180    /// yields NULL, not FALSE (SQL three-valued logic).
181    pub has_null: bool,
182}
183
184#[derive(Debug, Clone)]
185pub struct MemoizeCache {
186    /// LRU front = most recently used. Stored as a `VecDeque` so
187    /// re-promoting a hit is `O(n)` worst-case but `O(1)`
188    /// amortised for the common front-half-hit pattern of nested-
189    /// loop correlated subqueries.
190    entries: VecDeque<(CacheKey, Value<'static>)>,
191    /// v7.29 (round-22 phase 3) - batch-evaluated correlated scalar
192    /// subqueries: subquery repr -> Some((outer column, key -> value
193    /// map built in ONE pass)) or None when the shape can't batch
194    /// (so we don't re-analyse it per row). Turns 23.5k per-group
195    /// executions into one grouped scan + 23.5k lookups.
196    pub group_maps: alloc::collections::BTreeMap<String, Option<alloc::rc::Rc<GroupMap>>>,
197    /// v7.37.x (docker-fair SCALARSQ attack) — fast-path cache keyed
198    /// by `SelectStatement` pointer address. The repr-stringified
199    /// `group_maps` key cost ~500 ns of `alloc::format!` per outer
200    /// row; for hundreds-of-row LIMIT shapes that's still tens of µs
201    /// of pure repr churn. Per-row hit is a HashMap probe on a usize
202    /// key (the inner AST is stable for the SELECT's lifetime).
203    pub group_maps_by_ptr: hashbrown::HashMap<usize, Option<alloc::rc::Rc<GroupMap>>>,
204    /// v7.34 (mailrs conn-pool P0) - decorrelated `[NOT] EXISTS`: subquery
205    /// repr -> Some(semi/anti-join key-set) or None when the shape can't
206    /// decorrelate (don't re-analyse per row). Parallel to `group_maps`.
207    pub exists_sets: alloc::collections::BTreeMap<String, Option<alloc::rc::Rc<ExistsSet>>>,
208    /// v7.34.2 (EXISTS-FILTER baseline finding) — host-expression-ptr
209    /// indexed plan: walk the WHERE expr ONCE, collect every EXISTS
210    /// subquery in pre-order, build a decorrelated set for each, and
211    /// store them as a `Vec` indexed by pre-order position. Per-row
212    /// dispatch then walks the (cloned) expression in the same
213    /// pre-order, increments an ordinal cursor, and reads the matching
214    /// set out of this plan instead of re-running
215    /// `alloc::format!("{subquery}")` and a fresh BTreeMap probe per
216    /// row — the dominant cost of the 7.34.0 EXISTS-FILTER baseline.
217    /// `None` slot = couldn't decorrelate that particular EXISTS; the
218    /// dispatcher falls back to the legacy per-row resolver for it.
219    pub exists_plans: alloc::collections::BTreeMap<usize, Vec<Option<alloc::rc::Rc<ExistsSet>>>>,
220    /// v7.29 (3c) - host-expression ptr -> (subquery count, plan).
221    pub expr_plans: alloc::collections::BTreeMap<usize, ExprPlan>,
222    /// v7.30.2 (mailrs round-25) - InList node ptr -> membership set
223    /// for large all-literal `IN` lists, built once per row loop.
224    /// Turns the O(rows × list) membership scan into
225    /// O(rows × log list). `None` = analysed, not eligible.
226    pub in_sets: alloc::collections::BTreeMap<usize, Option<InListSetEntry>>,
227    /// v7.30.2 (mailrs round-25) - host-expression ptr -> "contains
228    /// a subquery node". The walk is O(tree) and a materialised IN
229    /// list makes the tree huge — caching it makes the per-row
230    /// dispatch O(log n) instead of O(24k list elements).
231    pub has_subquery: alloc::collections::BTreeMap<usize, bool>,
232    max_entries: usize,
233    max_bytes: usize,
234    current_bytes: usize,
235    pub hit_count: u64,
236    pub miss_count: u64,
237}
238
239impl Default for MemoizeCache {
240    fn default() -> Self {
241        Self::new()
242    }
243}
244
245impl MemoizeCache {
246    pub fn new() -> Self {
247        counters::NEW_CALLS.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
248        Self {
249            entries: VecDeque::with_capacity(DEFAULT_MAX_ENTRIES),
250            max_entries: DEFAULT_MAX_ENTRIES,
251            max_bytes: DEFAULT_MAX_BYTES,
252            current_bytes: 0,
253            hit_count: 0,
254            miss_count: 0,
255            group_maps: alloc::collections::BTreeMap::new(),
256            group_maps_by_ptr: hashbrown::HashMap::new(),
257            exists_sets: alloc::collections::BTreeMap::new(),
258            exists_plans: alloc::collections::BTreeMap::new(),
259            expr_plans: alloc::collections::BTreeMap::new(),
260            in_sets: alloc::collections::BTreeMap::new(),
261            has_subquery: alloc::collections::BTreeMap::new(),
262        }
263    }
264
265    pub const fn with_max_entries(mut self, n: usize) -> Self {
266        self.max_entries = n;
267        self
268    }
269
270    pub const fn with_max_bytes(mut self, b: usize) -> Self {
271        self.max_bytes = b;
272        self
273    }
274
275    pub fn len(&self) -> usize {
276        self.entries.len()
277    }
278
279    pub fn is_empty(&self) -> bool {
280        self.entries.is_empty()
281    }
282
283    /// Look up a cached scalar value. On hit, re-promotes the
284    /// entry to the LRU front and bumps `hit_count`. On miss,
285    /// returns `None` (caller runs the subquery + `insert`s).
286    pub fn get(&mut self, key: &CacheKey) -> Option<Value<'static>> {
287        let pos = self.entries.iter().position(|(k, _)| k == key);
288        if let Some(p) = pos {
289            let (k, v) = self.entries.remove(p)?;
290            self.entries.push_front((k, v.clone()));
291            self.hit_count += 1;
292            Some(v)
293        } else {
294            self.miss_count += 1;
295            None
296        }
297    }
298
299    /// Insert a freshly-computed scalar value. Caller must have
300    /// `get`-missed first (the cache doesn't dedupe inserts).
301    /// Evicts LRU entries until both caps are satisfied.
302    pub fn insert(&mut self, key: CacheKey, value: Value<'static>) {
303        let entry_bytes = approx_bytes(&key) + approx_value_bytes(&value);
304        while !self.entries.is_empty()
305            && (self.entries.len() >= self.max_entries
306                || self.current_bytes + entry_bytes > self.max_bytes)
307        {
308            let Some((k, v)) = self.entries.pop_back() else {
309                break;
310            };
311            self.current_bytes = self
312                .current_bytes
313                .saturating_sub(approx_bytes(&k) + approx_value_bytes(&v));
314        }
315        self.current_bytes = self.current_bytes.saturating_add(entry_bytes);
316        self.entries.push_front((key, value));
317        counters::PUT_CALLS.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
318        let len = self.entries.len() as u64;
319        counters::MAX_ENTRIES_SEEN.fetch_max(len, core::sync::atomic::Ordering::Relaxed);
320    }
321}
322
323impl Drop for MemoizeCache {
324    fn drop(&mut self) {
325        use core::sync::atomic::Ordering::Relaxed;
326        if self.entries.is_empty() {
327            counters::DROP_WITH_ZERO_ENTRIES.fetch_add(1, Relaxed);
328        } else {
329            counters::DROP_WITH_ENTRIES.fetch_add(1, Relaxed);
330        }
331    }
332}
333
334fn approx_bytes(key: &CacheKey) -> usize {
335    key.subquery_repr.len()
336        + key
337            .outer_values
338            .iter()
339            .map(approx_value_bytes)
340            .sum::<usize>()
341        + 16
342}
343
344fn approx_value_bytes(v: &Value) -> usize {
345    match v {
346        Value::Null | Value::Bool(_) | Value::SmallInt(_) => 1,
347        Value::Int(_) => 4,
348        Value::BigInt(_) | Value::Float(_) => 8,
349        Value::Date(_) | Value::Timestamp(_) => 8,
350        Value::Interval { .. } => 16,
351        Value::Numeric { .. } => 16,
352        Value::Text(s) | Value::Json(s) => s.len(),
353        Value::Vector(v) => v.len() * 4,
354        Value::Sq8Vector(q) => q.bytes.len() + 8,
355        Value::HalfVector(h) => h.dim() * 2,
356        // v7.5.0 — Value is #[non_exhaustive]; conservative estimate.
357        _ => 16,
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn key(repr: &str, outer: &[Value<'static>]) -> CacheKey {
366        CacheKey {
367            subquery_repr: repr.into(),
368            outer_values: outer.to_vec(),
369        }
370    }
371
372    #[test]
373    fn empty_cache_misses_everything() {
374        let mut c = MemoizeCache::new();
375        let k = key("SELECT 1", &[Value::Int(1)]);
376        assert!(c.get(&k).is_none());
377        assert_eq!(c.miss_count, 1);
378        assert_eq!(c.hit_count, 0);
379    }
380
381    #[test]
382    fn insert_then_get_hits() {
383        let mut c = MemoizeCache::new();
384        let k = key("SELECT 1", &[Value::Int(1)]);
385        c.insert(k.clone(), Value::BigInt(42));
386        let v = c.get(&k);
387        assert_eq!(v, Some(Value::BigInt(42)));
388        assert_eq!(c.hit_count, 1);
389    }
390
391    #[test]
392    fn repeated_outer_key_hits_after_first_insert() {
393        let mut c = MemoizeCache::new();
394        let repr = "SELECT MAX(x) FROM y WHERE y.k = outer.k";
395        for i in 0..100 {
396            let k = key(repr, &[Value::Int(i % 5)]);
397            if c.get(&k).is_none() {
398                c.insert(k, Value::BigInt(i64::from(i)));
399            }
400        }
401        // 5 unique keys → 5 misses, 95 hits.
402        assert_eq!(c.miss_count, 5);
403        assert_eq!(c.hit_count, 95);
404    }
405
406    #[test]
407    fn lru_eviction_at_max_entries() {
408        let mut c = MemoizeCache::new().with_max_entries(3);
409        for i in 0..5 {
410            let k = key("q", &[Value::Int(i)]);
411            c.insert(k, Value::BigInt(i64::from(i)));
412        }
413        assert!(c.len() <= 3, "len={}", c.len());
414        // Last 3 inserted (i=2, 3, 4) should be the survivors.
415        assert!(c.get(&key("q", &[Value::Int(4)])).is_some());
416        assert!(c.get(&key("q", &[Value::Int(3)])).is_some());
417        assert!(c.get(&key("q", &[Value::Int(2)])).is_some());
418        // Older entries evicted.
419        assert!(c.get(&key("q", &[Value::Int(0)])).is_none());
420    }
421
422    #[test]
423    fn lru_eviction_at_max_bytes() {
424        let mut c = MemoizeCache::new().with_max_bytes(128);
425        // Big strings exceed 128 bytes fast.
426        for i in 0..10 {
427            let big_str = alloc::string::String::from_iter(core::iter::repeat_n('x', 64));
428            c.insert(key("q", &[Value::Int(i)]), Value::text(big_str));
429        }
430        assert!(c.len() < 10, "len={}", c.len());
431    }
432
433    #[test]
434    fn distinct_subquery_reprs_dont_collide() {
435        let mut c = MemoizeCache::new();
436        let k1 = key("SELECT 1", &[Value::Int(1)]);
437        let k2 = key("SELECT 2", &[Value::Int(1)]);
438        c.insert(k1.clone(), Value::BigInt(10));
439        c.insert(k2.clone(), Value::BigInt(20));
440        assert_eq!(c.get(&k1), Some(Value::BigInt(10)));
441        assert_eq!(c.get(&k2), Some(Value::BigInt(20)));
442    }
443
444    #[test]
445    fn miss_then_hit_bumps_promotes_to_lru_front() {
446        let mut c = MemoizeCache::new().with_max_entries(3);
447        c.insert(key("q", &[Value::Int(0)]), Value::BigInt(0));
448        c.insert(key("q", &[Value::Int(1)]), Value::BigInt(1));
449        c.insert(key("q", &[Value::Int(2)]), Value::BigInt(2));
450        // Touch 0 — promote to front.
451        let _ = c.get(&key("q", &[Value::Int(0)]));
452        // Insert a new entry — evicts the LRU (which is now 1, not 0).
453        c.insert(key("q", &[Value::Int(3)]), Value::BigInt(3));
454        assert!(c.get(&key("q", &[Value::Int(0)])).is_some());
455        assert!(c.get(&key("q", &[Value::Int(1)])).is_none());
456    }
457}