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