Skip to main content

rlx_cpu/
dequant_cache.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4//! Cache dequantized GGUF weight bytes for static params.
5//!
6//! Qwen3.5 decode with `--packed` was re-dequantizing every K-quant
7//! weight on every matmul (hundreds of times per token). Keys are
8//! `(w_off, len, k, n, scheme, sample)` — arena byte offset plus a cheap
9//! head/mid/tail sample so distinct tensors that share an offset across
10//! graphs do not collide. Do **not** key on the absolute data pointer:
11//! the CPU arena base can move between forwards, which would treat every
12//! token as a cache miss and OOM after duplicating f32 weights.
13
14use rlx_ir::quant::QuantScheme;
15use std::collections::HashMap;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::{Arc, OnceLock, RwLock};
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
20struct DequantKey {
21    w_off: u64,
22    len: u64,
23    k: u32,
24    n: u32,
25    scheme: u8,
26    /// Cheap content sample (not a full-slab hash).
27    sample: u64,
28}
29
30fn weight_bytes_sample(w_bytes: &[u8]) -> u64 {
31    // O(1) content sample — full-slab DefaultHasher on every lookup made
32    // decode dominate on large GGUF mats (lm_head alone is hundreds of MiB).
33    use std::hash::{Hash, Hasher};
34    let mut hasher = std::collections::hash_map::DefaultHasher::new();
35    let n = w_bytes.len();
36    n.hash(&mut hasher);
37    if n == 0 {
38        return hasher.finish();
39    }
40    const CHUNK: usize = 64;
41    let head = CHUNK.min(n);
42    w_bytes[..head].hash(&mut hasher);
43    if n > CHUNK * 2 {
44        let mid = n / 2;
45        let half = CHUNK / 2;
46        w_bytes[mid - half..mid + half].hash(&mut hasher);
47        w_bytes[n - CHUNK..].hash(&mut hasher);
48    } else if n > CHUNK {
49        w_bytes[n - head..].hash(&mut hasher);
50    }
51    hasher.finish()
52}
53
54fn scheme_tag(scheme: QuantScheme) -> u8 {
55    match scheme {
56        QuantScheme::GgufQ4K => 1,
57        QuantScheme::GgufQ5K => 2,
58        QuantScheme::GgufQ6K => 3,
59        QuantScheme::GgufQ8K => 4,
60        QuantScheme::GgufQ4_0 => 5,
61        QuantScheme::GgufQ8_0 => 6,
62        QuantScheme::GgufQ2K => 7,
63        QuantScheme::GgufQ3K => 8,
64        QuantScheme::GgufIQ4NL => 9,
65        QuantScheme::GgufIQ4XS => 10,
66        QuantScheme::GgufIQ2XXS => 11,
67        QuantScheme::GgufIQ2XS => 12,
68        QuantScheme::GgufIQ2S => 13,
69        QuantScheme::GgufIQ3XXS => 14,
70        QuantScheme::GgufIQ3S => 15,
71        QuantScheme::GgufIQ1S => 16,
72        QuantScheme::GgufIQ1M => 17,
73        QuantScheme::GgufTQ1_0 => 18,
74        QuantScheme::GgufTQ2_0 => 19,
75        QuantScheme::GgufMXFP4 => 20,
76        QuantScheme::GgufNVFP4 => 21,
77        QuantScheme::GgufQ4_1 => 22,
78        QuantScheme::GgufQ5_0 => 23,
79        QuantScheme::GgufQ5_1 => 24,
80        QuantScheme::GgufQ1_0 => 25,
81        QuantScheme::GgufQ2_0 => 26,
82        QuantScheme::GgufFV5 => 27,
83        QuantScheme::GgufFV5B => 28,
84        _ => 255,
85    }
86}
87
88fn dequant_gguf_serial(w_bytes: &[u8], n_elems: usize, scheme: QuantScheme) -> Vec<f32> {
89    match scheme {
90        QuantScheme::GgufQ4K => rlx_gguf::dequant_q4_k(w_bytes, n_elems),
91        QuantScheme::GgufQ5K => rlx_gguf::dequant_q5_k(w_bytes, n_elems),
92        QuantScheme::GgufQ6K => rlx_gguf::dequant_q6_k(w_bytes, n_elems),
93        QuantScheme::GgufQ8K => rlx_gguf::dequant_q8_k(w_bytes, n_elems),
94        QuantScheme::GgufQ2K => rlx_gguf::dequant_q2_k(w_bytes, n_elems),
95        QuantScheme::GgufQ3K => rlx_gguf::dequant_q3_k(w_bytes, n_elems),
96        QuantScheme::GgufQ4_0 => rlx_gguf::dequant_q4_0(w_bytes, n_elems),
97        QuantScheme::GgufQ4_1 => rlx_gguf::dequant_q4_1(w_bytes, n_elems),
98        QuantScheme::GgufQ5_0 => rlx_gguf::dequant_q5_0(w_bytes, n_elems),
99        QuantScheme::GgufQ5_1 => rlx_gguf::dequant_q5_1(w_bytes, n_elems),
100        QuantScheme::GgufQ8_0 => rlx_gguf::dequant_q8_0(w_bytes, n_elems),
101        QuantScheme::GgufIQ4NL => rlx_gguf::iq_dequant::dequant_iq4_nl(w_bytes, n_elems),
102        QuantScheme::GgufIQ4XS => rlx_gguf::iq_dequant::dequant_iq4_xs(w_bytes, n_elems),
103        QuantScheme::GgufIQ2XXS => rlx_gguf::iq_dequant::dequant_iq2_xxs(w_bytes, n_elems),
104        QuantScheme::GgufIQ2XS => rlx_gguf::iq_dequant::dequant_iq2_xs(w_bytes, n_elems),
105        QuantScheme::GgufIQ2S => rlx_gguf::iq_dequant::dequant_iq2_s(w_bytes, n_elems),
106        QuantScheme::GgufIQ3XXS => rlx_gguf::iq_dequant::dequant_iq3_xxs(w_bytes, n_elems),
107        QuantScheme::GgufIQ3S => rlx_gguf::iq_dequant::dequant_iq3_s(w_bytes, n_elems),
108        QuantScheme::GgufIQ1S => rlx_gguf::iq_dequant::dequant_iq1_s(w_bytes, n_elems),
109        QuantScheme::GgufIQ1M => rlx_gguf::iq_dequant::dequant_iq1_m(w_bytes, n_elems),
110        QuantScheme::GgufTQ1_0 => rlx_gguf::tq_dequant::dequant_tq1_0(w_bytes, n_elems),
111        QuantScheme::GgufTQ2_0 => rlx_gguf::tq_dequant::dequant_tq2_0(w_bytes, n_elems),
112        QuantScheme::GgufMXFP4 => rlx_gguf::mx_dequant::dequant_mxfp4(w_bytes, n_elems),
113        QuantScheme::GgufNVFP4 => rlx_gguf::mx_dequant::dequant_nvfp4(w_bytes, n_elems),
114        QuantScheme::GgufQ1_0 => rlx_gguf::q1_dequant::dequant_q1_0(w_bytes, n_elems),
115        QuantScheme::GgufQ2_0 => rlx_gguf::q2_dequant::dequant_q2_0(w_bytes, n_elems),
116        QuantScheme::GgufFV5 => rlx_gguf::fv5_dequant::dequant_fv5(w_bytes, n_elems),
117        QuantScheme::GgufFV5B => rlx_gguf::fv5_dequant::dequant_fv5b(w_bytes, n_elems),
118        other => panic!("dequant_cache: unsupported GGUF scheme {other:?}"),
119    }
120    .expect("GGUF dequant failed")
121}
122
123/// GGUF dequant, parallelized **in place** across cores on block boundaries.
124///
125/// Each GGUF block dequants independently into its own 256-elem output slice,
126/// so per-block parallelism is **bit-identical** to the serial path with no
127/// intermediate copy — it just spreads the (compute-bound) work across the pool,
128/// which speeds the one-time dequant-cache warmup by ~cores×. Wired for the
129/// 256-elem K-quants with in-place block kernels (Q4_K / Q6_K — the LFM2.5
130/// linears + embed); every other scheme uses the serial path unchanged. Opt out
131/// with `RLX_DEQUANT_PARALLEL=0`.
132fn dequant_gguf(w_bytes: &[u8], k: usize, n: usize, scheme: QuantScheme) -> Vec<f32> {
133    use rayon::prelude::*;
134    const QK_K: usize = 256;
135    let n_elems = k * n;
136    let parallel = n_elems >= QK_K * 512
137        && n_elems.is_multiple_of(QK_K)
138        && matches!(scheme, QuantScheme::GgufQ4K | QuantScheme::GgufQ6K)
139        && rlx_ir::env::var("RLX_DEQUANT_PARALLEL").as_deref() != Some("0");
140    if parallel {
141        let block_bytes = scheme.gguf_block_bytes() as usize;
142        let n_blocks = n_elems / QK_K;
143        if w_bytes.len() >= n_blocks * block_bytes {
144            let wb = &w_bytes[..n_blocks * block_bytes];
145            let mut out = vec![0f32; n_elems];
146            // A few coarse chunks (≈ one per thread), each dequanting its blocks
147            // in place — avoids both the per-block rayon task overhead and any
148            // intermediate copy.
149            let threads = rayon::current_num_threads().max(1);
150            let blocks_per_chunk = n_blocks.div_ceil(threads).max(1);
151            out.par_chunks_mut(blocks_per_chunk * QK_K)
152                .enumerate()
153                .for_each(|(ci, out_chunk)| {
154                    let base = ci * blocks_per_chunk;
155                    for j in 0..(out_chunk.len() / QK_K) {
156                        let bb = &wb[(base + j) * block_bytes..(base + j + 1) * block_bytes];
157                        let arr: &mut [f32; QK_K] = (&mut out_chunk[j * QK_K..(j + 1) * QK_K])
158                            .try_into()
159                            .expect("256-elem block");
160                        match scheme {
161                            QuantScheme::GgufQ4K => rlx_gguf::dequant_q4_k_block(bb, arr),
162                            QuantScheme::GgufQ6K => rlx_gguf::dequant_q6_k_block(bb, arr),
163                            _ => unreachable!(),
164                        }
165                    }
166                });
167            return out;
168        }
169    }
170    dequant_gguf_serial(w_bytes, n_elems, scheme)
171}
172
173/// One cached slab plus its size and a last-touch tick for LRU ordering.
174struct Entry {
175    data: Arc<[f32]>,
176    bytes: usize,
177    /// Monotonic tick of the most recent hit/insert (interior-mutable so a
178    /// read-lock hit can refresh it without upgrading to a write lock).
179    last: AtomicU64,
180}
181
182/// The dequant cache: keyed slabs plus a running byte total for budgeting.
183#[derive(Default)]
184struct DequantCache {
185    map: HashMap<DequantKey, Entry>,
186    total_bytes: usize,
187}
188
189impl DequantCache {
190    /// Insert `arc` (`bytes` = its size) under `key`, then LRU-evict down to
191    /// `max` bytes when a budget is set. Never evicts `key` itself, so a slab
192    /// bigger than the whole budget still lands (and simply stands alone).
193    fn insert_evict(
194        &mut self,
195        key: DequantKey,
196        arc: &Arc<[f32]>,
197        bytes: usize,
198        max: Option<usize>,
199    ) {
200        let tick = TICK.fetch_add(1, Ordering::Relaxed);
201        if let Some(prev) = self.map.insert(
202            key,
203            Entry {
204                data: Arc::clone(arc),
205                bytes,
206                last: AtomicU64::new(tick),
207            },
208        ) {
209            // Replaced an existing slab (racy double-miss): correct the total.
210            self.total_bytes -= prev.bytes;
211        }
212        self.total_bytes += bytes;
213
214        let Some(max) = max else { return };
215        while self.total_bytes > max && self.map.len() > 1 {
216            let victim = self
217                .map
218                .iter()
219                .filter(|(k, _)| **k != key)
220                .min_by_key(|(_, e)| e.last.load(Ordering::Relaxed))
221                .map(|(k, _)| *k);
222            match victim {
223                Some(v) => {
224                    if let Some(e) = self.map.remove(&v) {
225                        self.total_bytes -= e.bytes;
226                        EVICTED_BYTES.fetch_add(e.bytes as u64, Ordering::Relaxed);
227                    }
228                }
229                None => break,
230            }
231        }
232    }
233}
234
235static CACHE: OnceLock<RwLock<DequantCache>> = OnceLock::new();
236/// Global monotonic clock for LRU recency.
237static TICK: AtomicU64 = AtomicU64::new(0);
238
239/// Bytes evicted over the process lifetime — the thrash signal below.
240static EVICTED_BYTES: AtomicU64 = AtomicU64::new(0);
241
242/// Set once the cache has been purged after thrash detection (see `gguf_weight_f32`).
243static THRASH_PURGED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
244
245/// Whether the cache is THRASHING: the f32 working set does not fit the budget,
246/// so entries are evicted before they are ever reused and every lookup pays a
247/// full re-dequant.
248///
249/// True once cumulative evictions exceed the budget itself, i.e. we have churned
250/// more than one whole cache — a model whose f32 form fits never trips this.
251///
252/// Callers use it to route around the cache. Measured on Muse-Glimmer-30B
253/// UD-Q4_K_XL (27.85B params ⇒ ~111 GB of f32 against a 15 GB budget): a
254/// decode-shaped `m = 1` forward took 43.1 s cached-and-thrashing vs 0.76 s on
255/// the fused int8 path — 57x. Repeat passes showed 43.1 / 43.2 / 43.5 s, i.e.
256/// the cache never paid for itself at all.
257pub fn cache_thrashing() -> bool {
258    match max_cache_bytes() {
259        // Trip at a QUARTER of the budget rather than a full one. Everything
260        // evicted before the verdict is wasted dequant work sitting directly in
261        // TTFT, so detecting sooner is a latency win; and a model whose f32 form
262        // fits never evicts at all, so no amount of lowering this can misfire on
263        // one. A quarter still demands real, sustained eviction pressure.
264        Some(max) => EVICTED_BYTES.load(Ordering::Relaxed) > (max as u64) / 4,
265        None => false,
266    }
267}
268
269fn cache_enabled() -> bool {
270    !matches!(
271        rlx_ir::env::var("RLX_DEQUANT_CACHE").as_deref(),
272        Some("0") | Some("false") | Some("off")
273    )
274}
275
276/// Optional total-bytes budget for the dequant cache (`RLX_DEQUANT_CACHE_MAX_BYTES`).
277///
278/// Unset ⇒ unbounded (historical behavior). Packed CPU decode caches an f32
279/// copy of every K-quant weight it touches (~model-size in f32 — e.g. Qwen3-0.6B
280/// holds ~2.5 GiB), so a cap bounds RSS by LRU-evicting cold slabs, trading a
281/// little re-dequant work on eviction misses for a predictable footprint.
282/// Prefer this over `RLX_DEQUANT_CACHE=0` (which disables caching entirely and
283/// re-dequantizes every matmul).
284/// Physical RAM in bytes, or `None` when it can't be determined. Self-contained
285/// on purpose: `rlx-runtime` has richer helpers but depends on this crate, not
286/// the other way round.
287fn physical_ram_bytes() -> Option<usize> {
288    static RAM: OnceLock<Option<usize>> = OnceLock::new();
289    *RAM.get_or_init(physical_ram_bytes_uncached)
290}
291
292fn physical_ram_bytes_uncached() -> Option<usize> {
293    #[cfg(target_os = "linux")]
294    {
295        let text = std::fs::read_to_string("/proc/meminfo").ok()?;
296        for line in text.lines() {
297            if let Some(rest) = line.strip_prefix("MemTotal:") {
298                let kb: usize = rest.split_whitespace().next()?.parse().ok()?;
299                return Some(kb * 1024);
300            }
301        }
302        None
303    }
304    #[cfg(target_vendor = "apple")]
305    {
306        // `sysctl -n hw.memsize` rather than an `extern "C" sysctlbyname`: a
307        // local extern decl collides with the one another crate in the graph
308        // already declares. Called once and memoized by the OnceLock below.
309        let out = std::process::Command::new("/usr/sbin/sysctl")
310            .args(["-n", "hw.memsize"])
311            .output()
312            .ok()?;
313        String::from_utf8(out.stdout).ok()?.trim().parse().ok()
314    }
315    #[cfg(not(any(target_os = "linux", target_vendor = "apple")))]
316    {
317        None
318    }
319}
320
321/// Fraction of physical RAM the dequant cache may hold by default.
322///
323/// This cache stores an f32 copy of every K-quant weight it touches, so it
324/// converges on the WHOLE model in f32 — 4 B/param no matter how small the
325/// quantized file is. Fine at 0.6B (~2.5 GiB); fatal at 30B: Muse-Glimmer-30B
326/// is 27.85B params ⇒ ~111 GB of cache, which OOM-killed a 61 GB box mid-prefill
327/// even though its packed arena was only 14.3 GB. A quarter of RAM keeps small
328/// models fully resident while bounding large ones to LRU churn.
329const DEFAULT_CACHE_RAM_FRACTION: f64 = 0.25;
330
331/// Total-bytes budget for the dequant cache. Resolution order:
332///   * `RLX_DEQUANT_CACHE_MAX_BYTES=<n>` — explicit cap.
333///   * `RLX_DEQUANT_CACHE_MAX_BYTES=0|unbounded|off` — no cap (the historical
334///     behavior; only safe when the f32 model comfortably fits RAM).
335///   * unset — [`DEFAULT_CACHE_RAM_FRACTION`] of physical RAM.
336///   * unset and RAM unknown — no cap.
337///
338/// Over-budget slabs are LRU-evicted, trading a little re-dequant work on a miss
339/// for a predictable footprint. Prefer this over `RLX_DEQUANT_CACHE=0`, which
340/// disables caching entirely and re-dequantizes on every matmul.
341fn max_cache_bytes() -> Option<usize> {
342    match rlx_ir::env::var("RLX_DEQUANT_CACHE_MAX_BYTES") {
343        Some(s) => {
344            let s = s.trim().to_ascii_lowercase();
345            if s == "0" || s == "unbounded" || s == "off" {
346                return None;
347            }
348            s.parse::<usize>().ok().filter(|&b| b > 0)
349        }
350        None => physical_ram_bytes()
351            .map(|ram| (ram as f64 * DEFAULT_CACHE_RAM_FRACTION) as usize)
352            .filter(|&b| b > 0),
353    }
354}
355
356/// Return dense `[k×n]` weights (GGUF row-major `[n,k]` layout) for `w_bytes`.
357///
358/// `w_off` is the arena byte offset of the quantized slab (stable across
359/// arena reallocations that preserve layout).
360pub fn gguf_weight_f32(
361    w_off: usize,
362    w_bytes: &[u8],
363    k: usize,
364    n: usize,
365    scheme: QuantScheme,
366) -> Arc<[f32]> {
367    if !cache_enabled() {
368        return Arc::from(dequant_gguf(w_bytes, k, n, scheme).into_boxed_slice());
369    }
370    let key = DequantKey {
371        w_off: w_off as u64,
372        len: w_bytes.len() as u64,
373        k: k as u32,
374        n: n as u32,
375        scheme: scheme_tag(scheme),
376        sample: weight_bytes_sample(w_bytes),
377    };
378    let cache = CACHE.get_or_init(|| RwLock::new(DequantCache::default()));
379    // Fast path: read-lock hit, refresh recency in place.
380    if let Some(hit) = {
381        let r = cache.read().expect("dequant cache poisoned");
382        r.map.get(&key).map(|e| {
383            e.last
384                .store(TICK.fetch_add(1, Ordering::Relaxed), Ordering::Relaxed);
385            Arc::clone(&e.data)
386        })
387    } {
388        return hit;
389    }
390    let dense = dequant_gguf(w_bytes, k, n, scheme);
391    // Cap individual entries so a pathological shape cannot ask for multi-GiB
392    // slabs; Qwen3-0.6B lm_head is ~622 MiB f32 and should still cache.
393    const MAX_CACHED_ELEMS: usize = 256 * 1024 * 1024; // 256M f32 ≈ 1 GiB
394    if dense.len() > MAX_CACHED_ELEMS {
395        return Arc::from(dense.into_boxed_slice());
396    }
397    let bytes = std::mem::size_of_val(&dense[..]);
398    let arc: Arc<[f32]> = Arc::from(dense.into_boxed_slice());
399    // Once thrashing, inserting is strictly negative-value: the slab is evicted
400    // before it can be reused, and we pay a write-lock plus an eviction scan on
401    // every miss. Hand the caller its dequant and skip the bookkeeping. The
402    // GEMV path avoids this entirely (see `prefer_cached_blas`); this keeps GEMM
403    // prefill, which still wants dequant+BLAS, from paying cache overhead too.
404    if cache_thrashing() {
405        // First time we notice, drop everything already cached: those slabs will
406        // never be reused (that is what thrashing means) and they are holding the
407        // full budget — 15 GB on a 61 GB box for Muse-Glimmer-30B, which pushed
408        // the bench to 60 GB RSS and into swap. Reclaiming it is free speed.
409        if !THRASH_PURGED.swap(true, Ordering::Relaxed) {
410            let mut w = cache.write().expect("dequant cache poisoned");
411            w.map.clear();
412            w.total_bytes = 0;
413        }
414        return arc;
415    }
416    {
417        let mut w = cache.write().expect("dequant cache poisoned");
418        // A racing thread may have inserted while we were dequantizing.
419        if let Some(e) = w.map.get(&key) {
420            e.last
421                .store(TICK.fetch_add(1, Ordering::Relaxed), Ordering::Relaxed);
422            return Arc::clone(&e.data);
423        }
424        w.insert_evict(key, &arc, bytes, max_cache_bytes());
425    }
426    arc
427}
428
429/// Drop cached dequantized weights (e.g. between model loads in tests).
430pub fn clear_dequant_cache() {
431    if let Some(c) = CACHE.get() {
432        let mut w = c.write().expect("dequant cache poisoned");
433        w.map.clear();
434        w.total_bytes = 0;
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn gguf_dequant_cache_hits_on_second_lookup() {
444        clear_dequant_cache();
445        const QK_K: usize = 256;
446        let mut packed = Vec::new();
447        packed.extend_from_slice(&half::f16::from_f32(1.0).to_le_bytes());
448        packed.extend_from_slice(&half::f16::from_f32(1.0).to_le_bytes());
449        let mut scales = [0u8; 12];
450        for s in &mut scales[0..4] {
451            *s = 0x01;
452        }
453        packed.extend_from_slice(&scales);
454        packed.extend(std::iter::repeat_n(0x77u8, QK_K / 2));
455        let k = 256;
456        let n = 1;
457        let w_off = 4096;
458        let a = gguf_weight_f32(w_off, &packed, k, n, QuantScheme::GgufQ4K);
459        let b = gguf_weight_f32(w_off, &packed, k, n, QuantScheme::GgufQ4K);
460        assert!(Arc::ptr_eq(&a, &b), "same offset + bytes should hit");
461        // Different arena offsets are distinct slots (base can move; offset is stable).
462        let other_off = gguf_weight_f32(w_off + 999, &packed, k, n, QuantScheme::GgufQ4K);
463        assert!(
464            !Arc::ptr_eq(&a, &other_off),
465            "different w_off should miss even with identical bytes"
466        );
467        let mut other = packed.clone();
468        other[0] ^= 0x01;
469        let c = gguf_weight_f32(w_off, &other, k, n, QuantScheme::GgufQ4K);
470        assert!(
471            !Arc::ptr_eq(&a, &c),
472            "different bytes at same offset should miss"
473        );
474    }
475
476    #[test]
477    fn gguf_dequant_cache_q4_1_q5_hits() {
478        clear_dequant_cache();
479        let w: Vec<f32> = (0..512).map(|i| (i as f32 * 0.01).sin()).collect();
480        for (scheme, ggml) in [
481            (QuantScheme::GgufQ4_1, rlx_gguf::GgmlType::Q4_1),
482            (QuantScheme::GgufQ5_0, rlx_gguf::GgmlType::Q5_0),
483            (QuantScheme::GgufQ5_1, rlx_gguf::GgmlType::Q5_1),
484        ] {
485            let packed = rlx_gguf::quantize(&w, ggml).unwrap();
486            let a = gguf_weight_f32(0, &packed, 32, 16, scheme);
487            let b = gguf_weight_f32(0, &packed, 32, 16, scheme);
488            assert!(Arc::ptr_eq(&a, &b), "cache hit expected for {scheme:?}");
489        }
490    }
491
492    fn key(off: u64) -> DequantKey {
493        DequantKey {
494            w_off: off,
495            len: 100,
496            k: 10,
497            n: 10,
498            scheme: 1,
499            sample: off,
500        }
501    }
502
503    #[test]
504    fn lru_evicts_to_budget() {
505        let mut c = DequantCache::default();
506        let data: Arc<[f32]> = Arc::from(vec![0f32; 100].into_boxed_slice());
507        let bytes = 400; // 100 f32
508        // Budget holds two 400-byte slabs (800); the third pushes over 1000.
509        c.insert_evict(key(1), &data, bytes, Some(1000));
510        c.insert_evict(key(2), &data, bytes, Some(1000));
511        assert_eq!(c.map.len(), 2);
512        assert_eq!(c.total_bytes, 800);
513        c.insert_evict(key(3), &data, bytes, Some(1000));
514        assert!(
515            c.total_bytes <= 1000,
516            "total {} exceeds budget",
517            c.total_bytes
518        );
519        assert_eq!(c.map.len(), 2);
520        assert!(
521            !c.map.contains_key(&key(1)),
522            "LRU (key 1) should be evicted"
523        );
524        assert!(
525            c.map.contains_key(&key(3)),
526            "just-inserted key must be kept"
527        );
528    }
529
530    #[test]
531    fn recent_hit_survives_eviction() {
532        let mut c = DequantCache::default();
533        let data: Arc<[f32]> = Arc::from(vec![0f32; 100].into_boxed_slice());
534        let bytes = 400;
535        c.insert_evict(key(1), &data, bytes, Some(1000));
536        c.insert_evict(key(2), &data, bytes, Some(1000));
537        // Touch key 1 so key 2 becomes the LRU.
538        c.map
539            .get(&key(1))
540            .unwrap()
541            .last
542            .store(TICK.fetch_add(1, Ordering::Relaxed), Ordering::Relaxed);
543        c.insert_evict(key(3), &data, bytes, Some(1000));
544        assert!(
545            c.map.contains_key(&key(1)),
546            "recently touched key must survive"
547        );
548        assert!(
549            !c.map.contains_key(&key(2)),
550            "untouched LRU (key 2) evicted"
551        );
552    }
553
554    #[test]
555    fn entry_larger_than_budget_still_lands() {
556        let mut c = DequantCache::default();
557        let big: Arc<[f32]> = Arc::from(vec![0f32; 100].into_boxed_slice());
558        c.insert_evict(key(1), &big, 400, Some(100)); // 400 > budget 100, alone → kept
559        assert_eq!(c.map.len(), 1);
560        assert!(c.map.contains_key(&key(1)));
561    }
562
563    #[test]
564    fn unbounded_when_no_budget() {
565        let mut c = DequantCache::default();
566        let data: Arc<[f32]> = Arc::from(vec![0f32; 100].into_boxed_slice());
567        for i in 0..10 {
568            c.insert_evict(key(i), &data, 400, None);
569        }
570        assert_eq!(c.map.len(), 10, "no budget → nothing evicted");
571        assert_eq!(c.total_bytes, 4000);
572    }
573}