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