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}