Skip to main content

ferrox_core/
cache.rs

1//! A per-layer KV cache, growable one position at a time during decode.
2//! Two growth strategies exist:
3//!
4//! - `with_pool`: PagedAttention-style block allocation. Many caches
5//!   (one per concurrent request, typically) draw fixed-size blocks
6//!   from one shared, bounded `KvBlockPool` instead of each
7//!   independently pre-committing to a worst-case context length.
8//!   Growth happens in fixed block-sized quanta, and a cache's blocks
9//!   return to the shared pool when it's dropped, so the pool's free
10//!   count is a real, live admission-control signal a caller can check
11//!   before accepting a new request. This is the block-*allocation*
12//!   half of PagedAttention; it does not (yet) change how attention
13//!   reads a cache -- `k`/`v` are still read as one contiguous slice
14//!   per sequence (see `Decoder::forward_token`/`forward_batch`), just
15//!   backed by capacity that grows in block-sized steps instead of
16//!   Rust's default exponential `Vec` growth. Wiring this into
17//!   `ferrox-server` as live per-request admission control via
18//!   `FERROX_KV_POOL_BLOCKS`/`FERROX_KV_POOL_BLOCK_SIZE`.
19
20use std::sync::{Arc, Mutex};
21
22/// Returned by `KvCache::push` (and `with_pool`) when a pool-backed
23/// cache needs another block but its shared `KvBlockPool` has none
24/// free. Caches built with `new`/`with_capacity` never return this --
25/// their growth is unconditional, matching their pre-paging behavior
26/// exactly.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct KvPoolExhausted;
29
30impl std::fmt::Display for KvPoolExhausted {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        write!(f, "KV cache block pool exhausted: no free blocks remain")
33    }
34}
35
36impl std::error::Error for KvPoolExhausted {}
37
38/// A bounded pool of fixed-size KV-cache blocks (in positions) shared
39/// across many `KvCache` instances, typically one pool per server
40/// process. Each `KvCache::with_pool` acquires one block up front and
41/// one more each time it grows past its currently held capacity;
42/// `free_blocks` is therefore a live, accurate admission-control
43/// signal -- a caller can check it before accepting a new request
44/// rather than discovering exhaustion only after committing memory.
45pub struct KvBlockPool {
46    block_size: usize,
47    total_blocks: usize,
48    free_blocks: usize,
49}
50
51impl KvBlockPool {
52    /// `block_size` positions per block, `total_blocks` blocks in the
53    /// whole shared budget (so `block_size * total_blocks` positions
54    /// total, across however many caches draw from this pool at once).
55    pub fn new(block_size: usize, total_blocks: usize) -> Self {
56        assert!(block_size > 0, "block_size must be positive");
57        KvBlockPool {
58            block_size,
59            total_blocks,
60            free_blocks: total_blocks,
61        }
62    }
63
64    pub fn block_size(&self) -> usize {
65        self.block_size
66    }
67
68    pub fn total_blocks(&self) -> usize {
69        self.total_blocks
70    }
71
72    pub fn free_blocks(&self) -> usize {
73        self.free_blocks
74    }
75
76    fn try_acquire(&mut self, n: usize) -> bool {
77        if n <= self.free_blocks {
78            self.free_blocks -= n;
79            true
80        } else {
81            false
82        }
83    }
84
85    fn release(&mut self, n: usize) {
86        self.free_blocks = (self.free_blocks + n).min(self.total_blocks);
87    }
88}
89
90struct PooledState {
91    pool: Arc<Mutex<KvBlockPool>>,
92    block_size: usize,
93    blocks_held: usize,
94}
95
96pub struct KvCache {
97    pub n_kv_heads: usize,
98    pub head_dim: usize,
99    pub k: Vec<f32>, // [seq_len, n_kv_heads, head_dim], flattened
100    pub v: Vec<f32>,
101    pub seq_len: usize,
102    /// The capacity (in positions) this cache was pre-allocated for,
103    /// if any. `None` for caches built with `new` or `with_pool`.
104    planned_capacity: Option<usize>,
105    /// `Some` for caches built with `with_pool`; tracks the shared
106    /// pool and how many blocks this cache currently holds, so its
107    /// blocks can be returned on drop.
108    pool_state: Option<PooledState>,
109}
110
111/// Cloning a pool-backed cache detaches the clone from pool accounting
112/// (its `k`/`v`/`seq_len` data is copied normally, but the clone does
113/// not hold or later release any blocks itself) -- mirroring how
114/// `ferrox-models::prefix_cache` already uses `KvCache::clone` to fork
115/// a cached prefix into a new, independent request's cache. Only the
116/// original cache's blocks are released, exactly once, when it drops.
117impl Clone for KvCache {
118    fn clone(&self) -> Self {
119        KvCache {
120            n_kv_heads: self.n_kv_heads,
121            head_dim: self.head_dim,
122            k: self.k.clone(),
123            v: self.v.clone(),
124            seq_len: self.seq_len,
125            planned_capacity: self.planned_capacity,
126            pool_state: None,
127        }
128    }
129}
130
131impl Drop for KvCache {
132    fn drop(&mut self) {
133        if let Some(state) = &self.pool_state {
134            if let Ok(mut pool) = state.pool.lock() {
135                pool.release(state.blocks_held);
136            }
137        }
138    }
139}
140
141impl KvCache {
142    pub fn new(n_kv_heads: usize, head_dim: usize) -> Self {
143        KvCache {
144            n_kv_heads,
145            head_dim,
146            k: Vec::new(),
147            v: Vec::new(),
148            seq_len: 0,
149            planned_capacity: None,
150            pool_state: None,
151        }
152    }
153
154    /// Pre-allocates storage for up to `max_seq_len` positions, so
155    /// `push` never triggers a reallocation-and-copy during decode.
156    /// Use this when the maximum context length is known ahead of time
157    pub fn with_capacity(n_kv_heads: usize, head_dim: usize, max_seq_len: usize) -> Self {
158        let elems_per_position = n_kv_heads * head_dim;
159        KvCache {
160            n_kv_heads,
161            head_dim,
162            k: Vec::with_capacity(max_seq_len * elems_per_position),
163            v: Vec::with_capacity(max_seq_len * elems_per_position),
164            seq_len: 0,
165            planned_capacity: Some(max_seq_len),
166            pool_state: None,
167        }
168    }
169
170    /// Acquires up front however many blocks from `pool` are needed to
171    /// cover `max_seq_len` positions (at least one, even if
172    /// `max_seq_len` is `0`), so a caller that knows its worst-case
173    /// sequence length ahead of time (as `ferrox-server` does: prompt
174    /// length + `max_tokens`) never needs to acquire another block
175    /// mid-decode. This matters beyond performance: `push` growing past
176    /// its currently held capacity can fail if the pool is exhausted by
177    /// *other* requests by then, and callers like
178    /// `ferrox_models::Decoder::forward_token` treat `push` as
179    /// infallible for non-pooled caches -- a pooled cache that
180    /// under-reserves at construction and then fails to grow later
181    /// would violate that assumption and panic mid-decode. Sizing to
182    /// `max_seq_len` up front turns that into an admission-control
183    /// decision made once, honestly, before any generation work starts,
184    /// exactly mirroring `with_capacity`'s worst-case pre-allocation --
185    /// just drawn from a shared pool instead of a private allocation.
186    /// Returns `Err(KvPoolExhausted)` without mutating anything if the
187    /// pool doesn't have that many blocks free.
188    pub fn with_pool(
189        n_kv_heads: usize,
190        head_dim: usize,
191        pool: Arc<Mutex<KvBlockPool>>,
192        max_seq_len: usize,
193    ) -> Result<Self, KvPoolExhausted> {
194        let block_size = pool.lock().unwrap().block_size();
195        let blocks_needed = max_seq_len.div_ceil(block_size).max(1);
196        if !pool.lock().unwrap().try_acquire(blocks_needed) {
197            return Err(KvPoolExhausted);
198        }
199        let elems_per_position = n_kv_heads * head_dim;
200        Ok(KvCache {
201            n_kv_heads,
202            head_dim,
203            k: Vec::with_capacity(blocks_needed * block_size * elems_per_position),
204            v: Vec::with_capacity(blocks_needed * block_size * elems_per_position),
205            seq_len: 0,
206            planned_capacity: None,
207            pool_state: Some(PooledState {
208                pool,
209                block_size,
210                blocks_held: blocks_needed,
211            }),
212        })
213    }
214
215    /// Appends one position's key/value vectors (each
216    /// `n_kv_heads * head_dim` long) to the cache. For pool-backed
217    /// caches, this may need to acquire another block first; if the
218    /// shared pool has none free, no data is appended and
219    /// `Err(KvPoolExhausted)` is returned. Caches built with `new` or
220    /// `with_capacity` always return `Ok`.
221    pub fn push(&mut self, k_step: &[f32], v_step: &[f32]) -> Result<(), KvPoolExhausted> {
222        assert_eq!(k_step.len(), self.n_kv_heads * self.head_dim);
223        assert_eq!(v_step.len(), self.n_kv_heads * self.head_dim);
224
225        let elems_per_position = self.n_kv_heads * self.head_dim;
226        if let Some(state) = &mut self.pool_state {
227            let capacity_positions = self.k.capacity() / elems_per_position;
228            if self.seq_len == capacity_positions {
229                if !state.pool.lock().unwrap().try_acquire(1) {
230                    return Err(KvPoolExhausted);
231                }
232                state.blocks_held += 1;
233                self.k.reserve_exact(state.block_size * elems_per_position);
234                self.v.reserve_exact(state.block_size * elems_per_position);
235            }
236        }
237
238        self.k.extend_from_slice(k_step);
239        self.v.extend_from_slice(v_step);
240        self.seq_len += 1;
241        Ok(())
242    }
243
244    /// Advance length by `n` positions without storing real K/V values
245    /// (zero-fill). Used when Metal owns the KV plane and the host cache
246    /// only needs matching `seq_len` for sync checks.
247    pub fn advance_len(&mut self, n: usize) -> Result<(), KvPoolExhausted> {
248        if n == 0 {
249            return Ok(());
250        }
251        let elems_per_position = self.n_kv_heads * self.head_dim;
252        let zeros = vec![0f32; elems_per_position];
253        for _ in 0..n {
254            self.push(&zeros, &zeros)?;
255        }
256        Ok(())
257    }
258
259    /// Returns this cache's blocks to its shared pool immediately
260    /// (rather than waiting for `Drop`) and detaches it from pool
261    /// accounting; a no-op for caches that aren't pool-backed, and
262    /// idempotent if called more than once.
263    pub fn release_to_pool(&mut self) {
264        if let Some(state) = self.pool_state.take() {
265            if let Ok(mut pool) = state.pool.lock() {
266                pool.release(state.blocks_held);
267            }
268        }
269    }
270
271    pub fn clear(&mut self) {
272        self.k.clear();
273        self.v.clear();
274        self.seq_len = 0;
275    }
276
277    /// Rolls the cache back to exactly `new_seq_len` positions,
278    /// discarding everything after. Used to reject speculatively
279    /// decoded draft tokens that turned out wrong: their K/V were
280    /// already pushed during batched verification, and rejection means
281    /// removing them so the next real decode step continues from the
282    /// last *accepted* position, not the last *attempted* one.
283    pub fn truncate(&mut self, new_seq_len: usize) {
284        assert!(
285            new_seq_len <= self.seq_len,
286            "truncate target {new_seq_len} must not exceed current seq_len {}",
287            self.seq_len
288        );
289        let elems_per_position = self.n_kv_heads * self.head_dim;
290        self.k.truncate(new_seq_len * elems_per_position);
291        self.v.truncate(new_seq_len * elems_per_position);
292        self.seq_len = new_seq_len;
293    }
294
295    /// Bytes currently resident for this cache's K and V buffers
296    /// combined (actual allocated capacity, not just used length) --
297    /// the number that matters for "does this fit in the context
298    /// budget,"
299    pub fn allocated_bytes(&self) -> usize {
300        (self.k.capacity() + self.v.capacity()) * std::mem::size_of::<f32>()
301    }
302
303    /// True if this cache was pre-allocated via `with_capacity` and
304    /// has not yet grown past that planned capacity (i.e. `push` has
305    /// never had to reallocate). Useful for tests/diagnostics
306    /// confirming the pre-allocation path actually avoided reallocs.
307    pub fn is_within_planned_capacity(&self) -> bool {
308        match self.planned_capacity {
309            Some(cap) => {
310                self.seq_len <= cap
311                    && self.k.capacity() >= self.seq_len * self.n_kv_heads * self.head_dim
312            }
313            None => false,
314        }
315    }
316}
317
318/// The other half of PagedAttention that `KvBlockPool`/`KvCache::with_pool`
319/// deliberately don't implement (see this module's doc comment): real,
320/// *shared* physical block storage that many sequences' block tables can
321/// address into, instead of each `KvCache` still owning its own private,
322/// contiguous `Vec`. `KvBlockPool` only ever bounds a *count* of blocks
323/// each cache may grow to; `PagedKvStore` is the actual backing memory,
324/// and a sequence's `PagedKvCache` holds a block table (an ordered list
325/// of block IDs into this shared store) instead of owning K/V data
326/// directly. This is what makes non-contiguous-block reads during
327/// attention (`causal_gqa_attention_paged`, in `attention.rs`) possible
328/// at all -- `causal_gqa_attention`'s existing contiguous-slice read
329/// pattern has no way to express "position 37 lives in block 12, cached
330/// out of order relative to block 5."
331pub struct PagedKvStore {
332    block_size: usize,
333    n_kv_heads: usize,
334    head_dim: usize,
335    k: Vec<f32>, // [total_blocks * block_size, n_kv_heads, head_dim], flattened
336    v: Vec<f32>,
337    free_block_ids: Vec<usize>,
338}
339
340impl PagedKvStore {
341    pub fn new(block_size: usize, total_blocks: usize, n_kv_heads: usize, head_dim: usize) -> Self {
342        assert!(block_size > 0, "block_size must be positive");
343        let elems_per_block = block_size * n_kv_heads * head_dim;
344        PagedKvStore {
345            block_size,
346            n_kv_heads,
347            head_dim,
348            k: vec![0.0; total_blocks * elems_per_block],
349            v: vec![0.0; total_blocks * elems_per_block],
350            // Pushed in descending order so `pop()` hands out ascending
351            // block IDs -- not load-bearing for correctness (any free ID
352            // works), just makes manual debugging/inspection saner.
353            free_block_ids: (0..total_blocks).rev().collect(),
354        }
355    }
356
357    pub fn block_size(&self) -> usize {
358        self.block_size
359    }
360
361    pub fn free_block_count(&self) -> usize {
362        self.free_block_ids.len()
363    }
364
365    fn acquire_block(&mut self) -> Option<usize> {
366        self.free_block_ids.pop()
367    }
368
369    fn release_block(&mut self, id: usize) {
370        self.free_block_ids.push(id);
371    }
372
373    fn elems_per_block(&self) -> usize {
374        self.block_size * self.n_kv_heads * self.head_dim
375    }
376
377    /// One position's K (or V) row within block `id` at `offset` (0-based
378    /// within the block) -- `[n_kv_heads * head_dim]` long. Used by
379    /// `causal_gqa_attention_paged` to read attention inputs directly out
380    /// of shared physical storage via a block table, and by
381    /// `PagedKvCache::push` to write a new position into it.
382    pub fn k_row(&self, id: usize, offset: usize) -> &[f32] {
383        let elems_per_position = self.n_kv_heads * self.head_dim;
384        let start = id * self.elems_per_block() + offset * elems_per_position;
385        &self.k[start..start + elems_per_position]
386    }
387
388    pub fn v_row(&self, id: usize, offset: usize) -> &[f32] {
389        let elems_per_position = self.n_kv_heads * self.head_dim;
390        let start = id * self.elems_per_block() + offset * elems_per_position;
391        &self.v[start..start + elems_per_position]
392    }
393
394    fn k_row_mut(&mut self, id: usize, offset: usize) -> &mut [f32] {
395        let elems_per_position = self.n_kv_heads * self.head_dim;
396        let start = id * self.elems_per_block() + offset * elems_per_position;
397        &mut self.k[start..start + elems_per_position]
398    }
399
400    fn v_row_mut(&mut self, id: usize, offset: usize) -> &mut [f32] {
401        let elems_per_position = self.n_kv_heads * self.head_dim;
402        let start = id * self.elems_per_block() + offset * elems_per_position;
403        &mut self.v[start..start + elems_per_position]
404    }
405}
406
407/// Returned when a `PagedKvCache` needs another block but its
408/// `PagedKvStore` has none free -- the paged-storage analog of
409/// `KvPoolExhausted`.
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411pub struct PagedStoreExhausted;
412
413impl std::fmt::Display for PagedStoreExhausted {
414    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415        write!(f, "paged KV store exhausted: no free blocks remain")
416    }
417}
418
419impl std::error::Error for PagedStoreExhausted {}
420
421/// One sequence's view into a shared `PagedKvStore`: a block table
422/// (which physical blocks this sequence's positions live in, in order)
423/// plus how many positions have been written so far. Unlike `KvCache`,
424/// this holds no K/V data itself -- every read and write goes through
425/// the shared store.
426#[derive(Debug, Clone, Default)]
427pub struct PagedKvCache {
428    block_table: Vec<usize>,
429    seq_len: usize,
430}
431
432impl PagedKvCache {
433    pub fn new() -> Self {
434        PagedKvCache {
435            block_table: Vec::new(),
436            seq_len: 0,
437        }
438    }
439
440    pub fn seq_len(&self) -> usize {
441        self.seq_len
442    }
443
444    pub fn block_table(&self) -> &[usize] {
445        &self.block_table
446    }
447
448    /// Appends one position's key/value vectors, acquiring a new block
449    /// from `store` first if the current tail block is full (or none
450    /// held yet). Mirrors `KvCache::push`'s signature/semantics exactly,
451    /// just against shared storage instead of a private buffer.
452    pub fn push(
453        &mut self,
454        store: &mut PagedKvStore,
455        k_step: &[f32],
456        v_step: &[f32],
457    ) -> Result<(), PagedStoreExhausted> {
458        let block_size = store.block_size();
459        let offset_in_block = self.seq_len % block_size;
460        if offset_in_block == 0 {
461            let id = store.acquire_block().ok_or(PagedStoreExhausted)?;
462            self.block_table.push(id);
463        }
464        let block_id = *self
465            .block_table
466            .last()
467            .expect("offset_in_block == 0 branch above always pushes one first");
468        store
469            .k_row_mut(block_id, offset_in_block)
470            .copy_from_slice(k_step);
471        store
472            .v_row_mut(block_id, offset_in_block)
473            .copy_from_slice(v_step);
474        self.seq_len += 1;
475        Ok(())
476    }
477
478    /// Releases every block this sequence holds back to `store`. Must be
479    /// called explicitly (there's no `Drop` here, since dropping needs a
480    /// `&mut PagedKvStore` this type doesn't own a reference to) --
481    /// mirrors `KvCache::release_to_pool`, just not automatic.
482    pub fn release(&mut self, store: &mut PagedKvStore) {
483        for id in self.block_table.drain(..) {
484            store.release_block(id);
485        }
486        self.seq_len = 0;
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn push_grows_seq_len_and_stores_values() {
496        let mut cache = KvCache::new(2, 2);
497        cache
498            .push(&[1.0, 2.0, 3.0, 4.0], &[5.0, 6.0, 7.0, 8.0])
499            .unwrap();
500        assert_eq!(cache.seq_len, 1);
501        cache
502            .push(&[9.0, 10.0, 11.0, 12.0], &[13.0, 14.0, 15.0, 16.0])
503            .unwrap();
504        assert_eq!(cache.seq_len, 2);
505        assert_eq!(cache.k.len(), 2 * 2 * 2);
506        assert_eq!(cache.k[4], 9.0);
507    }
508
509    #[test]
510    #[should_panic]
511    fn push_wrong_size_panics() {
512        let mut cache = KvCache::new(2, 2);
513        let _ = cache.push(&[1.0, 2.0], &[1.0, 2.0]); // too short
514    }
515
516    #[test]
517    fn clear_resets_state() {
518        let mut cache = KvCache::new(1, 1);
519        cache.push(&[1.0], &[2.0]).unwrap();
520        cache.clear();
521        assert_eq!(cache.seq_len, 0);
522        assert!(cache.k.is_empty());
523    }
524
525    #[test]
526    fn truncate_rolls_back_to_exact_length_preserving_earlier_data() {
527        let mut cache = KvCache::new(2, 2);
528        cache
529            .push(&[1.0, 2.0, 3.0, 4.0], &[10.0, 20.0, 30.0, 40.0])
530            .unwrap();
531        cache
532            .push(&[5.0, 6.0, 7.0, 8.0], &[50.0, 60.0, 70.0, 80.0])
533            .unwrap();
534        cache
535            .push(&[9.0, 9.0, 9.0, 9.0], &[90.0, 90.0, 90.0, 90.0])
536            .unwrap();
537        assert_eq!(cache.seq_len, 3);
538
539        cache.truncate(1);
540        assert_eq!(cache.seq_len, 1);
541        assert_eq!(cache.k, vec![1.0, 2.0, 3.0, 4.0]);
542        assert_eq!(cache.v, vec![10.0, 20.0, 30.0, 40.0]);
543    }
544
545    #[test]
546    fn truncate_to_current_length_is_a_no_op() {
547        let mut cache = KvCache::new(1, 2);
548        cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
549        cache.truncate(1);
550        assert_eq!(cache.seq_len, 1);
551        assert_eq!(cache.k, vec![1.0, 2.0]);
552    }
553
554    #[test]
555    fn truncate_to_zero_empties_the_cache() {
556        let mut cache = KvCache::new(1, 2);
557        cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
558        cache.truncate(0);
559        assert_eq!(cache.seq_len, 0);
560        assert!(cache.k.is_empty());
561        assert!(cache.v.is_empty());
562    }
563
564    #[test]
565    #[should_panic]
566    fn truncate_beyond_current_length_panics() {
567        let mut cache = KvCache::new(1, 2);
568        cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
569        cache.truncate(5);
570    }
571
572    #[test]
573    fn push_after_truncate_continues_correctly() {
574        let mut cache = KvCache::new(1, 1);
575        cache.push(&[1.0], &[10.0]).unwrap();
576        cache.push(&[2.0], &[20.0]).unwrap();
577        cache.push(&[3.0], &[30.0]).unwrap(); // this one will be "rejected"
578        cache.truncate(2);
579        cache.push(&[99.0], &[990.0]).unwrap(); // real continuation after rejection
580        assert_eq!(cache.seq_len, 3);
581        assert_eq!(cache.k, vec![1.0, 2.0, 99.0]);
582        assert_eq!(cache.v, vec![10.0, 20.0, 990.0]);
583    }
584
585    #[test]
586    fn with_capacity_preallocates_and_never_reallocates_within_plan() {
587        let n_kv_heads = 4;
588        let head_dim = 8;
589        let max_seq_len = 16;
590        let mut cache = KvCache::with_capacity(n_kv_heads, head_dim, max_seq_len);
591
592        let expected_elems = max_seq_len * n_kv_heads * head_dim;
593        assert!(cache.k.capacity() >= expected_elems);
594        assert!(cache.v.capacity() >= expected_elems);
595
596        let step = vec![0.5f32; n_kv_heads * head_dim];
597        let k_ptr_before = cache.k.as_ptr();
598        for _ in 0..max_seq_len {
599            cache.push(&step, &step).unwrap();
600        }
601        let k_ptr_after = cache.k.as_ptr();
602        assert_eq!(
603            k_ptr_before, k_ptr_after,
604            "pushing exactly up to the planned capacity must not reallocate"
605        );
606        assert!(cache.is_within_planned_capacity());
607    }
608
609    #[test]
610    fn allocated_bytes_reflects_preallocated_capacity_not_just_used_length() {
611        let cache = KvCache::with_capacity(4, 8, 100);
612        // 100 positions * 4 kv_heads * 8 head_dim * 2 (k+v) * 4 bytes/f32
613        let expected_min = 100 * 4 * 8 * 2 * 4;
614        assert!(
615            cache.allocated_bytes() >= expected_min,
616            "allocated_bytes={} expected_min={expected_min}",
617            cache.allocated_bytes()
618        );
619        // Nothing has been pushed yet, but the memory is already reserved.
620        assert_eq!(cache.seq_len, 0);
621    }
622
623    #[test]
624    fn grow_as_you_go_cache_reports_not_within_planned_capacity() {
625        let mut cache = KvCache::new(2, 2);
626        cache
627            .push(&[1.0, 2.0, 3.0, 4.0], &[5.0, 6.0, 7.0, 8.0])
628            .unwrap();
629        assert!(
630            !cache.is_within_planned_capacity(),
631            "a cache built with `new` has no plan to be within"
632        );
633    }
634
635    #[test]
636    fn with_pool_acquires_one_block_and_reports_it_in_free_blocks() {
637        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 10)));
638        let cache = KvCache::with_pool(2, 2, pool.clone(), 0).unwrap();
639        assert_eq!(pool.lock().unwrap().free_blocks(), 9);
640        assert_eq!(cache.seq_len, 0);
641    }
642
643    #[test]
644    fn with_pool_fails_without_mutating_the_pool_when_exhausted() {
645        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 0)));
646        let result = KvCache::with_pool(2, 2, pool.clone(), 0);
647        assert!(result.is_err());
648        assert_eq!(pool.lock().unwrap().free_blocks(), 0);
649    }
650
651    #[test]
652    fn push_acquires_additional_blocks_as_the_cache_crosses_block_boundaries() {
653        let block_size = 2;
654        let pool = Arc::new(Mutex::new(KvBlockPool::new(block_size, 10)));
655        let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
656        assert_eq!(pool.lock().unwrap().free_blocks(), 9);
657
658        // First block holds `block_size` = 2 positions; pushing them
659        // must not need a second block.
660        cache.push(&[1.0], &[1.0]).unwrap();
661        cache.push(&[2.0], &[2.0]).unwrap();
662        assert_eq!(
663            pool.lock().unwrap().free_blocks(),
664            9,
665            "filling exactly the first block must not acquire a second one"
666        );
667
668        // The third position crosses into a second block.
669        cache.push(&[3.0], &[3.0]).unwrap();
670        assert_eq!(pool.lock().unwrap().free_blocks(), 8);
671        assert_eq!(cache.seq_len, 3);
672        assert_eq!(cache.k, vec![1.0, 2.0, 3.0]);
673    }
674
675    #[test]
676    fn push_returns_pool_exhausted_and_leaves_state_unchanged_when_no_blocks_remain() {
677        let block_size = 1;
678        let pool = Arc::new(Mutex::new(KvBlockPool::new(block_size, 1)));
679        let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
680        assert_eq!(pool.lock().unwrap().free_blocks(), 0);
681
682        cache.push(&[1.0], &[1.0]).unwrap(); // fills the one held block
683
684        let before_k = cache.k.clone();
685        let result = cache.push(&[2.0], &[2.0]);
686        assert_eq!(result, Err(KvPoolExhausted));
687        assert_eq!(cache.seq_len, 1, "a failed push must not change seq_len");
688        assert_eq!(cache.k, before_k, "a failed push must not append data");
689    }
690
691    #[test]
692    fn dropping_a_pooled_cache_returns_its_blocks_to_the_pool() {
693        let pool = Arc::new(Mutex::new(KvBlockPool::new(1, 2)));
694        {
695            let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
696            cache.push(&[1.0], &[1.0]).unwrap(); // fills the first (only held) block
697            cache.push(&[2.0], &[2.0]).unwrap(); // crosses into a second block
698            assert_eq!(pool.lock().unwrap().free_blocks(), 0);
699        }
700        assert_eq!(
701            pool.lock().unwrap().free_blocks(),
702            2,
703            "both blocks held by the dropped cache must return to the pool"
704        );
705    }
706
707    #[test]
708    fn release_to_pool_is_explicit_and_idempotent() {
709        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 5)));
710        let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
711        assert_eq!(pool.lock().unwrap().free_blocks(), 4);
712
713        cache.release_to_pool();
714        assert_eq!(pool.lock().unwrap().free_blocks(), 5);
715
716        cache.release_to_pool(); // no-op, must not over-release
717        assert_eq!(pool.lock().unwrap().free_blocks(), 5);
718
719        drop(cache); // must not release again either
720        assert_eq!(pool.lock().unwrap().free_blocks(), 5);
721    }
722
723    #[test]
724    fn two_pooled_caches_share_one_bounded_budget() {
725        let pool = Arc::new(Mutex::new(KvBlockPool::new(1, 1)));
726        let cache_a = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
727        let cache_b = KvCache::with_pool(1, 1, pool.clone(), 0);
728        assert!(
729            cache_b.is_err(),
730            "a second concurrent request must not be admitted when the shared budget is full"
731        );
732
733        drop(cache_a);
734        let cache_c = KvCache::with_pool(1, 1, pool, 0);
735        assert!(
736            cache_c.is_ok(),
737            "once the first request's cache is dropped, its budget must become available again"
738        );
739    }
740
741    #[test]
742    fn cloning_a_pooled_cache_detaches_the_clone_from_pool_accounting() {
743        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 3)));
744        let original = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
745        assert_eq!(pool.lock().unwrap().free_blocks(), 2);
746
747        let clone = original.clone();
748        assert_eq!(
749            pool.lock().unwrap().free_blocks(),
750            2,
751            "cloning must not acquire additional blocks"
752        );
753        assert_eq!(clone.k, original.k);
754
755        drop(clone);
756        assert_eq!(
757            pool.lock().unwrap().free_blocks(),
758            2,
759            "dropping a detached clone must not release the original's blocks"
760        );
761
762        drop(original);
763        assert_eq!(
764            pool.lock().unwrap().free_blocks(),
765            3,
766            "dropping the original must release its blocks exactly once"
767        );
768    }
769}