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    /// Appends a block the caller already owns, without taking one from
517    /// the store.
518    ///
519    /// This is how a sliding window recycles. A block whose positions
520    /// have fallen behind the window is never read again -- the paged
521    /// attention kernel indexes `block_table[t / block_size]` only for
522    /// `t >= seq_len - window` -- so its storage can back a *later*
523    /// position instead of being handed back and re-acquired. The table
524    /// keeps its absolute-position indexing and simply names the same
525    /// physical block at two indices: the stale one, which nothing
526    /// reads, and the live one.
527    ///
528    /// That aliasing is the reason this is a separate method rather than
529    /// a flag on [`Self::reserve`]. A caller that recycles owns the
530    /// obligation to release each distinct block exactly once, and to
531    /// have established that the donor index really is out of window --
532    /// neither of which this type can check for itself.
533    pub fn append_block(&mut self, block_id: usize) {
534        self.block_table.push(block_id);
535    }
536
537    /// Releases every block this sequence holds back to `store`. Must be
538    /// called explicitly (there's no `Drop` here, since dropping needs a
539    /// `&mut PagedKvStore` this type doesn't own a reference to) --
540    /// mirrors `KvCache::release_to_pool`, just not automatic.
541    ///
542    /// Each *distinct* block once: a table that has recycled through
543    /// [`Self::append_block`] names one block at more than one index, and
544    /// releasing per index would put the same id on the free list twice,
545    /// after which two sequences are handed the same memory.
546    pub fn release(&mut self, store: &mut PagedKvStore) {
547        let mut seen: Vec<usize> = Vec::new();
548        for id in self.block_table.drain(..) {
549            if !seen.contains(&id) {
550                seen.push(id);
551                store.release_block(id);
552            }
553        }
554        self.seq_len = 0;
555    }
556
557    /// How many *additional* blocks appending `n_new` positions would
558    /// take from `store`, given what this sequence already holds.
559    ///
560    /// Counted against held CAPACITY rather than against `seq_len`, so
561    /// it is right in both cases. The tail block is usually part-full,
562    /// so the answer is never simply `n_new / block_size`: positions
563    /// that land in a block already held cost nothing. And a sequence
564    /// that pre-reserved (see [`Self::reserve`]) holds blocks beyond
565    /// its length, which a `seq_len`-only sum would ask for twice.
566    ///
567    /// Callers that must not fail part-way through a write check this
568    /// against [`PagedKvStore::free_block_count`] before touching
569    /// anything.
570    pub fn blocks_needed_for(&self, store: &PagedKvStore, n_new: usize) -> usize {
571        let held_capacity = self.block_table.len() * store.block_size();
572        let unused = held_capacity.saturating_sub(self.seq_len);
573        n_new.saturating_sub(unused).div_ceil(store.block_size())
574    }
575
576    /// Takes the blocks `n_new` more positions will need, without
577    /// advancing `seq_len`.
578    ///
579    /// This is what makes a multi-layer append all-or-nothing. The
580    /// check and the taking happen together, so every later
581    /// [`Self::push`] writes into a block this sequence already owns
582    /// and cannot fail. Reserving and then not filling is harmless: the
583    /// blocks are this sequence's until it releases, and `seq_len`
584    /// still says how far it really got.
585    pub fn reserve(
586        &mut self,
587        store: &mut PagedKvStore,
588        n_new: usize,
589    ) -> Result<(), PagedStoreExhausted> {
590        let need = self.blocks_needed_for(store, n_new);
591        if need > store.free_block_count() {
592            return Err(PagedStoreExhausted);
593        }
594        for _ in 0..need {
595            let id = store
596                .acquire_block()
597                .expect("checked against free_block_count immediately above");
598            self.block_table.push(id);
599        }
600        Ok(())
601    }
602
603    /// Installs a block table the caller allocated, with `seq_len`
604    /// positions already computed in it.
605    ///
606    /// This is how a sequence starts life on top of a cached prefix:
607    /// the blocks are somebody else's, already full, and this sequence
608    /// appends past them. `seq_len` MUST be a whole number of blocks,
609    /// because the first append writes at `seq_len` and a shared block
610    /// must never be written -- another sequence is attending over it.
611    /// A ragged length would put that write inside the last shared
612    /// block, corrupting a prefix every other holder is reading.
613    pub fn adopt_blocks(&mut self, block_table: Vec<usize>, seq_len: usize, block_size: usize) {
614        assert_eq!(
615            seq_len % block_size,
616            0,
617            "an adopted prefix must end on a block boundary, or the first \
618             append writes into a block another sequence is reading"
619        );
620        assert!(
621            seq_len / block_size <= block_table.len(),
622            "block table too short for the adopted length"
623        );
624        self.block_table = block_table;
625        self.seq_len = seq_len;
626    }
627
628    /// Copies this sequence's KV out of the shared store into a plain
629    /// contiguous [`KvCache`].
630    ///
631    /// This is what lets the batched prefill path run *unchanged* over
632    /// paged storage. Its fast arm hands `cache.k` / `cache.v` to a
633    /// blocked kernel that reads them as flat slices, and a block table
634    /// cannot be expressed that way. Rather than maintain a second
635    /// prefill kernel that reads through the table -- a copy that could
636    /// drift from the one every other model path uses -- the pages are
637    /// materialised once per layer, the existing kernel runs, and the
638    /// new rows go back with [`Self::append_contiguous`].
639    ///
640    /// The cost is one `seq_len * n_kv_heads * head_dim` copy per layer
641    /// per prefill call, against matmuls that dominate prefill. Decode
642    /// still reads through the block table and copies nothing, which is
643    /// where page sharing actually pays.
644    pub fn to_contiguous(&self, store: &PagedKvStore) -> KvCache {
645        let elems_per_position = store.n_kv_heads * store.head_dim;
646        let mut cache = KvCache::with_capacity(store.n_kv_heads, store.head_dim, self.seq_len);
647        cache.k.reserve_exact(self.seq_len * elems_per_position);
648        cache.v.reserve_exact(self.seq_len * elems_per_position);
649        for pos in 0..self.seq_len {
650            let block_id = self.block_table[pos / store.block_size];
651            let offset = pos % store.block_size;
652            cache.k.extend_from_slice(store.k_row(block_id, offset));
653            cache.v.extend_from_slice(store.v_row(block_id, offset));
654        }
655        cache.seq_len = self.seq_len;
656        cache
657    }
658
659    /// Appends `count` positions' worth of contiguous K/V rows, the
660    /// inverse of [`Self::to_contiguous`].
661    ///
662    /// Blocks are reserved for the whole append *before* the first row
663    /// is written, so a store that cannot hold the request refuses it
664    /// having changed nothing. Writing rows until the store runs dry
665    /// would leave the sequence with a `seq_len` that disagrees with
666    /// the model's own idea of how far it has got, which is not a
667    /// recoverable state.
668    pub fn append_contiguous(
669        &mut self,
670        store: &mut PagedKvStore,
671        k: &[f32],
672        v: &[f32],
673        count: usize,
674    ) -> Result<(), PagedStoreExhausted> {
675        let elems_per_position = store.n_kv_heads * store.head_dim;
676        assert_eq!(k.len(), count * elems_per_position, "k row count");
677        assert_eq!(v.len(), count * elems_per_position, "v row count");
678        if self.blocks_needed_for(store, count) > store.free_block_count() {
679            return Err(PagedStoreExhausted);
680        }
681        for i in 0..count {
682            let lo = i * elems_per_position;
683            let hi = lo + elems_per_position;
684            self.push(store, &k[lo..hi], &v[lo..hi])
685                .expect("blocks reserved above, so no push here can exhaust the store");
686        }
687        Ok(())
688    }
689}
690
691/// Per-layer [`PagedKvStore`]s that many concurrent requests share.
692///
693/// # Why a lock per layer, and why two phases
694///
695/// `ferrox-server` runs generation on `spawn_blocking` with, in its own
696/// words, "no I/O and no shared lock". `KvBlockPool` survives that
697/// because it only bounds a *count*: each `KvCache` owns a private
698/// `Vec`, and the pool mutex is taken briefly at acquire and release,
699/// never during a forward. A `PagedKvStore` is the opposite -- it IS
700/// the backing memory -- so sharing one across concurrent requests
701/// needs an answer to "who may touch these bytes when".
702///
703/// The answer the API already implies: attention takes
704/// `&PagedKvStore` and only `push` takes `&mut`. So the accesses split
705/// cleanly into many concurrent readers and one short exclusive write
706/// per position, which is exactly an `RwLock` -- and one per LAYER
707/// rather than one for the whole model, so two requests contend only
708/// when both are writing the same layer at the same instant.
709///
710/// A caller must therefore take the write guard for the push alone and
711/// drop it before attending under a read guard. Holding the write
712/// guard across attention would serialise the expensive half and give
713/// back a global lock with extra steps. Nothing breaks in the gap: a
714/// sequence's block table and length are its own, and another
715/// request's push in between only touches blocks it exclusively holds.
716///
717/// # Deadlock
718///
719/// [`Self::write_all`] is the one place several layers are held at
720/// once, and it takes them in ascending layer order. Every caller
721/// getting the same order is what makes that safe; there is no other
722/// multi-layer acquisition in the codebase, and a new one must follow
723/// the same rule.
724///
725/// # Poisoning
726///
727/// A panic while holding a store leaves the KV mid-write, which is not
728/// recoverable state, but it is also not *unsound* -- the bytes are
729/// plain `f32`. Poison is stepped over with `into_inner`, matching how
730/// `ferrox-server` already treats its pool mutex: a poisoned lock
731/// should not turn one request's panic into a permanently dead server.
732pub struct SharedPagedKv {
733    layers: Vec<RwLock<PagedKvStore>>,
734    /// Guarded separately from the layers, and always taken BEFORE
735    /// them, never while a layer guard is held. That one-way order is
736    /// what keeps group allocation and the per-layer push paths from
737    /// deadlocking against each other.
738    groups: Mutex<GroupTable>,
739}
740
741impl SharedPagedKv {
742    /// One store per layer, each with `blocks_per_layer` blocks.
743    pub fn new(
744        n_layers: usize,
745        block_size: usize,
746        blocks_per_layer: usize,
747        n_kv_heads: usize,
748        head_dim: usize,
749    ) -> Self {
750        SharedPagedKv {
751            layers: (0..n_layers)
752                .map(|_| {
753                    RwLock::new(PagedKvStore::new(
754                        block_size,
755                        blocks_per_layer,
756                        n_kv_heads,
757                        head_dim,
758                    ))
759                })
760                .collect(),
761            groups: Mutex::new(GroupTable::default()),
762        }
763    }
764
765    /// Wraps stores the caller built, for tests and for callers that
766    /// size layers differently.
767    pub fn from_stores(stores: Vec<PagedKvStore>) -> Self {
768        SharedPagedKv {
769            layers: stores.into_iter().map(RwLock::new).collect(),
770            groups: Mutex::new(GroupTable::default()),
771        }
772    }
773
774    pub fn layer_count(&self) -> usize {
775        self.layers.len()
776    }
777
778    /// Shared access to one layer, for attention.
779    pub fn read(&self, layer: usize) -> RwLockReadGuard<'_, PagedKvStore> {
780        self.layers[layer]
781            .read()
782            .unwrap_or_else(|poisoned| poisoned.into_inner())
783    }
784
785    /// Exclusive access to one layer, for a push. Hold it for the push
786    /// and nothing else -- see the type docs.
787    pub fn write(&self, layer: usize) -> RwLockWriteGuard<'_, PagedKvStore> {
788        self.layers[layer]
789            .write()
790            .unwrap_or_else(|poisoned| poisoned.into_inner())
791    }
792
793    /// Every layer at once, in ascending order, so a multi-layer append
794    /// is atomic against other requests.
795    ///
796    /// This is what makes "all layers advance or none do" hold under
797    /// concurrency rather than only single-threaded: checking free
798    /// space and then appending are separate steps, and without the
799    /// guards spanning both, another request can take the blocks in
800    /// between and leave this one half-written.
801    ///
802    /// Ascending order is the deadlock rule; see the type docs.
803    pub fn write_all(&self) -> Vec<RwLockWriteGuard<'_, PagedKvStore>> {
804        self.layers
805            .iter()
806            .map(|l| l.write().unwrap_or_else(|poisoned| poisoned.into_inner()))
807            .collect()
808    }
809
810    /// Free blocks in one layer, for admission control. A snapshot: by
811    /// the time a caller acts on it another request may have taken
812    /// them, which is why the append itself re-checks under the guard.
813    pub fn free_blocks(&self, layer: usize) -> usize {
814        self.read(layer).free_block_count()
815    }
816
817    /// Takes one block from EVERY layer as a single group, refcount 1.
818    ///
819    /// All layers or none: a group that existed in some layers and not
820    /// others could not answer "which block holds position p in layer
821    /// l", which is the only question it exists to answer.
822    pub fn acquire_group(&self) -> Option<PageGroup> {
823        let mut guards = self.write_all();
824        if guards.iter().any(|s| s.free_block_count() == 0) {
825            return None;
826        }
827        let blocks: Vec<usize> = guards
828            .iter_mut()
829            .map(|s| {
830                s.acquire_block()
831                    .expect("checked every layer under these same guards")
832            })
833            .collect();
834        let mut groups = self
835            .groups
836            .lock()
837            .unwrap_or_else(|poisoned| poisoned.into_inner());
838        Some(PageGroup(groups.insert(blocks)))
839    }
840
841    /// One more holder of `group`.
842    ///
843    /// Called when a second sequence adopts a cached prefix. Without
844    /// it, the first sequence to finish frees pages the second is
845    /// still attending over -- a use-after-free that shows up as
846    /// another conversation's tokens rather than as a crash.
847    pub fn retain_group(&self, group: PageGroup) {
848        let mut groups = self
849            .groups
850            .lock()
851            .unwrap_or_else(|poisoned| poisoned.into_inner());
852        groups.retain(group.0);
853    }
854
855    /// One fewer holder. At zero the blocks go back to their layers.
856    ///
857    /// Returns whether this was the last holder, so a caller can assert
858    /// on it rather than guess.
859    pub fn release_group(&self, group: PageGroup) -> bool {
860        let blocks = {
861            let mut groups = self
862                .groups
863                .lock()
864                .unwrap_or_else(|poisoned| poisoned.into_inner());
865            match groups.release(group.0) {
866                Some(blocks) => blocks,
867                None => return false,
868            }
869        };
870        // The groups lock is dropped before the layer guards are taken,
871        // so the lock order is always groups-then-layers and never the
872        // reverse. See the type docs on deadlock.
873        let mut guards = self.write_all();
874        for (store, block) in guards.iter_mut().zip(blocks) {
875            store.release_block(block);
876        }
877        true
878    }
879
880    /// Which block in each layer this group owns, indexed by layer.
881    pub fn group_blocks(&self, group: PageGroup) -> Vec<usize> {
882        let groups = self
883            .groups
884            .lock()
885            .unwrap_or_else(|poisoned| poisoned.into_inner());
886        groups.blocks(group.0).to_vec()
887    }
888
889    /// How many holders `group` has. Zero means it does not exist.
890    pub fn group_refs(&self, group: PageGroup) -> u32 {
891        let groups = self
892            .groups
893            .lock()
894            .unwrap_or_else(|poisoned| poisoned.into_inner());
895        groups.refs(group.0)
896    }
897
898    /// Groups that could still be allocated, bounded by the layer with
899    /// the fewest free blocks: a group needs one from each.
900    pub fn free_groups(&self) -> usize {
901        (0..self.layers.len())
902            .map(|l| self.free_blocks(l))
903            .min()
904            .unwrap_or(0)
905    }
906}
907
908/// A handle to one block in every layer.
909///
910/// The unit of sharing between sequences, and the only thing small
911/// enough to be what a radix prefix cache stores: that cache maps a
912/// token prefix to ONE index per token, while a position's KV lives in
913/// `n_layers` different blocks. A group is the name for all of them.
914#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
915pub struct PageGroup(pub u32);
916
917/// Group ids, their per-layer blocks, and how many holders each has.
918#[derive(Debug, Default)]
919struct GroupTable {
920    /// Indexed by group id. `None` for an id currently on the free list.
921    blocks: Vec<Option<Vec<usize>>>,
922    refs: Vec<u32>,
923    free_ids: Vec<u32>,
924}
925
926impl GroupTable {
927    fn insert(&mut self, blocks: Vec<usize>) -> u32 {
928        if let Some(id) = self.free_ids.pop() {
929            self.blocks[id as usize] = Some(blocks);
930            self.refs[id as usize] = 1;
931            return id;
932        }
933        self.blocks.push(Some(blocks));
934        self.refs.push(1);
935        (self.blocks.len() - 1) as u32
936    }
937
938    fn retain(&mut self, id: u32) {
939        let refs = &mut self.refs[id as usize];
940        assert!(*refs > 0, "cannot retain group {id}, which has no holders");
941        *refs += 1;
942    }
943
944    /// Drops one holder, returning the blocks to free only when the
945    /// last one goes.
946    fn release(&mut self, id: u32) -> Option<Vec<usize>> {
947        let refs = &mut self.refs[id as usize];
948        assert!(*refs > 0, "double free of group {id}");
949        *refs -= 1;
950        if *refs > 0 {
951            return None;
952        }
953        // The id is reusable now, but only after the blocks are out:
954        // handing the id back while it still named blocks would let a
955        // later `acquire_group` believe it owns them too.
956        let blocks = self.blocks[id as usize]
957            .take()
958            .expect("a group with holders always has blocks");
959        self.free_ids.push(id);
960        Some(blocks)
961    }
962
963    fn blocks(&self, id: u32) -> &[usize] {
964        self.blocks[id as usize]
965            .as_deref()
966            .expect("group has no blocks; it was already released")
967    }
968
969    fn refs(&self, id: u32) -> u32 {
970        self.refs.get(id as usize).copied().unwrap_or(0)
971    }
972}
973
974#[cfg(test)]
975mod tests {
976    use super::*;
977
978    /// `blocks_needed_for` is the reservation the whole no-partial-write
979    /// guarantee rests on, and it is wrong in two opposite directions
980    /// that fail very differently.
981    ///
982    /// UNDER-counting is the dangerous one: `append_contiguous` reserves
983    /// on this answer and then pushes with an `expect`, so too small a
984    /// number panics part-way through a layer -- exactly the corrupted
985    /// state the reservation exists to prevent. Over-counting merely
986    /// refuses a request that would have fitted.
987    ///
988    /// Both mistakes are one edit away. Flooring instead of ceiling
989    /// under-counts whenever the append does not land on a block
990    /// boundary; ignoring the part-full tail over-counts whenever a
991    /// sequence is mid-block, which after the first token is almost
992    /// always. Neither shows up when the numbers happen to divide
993    /// evenly, so the cases here are chosen so that they do not.
994    #[test]
995    fn blocks_needed_for_accounts_for_the_part_full_tail_block() {
996        let mut store = PagedKvStore::new(/* block_size = */ 4, 64, 1, 1);
997        let mut cache = PagedKvCache::new();
998        let row = [1.0f32];
999        // Real pushes rather than poking `seq_len`: the count is
1000        // against blocks this sequence HOLDS, so a length with no
1001        // blocks behind it is a state that cannot occur and would only
1002        // let the test agree with an arithmetic nothing produces.
1003        let advance = |cache: &mut PagedKvCache, store: &mut PagedKvStore, n: usize| {
1004            for _ in 0..n {
1005                cache.push(store, &row, &row).unwrap();
1006            }
1007        };
1008
1009        // Empty: a whole-block boundary, and a remainder that a floor
1010        // would round away.
1011        assert_eq!(cache.blocks_needed_for(&store, 0), 0);
1012        assert_eq!(cache.blocks_needed_for(&store, 1), 1);
1013        assert_eq!(cache.blocks_needed_for(&store, 4), 1);
1014        assert_eq!(cache.blocks_needed_for(&store, 5), 2, "5 into 4s needs 2");
1015
1016        // One position in: three slots free in the tail, so appending up
1017        // to three costs NOTHING. Ignoring the tail would say 1.
1018        advance(&mut cache, &mut store, 1);
1019        assert_eq!(cache.blocks_needed_for(&store, 3), 0, "fits in the tail");
1020        assert_eq!(cache.blocks_needed_for(&store, 4), 1);
1021        assert_eq!(cache.blocks_needed_for(&store, 8), 2);
1022
1023        // The awkward case: 1 free in the tail, 6 to append. 5 spill
1024        // over 4-wide blocks, so 2. A floor gives 1 and a tail-blind
1025        // ceil gives 2 for the wrong reason, so this pins the shape.
1026        advance(&mut cache, &mut store, 2); // seq_len = 3
1027        assert_eq!(cache.blocks_needed_for(&store, 6), 2);
1028        assert_eq!(cache.blocks_needed_for(&store, 5), 1);
1029
1030        // Tail exactly full: no free slots, so this behaves like empty.
1031        advance(&mut cache, &mut store, 1); // seq_len = 4
1032        assert_eq!(cache.blocks_needed_for(&store, 1), 1);
1033        assert_eq!(cache.blocks_needed_for(&store, 4), 1);
1034
1035        // A RESERVED block is capacity this sequence already holds, so
1036        // it must not be asked for twice. Counting from `seq_len` alone
1037        // would say 1 here and take a second block for positions the
1038        // reservation already covers.
1039        cache.reserve(&mut store, 4).unwrap();
1040        assert_eq!(
1041            cache.blocks_needed_for(&store, 4),
1042            0,
1043            "a reserved block is already held"
1044        );
1045        assert_eq!(cache.blocks_needed_for(&store, 5), 1);
1046    }
1047
1048    /// A group takes one block from every layer, and gives them all
1049    /// back together.
1050    ///
1051    /// All-or-nothing is the point: a group holding blocks in some
1052    /// layers and not others cannot answer "which block holds position
1053    /// p in layer l", which is the only question it exists for.
1054    #[test]
1055    fn a_group_takes_one_block_from_every_layer_and_returns_them_together() {
1056        let kv = SharedPagedKv::new(3, 2, 4, 1, 1);
1057        assert_eq!(kv.free_groups(), 4);
1058
1059        let g = kv.acquire_group().expect("4 groups available");
1060        let blocks = kv.group_blocks(g);
1061        assert_eq!(blocks.len(), 3, "one block per layer");
1062        for l in 0..3 {
1063            assert_eq!(kv.free_blocks(l), 3, "layer {l} gave up exactly one");
1064        }
1065        assert_eq!(kv.free_groups(), 3);
1066
1067        assert!(kv.release_group(g), "sole holder, so this frees it");
1068        for l in 0..3 {
1069            assert_eq!(kv.free_blocks(l), 4, "layer {l} got its block back");
1070        }
1071        assert_eq!(kv.free_groups(), 4);
1072    }
1073
1074    /// A group survives until its LAST holder releases it.
1075    ///
1076    /// This is what makes prefix sharing safe. Two sequences off one
1077    /// system prompt hold the same pages; if the first to finish freed
1078    /// them, the second would keep attending over blocks the store had
1079    /// already handed to somebody else -- surfacing as another
1080    /// conversation's tokens, not as a crash.
1081    #[test]
1082    fn a_group_shared_by_two_holders_survives_the_first_release() {
1083        let kv = SharedPagedKv::new(2, 2, 2, 1, 1);
1084        let g = kv.acquire_group().unwrap();
1085        let blocks = kv.group_blocks(g);
1086        kv.retain_group(g);
1087        assert_eq!(kv.group_refs(g), 2);
1088
1089        assert!(
1090            !kv.release_group(g),
1091            "one holder remains, so nothing is freed"
1092        );
1093        assert_eq!(kv.group_refs(g), 1);
1094        assert_eq!(kv.free_blocks(0), 1, "the blocks are still held");
1095        assert_eq!(kv.group_blocks(g), blocks, "and still name the same blocks");
1096
1097        assert!(kv.release_group(g), "last holder frees it");
1098        assert_eq!(kv.group_refs(g), 0);
1099        assert_eq!(kv.free_blocks(0), 2);
1100    }
1101
1102    /// Exhaustion is per group, bounded by the tightest layer.
1103    ///
1104    /// A layer with one block left caps the whole pool at one more
1105    /// group however much room the others have, because a group needs
1106    /// one block from each.
1107    #[test]
1108    fn group_capacity_is_bounded_by_the_layer_with_the_fewest_blocks() {
1109        let kv = SharedPagedKv::from_stores(vec![
1110            PagedKvStore::new(2, 5, 1, 1),
1111            PagedKvStore::new(2, 1, 1, 1),
1112        ]);
1113        assert_eq!(kv.free_groups(), 1, "layer 1 has only one block");
1114
1115        let g = kv.acquire_group().expect("one group fits");
1116        assert_eq!(kv.free_groups(), 0);
1117        assert!(
1118            kv.acquire_group().is_none(),
1119            "layer 1 is empty, so no group can be formed"
1120        );
1121        // The refused attempt must not have taken layer 0's block.
1122        assert_eq!(kv.free_blocks(0), 4, "a refused group leaks nothing");
1123        kv.release_group(g);
1124        assert_eq!(kv.free_blocks(0), 5);
1125    }
1126
1127    /// A released id is reused, with a refcount that starts over.
1128    #[test]
1129    fn a_released_group_id_is_reused_with_a_fresh_refcount() {
1130        let kv = SharedPagedKv::new(1, 2, 2, 1, 1);
1131        let first = kv.acquire_group().unwrap();
1132        kv.retain_group(first);
1133        assert_eq!(kv.group_refs(first), 2);
1134        kv.release_group(first);
1135        kv.release_group(first);
1136        assert_eq!(kv.group_refs(first), 0, "gone, not merely decremented");
1137
1138        let second = kv.acquire_group().unwrap();
1139        assert_eq!(second, first, "the id is reused");
1140        assert_eq!(
1141            kv.group_refs(second),
1142            1,
1143            "a reused id must not inherit the old count"
1144        );
1145        assert_eq!(kv.group_blocks(second).len(), 1);
1146        assert_eq!(kv.free_blocks(0), 1);
1147    }
1148
1149    /// Reading a group after its last holder released it PANICS rather
1150    /// than answering with stale blocks.
1151    ///
1152    /// This is the observable half of clearing the entry on release,
1153    /// and the reason it is `take` rather than `clone`: a caller still
1154    /// holding a `PageGroup` after releasing it is exactly the bug
1155    /// refcounting exists to prevent, and blocks that now belong to
1156    /// somebody else are the worst possible answer -- the caller reads
1157    /// another sequence's KV and nothing says so.
1158    ///
1159    /// Written after sabotage showed the previous test here passed with
1160    /// `clone` in place of `take`: `insert` overwrites the entry on
1161    /// reuse, so a stale entry was never reachable through the path
1162    /// that test took. This one reaches it.
1163    #[test]
1164    #[should_panic(expected = "already released")]
1165    fn reading_a_released_group_panics_rather_than_returning_stale_blocks() {
1166        let kv = SharedPagedKv::new(2, 2, 2, 1, 1);
1167        let g = kv.acquire_group().unwrap();
1168        assert!(kv.release_group(g));
1169        let _ = kv.group_blocks(g);
1170    }
1171
1172    /// Releasing a group nobody holds is a bug, not a no-op.
1173    ///
1174    /// Silently ignoring it would let a double release return the same
1175    /// blocks to the store twice, after which two sequences are handed
1176    /// the same page and both write it.
1177    #[test]
1178    #[should_panic(expected = "double free of group")]
1179    fn releasing_a_group_twice_panics_rather_than_freeing_it_twice() {
1180        let kv = SharedPagedKv::new(1, 2, 2, 1, 1);
1181        let g = kv.acquire_group().unwrap();
1182        assert!(kv.release_group(g));
1183        kv.release_group(g);
1184    }
1185
1186    /// A recycled block backs a later position without the store ever
1187    /// being asked for another one, and the later position's writes are
1188    /// what a read at that position returns.
1189    ///
1190    /// This is the whole sliding-window mechanism in miniature. Blocks
1191    /// of two, four positions, and only two blocks in the store: without
1192    /// recycling, position 2 has nowhere to go.
1193    #[test]
1194    fn a_recycled_block_backs_a_later_position_without_touching_the_store() {
1195        let mut store = PagedKvStore::new(2, 2, 1, 2);
1196        let mut cache = PagedKvCache::new();
1197        cache.push(&mut store, &[1.0, 1.0], &[1.0, 1.0]).unwrap();
1198        cache.push(&mut store, &[2.0, 2.0], &[2.0, 2.0]).unwrap();
1199        assert_eq!(store.free_block_count(), 1, "one block per position pair");
1200
1201        // Positions 0..2 have fallen behind a window of two. Their block
1202        // backs positions 2..4 instead, and the store is untouched.
1203        let recycled = cache.block_table()[0];
1204        cache.append_block(recycled);
1205        assert_eq!(
1206            store.free_block_count(),
1207            1,
1208            "recycling must not take a block from the store"
1209        );
1210        cache.push(&mut store, &[3.0, 3.0], &[3.0, 3.0]).unwrap();
1211        assert_eq!(cache.seq_len(), 3);
1212        assert_eq!(
1213            cache.block_table(),
1214            &[recycled, recycled],
1215            "the same block at the stale index and the live one"
1216        );
1217
1218        // Reading position 2 sees the new row. Position 0's row is gone,
1219        // which is exactly what "behind the window" means -- the kernel
1220        // never indexes it.
1221        let flat = cache.to_contiguous(&store);
1222        assert_eq!(&flat.k[4..6], &[3.0, 3.0], "position 2 reads its own row");
1223        assert_eq!(
1224            &flat.k[0..2],
1225            &[3.0, 3.0],
1226            "position 0 now reads the recycled row, and nothing may read it"
1227        );
1228    }
1229
1230    /// Releasing an aliased table hands each block back ONCE.
1231    ///
1232    /// Per index instead of per distinct block would put the recycled id
1233    /// on the free list twice, and the next two acquisitions would hand
1234    /// two sequences the same memory -- which does not fail, it
1235    /// interleaves two conversations' KV.
1236    #[test]
1237    fn releasing_a_recycled_table_gives_each_block_back_once() {
1238        // Exactly one block in the store, so "handed back twice" is
1239        // observable as a second acquisition succeeding.
1240        let mut store = PagedKvStore::new(2, 1, 1, 2);
1241        let mut cache = PagedKvCache::new();
1242        cache.push(&mut store, &[1.0, 1.0], &[1.0, 1.0]).unwrap();
1243        let held = cache.block_table()[0];
1244        cache.append_block(held);
1245        cache.append_block(held);
1246
1247        let free_before = store.free_block_count();
1248        cache.release(&mut store);
1249        assert_eq!(
1250            store.free_block_count(),
1251            free_before + 1,
1252            "three table entries naming one block are one block back"
1253        );
1254        // And the store agrees: it can hand out that block once.
1255        assert!(store.acquire_block().is_some());
1256        assert!(store.acquire_block().is_none());
1257    }
1258
1259    #[test]
1260    fn a_gathered_sequence_round_trips_through_the_store() {
1261        let mut store = PagedKvStore::new(2, 8, 2, 2);
1262        let mut cache = PagedKvCache::new();
1263        // Five positions over blocks of two: the tail block is half
1264        // full, which is where an off-by-one in the gather shows up.
1265        let rows: Vec<[f32; 4]> = (0..5)
1266            .map(|i| {
1267                let b = i as f32 * 10.0;
1268                [b + 1.0, b + 2.0, b + 3.0, b + 4.0]
1269            })
1270            .collect();
1271        for r in &rows {
1272            cache.push(&mut store, r, r).unwrap();
1273        }
1274
1275        let flat = cache.to_contiguous(&store);
1276        assert_eq!(flat.seq_len, 5);
1277        assert_eq!(flat.k.len(), 5 * 4);
1278        for (i, r) in rows.iter().enumerate() {
1279            assert_eq!(&flat.k[i * 4..(i + 1) * 4], r, "position {i} k");
1280            assert_eq!(&flat.v[i * 4..(i + 1) * 4], r, "position {i} v");
1281        }
1282
1283        // And appending those same rows back onto a fresh sequence
1284        // reproduces the store's view of them exactly.
1285        let mut rebuilt = PagedKvCache::new();
1286        let mut store2 = PagedKvStore::new(2, 8, 2, 2);
1287        rebuilt
1288            .append_contiguous(&mut store2, &flat.k, &flat.v, 5)
1289            .unwrap();
1290        let again = rebuilt.to_contiguous(&store2);
1291        assert_eq!(again.k, flat.k);
1292        assert_eq!(again.v, flat.v);
1293        assert_eq!(again.seq_len, flat.seq_len);
1294    }
1295
1296    #[test]
1297    fn push_grows_seq_len_and_stores_values() {
1298        let mut cache = KvCache::new(2, 2);
1299        cache
1300            .push(&[1.0, 2.0, 3.0, 4.0], &[5.0, 6.0, 7.0, 8.0])
1301            .unwrap();
1302        assert_eq!(cache.seq_len, 1);
1303        cache
1304            .push(&[9.0, 10.0, 11.0, 12.0], &[13.0, 14.0, 15.0, 16.0])
1305            .unwrap();
1306        assert_eq!(cache.seq_len, 2);
1307        assert_eq!(cache.k.len(), 2 * 2 * 2);
1308        assert_eq!(cache.k[4], 9.0);
1309    }
1310
1311    #[test]
1312    #[should_panic]
1313    fn push_wrong_size_panics() {
1314        let mut cache = KvCache::new(2, 2);
1315        let _ = cache.push(&[1.0, 2.0], &[1.0, 2.0]); // too short
1316    }
1317
1318    #[test]
1319    fn clear_resets_state() {
1320        let mut cache = KvCache::new(1, 1);
1321        cache.push(&[1.0], &[2.0]).unwrap();
1322        cache.clear();
1323        assert_eq!(cache.seq_len, 0);
1324        assert!(cache.k.is_empty());
1325    }
1326
1327    #[test]
1328    fn truncate_rolls_back_to_exact_length_preserving_earlier_data() {
1329        let mut cache = KvCache::new(2, 2);
1330        cache
1331            .push(&[1.0, 2.0, 3.0, 4.0], &[10.0, 20.0, 30.0, 40.0])
1332            .unwrap();
1333        cache
1334            .push(&[5.0, 6.0, 7.0, 8.0], &[50.0, 60.0, 70.0, 80.0])
1335            .unwrap();
1336        cache
1337            .push(&[9.0, 9.0, 9.0, 9.0], &[90.0, 90.0, 90.0, 90.0])
1338            .unwrap();
1339        assert_eq!(cache.seq_len, 3);
1340
1341        cache.truncate(1);
1342        assert_eq!(cache.seq_len, 1);
1343        assert_eq!(cache.k, vec![1.0, 2.0, 3.0, 4.0]);
1344        assert_eq!(cache.v, vec![10.0, 20.0, 30.0, 40.0]);
1345    }
1346
1347    #[test]
1348    fn truncate_to_current_length_is_a_no_op() {
1349        let mut cache = KvCache::new(1, 2);
1350        cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
1351        cache.truncate(1);
1352        assert_eq!(cache.seq_len, 1);
1353        assert_eq!(cache.k, vec![1.0, 2.0]);
1354    }
1355
1356    #[test]
1357    fn truncate_to_zero_empties_the_cache() {
1358        let mut cache = KvCache::new(1, 2);
1359        cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
1360        cache.truncate(0);
1361        assert_eq!(cache.seq_len, 0);
1362        assert!(cache.k.is_empty());
1363        assert!(cache.v.is_empty());
1364    }
1365
1366    #[test]
1367    #[should_panic]
1368    fn truncate_beyond_current_length_panics() {
1369        let mut cache = KvCache::new(1, 2);
1370        cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
1371        cache.truncate(5);
1372    }
1373
1374    #[test]
1375    fn push_after_truncate_continues_correctly() {
1376        let mut cache = KvCache::new(1, 1);
1377        cache.push(&[1.0], &[10.0]).unwrap();
1378        cache.push(&[2.0], &[20.0]).unwrap();
1379        cache.push(&[3.0], &[30.0]).unwrap(); // this one will be "rejected"
1380        cache.truncate(2);
1381        cache.push(&[99.0], &[990.0]).unwrap(); // real continuation after rejection
1382        assert_eq!(cache.seq_len, 3);
1383        assert_eq!(cache.k, vec![1.0, 2.0, 99.0]);
1384        assert_eq!(cache.v, vec![10.0, 20.0, 990.0]);
1385    }
1386
1387    #[test]
1388    fn with_capacity_preallocates_and_never_reallocates_within_plan() {
1389        let n_kv_heads = 4;
1390        let head_dim = 8;
1391        let max_seq_len = 16;
1392        let mut cache = KvCache::with_capacity(n_kv_heads, head_dim, max_seq_len);
1393
1394        let expected_elems = max_seq_len * n_kv_heads * head_dim;
1395        assert!(cache.k.capacity() >= expected_elems);
1396        assert!(cache.v.capacity() >= expected_elems);
1397
1398        let step = vec![0.5f32; n_kv_heads * head_dim];
1399        let k_ptr_before = cache.k.as_ptr();
1400        for _ in 0..max_seq_len {
1401            cache.push(&step, &step).unwrap();
1402        }
1403        let k_ptr_after = cache.k.as_ptr();
1404        assert_eq!(
1405            k_ptr_before, k_ptr_after,
1406            "pushing exactly up to the planned capacity must not reallocate"
1407        );
1408        assert!(cache.is_within_planned_capacity());
1409    }
1410
1411    #[test]
1412    fn allocated_bytes_reflects_preallocated_capacity_not_just_used_length() {
1413        let cache = KvCache::with_capacity(4, 8, 100);
1414        // 100 positions * 4 kv_heads * 8 head_dim * 2 (k+v) * 4 bytes/f32
1415        let expected_min = 100 * 4 * 8 * 2 * 4;
1416        assert!(
1417            cache.allocated_bytes() >= expected_min,
1418            "allocated_bytes={} expected_min={expected_min}",
1419            cache.allocated_bytes()
1420        );
1421        // Nothing has been pushed yet, but the memory is already reserved.
1422        assert_eq!(cache.seq_len, 0);
1423    }
1424
1425    #[test]
1426    fn grow_as_you_go_cache_reports_not_within_planned_capacity() {
1427        let mut cache = KvCache::new(2, 2);
1428        cache
1429            .push(&[1.0, 2.0, 3.0, 4.0], &[5.0, 6.0, 7.0, 8.0])
1430            .unwrap();
1431        assert!(
1432            !cache.is_within_planned_capacity(),
1433            "a cache built with `new` has no plan to be within"
1434        );
1435    }
1436
1437    #[test]
1438    fn with_pool_acquires_one_block_and_reports_it_in_free_blocks() {
1439        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 10)));
1440        let cache = KvCache::with_pool(2, 2, pool.clone(), 0).unwrap();
1441        assert_eq!(pool.lock().unwrap().free_blocks(), 9);
1442        assert_eq!(cache.seq_len, 0);
1443    }
1444
1445    #[test]
1446    fn with_pool_fails_without_mutating_the_pool_when_exhausted() {
1447        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 0)));
1448        let result = KvCache::with_pool(2, 2, pool.clone(), 0);
1449        assert!(result.is_err());
1450        assert_eq!(pool.lock().unwrap().free_blocks(), 0);
1451    }
1452
1453    #[test]
1454    fn push_acquires_additional_blocks_as_the_cache_crosses_block_boundaries() {
1455        let block_size = 2;
1456        let pool = Arc::new(Mutex::new(KvBlockPool::new(block_size, 10)));
1457        let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1458        assert_eq!(pool.lock().unwrap().free_blocks(), 9);
1459
1460        // First block holds `block_size` = 2 positions; pushing them
1461        // must not need a second block.
1462        cache.push(&[1.0], &[1.0]).unwrap();
1463        cache.push(&[2.0], &[2.0]).unwrap();
1464        assert_eq!(
1465            pool.lock().unwrap().free_blocks(),
1466            9,
1467            "filling exactly the first block must not acquire a second one"
1468        );
1469
1470        // The third position crosses into a second block.
1471        cache.push(&[3.0], &[3.0]).unwrap();
1472        assert_eq!(pool.lock().unwrap().free_blocks(), 8);
1473        assert_eq!(cache.seq_len, 3);
1474        assert_eq!(cache.k, vec![1.0, 2.0, 3.0]);
1475    }
1476
1477    #[test]
1478    fn push_returns_pool_exhausted_and_leaves_state_unchanged_when_no_blocks_remain() {
1479        let block_size = 1;
1480        let pool = Arc::new(Mutex::new(KvBlockPool::new(block_size, 1)));
1481        let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1482        assert_eq!(pool.lock().unwrap().free_blocks(), 0);
1483
1484        cache.push(&[1.0], &[1.0]).unwrap(); // fills the one held block
1485
1486        let before_k = cache.k.clone();
1487        let result = cache.push(&[2.0], &[2.0]);
1488        assert_eq!(result, Err(KvPoolExhausted));
1489        assert_eq!(cache.seq_len, 1, "a failed push must not change seq_len");
1490        assert_eq!(cache.k, before_k, "a failed push must not append data");
1491    }
1492
1493    #[test]
1494    fn dropping_a_pooled_cache_returns_its_blocks_to_the_pool() {
1495        let pool = Arc::new(Mutex::new(KvBlockPool::new(1, 2)));
1496        {
1497            let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1498            cache.push(&[1.0], &[1.0]).unwrap(); // fills the first (only held) block
1499            cache.push(&[2.0], &[2.0]).unwrap(); // crosses into a second block
1500            assert_eq!(pool.lock().unwrap().free_blocks(), 0);
1501        }
1502        assert_eq!(
1503            pool.lock().unwrap().free_blocks(),
1504            2,
1505            "both blocks held by the dropped cache must return to the pool"
1506        );
1507    }
1508
1509    #[test]
1510    fn release_to_pool_is_explicit_and_idempotent() {
1511        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 5)));
1512        let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1513        assert_eq!(pool.lock().unwrap().free_blocks(), 4);
1514
1515        cache.release_to_pool();
1516        assert_eq!(pool.lock().unwrap().free_blocks(), 5);
1517
1518        cache.release_to_pool(); // no-op, must not over-release
1519        assert_eq!(pool.lock().unwrap().free_blocks(), 5);
1520
1521        drop(cache); // must not release again either
1522        assert_eq!(pool.lock().unwrap().free_blocks(), 5);
1523    }
1524
1525    #[test]
1526    fn two_pooled_caches_share_one_bounded_budget() {
1527        let pool = Arc::new(Mutex::new(KvBlockPool::new(1, 1)));
1528        let cache_a = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1529        let cache_b = KvCache::with_pool(1, 1, pool.clone(), 0);
1530        assert!(
1531            cache_b.is_err(),
1532            "a second concurrent request must not be admitted when the shared budget is full"
1533        );
1534
1535        drop(cache_a);
1536        let cache_c = KvCache::with_pool(1, 1, pool, 0);
1537        assert!(
1538            cache_c.is_ok(),
1539            "once the first request's cache is dropped, its budget must become available again"
1540        );
1541    }
1542
1543    #[test]
1544    fn cloning_a_pooled_cache_detaches_the_clone_from_pool_accounting() {
1545        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 3)));
1546        let original = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1547        assert_eq!(pool.lock().unwrap().free_blocks(), 2);
1548
1549        let clone = original.clone();
1550        assert_eq!(
1551            pool.lock().unwrap().free_blocks(),
1552            2,
1553            "cloning must not acquire additional blocks"
1554        );
1555        assert_eq!(clone.k, original.k);
1556
1557        drop(clone);
1558        assert_eq!(
1559            pool.lock().unwrap().free_blocks(),
1560            2,
1561            "dropping a detached clone must not release the original's blocks"
1562        );
1563
1564        drop(original);
1565        assert_eq!(
1566            pool.lock().unwrap().free_blocks(),
1567            3,
1568            "dropping the original must release its blocks exactly once"
1569        );
1570    }
1571
1572    /// A resize is arithmetic, and the one rule that is not: shrinking
1573    /// past what is held is refused, and the pool is left exactly as it
1574    /// was.
1575    ///
1576    /// Clamping to zero instead would silently over-promise -- the
1577    /// caches holding those blocks do not give them back, so every
1578    /// later acquire would decide against a budget that does not
1579    /// describe the memory in use. This test fails under that clamp.
1580    #[test]
1581    fn a_pool_refuses_to_shrink_below_what_is_already_held() {
1582        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 10)));
1583        let held = KvCache::with_pool(2, 4, Arc::clone(&pool), 24).expect("blocks");
1584        let in_use = {
1585            let p = pool.lock().unwrap();
1586            p.total_blocks() - p.free_blocks()
1587        };
1588        assert!(in_use > 0, "the fixture must actually hold blocks");
1589
1590        let mut p = pool.lock().unwrap();
1591        assert_eq!(p.resize(in_use - 1), Err(in_use));
1592        assert_eq!(p.total_blocks(), 10, "a refused resize changes nothing");
1593        assert_eq!(p.free_blocks(), 10 - in_use);
1594
1595        // Down to exactly what is held is legal, and leaves nothing free.
1596        assert_eq!(p.resize(in_use), Ok(()));
1597        assert_eq!(p.free_blocks(), 0);
1598        drop(p);
1599        drop(held);
1600    }
1601
1602    /// Growing hands the new blocks to the free list without disturbing
1603    /// what is held, which is the whole point of a live re-split.
1604    #[test]
1605    fn growing_a_pool_adds_to_what_is_free_and_not_to_what_is_held() {
1606        let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 8)));
1607        let held = KvCache::with_pool(2, 4, Arc::clone(&pool), 16).expect("blocks");
1608        let mut p = pool.lock().unwrap();
1609        let in_use = p.total_blocks() - p.free_blocks();
1610
1611        assert_eq!(p.resize(32), Ok(()));
1612        assert_eq!(p.total_blocks(), 32);
1613        assert_eq!(p.free_blocks(), 32 - in_use);
1614        drop(p);
1615        drop(held);
1616    }
1617}