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