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, RwLock, RwLockReadGuard, RwLockWriteGuard};
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    /// Re-budget the pool.
77    ///
78    /// The pool is an *accounting* budget, not an allocator: each
79    /// `KvCache` owns its own buffer and this counts how many blocks
80    /// the deployment has promised. So a resize is arithmetic, with one
81    /// rule that is not.
82    ///
83    /// Shrinking below what is currently held is REFUSED and the pool
84    /// is left exactly as it was. `free_blocks` would have to go
85    /// negative to represent it, and the alternative -- clamping it to
86    /// zero -- silently over-promises: the caches already holding those
87    /// blocks do not give them back, so every later `try_acquire`
88    /// would be deciding against a budget that does not describe the
89    /// memory in use.
90    ///
91    /// Returns the number of blocks currently held when it refuses, so
92    /// the caller can say what the floor actually is rather than making
93    /// the operator find it by being rejected.
94    pub fn resize(&mut self, total_blocks: usize) -> Result<(), usize> {
95        let in_use = self.total_blocks - self.free_blocks;
96        if total_blocks < in_use {
97            return Err(in_use);
98        }
99        self.free_blocks = total_blocks - in_use;
100        self.total_blocks = total_blocks;
101        Ok(())
102    }
103
104    fn try_acquire(&mut self, n: usize) -> bool {
105        if n <= self.free_blocks {
106            self.free_blocks -= n;
107            true
108        } else {
109            false
110        }
111    }
112
113    fn release(&mut self, n: usize) {
114        self.free_blocks = (self.free_blocks + n).min(self.total_blocks);
115    }
116}
117
118struct PooledState {
119    pool: Arc<Mutex<KvBlockPool>>,
120    block_size: usize,
121    blocks_held: usize,
122}
123
124pub struct KvCache {
125    pub n_kv_heads: usize,
126    pub head_dim: usize,
127    pub k: Vec<f32>, // [seq_len, n_kv_heads, head_dim], flattened
128    pub v: Vec<f32>,
129    pub seq_len: usize,
130    /// The capacity (in positions) this cache was pre-allocated for,
131    /// if any. `None` for caches built with `new` or `with_pool`.
132    planned_capacity: Option<usize>,
133    /// `Some` for caches built with `with_pool`; tracks the shared
134    /// pool and how many blocks this cache currently holds, so its
135    /// blocks can be returned on drop.
136    pool_state: Option<PooledState>,
137}
138
139/// Cloning a pool-backed cache detaches the clone from pool accounting
140/// (its `k`/`v`/`seq_len` data is copied normally, but the clone does
141/// not hold or later release any blocks itself) -- mirroring how
142/// `ferrox-models::prefix_cache` already uses `KvCache::clone` to fork
143/// a cached prefix into a new, independent request's cache. Only the
144/// original cache's blocks are released, exactly once, when it drops.
145impl Clone for KvCache {
146    fn clone(&self) -> Self {
147        KvCache {
148            n_kv_heads: self.n_kv_heads,
149            head_dim: self.head_dim,
150            k: self.k.clone(),
151            v: self.v.clone(),
152            seq_len: self.seq_len,
153            planned_capacity: self.planned_capacity,
154            pool_state: None,
155        }
156    }
157}
158
159impl Drop for KvCache {
160    fn drop(&mut self) {
161        if let Some(state) = &self.pool_state {
162            if let Ok(mut pool) = state.pool.lock() {
163                pool.release(state.blocks_held);
164            }
165        }
166    }
167}
168
169impl KvCache {
170    pub fn new(n_kv_heads: usize, head_dim: usize) -> Self {
171        KvCache {
172            n_kv_heads,
173            head_dim,
174            k: Vec::new(),
175            v: Vec::new(),
176            seq_len: 0,
177            planned_capacity: None,
178            pool_state: None,
179        }
180    }
181
182    /// Pre-allocates storage for up to `max_seq_len` positions, so
183    /// `push` never triggers a reallocation-and-copy during decode.
184    /// Use this when the maximum context length is known ahead of time
185    pub fn with_capacity(n_kv_heads: usize, head_dim: usize, max_seq_len: usize) -> Self {
186        let elems_per_position = n_kv_heads * head_dim;
187        KvCache {
188            n_kv_heads,
189            head_dim,
190            k: Vec::with_capacity(max_seq_len * elems_per_position),
191            v: Vec::with_capacity(max_seq_len * elems_per_position),
192            seq_len: 0,
193            planned_capacity: Some(max_seq_len),
194            pool_state: None,
195        }
196    }
197
198    /// Acquires up front however many blocks from `pool` are needed to
199    /// cover `max_seq_len` positions (at least one, even if
200    /// `max_seq_len` is `0`), so a caller that knows its worst-case
201    /// sequence length ahead of time (as `ferrox-server` does: prompt
202    /// length + `max_tokens`) never needs to acquire another block
203    /// mid-decode. This matters beyond performance: `push` growing past
204    /// its currently held capacity can fail if the pool is exhausted by
205    /// *other* requests by then, and callers like
206    /// `ferrox_models::Decoder::forward_token` treat `push` as
207    /// infallible for non-pooled caches -- a pooled cache that
208    /// under-reserves at construction and then fails to grow later
209    /// would violate that assumption and panic mid-decode. Sizing to
210    /// `max_seq_len` up front turns that into an admission-control
211    /// decision made once, honestly, before any generation work starts,
212    /// exactly mirroring `with_capacity`'s worst-case pre-allocation --
213    /// just drawn from a shared pool instead of a private allocation.
214    /// Returns `Err(KvPoolExhausted)` without mutating anything if the
215    /// pool doesn't have that many blocks free.
216    pub fn with_pool(
217        n_kv_heads: usize,
218        head_dim: usize,
219        pool: Arc<Mutex<KvBlockPool>>,
220        max_seq_len: usize,
221    ) -> Result<Self, KvPoolExhausted> {
222        let block_size = pool.lock().unwrap().block_size();
223        let blocks_needed = max_seq_len.div_ceil(block_size).max(1);
224        if !pool.lock().unwrap().try_acquire(blocks_needed) {
225            return Err(KvPoolExhausted);
226        }
227        let elems_per_position = n_kv_heads * head_dim;
228        Ok(KvCache {
229            n_kv_heads,
230            head_dim,
231            k: Vec::with_capacity(blocks_needed * block_size * elems_per_position),
232            v: Vec::with_capacity(blocks_needed * block_size * elems_per_position),
233            seq_len: 0,
234            planned_capacity: None,
235            pool_state: Some(PooledState {
236                pool,
237                block_size,
238                blocks_held: blocks_needed,
239            }),
240        })
241    }
242
243    /// Appends one position's key/value vectors (each
244    /// `n_kv_heads * head_dim` long) to the cache. For pool-backed
245    /// caches, this may need to acquire another block first; if the
246    /// shared pool has none free, no data is appended and
247    /// `Err(KvPoolExhausted)` is returned. Caches built with `new` or
248    /// `with_capacity` always return `Ok`.
249    pub fn push(&mut self, k_step: &[f32], v_step: &[f32]) -> Result<(), KvPoolExhausted> {
250        assert_eq!(k_step.len(), self.n_kv_heads * self.head_dim);
251        assert_eq!(v_step.len(), self.n_kv_heads * self.head_dim);
252
253        let elems_per_position = self.n_kv_heads * self.head_dim;
254        if let Some(state) = &mut self.pool_state {
255            let capacity_positions = self.k.capacity() / elems_per_position;
256            if self.seq_len == capacity_positions {
257                if !state.pool.lock().unwrap().try_acquire(1) {
258                    return Err(KvPoolExhausted);
259                }
260                state.blocks_held += 1;
261                self.k.reserve_exact(state.block_size * elems_per_position);
262                self.v.reserve_exact(state.block_size * elems_per_position);
263            }
264        }
265
266        self.k.extend_from_slice(k_step);
267        self.v.extend_from_slice(v_step);
268        self.seq_len += 1;
269        Ok(())
270    }
271
272    /// Advance length by `n` positions without storing real K/V values
273    /// (zero-fill). Used when Metal owns the KV plane and the host cache
274    /// only needs matching `seq_len` for sync checks.
275    pub fn advance_len(&mut self, n: usize) -> Result<(), KvPoolExhausted> {
276        if n == 0 {
277            return Ok(());
278        }
279        let elems_per_position = self.n_kv_heads * self.head_dim;
280        let zeros = vec![0f32; elems_per_position];
281        for _ in 0..n {
282            self.push(&zeros, &zeros)?;
283        }
284        Ok(())
285    }
286
287    /// Returns this cache's blocks to its shared pool immediately
288    /// (rather than waiting for `Drop`) and detaches it from pool
289    /// accounting; a no-op for caches that aren't pool-backed, and
290    /// idempotent if called more than once.
291    pub fn release_to_pool(&mut self) {
292        if let Some(state) = self.pool_state.take() {
293            if let Ok(mut pool) = state.pool.lock() {
294                pool.release(state.blocks_held);
295            }
296        }
297    }
298
299    pub fn clear(&mut self) {
300        self.k.clear();
301        self.v.clear();
302        self.seq_len = 0;
303    }
304
305    /// Rolls the cache back to exactly `new_seq_len` positions,
306    /// discarding everything after. Used to reject speculatively
307    /// decoded draft tokens that turned out wrong: their K/V were
308    /// already pushed during batched verification, and rejection means
309    /// removing them so the next real decode step continues from the
310    /// last *accepted* position, not the last *attempted* one.
311    pub fn truncate(&mut self, new_seq_len: usize) {
312        assert!(
313            new_seq_len <= self.seq_len,
314            "truncate target {new_seq_len} must not exceed current seq_len {}",
315            self.seq_len
316        );
317        let elems_per_position = self.n_kv_heads * self.head_dim;
318        self.k.truncate(new_seq_len * elems_per_position);
319        self.v.truncate(new_seq_len * elems_per_position);
320        self.seq_len = new_seq_len;
321    }
322
323    /// Bytes currently resident for this cache's K and V buffers
324    /// combined (actual allocated capacity, not just used length) --
325    /// the number that matters for "does this fit in the context
326    /// budget,"
327    pub fn allocated_bytes(&self) -> usize {
328        (self.k.capacity() + self.v.capacity()) * std::mem::size_of::<f32>()
329    }
330
331    /// True if this cache was pre-allocated via `with_capacity` and
332    /// has not yet grown past that planned capacity (i.e. `push` has
333    /// never had to reallocate). Useful for tests/diagnostics
334    /// confirming the pre-allocation path actually avoided reallocs.
335    pub fn is_within_planned_capacity(&self) -> bool {
336        match self.planned_capacity {
337            Some(cap) => {
338                self.seq_len <= cap
339                    && self.k.capacity() >= self.seq_len * self.n_kv_heads * self.head_dim
340            }
341            None => false,
342        }
343    }
344}
345
346/// The other half of PagedAttention that `KvBlockPool`/`KvCache::with_pool`
347/// deliberately don't implement (see this module's doc comment): real,
348/// *shared* physical block storage that many sequences' block tables can
349/// address into, instead of each `KvCache` still owning its own private,
350/// contiguous `Vec`. `KvBlockPool` only ever bounds a *count* of blocks
351/// each cache may grow to; `PagedKvStore` is the actual backing memory,
352/// and a sequence's `PagedKvCache` holds a block table (an ordered list
353/// of block IDs into this shared store) instead of owning K/V data
354/// directly. This is what makes non-contiguous-block reads during
355/// attention (`causal_gqa_attention_paged`, in `attention.rs`) possible
356/// at all -- `causal_gqa_attention`'s existing contiguous-slice read
357/// pattern has no way to express "position 37 lives in block 12, cached
358/// out of order relative to block 5."
359pub struct PagedKvStore {
360    block_size: usize,
361    n_kv_heads: usize,
362    head_dim: usize,
363    k: Vec<f32>, // [total_blocks * block_size, n_kv_heads, head_dim], flattened
364    v: Vec<f32>,
365    free_block_ids: Vec<usize>,
366}
367
368impl PagedKvStore {
369    pub fn new(block_size: usize, total_blocks: usize, n_kv_heads: usize, head_dim: usize) -> Self {
370        assert!(block_size > 0, "block_size must be positive");
371        let elems_per_block = block_size * n_kv_heads * head_dim;
372        PagedKvStore {
373            block_size,
374            n_kv_heads,
375            head_dim,
376            k: vec![0.0; total_blocks * elems_per_block],
377            v: vec![0.0; total_blocks * elems_per_block],
378            // Pushed in descending order so `pop()` hands out ascending
379            // block IDs -- not load-bearing for correctness (any free ID
380            // works), just makes manual debugging/inspection saner.
381            free_block_ids: (0..total_blocks).rev().collect(),
382        }
383    }
384
385    pub fn block_size(&self) -> usize {
386        self.block_size
387    }
388
389    pub fn free_block_count(&self) -> usize {
390        self.free_block_ids.len()
391    }
392
393    pub fn n_kv_heads(&self) -> usize {
394        self.n_kv_heads
395    }
396
397    pub fn head_dim(&self) -> usize {
398        self.head_dim
399    }
400
401    fn acquire_block(&mut self) -> Option<usize> {
402        self.free_block_ids.pop()
403    }
404
405    fn release_block(&mut self, id: usize) {
406        self.free_block_ids.push(id);
407    }
408
409    fn elems_per_block(&self) -> usize {
410        self.block_size * self.n_kv_heads * self.head_dim
411    }
412
413    /// One position's K (or V) row within block `id` at `offset` (0-based
414    /// within the block) -- `[n_kv_heads * head_dim]` long. Used by
415    /// `causal_gqa_attention_paged` to read attention inputs directly out
416    /// of shared physical storage via a block table, and by
417    /// `PagedKvCache::push` to write a new position into it.
418    pub fn k_row(&self, id: usize, offset: usize) -> &[f32] {
419        let elems_per_position = self.n_kv_heads * self.head_dim;
420        let start = id * self.elems_per_block() + offset * elems_per_position;
421        &self.k[start..start + elems_per_position]
422    }
423
424    pub fn v_row(&self, id: usize, offset: usize) -> &[f32] {
425        let elems_per_position = self.n_kv_heads * self.head_dim;
426        let start = id * self.elems_per_block() + offset * elems_per_position;
427        &self.v[start..start + elems_per_position]
428    }
429
430    fn k_row_mut(&mut self, id: usize, offset: usize) -> &mut [f32] {
431        let elems_per_position = self.n_kv_heads * self.head_dim;
432        let start = id * self.elems_per_block() + offset * elems_per_position;
433        &mut self.k[start..start + elems_per_position]
434    }
435
436    fn v_row_mut(&mut self, id: usize, offset: usize) -> &mut [f32] {
437        let elems_per_position = self.n_kv_heads * self.head_dim;
438        let start = id * self.elems_per_block() + offset * elems_per_position;
439        &mut self.v[start..start + elems_per_position]
440    }
441}
442
443/// Returned when a `PagedKvCache` needs another block but its
444/// `PagedKvStore` has none free -- the paged-storage analog of
445/// `KvPoolExhausted`.
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub struct PagedStoreExhausted;
448
449impl std::fmt::Display for PagedStoreExhausted {
450    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
451        write!(f, "paged KV store exhausted: no free blocks remain")
452    }
453}
454
455impl std::error::Error for PagedStoreExhausted {}
456
457/// One sequence's view into a shared `PagedKvStore`: a block table
458/// (which physical blocks this sequence's positions live in, in order)
459/// plus how many positions have been written so far. Unlike `KvCache`,
460/// this holds no K/V data itself -- every read and write goes through
461/// the shared store.
462#[derive(Debug, Clone, Default)]
463pub struct PagedKvCache {
464    block_table: Vec<usize>,
465    seq_len: usize,
466}
467
468impl PagedKvCache {
469    pub fn new() -> Self {
470        PagedKvCache {
471            block_table: Vec::new(),
472            seq_len: 0,
473        }
474    }
475
476    pub fn seq_len(&self) -> usize {
477        self.seq_len
478    }
479
480    pub fn block_table(&self) -> &[usize] {
481        &self.block_table
482    }
483
484    /// Appends one position's key/value vectors, acquiring a new block
485    /// from `store` first if the current tail block is full (or none
486    /// held yet). Mirrors `KvCache::push`'s signature/semantics exactly,
487    /// just against shared storage instead of a private buffer.
488    pub fn push(
489        &mut self,
490        store: &mut PagedKvStore,
491        k_step: &[f32],
492        v_step: &[f32],
493    ) -> Result<(), PagedStoreExhausted> {
494        let block_size = store.block_size();
495        let offset_in_block = self.seq_len % block_size;
496        // Index by position rather than taking the tail block: a
497        // sequence that pre-reserved (see `reserve`) already holds the
498        // block this position belongs in, and appending another would
499        // both leak a block and write the row in the wrong place.
500        let block_index = self.seq_len / block_size;
501        if block_index >= self.block_table.len() {
502            let id = store.acquire_block().ok_or(PagedStoreExhausted)?;
503            self.block_table.push(id);
504        }
505        let block_id = self.block_table[block_index];
506        store
507            .k_row_mut(block_id, offset_in_block)
508            .copy_from_slice(k_step);
509        store
510            .v_row_mut(block_id, offset_in_block)
511            .copy_from_slice(v_step);
512        self.seq_len += 1;
513        Ok(())
514    }
515
516    /// Releases every block this sequence holds back to `store`. Must be
517    /// called explicitly (there's no `Drop` here, since dropping needs a
518    /// `&mut PagedKvStore` this type doesn't own a reference to) --
519    /// mirrors `KvCache::release_to_pool`, just not automatic.
520    pub fn release(&mut self, store: &mut PagedKvStore) {
521        for id in self.block_table.drain(..) {
522            store.release_block(id);
523        }
524        self.seq_len = 0;
525    }
526
527    /// How many *additional* blocks appending `n_new` positions would
528    /// take from `store`, given what this sequence already holds.
529    ///
530    /// Counted against held CAPACITY rather than against `seq_len`, so
531    /// it is right in both cases. The tail block is usually part-full,
532    /// so the answer is never simply `n_new / block_size`: positions
533    /// that land in a block already held cost nothing. And a sequence
534    /// that pre-reserved (see [`Self::reserve`]) holds blocks beyond
535    /// its length, which a `seq_len`-only sum would ask for twice.
536    ///
537    /// Callers that must not fail part-way through a write check this
538    /// against [`PagedKvStore::free_block_count`] before touching
539    /// anything.
540    pub fn blocks_needed_for(&self, store: &PagedKvStore, n_new: usize) -> usize {
541        let held_capacity = self.block_table.len() * store.block_size();
542        let unused = held_capacity.saturating_sub(self.seq_len);
543        n_new.saturating_sub(unused).div_ceil(store.block_size())
544    }
545
546    /// Takes the blocks `n_new` more positions will need, without
547    /// advancing `seq_len`.
548    ///
549    /// This is what makes a multi-layer append all-or-nothing. The
550    /// check and the taking happen together, so every later
551    /// [`Self::push`] writes into a block this sequence already owns
552    /// and cannot fail. Reserving and then not filling is harmless: the
553    /// blocks are this sequence's until it releases, and `seq_len`
554    /// still says how far it really got.
555    pub fn reserve(
556        &mut self,
557        store: &mut PagedKvStore,
558        n_new: usize,
559    ) -> Result<(), PagedStoreExhausted> {
560        let need = self.blocks_needed_for(store, n_new);
561        if need > store.free_block_count() {
562            return Err(PagedStoreExhausted);
563        }
564        for _ in 0..need {
565            let id = store
566                .acquire_block()
567                .expect("checked against free_block_count immediately above");
568            self.block_table.push(id);
569        }
570        Ok(())
571    }
572
573    /// Installs a block table the caller allocated, with `seq_len`
574    /// positions already computed in it.
575    ///
576    /// This is how a sequence starts life on top of a cached prefix:
577    /// the blocks are somebody else's, already full, and this sequence
578    /// appends past them. `seq_len` MUST be a whole number of blocks,
579    /// because the first append writes at `seq_len` and a shared block
580    /// must never be written -- another sequence is attending over it.
581    /// A ragged length would put that write inside the last shared
582    /// block, corrupting a prefix every other holder is reading.
583    pub fn adopt_blocks(&mut self, block_table: Vec<usize>, seq_len: usize, block_size: usize) {
584        assert_eq!(
585            seq_len % block_size,
586            0,
587            "an adopted prefix must end on a block boundary, or the first \
588             append writes into a block another sequence is reading"
589        );
590        assert!(
591            seq_len / block_size <= block_table.len(),
592            "block table too short for the adopted length"
593        );
594        self.block_table = block_table;
595        self.seq_len = seq_len;
596    }
597
598    /// Copies this sequence's KV out of the shared store into a plain
599    /// contiguous [`KvCache`].
600    ///
601    /// This is what lets the batched prefill path run *unchanged* over
602    /// paged storage. Its fast arm hands `cache.k` / `cache.v` to a
603    /// blocked kernel that reads them as flat slices, and a block table
604    /// cannot be expressed that way. Rather than maintain a second
605    /// prefill kernel that reads through the table -- a copy that could
606    /// drift from the one every other model path uses -- the pages are
607    /// materialised once per layer, the existing kernel runs, and the
608    /// new rows go back with [`Self::append_contiguous`].
609    ///
610    /// The cost is one `seq_len * n_kv_heads * head_dim` copy per layer
611    /// per prefill call, against matmuls that dominate prefill. Decode
612    /// still reads through the block table and copies nothing, which is
613    /// where page sharing actually pays.
614    pub fn to_contiguous(&self, store: &PagedKvStore) -> KvCache {
615        let elems_per_position = store.n_kv_heads * store.head_dim;
616        let mut cache = KvCache::with_capacity(store.n_kv_heads, store.head_dim, self.seq_len);
617        cache.k.reserve_exact(self.seq_len * elems_per_position);
618        cache.v.reserve_exact(self.seq_len * elems_per_position);
619        for pos in 0..self.seq_len {
620            let block_id = self.block_table[pos / store.block_size];
621            let offset = pos % store.block_size;
622            cache.k.extend_from_slice(store.k_row(block_id, offset));
623            cache.v.extend_from_slice(store.v_row(block_id, offset));
624        }
625        cache.seq_len = self.seq_len;
626        cache
627    }
628
629    /// Appends `count` positions' worth of contiguous K/V rows, the
630    /// inverse of [`Self::to_contiguous`].
631    ///
632    /// Blocks are reserved for the whole append *before* the first row
633    /// is written, so a store that cannot hold the request refuses it
634    /// having changed nothing. Writing rows until the store runs dry
635    /// would leave the sequence with a `seq_len` that disagrees with
636    /// the model's own idea of how far it has got, which is not a
637    /// recoverable state.
638    pub fn append_contiguous(
639        &mut self,
640        store: &mut PagedKvStore,
641        k: &[f32],
642        v: &[f32],
643        count: usize,
644    ) -> Result<(), PagedStoreExhausted> {
645        let elems_per_position = store.n_kv_heads * store.head_dim;
646        assert_eq!(k.len(), count * elems_per_position, "k row count");
647        assert_eq!(v.len(), count * elems_per_position, "v row count");
648        if self.blocks_needed_for(store, count) > store.free_block_count() {
649            return Err(PagedStoreExhausted);
650        }
651        for i in 0..count {
652            let lo = i * elems_per_position;
653            let hi = lo + elems_per_position;
654            self.push(store, &k[lo..hi], &v[lo..hi])
655                .expect("blocks reserved above, so no push here can exhaust the store");
656        }
657        Ok(())
658    }
659}
660
661/// Per-layer [`PagedKvStore`]s that many concurrent requests share.
662///
663/// # Why a lock per layer, and why two phases
664///
665/// `ferrox-server` runs generation on `spawn_blocking` with, in its own
666/// words, "no I/O and no shared lock". `KvBlockPool` survives that
667/// because it only bounds a *count*: each `KvCache` owns a private
668/// `Vec`, and the pool mutex is taken briefly at acquire and release,
669/// never during a forward. A `PagedKvStore` is the opposite -- it IS
670/// the backing memory -- so sharing one across concurrent requests
671/// needs an answer to "who may touch these bytes when".
672///
673/// The answer the API already implies: attention takes
674/// `&PagedKvStore` and only `push` takes `&mut`. So the accesses split
675/// cleanly into many concurrent readers and one short exclusive write
676/// per position, which is exactly an `RwLock` -- and one per LAYER
677/// rather than one for the whole model, so two requests contend only
678/// when both are writing the same layer at the same instant.
679///
680/// A caller must therefore take the write guard for the push alone and
681/// drop it before attending under a read guard. Holding the write
682/// guard across attention would serialise the expensive half and give
683/// back a global lock with extra steps. Nothing breaks in the gap: a
684/// sequence's block table and length are its own, and another
685/// request's push in between only touches blocks it exclusively holds.
686///
687/// # Deadlock
688///
689/// [`Self::write_all`] is the one place several layers are held at
690/// once, and it takes them in ascending layer order. Every caller
691/// getting the same order is what makes that safe; there is no other
692/// multi-layer acquisition in the codebase, and a new one must follow
693/// the same rule.
694///
695/// # Poisoning
696///
697/// A panic while holding a store leaves the KV mid-write, which is not
698/// recoverable state, but it is also not *unsound* -- the bytes are
699/// plain `f32`. Poison is stepped over with `into_inner`, matching how
700/// `ferrox-server` already treats its pool mutex: a poisoned lock
701/// should not turn one request's panic into a permanently dead server.
702pub struct SharedPagedKv {
703    layers: Vec<RwLock<PagedKvStore>>,
704    /// Guarded separately from the layers, and always taken BEFORE
705    /// them, never while a layer guard is held. That one-way order is
706    /// what keeps group allocation and the per-layer push paths from
707    /// deadlocking against each other.
708    groups: Mutex<GroupTable>,
709}
710
711impl SharedPagedKv {
712    /// One store per layer, each with `blocks_per_layer` blocks.
713    pub fn new(
714        n_layers: usize,
715        block_size: usize,
716        blocks_per_layer: usize,
717        n_kv_heads: usize,
718        head_dim: usize,
719    ) -> Self {
720        SharedPagedKv {
721            layers: (0..n_layers)
722                .map(|_| {
723                    RwLock::new(PagedKvStore::new(
724                        block_size,
725                        blocks_per_layer,
726                        n_kv_heads,
727                        head_dim,
728                    ))
729                })
730                .collect(),
731            groups: Mutex::new(GroupTable::default()),
732        }
733    }
734
735    /// Wraps stores the caller built, for tests and for callers that
736    /// size layers differently.
737    pub fn from_stores(stores: Vec<PagedKvStore>) -> Self {
738        SharedPagedKv {
739            layers: stores.into_iter().map(RwLock::new).collect(),
740            groups: Mutex::new(GroupTable::default()),
741        }
742    }
743
744    pub fn layer_count(&self) -> usize {
745        self.layers.len()
746    }
747
748    /// Shared access to one layer, for attention.
749    pub fn read(&self, layer: usize) -> RwLockReadGuard<'_, PagedKvStore> {
750        self.layers[layer]
751            .read()
752            .unwrap_or_else(|poisoned| poisoned.into_inner())
753    }
754
755    /// Exclusive access to one layer, for a push. Hold it for the push
756    /// and nothing else -- see the type docs.
757    pub fn write(&self, layer: usize) -> RwLockWriteGuard<'_, PagedKvStore> {
758        self.layers[layer]
759            .write()
760            .unwrap_or_else(|poisoned| poisoned.into_inner())
761    }
762
763    /// Every layer at once, in ascending order, so a multi-layer append
764    /// is atomic against other requests.
765    ///
766    /// This is what makes "all layers advance or none do" hold under
767    /// concurrency rather than only single-threaded: checking free
768    /// space and then appending are separate steps, and without the
769    /// guards spanning both, another request can take the blocks in
770    /// between and leave this one half-written.
771    ///
772    /// Ascending order is the deadlock rule; see the type docs.
773    pub fn write_all(&self) -> Vec<RwLockWriteGuard<'_, PagedKvStore>> {
774        self.layers
775            .iter()
776            .map(|l| l.write().unwrap_or_else(|poisoned| poisoned.into_inner()))
777            .collect()
778    }
779
780    /// Free blocks in one layer, for admission control. A snapshot: by
781    /// the time a caller acts on it another request may have taken
782    /// them, which is why the append itself re-checks under the guard.
783    pub fn free_blocks(&self, layer: usize) -> usize {
784        self.read(layer).free_block_count()
785    }
786
787    /// Takes one block from EVERY layer as a single group, refcount 1.
788    ///
789    /// All layers or none: a group that existed in some layers and not
790    /// others could not answer "which block holds position p in layer
791    /// l", which is the only question it exists to answer.
792    pub fn acquire_group(&self) -> Option<PageGroup> {
793        let mut guards = self.write_all();
794        if guards.iter().any(|s| s.free_block_count() == 0) {
795            return None;
796        }
797        let blocks: Vec<usize> = guards
798            .iter_mut()
799            .map(|s| {
800                s.acquire_block()
801                    .expect("checked every layer under these same guards")
802            })
803            .collect();
804        let mut groups = self
805            .groups
806            .lock()
807            .unwrap_or_else(|poisoned| poisoned.into_inner());
808        Some(PageGroup(groups.insert(blocks)))
809    }
810
811    /// One more holder of `group`.
812    ///
813    /// Called when a second sequence adopts a cached prefix. Without
814    /// it, the first sequence to finish frees pages the second is
815    /// still attending over -- a use-after-free that shows up as
816    /// another conversation's tokens rather than as a crash.
817    pub fn retain_group(&self, group: PageGroup) {
818        let mut groups = self
819            .groups
820            .lock()
821            .unwrap_or_else(|poisoned| poisoned.into_inner());
822        groups.retain(group.0);
823    }
824
825    /// One fewer holder. At zero the blocks go back to their layers.
826    ///
827    /// Returns whether this was the last holder, so a caller can assert
828    /// on it rather than guess.
829    pub fn release_group(&self, group: PageGroup) -> bool {
830        let blocks = {
831            let mut groups = self
832                .groups
833                .lock()
834                .unwrap_or_else(|poisoned| poisoned.into_inner());
835            match groups.release(group.0) {
836                Some(blocks) => blocks,
837                None => return false,
838            }
839        };
840        // The groups lock is dropped before the layer guards are taken,
841        // so the lock order is always groups-then-layers and never the
842        // reverse. See the type docs on deadlock.
843        let mut guards = self.write_all();
844        for (store, block) in guards.iter_mut().zip(blocks) {
845            store.release_block(block);
846        }
847        true
848    }
849
850    /// Which block in each layer this group owns, indexed by layer.
851    pub fn group_blocks(&self, group: PageGroup) -> Vec<usize> {
852        let groups = self
853            .groups
854            .lock()
855            .unwrap_or_else(|poisoned| poisoned.into_inner());
856        groups.blocks(group.0).to_vec()
857    }
858
859    /// How many holders `group` has. Zero means it does not exist.
860    pub fn group_refs(&self, group: PageGroup) -> u32 {
861        let groups = self
862            .groups
863            .lock()
864            .unwrap_or_else(|poisoned| poisoned.into_inner());
865        groups.refs(group.0)
866    }
867
868    /// Groups that could still be allocated, bounded by the layer with
869    /// the fewest free blocks: a group needs one from each.
870    pub fn free_groups(&self) -> usize {
871        (0..self.layers.len())
872            .map(|l| self.free_blocks(l))
873            .min()
874            .unwrap_or(0)
875    }
876}
877
878/// A handle to one block in every layer.
879///
880/// The unit of sharing between sequences, and the only thing small
881/// enough to be what a radix prefix cache stores: that cache maps a
882/// token prefix to ONE index per token, while a position's KV lives in
883/// `n_layers` different blocks. A group is the name for all of them.
884#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
885pub struct PageGroup(pub u32);
886
887/// Group ids, their per-layer blocks, and how many holders each has.
888#[derive(Debug, Default)]
889struct GroupTable {
890    /// Indexed by group id. `None` for an id currently on the free list.
891    blocks: Vec<Option<Vec<usize>>>,
892    refs: Vec<u32>,
893    free_ids: Vec<u32>,
894}
895
896impl GroupTable {
897    fn insert(&mut self, blocks: Vec<usize>) -> u32 {
898        if let Some(id) = self.free_ids.pop() {
899            self.blocks[id as usize] = Some(blocks);
900            self.refs[id as usize] = 1;
901            return id;
902        }
903        self.blocks.push(Some(blocks));
904        self.refs.push(1);
905        (self.blocks.len() - 1) as u32
906    }
907
908    fn retain(&mut self, id: u32) {
909        let refs = &mut self.refs[id as usize];
910        assert!(*refs > 0, "cannot retain group {id}, which has no holders");
911        *refs += 1;
912    }
913
914    /// Drops one holder, returning the blocks to free only when the
915    /// last one goes.
916    fn release(&mut self, id: u32) -> Option<Vec<usize>> {
917        let refs = &mut self.refs[id as usize];
918        assert!(*refs > 0, "double free of group {id}");
919        *refs -= 1;
920        if *refs > 0 {
921            return None;
922        }
923        // The id is reusable now, but only after the blocks are out:
924        // handing the id back while it still named blocks would let a
925        // later `acquire_group` believe it owns them too.
926        let blocks = self.blocks[id as usize]
927            .take()
928            .expect("a group with holders always has blocks");
929        self.free_ids.push(id);
930        Some(blocks)
931    }
932
933    fn blocks(&self, id: u32) -> &[usize] {
934        self.blocks[id as usize]
935            .as_deref()
936            .expect("group has no blocks; it was already released")
937    }
938
939    fn refs(&self, id: u32) -> u32 {
940        self.refs.get(id as usize).copied().unwrap_or(0)
941    }
942}
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947
948    /// `blocks_needed_for` is the reservation the whole no-partial-write
949    /// guarantee rests on, and it is wrong in two opposite directions
950    /// that fail very differently.
951    ///
952    /// UNDER-counting is the dangerous one: `append_contiguous` reserves
953    /// on this answer and then pushes with an `expect`, so too small a
954    /// number panics part-way through a layer -- exactly the corrupted
955    /// state the reservation exists to prevent. Over-counting merely
956    /// refuses a request that would have fitted.
957    ///
958    /// Both mistakes are one edit away. Flooring instead of ceiling
959    /// under-counts whenever the append does not land on a block
960    /// boundary; ignoring the part-full tail over-counts whenever a
961    /// sequence is mid-block, which after the first token is almost
962    /// always. Neither shows up when the numbers happen to divide
963    /// evenly, so the cases here are chosen so that they do not.
964    #[test]
965    fn blocks_needed_for_accounts_for_the_part_full_tail_block() {
966        let mut store = PagedKvStore::new(/* block_size = */ 4, 64, 1, 1);
967        let mut cache = PagedKvCache::new();
968        let row = [1.0f32];
969        // Real pushes rather than poking `seq_len`: the count is
970        // against blocks this sequence HOLDS, so a length with no
971        // blocks behind it is a state that cannot occur and would only
972        // let the test agree with an arithmetic nothing produces.
973        let advance = |cache: &mut PagedKvCache, store: &mut PagedKvStore, n: usize| {
974            for _ in 0..n {
975                cache.push(store, &row, &row).unwrap();
976            }
977        };
978
979        // Empty: a whole-block boundary, and a remainder that a floor
980        // would round away.
981        assert_eq!(cache.blocks_needed_for(&store, 0), 0);
982        assert_eq!(cache.blocks_needed_for(&store, 1), 1);
983        assert_eq!(cache.blocks_needed_for(&store, 4), 1);
984        assert_eq!(cache.blocks_needed_for(&store, 5), 2, "5 into 4s needs 2");
985
986        // One position in: three slots free in the tail, so appending up
987        // to three costs NOTHING. Ignoring the tail would say 1.
988        advance(&mut cache, &mut store, 1);
989        assert_eq!(cache.blocks_needed_for(&store, 3), 0, "fits in the tail");
990        assert_eq!(cache.blocks_needed_for(&store, 4), 1);
991        assert_eq!(cache.blocks_needed_for(&store, 8), 2);
992
993        // The awkward case: 1 free in the tail, 6 to append. 5 spill
994        // over 4-wide blocks, so 2. A floor gives 1 and a tail-blind
995        // ceil gives 2 for the wrong reason, so this pins the shape.
996        advance(&mut cache, &mut store, 2); // seq_len = 3
997        assert_eq!(cache.blocks_needed_for(&store, 6), 2);
998        assert_eq!(cache.blocks_needed_for(&store, 5), 1);
999
1000        // Tail exactly full: no free slots, so this behaves like empty.
1001        advance(&mut cache, &mut store, 1); // seq_len = 4
1002        assert_eq!(cache.blocks_needed_for(&store, 1), 1);
1003        assert_eq!(cache.blocks_needed_for(&store, 4), 1);
1004
1005        // A RESERVED block is capacity this sequence already holds, so
1006        // it must not be asked for twice. Counting from `seq_len` alone
1007        // would say 1 here and take a second block for positions the
1008        // reservation already covers.
1009        cache.reserve(&mut store, 4).unwrap();
1010        assert_eq!(
1011            cache.blocks_needed_for(&store, 4),
1012            0,
1013            "a reserved block is already held"
1014        );
1015        assert_eq!(cache.blocks_needed_for(&store, 5), 1);
1016    }
1017
1018    /// A group takes one block from every layer, and gives them all
1019    /// back together.
1020    ///
1021    /// All-or-nothing is the point: a group holding blocks in some
1022    /// layers and not others cannot answer "which block holds position
1023    /// p in layer l", which is the only question it exists for.
1024    #[test]
1025    fn a_group_takes_one_block_from_every_layer_and_returns_them_together() {
1026        let kv = SharedPagedKv::new(3, 2, 4, 1, 1);
1027        assert_eq!(kv.free_groups(), 4);
1028
1029        let g = kv.acquire_group().expect("4 groups available");
1030        let blocks = kv.group_blocks(g);
1031        assert_eq!(blocks.len(), 3, "one block per layer");
1032        for l in 0..3 {
1033            assert_eq!(kv.free_blocks(l), 3, "layer {l} gave up exactly one");
1034        }
1035        assert_eq!(kv.free_groups(), 3);
1036
1037        assert!(kv.release_group(g), "sole holder, so this frees it");
1038        for l in 0..3 {
1039            assert_eq!(kv.free_blocks(l), 4, "layer {l} got its block back");
1040        }
1041        assert_eq!(kv.free_groups(), 4);
1042    }
1043
1044    /// A group survives until its LAST holder releases it.
1045    ///
1046    /// This is what makes prefix sharing safe. Two sequences off one
1047    /// system prompt hold the same pages; if the first to finish freed
1048    /// them, the second would keep attending over blocks the store had
1049    /// already handed to somebody else -- surfacing as another
1050    /// conversation's tokens, not as a crash.
1051    #[test]
1052    fn a_group_shared_by_two_holders_survives_the_first_release() {
1053        let kv = SharedPagedKv::new(2, 2, 2, 1, 1);
1054        let g = kv.acquire_group().unwrap();
1055        let blocks = kv.group_blocks(g);
1056        kv.retain_group(g);
1057        assert_eq!(kv.group_refs(g), 2);
1058
1059        assert!(
1060            !kv.release_group(g),
1061            "one holder remains, so nothing is freed"
1062        );
1063        assert_eq!(kv.group_refs(g), 1);
1064        assert_eq!(kv.free_blocks(0), 1, "the blocks are still held");
1065        assert_eq!(kv.group_blocks(g), blocks, "and still name the same blocks");
1066
1067        assert!(kv.release_group(g), "last holder frees it");
1068        assert_eq!(kv.group_refs(g), 0);
1069        assert_eq!(kv.free_blocks(0), 2);
1070    }
1071
1072    /// Exhaustion is per group, bounded by the tightest layer.
1073    ///
1074    /// A layer with one block left caps the whole pool at one more
1075    /// group however much room the others have, because a group needs
1076    /// one block from each.
1077    #[test]
1078    fn group_capacity_is_bounded_by_the_layer_with_the_fewest_blocks() {
1079        let kv = SharedPagedKv::from_stores(vec![
1080            PagedKvStore::new(2, 5, 1, 1),
1081            PagedKvStore::new(2, 1, 1, 1),
1082        ]);
1083        assert_eq!(kv.free_groups(), 1, "layer 1 has only one block");
1084
1085        let g = kv.acquire_group().expect("one group fits");
1086        assert_eq!(kv.free_groups(), 0);
1087        assert!(
1088            kv.acquire_group().is_none(),
1089            "layer 1 is empty, so no group can be formed"
1090        );
1091        // The refused attempt must not have taken layer 0's block.
1092        assert_eq!(kv.free_blocks(0), 4, "a refused group leaks nothing");
1093        kv.release_group(g);
1094        assert_eq!(kv.free_blocks(0), 5);
1095    }
1096
1097    /// A released id is reused, with a refcount that starts over.
1098    #[test]
1099    fn a_released_group_id_is_reused_with_a_fresh_refcount() {
1100        let kv = SharedPagedKv::new(1, 2, 2, 1, 1);
1101        let first = kv.acquire_group().unwrap();
1102        kv.retain_group(first);
1103        assert_eq!(kv.group_refs(first), 2);
1104        kv.release_group(first);
1105        kv.release_group(first);
1106        assert_eq!(kv.group_refs(first), 0, "gone, not merely decremented");
1107
1108        let second = kv.acquire_group().unwrap();
1109        assert_eq!(second, first, "the id is reused");
1110        assert_eq!(
1111            kv.group_refs(second),
1112            1,
1113            "a reused id must not inherit the old count"
1114        );
1115        assert_eq!(kv.group_blocks(second).len(), 1);
1116        assert_eq!(kv.free_blocks(0), 1);
1117    }
1118
1119    /// Reading a group after its last holder released it PANICS rather
1120    /// than answering with stale blocks.
1121    ///
1122    /// This is the observable half of clearing the entry on release,
1123    /// and the reason it is `take` rather than `clone`: a caller still
1124    /// holding a `PageGroup` after releasing it is exactly the bug
1125    /// refcounting exists to prevent, and blocks that now belong to
1126    /// somebody else are the worst possible answer -- the caller reads
1127    /// another sequence's KV and nothing says so.
1128    ///
1129    /// Written after sabotage showed the previous test here passed with
1130    /// `clone` in place of `take`: `insert` overwrites the entry on
1131    /// reuse, so a stale entry was never reachable through the path
1132    /// that test took. This one reaches it.
1133    #[test]
1134    #[should_panic(expected = "already released")]
1135    fn reading_a_released_group_panics_rather_than_returning_stale_blocks() {
1136        let kv = SharedPagedKv::new(2, 2, 2, 1, 1);
1137        let g = kv.acquire_group().unwrap();
1138        assert!(kv.release_group(g));
1139        let _ = kv.group_blocks(g);
1140    }
1141
1142    /// Releasing a group nobody holds is a bug, not a no-op.
1143    ///
1144    /// Silently ignoring it would let a double release return the same
1145    /// blocks to the store twice, after which two sequences are handed
1146    /// the same page and both write it.
1147    #[test]
1148    #[should_panic(expected = "double free of group")]
1149    fn releasing_a_group_twice_panics_rather_than_freeing_it_twice() {
1150        let kv = SharedPagedKv::new(1, 2, 2, 1, 1);
1151        let g = kv.acquire_group().unwrap();
1152        assert!(kv.release_group(g));
1153        kv.release_group(g);
1154    }
1155
1156    #[test]
1157    fn a_gathered_sequence_round_trips_through_the_store() {
1158        let mut store = PagedKvStore::new(2, 8, 2, 2);
1159        let mut cache = PagedKvCache::new();
1160        // Five positions over blocks of two: the tail block is half
1161        // full, which is where an off-by-one in the gather shows up.
1162        let rows: Vec<[f32; 4]> = (0..5)
1163            .map(|i| {
1164                let b = i as f32 * 10.0;
1165                [b + 1.0, b + 2.0, b + 3.0, b + 4.0]
1166            })
1167            .collect();
1168        for r in &rows {
1169            cache.push(&mut store, r, r).unwrap();
1170        }
1171
1172        let flat = cache.to_contiguous(&store);
1173        assert_eq!(flat.seq_len, 5);
1174        assert_eq!(flat.k.len(), 5 * 4);
1175        for (i, r) in rows.iter().enumerate() {
1176            assert_eq!(&flat.k[i * 4..(i + 1) * 4], r, "position {i} k");
1177            assert_eq!(&flat.v[i * 4..(i + 1) * 4], r, "position {i} v");
1178        }
1179
1180        // And appending those same rows back onto a fresh sequence
1181        // reproduces the store's view of them exactly.
1182        let mut rebuilt = PagedKvCache::new();
1183        let mut store2 = PagedKvStore::new(2, 8, 2, 2);
1184        rebuilt
1185            .append_contiguous(&mut store2, &flat.k, &flat.v, 5)
1186            .unwrap();
1187        let again = rebuilt.to_contiguous(&store2);
1188        assert_eq!(again.k, flat.k);
1189        assert_eq!(again.v, flat.v);
1190        assert_eq!(again.seq_len, flat.seq_len);
1191    }
1192
1193    #[test]
1194    fn push_grows_seq_len_and_stores_values() {
1195        let mut cache = KvCache::new(2, 2);
1196        cache
1197            .push(&[1.0, 2.0, 3.0, 4.0], &[5.0, 6.0, 7.0, 8.0])
1198            .unwrap();
1199        assert_eq!(cache.seq_len, 1);
1200        cache
1201            .push(&[9.0, 10.0, 11.0, 12.0], &[13.0, 14.0, 15.0, 16.0])
1202            .unwrap();
1203        assert_eq!(cache.seq_len, 2);
1204        assert_eq!(cache.k.len(), 2 * 2 * 2);
1205        assert_eq!(cache.k[4], 9.0);
1206    }
1207
1208    #[test]
1209    #[should_panic]
1210    fn push_wrong_size_panics() {
1211        let mut cache = KvCache::new(2, 2);
1212        let _ = cache.push(&[1.0, 2.0], &[1.0, 2.0]); // too short
1213    }
1214
1215    #[test]
1216    fn clear_resets_state() {
1217        let mut cache = KvCache::new(1, 1);
1218        cache.push(&[1.0], &[2.0]).unwrap();
1219        cache.clear();
1220        assert_eq!(cache.seq_len, 0);
1221        assert!(cache.k.is_empty());
1222    }
1223
1224    #[test]
1225    fn truncate_rolls_back_to_exact_length_preserving_earlier_data() {
1226        let mut cache = KvCache::new(2, 2);
1227        cache
1228            .push(&[1.0, 2.0, 3.0, 4.0], &[10.0, 20.0, 30.0, 40.0])
1229            .unwrap();
1230        cache
1231            .push(&[5.0, 6.0, 7.0, 8.0], &[50.0, 60.0, 70.0, 80.0])
1232            .unwrap();
1233        cache
1234            .push(&[9.0, 9.0, 9.0, 9.0], &[90.0, 90.0, 90.0, 90.0])
1235            .unwrap();
1236        assert_eq!(cache.seq_len, 3);
1237
1238        cache.truncate(1);
1239        assert_eq!(cache.seq_len, 1);
1240        assert_eq!(cache.k, vec![1.0, 2.0, 3.0, 4.0]);
1241        assert_eq!(cache.v, vec![10.0, 20.0, 30.0, 40.0]);
1242    }
1243
1244    #[test]
1245    fn truncate_to_current_length_is_a_no_op() {
1246        let mut cache = KvCache::new(1, 2);
1247        cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
1248        cache.truncate(1);
1249        assert_eq!(cache.seq_len, 1);
1250        assert_eq!(cache.k, vec![1.0, 2.0]);
1251    }
1252
1253    #[test]
1254    fn truncate_to_zero_empties_the_cache() {
1255        let mut cache = KvCache::new(1, 2);
1256        cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
1257        cache.truncate(0);
1258        assert_eq!(cache.seq_len, 0);
1259        assert!(cache.k.is_empty());
1260        assert!(cache.v.is_empty());
1261    }
1262
1263    #[test]
1264    #[should_panic]
1265    fn truncate_beyond_current_length_panics() {
1266        let mut cache = KvCache::new(1, 2);
1267        cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
1268        cache.truncate(5);
1269    }
1270
1271    #[test]
1272    fn push_after_truncate_continues_correctly() {
1273        let mut cache = KvCache::new(1, 1);
1274        cache.push(&[1.0], &[10.0]).unwrap();
1275        cache.push(&[2.0], &[20.0]).unwrap();
1276        cache.push(&[3.0], &[30.0]).unwrap(); // this one will be "rejected"
1277        cache.truncate(2);
1278        cache.push(&[99.0], &[990.0]).unwrap(); // real continuation after rejection
1279        assert_eq!(cache.seq_len, 3);
1280        assert_eq!(cache.k, vec![1.0, 2.0, 99.0]);
1281        assert_eq!(cache.v, vec![10.0, 20.0, 990.0]);
1282    }
1283
1284    #[test]
1285    fn with_capacity_preallocates_and_never_reallocates_within_plan() {
1286        let n_kv_heads = 4;
1287        let head_dim = 8;
1288        let max_seq_len = 16;
1289        let mut cache = KvCache::with_capacity(n_kv_heads, head_dim, max_seq_len);
1290
1291        let expected_elems = max_seq_len * n_kv_heads * head_dim;
1292        assert!(cache.k.capacity() >= expected_elems);
1293        assert!(cache.v.capacity() >= expected_elems);
1294
1295        let step = vec![0.5f32; n_kv_heads * head_dim];
1296        let k_ptr_before = cache.k.as_ptr();
1297        for _ in 0..max_seq_len {
1298            cache.push(&step, &step).unwrap();
1299        }
1300        let k_ptr_after = cache.k.as_ptr();
1301        assert_eq!(
1302            k_ptr_before, k_ptr_after,
1303            "pushing exactly up to the planned capacity must not reallocate"
1304        );
1305        assert!(cache.is_within_planned_capacity());
1306    }
1307
1308    #[test]
1309    fn allocated_bytes_reflects_preallocated_capacity_not_just_used_length() {
1310        let cache = KvCache::with_capacity(4, 8, 100);
1311        // 100 positions * 4 kv_heads * 8 head_dim * 2 (k+v) * 4 bytes/f32
1312        let expected_min = 100 * 4 * 8 * 2 * 4;
1313        assert!(
1314            cache.allocated_bytes() >= expected_min,
1315            "allocated_bytes={} expected_min={expected_min}",
1316            cache.allocated_bytes()
1317        );
1318        // Nothing has been pushed yet, but the memory is already reserved.
1319        assert_eq!(cache.seq_len, 0);
1320    }
1321
1322    #[test]
1323    fn grow_as_you_go_cache_reports_not_within_planned_capacity() {
1324        let mut cache = KvCache::new(2, 2);
1325        cache
1326            .push(&[1.0, 2.0, 3.0, 4.0], &[5.0, 6.0, 7.0, 8.0])
1327            .unwrap();
1328        assert!(
1329            !cache.is_within_planned_capacity(),
1330            "a cache built with `new` has no plan to be within"
1331        );
1332    }
1333
1334    #[test]
1335    fn with_pool_acquires_one_block_and_reports_it_in_free_blocks() {
1336        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 10)));
1337        let cache = KvCache::with_pool(2, 2, pool.clone(), 0).unwrap();
1338        assert_eq!(pool.lock().unwrap().free_blocks(), 9);
1339        assert_eq!(cache.seq_len, 0);
1340    }
1341
1342    #[test]
1343    fn with_pool_fails_without_mutating_the_pool_when_exhausted() {
1344        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 0)));
1345        let result = KvCache::with_pool(2, 2, pool.clone(), 0);
1346        assert!(result.is_err());
1347        assert_eq!(pool.lock().unwrap().free_blocks(), 0);
1348    }
1349
1350    #[test]
1351    fn push_acquires_additional_blocks_as_the_cache_crosses_block_boundaries() {
1352        let block_size = 2;
1353        let pool = Arc::new(Mutex::new(KvBlockPool::new(block_size, 10)));
1354        let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1355        assert_eq!(pool.lock().unwrap().free_blocks(), 9);
1356
1357        // First block holds `block_size` = 2 positions; pushing them
1358        // must not need a second block.
1359        cache.push(&[1.0], &[1.0]).unwrap();
1360        cache.push(&[2.0], &[2.0]).unwrap();
1361        assert_eq!(
1362            pool.lock().unwrap().free_blocks(),
1363            9,
1364            "filling exactly the first block must not acquire a second one"
1365        );
1366
1367        // The third position crosses into a second block.
1368        cache.push(&[3.0], &[3.0]).unwrap();
1369        assert_eq!(pool.lock().unwrap().free_blocks(), 8);
1370        assert_eq!(cache.seq_len, 3);
1371        assert_eq!(cache.k, vec![1.0, 2.0, 3.0]);
1372    }
1373
1374    #[test]
1375    fn push_returns_pool_exhausted_and_leaves_state_unchanged_when_no_blocks_remain() {
1376        let block_size = 1;
1377        let pool = Arc::new(Mutex::new(KvBlockPool::new(block_size, 1)));
1378        let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1379        assert_eq!(pool.lock().unwrap().free_blocks(), 0);
1380
1381        cache.push(&[1.0], &[1.0]).unwrap(); // fills the one held block
1382
1383        let before_k = cache.k.clone();
1384        let result = cache.push(&[2.0], &[2.0]);
1385        assert_eq!(result, Err(KvPoolExhausted));
1386        assert_eq!(cache.seq_len, 1, "a failed push must not change seq_len");
1387        assert_eq!(cache.k, before_k, "a failed push must not append data");
1388    }
1389
1390    #[test]
1391    fn dropping_a_pooled_cache_returns_its_blocks_to_the_pool() {
1392        let pool = Arc::new(Mutex::new(KvBlockPool::new(1, 2)));
1393        {
1394            let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1395            cache.push(&[1.0], &[1.0]).unwrap(); // fills the first (only held) block
1396            cache.push(&[2.0], &[2.0]).unwrap(); // crosses into a second block
1397            assert_eq!(pool.lock().unwrap().free_blocks(), 0);
1398        }
1399        assert_eq!(
1400            pool.lock().unwrap().free_blocks(),
1401            2,
1402            "both blocks held by the dropped cache must return to the pool"
1403        );
1404    }
1405
1406    #[test]
1407    fn release_to_pool_is_explicit_and_idempotent() {
1408        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 5)));
1409        let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1410        assert_eq!(pool.lock().unwrap().free_blocks(), 4);
1411
1412        cache.release_to_pool();
1413        assert_eq!(pool.lock().unwrap().free_blocks(), 5);
1414
1415        cache.release_to_pool(); // no-op, must not over-release
1416        assert_eq!(pool.lock().unwrap().free_blocks(), 5);
1417
1418        drop(cache); // must not release again either
1419        assert_eq!(pool.lock().unwrap().free_blocks(), 5);
1420    }
1421
1422    #[test]
1423    fn two_pooled_caches_share_one_bounded_budget() {
1424        let pool = Arc::new(Mutex::new(KvBlockPool::new(1, 1)));
1425        let cache_a = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1426        let cache_b = KvCache::with_pool(1, 1, pool.clone(), 0);
1427        assert!(
1428            cache_b.is_err(),
1429            "a second concurrent request must not be admitted when the shared budget is full"
1430        );
1431
1432        drop(cache_a);
1433        let cache_c = KvCache::with_pool(1, 1, pool, 0);
1434        assert!(
1435            cache_c.is_ok(),
1436            "once the first request's cache is dropped, its budget must become available again"
1437        );
1438    }
1439
1440    #[test]
1441    fn cloning_a_pooled_cache_detaches_the_clone_from_pool_accounting() {
1442        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 3)));
1443        let original = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1444        assert_eq!(pool.lock().unwrap().free_blocks(), 2);
1445
1446        let clone = original.clone();
1447        assert_eq!(
1448            pool.lock().unwrap().free_blocks(),
1449            2,
1450            "cloning must not acquire additional blocks"
1451        );
1452        assert_eq!(clone.k, original.k);
1453
1454        drop(clone);
1455        assert_eq!(
1456            pool.lock().unwrap().free_blocks(),
1457            2,
1458            "dropping a detached clone must not release the original's blocks"
1459        );
1460
1461        drop(original);
1462        assert_eq!(
1463            pool.lock().unwrap().free_blocks(),
1464            3,
1465            "dropping the original must release its blocks exactly once"
1466        );
1467    }
1468
1469    /// A resize is arithmetic, and the one rule that is not: shrinking
1470    /// past what is held is refused, and the pool is left exactly as it
1471    /// was.
1472    ///
1473    /// Clamping to zero instead would silently over-promise -- the
1474    /// caches holding those blocks do not give them back, so every
1475    /// later acquire would decide against a budget that does not
1476    /// describe the memory in use. This test fails under that clamp.
1477    #[test]
1478    fn a_pool_refuses_to_shrink_below_what_is_already_held() {
1479        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 10)));
1480        let held = KvCache::with_pool(2, 4, Arc::clone(&pool), 24).expect("blocks");
1481        let in_use = {
1482            let p = pool.lock().unwrap();
1483            p.total_blocks() - p.free_blocks()
1484        };
1485        assert!(in_use > 0, "the fixture must actually hold blocks");
1486
1487        let mut p = pool.lock().unwrap();
1488        assert_eq!(p.resize(in_use - 1), Err(in_use));
1489        assert_eq!(p.total_blocks(), 10, "a refused resize changes nothing");
1490        assert_eq!(p.free_blocks(), 10 - in_use);
1491
1492        // Down to exactly what is held is legal, and leaves nothing free.
1493        assert_eq!(p.resize(in_use), Ok(()));
1494        assert_eq!(p.free_blocks(), 0);
1495        drop(p);
1496        drop(held);
1497    }
1498
1499    /// Growing hands the new blocks to the free list without disturbing
1500    /// what is held, which is the whole point of a live re-split.
1501    #[test]
1502    fn growing_a_pool_adds_to_what_is_free_and_not_to_what_is_held() {
1503        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 8)));
1504        let held = KvCache::with_pool(2, 4, Arc::clone(&pool), 16).expect("blocks");
1505        let mut p = pool.lock().unwrap();
1506        let in_use = p.total_blocks() - p.free_blocks();
1507
1508        assert_eq!(p.resize(32), Ok(()));
1509        assert_eq!(p.total_blocks(), 32);
1510        assert_eq!(p.free_blocks(), 32 - in_use);
1511        drop(p);
1512        drop(held);
1513    }
1514}