frink_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//! `frink-server` as live per-request admission control via
18//! `FRINK_KV_POOL_BLOCKS`/`FRINK_KV_POOL_BLOCK_SIZE`.
19
20use std::sync::{Arc, Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard};
21
22use crate::kv_swa::KvWindow;
23
24/// Returned by `KvCache::push` (and `with_pool`) when a pool-backed
25/// cache needs another block but its shared `KvBlockPool` has none
26/// free. Caches built with `new`/`with_capacity` never return this --
27/// their growth is unconditional, matching their pre-paging behavior
28/// exactly.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct KvPoolExhausted;
31
32impl std::fmt::Display for KvPoolExhausted {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 write!(f, "KV cache block pool exhausted: no free blocks remain")
35 }
36}
37
38impl std::error::Error for KvPoolExhausted {}
39
40/// A bounded pool of fixed-size KV-cache blocks (in positions) shared
41/// across many `KvCache` instances, typically one pool per server
42/// process. Each `KvCache::with_pool` acquires one block up front and
43/// one more each time it grows past its currently held capacity;
44/// `free_blocks` is therefore a live, accurate admission-control
45/// signal -- a caller can check it before accepting a new request
46/// rather than discovering exhaustion only after committing memory.
47pub struct KvBlockPool {
48 block_size: usize,
49 total_blocks: usize,
50 free_blocks: usize,
51}
52
53impl KvBlockPool {
54 /// `block_size` positions per block, `total_blocks` blocks in the
55 /// whole shared budget (so `block_size * total_blocks` positions
56 /// total, across however many caches draw from this pool at once).
57 pub fn new(block_size: usize, total_blocks: usize) -> Self {
58 assert!(block_size > 0, "block_size must be positive");
59 KvBlockPool {
60 block_size,
61 total_blocks,
62 free_blocks: total_blocks,
63 }
64 }
65
66 pub fn block_size(&self) -> usize {
67 self.block_size
68 }
69
70 pub fn total_blocks(&self) -> usize {
71 self.total_blocks
72 }
73
74 pub fn free_blocks(&self) -> usize {
75 self.free_blocks
76 }
77
78 /// Re-budget the pool.
79 ///
80 /// The pool is an *accounting* budget, not an allocator: each
81 /// `KvCache` owns its own buffer and this counts how many blocks
82 /// the deployment has promised. So a resize is arithmetic, with one
83 /// rule that is not.
84 ///
85 /// Shrinking below what is currently held is REFUSED and the pool
86 /// is left exactly as it was. `free_blocks` would have to go
87 /// negative to represent it, and the alternative -- clamping it to
88 /// zero -- silently over-promises: the caches already holding those
89 /// blocks do not give them back, so every later `try_acquire`
90 /// would be deciding against a budget that does not describe the
91 /// memory in use.
92 ///
93 /// Returns the number of blocks currently held when it refuses, so
94 /// the caller can say what the floor actually is rather than making
95 /// the operator find it by being rejected.
96 pub fn resize(&mut self, total_blocks: usize) -> Result<(), usize> {
97 let in_use = self.total_blocks - self.free_blocks;
98 if total_blocks < in_use {
99 return Err(in_use);
100 }
101 self.free_blocks = total_blocks - in_use;
102 self.total_blocks = total_blocks;
103 Ok(())
104 }
105
106 fn try_acquire(&mut self, n: usize) -> bool {
107 if n <= self.free_blocks {
108 self.free_blocks -= n;
109 true
110 } else {
111 false
112 }
113 }
114
115 fn release(&mut self, n: usize) {
116 self.free_blocks = (self.free_blocks + n).min(self.total_blocks);
117 }
118}
119
120struct PooledState {
121 pool: Arc<Mutex<KvBlockPool>>,
122 block_size: usize,
123 blocks_held: usize,
124}
125
126pub struct KvCache {
127 pub n_kv_heads: usize,
128 /// The K head width. Also the Q head width and the width RoPE
129 /// rotates within (`n_embd_head_k`).
130 pub head_dim: usize,
131 /// The V head width (`n_embd_head_v`). Equal to `head_dim` for every
132 /// architecture but MiMo-V2 (`head_dim: 192, v_head_dim: 128`);
133 /// `frink_models::kv_head_dims` is the seam. Every row of `k` is
134 /// `n_kv_heads * head_dim` wide and every row of `v` is
135 /// `n_kv_heads * v_head_dim`, so the two buffers are sized and
136 /// indexed by their OWN width throughout this file.
137 pub v_head_dim: usize,
138 pub k: Vec<f32>, // [rows, n_kv_heads, head_dim], flattened
139 pub v: Vec<f32>, // [rows, n_kv_heads, v_head_dim], flattened
140 /// This sequence's KV on the Metal device, when a layer's attention
141 /// runs there.
142 ///
143 /// It lives HERE and not on the model because it is per-sequence
144 /// state: two requests in flight have two histories, and a mirror
145 /// hung off a shared `Decoder` would hand one sequence the other's
146 /// keys. Travelling with the cache also means the lifecycle is the
147 /// cache's.
148 ///
149 /// It is a MIRROR, not the authority: `k` and `v` above stay
150 /// complete, so truncation, the prefix cache, a slot file and every
151 /// host reader go on working untouched. It is used only to attend,
152 /// and only while its own `seq_len` equals the cache's `rows()`.
153 /// Anything that moves a cache backwards -- a truncate, a restored
154 /// prefix, a clone -- leaves the two disagreeing and the next use
155 /// re-uploads from `k`/`v` rather than trusting it. One upload, in
156 /// exchange for not having to find every such place.
157 #[cfg(feature = "metal")]
158 pub metal_attn: Option<frink_metal::attn::MetalKvBuffers>,
159 /// Positions this sequence has consumed.
160 ///
161 /// **Not the same thing as the number of rows in `k`/`v`**, and the
162 /// distinction is the whole reason this field is private. They are
163 /// equal today because nothing evicts, and they stop being equal
164 /// the moment a windowed layer drops a position behind its window
165 /// (#61): `positions` keeps counting, `rows` does not.
166 ///
167 /// Every reader has to say which one it meant, so there is no
168 /// `seq_len` any more. [`Self::positions`] is what RoPE, a resume
169 /// point and a truncate target mean; [`Self::rows`] is what
170 /// attention iterates and what the bytes cost.
171 ///
172 /// Two bugs have already been caused by the two being one field.
173 /// `PrefillState` read the KV's length as the position to resume at
174 /// (#37), and `DraftModelSpeculator::sync` trusted a counter beside
175 /// a cache that a device-resident backend leaves empty. Both were
176 /// right to read the store rather than keep a copy; both would be
177 /// wrong the day the store evicts.
178 positions: usize,
179 /// The capacity (in positions) this cache was pre-allocated for,
180 /// if any. `None` for caches built with `new` or `with_pool`.
181 planned_capacity: Option<usize>,
182 /// `Some` for caches built with `with_pool`; tracks the shared
183 /// pool and how many blocks this cache currently holds, so its
184 /// blocks can be returned on drop.
185 pool_state: Option<PooledState>,
186 /// `Some` once a windowed layer's cache has been told it may drop
187 /// rows behind its window (#61). `None` -- the default, and what
188 /// every constructor produces -- means this cache keeps every
189 /// position it was ever pushed, which is what every store in this
190 /// engine did before.
191 ///
192 /// Armed by the decoder, never by a constructor, because the fact
193 /// that a layer is windowed lives in `ModelConfig` and the decision
194 /// that eviction is *safe for this run* lives in
195 /// `frink_models::decoder::kv_window`. See [`Self::arm_window`].
196 window: Option<KvWindow>,
197 /// A recurrent layer's state (`crate::recurrent_state`), `None` for
198 /// every attention layer and for a recurrent layer's sequence that
199 /// has not run a token yet. Created by the layer's block at the
200 /// size its weights name; cloned with the cache; cleared with it;
201 /// and the reason [`Self::truncate`] can refuse
202 /// ([`Self::can_truncate_to`]). Public because the block that owns
203 /// the geometry lives in another crate.
204 pub recurrent: Option<crate::recurrent_state::RecurrentState>,
205}
206
207/// Cloning a pool-backed cache detaches the clone from pool accounting
208/// (its `k`/`v`/`seq_len` data is copied normally, but the clone does
209/// not hold or later release any blocks itself) -- mirroring how
210/// `frink-models::prefix_cache` already uses `KvCache::clone` to fork
211/// a cached prefix into a new, independent request's cache. Only the
212/// original cache's blocks are released, exactly once, when it drops.
213impl Clone for KvCache {
214 fn clone(&self) -> Self {
215 KvCache {
216 n_kv_heads: self.n_kv_heads,
217 head_dim: self.head_dim,
218 v_head_dim: self.v_head_dim,
219 k: self.k.clone(),
220 v: self.v.clone(),
221 positions: self.positions,
222 planned_capacity: self.planned_capacity,
223 pool_state: None,
224 // Carried, not reset: a clone of a cache that has already
225 // dropped rows is a cache that has already dropped rows,
226 // and pretending otherwise would let the clone's
227 // `positions` be read as a row count again.
228 window: self.window,
229 #[cfg(feature = "metal")]
230 metal_attn: None,
231 recurrent: self.recurrent.clone(),
232 }
233 }
234}
235
236impl Drop for KvCache {
237 fn drop(&mut self) {
238 if let Some(state) = &self.pool_state {
239 if let Ok(mut pool) = state.pool.lock() {
240 pool.release(state.blocks_held);
241 }
242 }
243 }
244}
245
246impl KvCache {
247 /// Positions this sequence has consumed: what RoPE means, what a
248 /// resume point means, and what a truncate target is measured in.
249 ///
250 /// Monotonic except through [`Self::truncate`] and [`Self::clear`].
251 /// Equal to [`Self::rows`] today, and deliberately a different
252 /// method so it stops being equal safely (#61).
253 #[inline]
254 pub fn positions(&self) -> usize {
255 self.positions
256 }
257
258 /// Sets the position counter directly, for a constructor that
259 /// filled `k`/`v` by hand rather than through `push`.
260 ///
261 /// Deliberately narrow and deliberately not `pub`: the only honest
262 /// caller is one that has just written exactly this many rows, and
263 /// a public setter on a counter the buffer should imply is how the
264 /// two drift apart again.
265 pub(crate) fn set_positions(&mut self, positions: usize) {
266 debug_assert_eq!(
267 positions,
268 self.rows(),
269 "set_positions must agree with the rows just written"
270 );
271 self.positions = positions;
272 }
273
274 /// Test-only: sets the position counter WITHOUT the agreement check
275 /// [`Self::set_positions`] makes.
276 ///
277 /// Exists for one caller: `kv_signature`'s test that a serialized
278 /// payload whose declared count contradicts its buffers is
279 /// rejected. That contradiction is the thing under test, so it has
280 /// to be constructible, and it must not be constructible anywhere
281 /// else.
282 #[cfg(test)]
283 pub(crate) fn force_positions_for_test(&mut self, positions: usize) {
284 self.positions = positions;
285 }
286
287 /// Rows of K/V actually resident: what attention iterates over and
288 /// what the memory costs.
289 ///
290 /// Derived from the buffer rather than counted alongside it, so it
291 /// cannot drift from what is really there. That is the same rule
292 /// the batched prefill learned in #37: read the cursor, do not keep
293 /// a copy of it.
294 #[inline]
295 /// The positions this cache was pre-allocated for, when it was.
296 ///
297 /// A device-side mirror of the KV needs a fixed buffer, so it can
298 /// only be made for a cache that already knows how far it can
299 /// grow; an unbounded one takes the host path
300 /// (`crate::decoder::device_attention`).
301 pub fn capacity_positions(&self) -> Option<usize> {
302 self.planned_capacity
303 }
304
305 pub fn rows(&self) -> usize {
306 let k_width = self.k_width();
307 if k_width == 0 {
308 return 0;
309 }
310 self.k.len() / k_width
311 }
312
313 /// One position's K row: `n_kv_heads * head_dim` elements.
314 #[inline]
315 pub fn k_width(&self) -> usize {
316 self.n_kv_heads * self.head_dim
317 }
318
319 /// One position's V row: `n_kv_heads * v_head_dim` elements.
320 #[inline]
321 pub fn v_width(&self) -> usize {
322 self.n_kv_heads * self.v_head_dim
323 }
324
325 /// A cache whose K and V heads share one width -- every architecture
326 /// but MiMo-V2. [`Self::new_split`] is the general form.
327 pub fn new(n_kv_heads: usize, head_dim: usize) -> Self {
328 Self::new_split(n_kv_heads, head_dim, head_dim)
329 }
330
331 pub fn new_split(n_kv_heads: usize, head_dim: usize, v_head_dim: usize) -> Self {
332 KvCache {
333 n_kv_heads,
334 head_dim,
335 v_head_dim,
336 k: Vec::new(),
337 v: Vec::new(),
338 positions: 0,
339 planned_capacity: None,
340 pool_state: None,
341 window: None,
342 #[cfg(feature = "metal")]
343 metal_attn: None,
344 recurrent: None,
345 }
346 }
347
348 /// Pre-allocates storage for up to `max_seq_len` positions, so
349 /// `push` never triggers a reallocation-and-copy during decode.
350 /// Use this when the maximum context length is known ahead of time
351 pub fn with_capacity(n_kv_heads: usize, head_dim: usize, max_seq_len: usize) -> Self {
352 Self::with_capacity_split(n_kv_heads, head_dim, head_dim, max_seq_len)
353 }
354
355 /// [`Self::with_capacity`] with a V head width of its own.
356 pub fn with_capacity_split(
357 n_kv_heads: usize,
358 head_dim: usize,
359 v_head_dim: usize,
360 max_seq_len: usize,
361 ) -> Self {
362 KvCache {
363 n_kv_heads,
364 head_dim,
365 v_head_dim,
366 k: Vec::with_capacity(max_seq_len * n_kv_heads * head_dim),
367 v: Vec::with_capacity(max_seq_len * n_kv_heads * v_head_dim),
368 positions: 0,
369 planned_capacity: Some(max_seq_len),
370 pool_state: None,
371 window: None,
372 #[cfg(feature = "metal")]
373 metal_attn: None,
374 recurrent: None,
375 }
376 }
377
378 /// Acquires up front however many blocks from `pool` are needed to
379 /// cover `max_seq_len` positions (at least one, even if
380 /// `max_seq_len` is `0`), so a caller that knows its worst-case
381 /// sequence length ahead of time (as `frink-server` does: prompt
382 /// length + `max_tokens`) never needs to acquire another block
383 /// mid-decode. This matters beyond performance: `push` growing past
384 /// its currently held capacity can fail if the pool is exhausted by
385 /// *other* requests by then, and callers like
386 /// `frink_models::Decoder::forward_token` treat `push` as
387 /// infallible for non-pooled caches -- a pooled cache that
388 /// under-reserves at construction and then fails to grow later
389 /// would violate that assumption and panic mid-decode. Sizing to
390 /// `max_seq_len` up front turns that into an admission-control
391 /// decision made once, honestly, before any generation work starts,
392 /// exactly mirroring `with_capacity`'s worst-case pre-allocation --
393 /// just drawn from a shared pool instead of a private allocation.
394 /// Returns `Err(KvPoolExhausted)` without mutating anything if the
395 /// pool doesn't have that many blocks free.
396 pub fn with_pool(
397 n_kv_heads: usize,
398 head_dim: usize,
399 pool: Arc<Mutex<KvBlockPool>>,
400 max_seq_len: usize,
401 ) -> Result<Self, KvPoolExhausted> {
402 Self::with_pool_split(n_kv_heads, head_dim, head_dim, pool, max_seq_len)
403 }
404
405 /// [`Self::with_pool`] with a V head width of its own.
406 pub fn with_pool_split(
407 n_kv_heads: usize,
408 head_dim: usize,
409 v_head_dim: usize,
410 pool: Arc<Mutex<KvBlockPool>>,
411 max_seq_len: usize,
412 ) -> Result<Self, KvPoolExhausted> {
413 let block_size = pool.lock().unwrap().block_size();
414 let blocks_needed = max_seq_len.div_ceil(block_size).max(1);
415 if !pool.lock().unwrap().try_acquire(blocks_needed) {
416 return Err(KvPoolExhausted);
417 }
418 Ok(KvCache {
419 n_kv_heads,
420 head_dim,
421 v_head_dim,
422 k: Vec::with_capacity(blocks_needed * block_size * n_kv_heads * head_dim),
423 v: Vec::with_capacity(blocks_needed * block_size * n_kv_heads * v_head_dim),
424 positions: 0,
425 planned_capacity: None,
426 pool_state: Some(PooledState {
427 pool,
428 block_size,
429 blocks_held: blocks_needed,
430 }),
431 window: None,
432 #[cfg(feature = "metal")]
433 metal_attn: None,
434 recurrent: None,
435 })
436 }
437
438 /// Appends one position's key/value vectors (each
439 /// `n_kv_heads * head_dim` long) to the cache. For pool-backed
440 /// caches, this may need to acquire another block first; if the
441 /// shared pool has none free, no data is appended and
442 /// `Err(KvPoolExhausted)` is returned. Caches built with `new` or
443 /// `with_capacity` always return `Ok`.
444 pub fn push(&mut self, k_step: &[f32], v_step: &[f32]) -> Result<(), KvPoolExhausted> {
445 assert_eq!(k_step.len(), self.k_width());
446 assert_eq!(v_step.len(), self.v_width());
447
448 let (k_width, v_width) = (self.k_width(), self.v_width());
449 if let Some(state) = &mut self.pool_state {
450 let capacity_positions = self.k.capacity() / k_width;
451 // ROWS, not positions: this asks whether the buffer is
452 // full, and an evicting cache's buffer is shorter than its
453 // position count. Equal for a cache that never evicts.
454 let rows = self.k.len() / k_width;
455 if rows == capacity_positions {
456 if !state.pool.lock().unwrap().try_acquire(1) {
457 return Err(KvPoolExhausted);
458 }
459 state.blocks_held += 1;
460 self.k.reserve_exact(state.block_size * k_width);
461 self.v.reserve_exact(state.block_size * v_width);
462 }
463 }
464
465 self.k.extend_from_slice(k_step);
466 self.v.extend_from_slice(v_step);
467 self.positions += 1;
468 Ok(())
469 }
470
471 /// Advance length by `n` positions without storing real K/V values
472 /// (zero-fill). Used when Metal owns the KV plane and the host cache
473 /// only needs matching `seq_len` for sync checks.
474 pub fn advance_len(&mut self, n: usize) -> Result<(), KvPoolExhausted> {
475 if n == 0 {
476 return Ok(());
477 }
478 let k_zeros = vec![0f32; self.k_width()];
479 let v_zeros = vec![0f32; self.v_width()];
480 for _ in 0..n {
481 self.push(&k_zeros, &v_zeros)?;
482 }
483 Ok(())
484 }
485
486 /// Returns this cache's blocks to its shared pool immediately
487 /// (rather than waiting for `Drop`) and detaches it from pool
488 /// accounting; a no-op for caches that aren't pool-backed, and
489 /// idempotent if called more than once.
490 pub fn release_to_pool(&mut self) {
491 if let Some(state) = self.pool_state.take() {
492 if let Ok(mut pool) = state.pool.lock() {
493 pool.release(state.blocks_held);
494 }
495 }
496 }
497
498 pub fn clear(&mut self) {
499 self.k.clear();
500 self.v.clear();
501 self.positions = 0;
502 self.recurrent = None;
503 }
504
505 /// Whether [`Self::truncate`] to `new_seq_len` is possible.
506 ///
507 /// Always, for an attention layer's cache: its rows are a history.
508 /// For a cache holding a recurrent state only to zero (the state
509 /// resets) or to where it already is: the state is a reduction
510 /// over the whole prefix and has no "one token ago"
511 /// (`crate::recurrent_state`). Every caller that rolls a cache back
512 /// to a middle position asks this first, or is fenced off the model.
513 pub fn can_truncate_to(&self, new_seq_len: usize) -> bool {
514 self.recurrent.is_none() || new_seq_len == 0 || new_seq_len == self.positions
515 }
516
517 /// Rolls the cache back to exactly `new_seq_len` positions,
518 /// discarding everything after. Used to reject speculatively
519 /// decoded draft tokens that turned out wrong: their K/V were
520 /// already pushed during batched verification, and rejection means
521 /// removing them so the next real decode step continues from the
522 /// last *accepted* position, not the last *attempted* one.
523 /// Rolls the cache back to exactly `new_seq_len` POSITIONS.
524 ///
525 /// A windowed cache can only roll back into rows it still holds. A
526 /// target further back than [`Self::rows`] names a position this
527 /// cache dropped behind its window, and there is no honest thing to
528 /// return for it -- so it stops, rather than silently rolling back
529 /// to the oldest row it happens to have and answering the next
530 /// token out of a history with a hole in it.
531 ///
532 /// Unreachable for a cache that has not been armed by
533 /// [`Self::arm_window`], which is every cache unless
534 /// `FRINK_KV_WINDOW` is on: `rows == positions` there, so the
535 /// second precondition is implied by the first.
536 pub fn truncate(&mut self, new_seq_len: usize) {
537 assert!(
538 new_seq_len <= self.positions,
539 "truncate target {new_seq_len} must not exceed current seq_len {}",
540 self.positions
541 );
542 let rows = self.rows();
543 let dropped = self.positions - new_seq_len;
544 assert!(
545 dropped <= rows,
546 "truncate target {new_seq_len} is {dropped} positions back but only {rows} rows \
547 are resident: this cache evicted behind a {:?} window (#61). Turn off \
548 FRINK_KV_WINDOW for a workload that rolls the KV cache back this far.",
549 self.window.map(|w| w.window())
550 );
551 assert!(
552 self.can_truncate_to(new_seq_len),
553 "truncate target {new_seq_len} on a cache holding a recurrent state at {} positions: \
554 a Mamba state is a reduction over the whole prefix and cannot be rolled back to a \
555 middle position (`crate::recurrent_state`); the caller should have asked \
556 `can_truncate_to` or been fenced off a recurrent model",
557 self.positions
558 );
559 if new_seq_len == 0 {
560 self.recurrent = None;
561 }
562 let keep_rows = rows - dropped;
563 self.k.truncate(keep_rows * self.k_width());
564 self.v.truncate(keep_rows * self.v_width());
565 self.positions = new_seq_len;
566 }
567
568 /// Tells this cache it may drop rows that have fallen behind
569 /// `window` (#61 step 2).
570 ///
571 /// Arming alone drops nothing: [`Self::evict_behind_window`] is what
572 /// drops, and the holder calls it at a point where it knows nothing
573 /// is mid-read. That split is deliberate. `push` is the obvious
574 /// place to evict and it is the wrong one: `Decoder::forward_batch`
575 /// writes a whole prefill batch into the cache and only then reads
576 /// it back, against a row offset it captured BEFORE the writes.
577 /// Evicting inside `push` would move every row out from under that
578 /// offset, and the prompt would be attended over shifted keys. So
579 /// eviction is something the holder asks for, and asking for it too
580 /// rarely only costs memory -- over-retention is always correct,
581 /// under-retention is wrong logits.
582 ///
583 /// Idempotent; a second call with a different window replaces the
584 /// first, and rows already dropped stay dropped.
585 pub fn arm_window(&mut self, window: KvWindow) {
586 self.window = Some(window);
587 }
588
589 /// The window this cache evicts behind, or `None` if it keeps
590 /// everything.
591 pub fn window(&self) -> Option<KvWindow> {
592 self.window
593 }
594
595 /// Drops rows that have fallen behind the armed window, returning
596 /// how many rows went. Zero, always, for an unarmed cache.
597 ///
598 /// The rows dropped are the OLDEST ones, so what remains is still a
599 /// contiguous suffix of the sequence: row `i` of `rows` holds
600 /// absolute position `positions - rows + i`. Every windowed
601 /// attention kernel reads the last `window` rows and nothing else,
602 /// and [`KvWindow::rows_after`] guarantees at least that many
603 /// survive, so the set of rows a kernel reads is byte-for-byte the
604 /// set it would have read with no eviction at all.
605 pub fn evict_behind_window(&mut self) -> usize {
606 let Some(window) = self.window else {
607 return 0;
608 };
609 let (k_width, v_width) = (self.k_width(), self.v_width());
610 if k_width == 0 {
611 return 0;
612 }
613 let rows = self.k.len() / k_width;
614 let keep = window.rows_after(self.positions).min(rows);
615 let drop_rows = rows - keep;
616 if drop_rows == 0 {
617 return 0;
618 }
619 // One `drain` per eviction, not one per token: the whole reason
620 // `KvWindow` carries slack. This moves `keep` rows down, and it
621 // happens once every `slack + 1` positions.
622 self.k.drain(..drop_rows * k_width);
623 self.v.drain(..drop_rows * v_width);
624 // `drain` frees no memory, and the saving this exists for is
625 // memory. A cache that just absorbed a 32k-token prefill holds
626 // 32k rows of capacity behind `window + slack` rows of data
627 // until something hands it back. Only when the excess is large
628 // enough to be worth a realloc-and-copy: shrinking on every
629 // eviction would trade the block drain for a full copy.
630 //
631 // **Never for a pool-backed cache**, and the reason is a bug
632 // rather than a preference. `push` asks whether the buffer is
633 // full by comparing rows against `k.capacity()`, and takes
634 // another block from the shared pool when they meet. Shrinking
635 // the buffer to a window's worth would make that condition true
636 // every `slack + 1` tokens forever, so a windowed pooled cache
637 // would draw a fresh block from the pool on a cadence, hold
638 // every one of them until it drops, and exhaust the pool
639 // mid-answer -- where `push` is documented infallible and the
640 // caller panics. The pool has already promised this cache its
641 // blocks; handing the capacity back without handing the blocks
642 // back saves nothing and costs that.
643 if self.pool_state.is_none() {
644 let want_k = window.max_rows() * k_width;
645 if self.k.capacity() > want_k.saturating_mul(2) {
646 self.k.shrink_to(want_k);
647 self.v.shrink_to(window.max_rows() * v_width);
648 }
649 }
650 drop_rows
651 }
652
653 /// Bytes currently resident for this cache's K and V buffers
654 /// combined (actual allocated capacity, not just used length) --
655 /// the number that matters for "does this fit in the context
656 /// budget,"
657 pub fn allocated_bytes(&self) -> usize {
658 (self.k.capacity() + self.v.capacity()) * std::mem::size_of::<f32>()
659 }
660
661 /// True if this cache was pre-allocated via `with_capacity` and
662 /// has not yet grown past that planned capacity (i.e. `push` has
663 /// never had to reallocate). Useful for tests/diagnostics
664 /// confirming the pre-allocation path actually avoided reallocs.
665 pub fn is_within_planned_capacity(&self) -> bool {
666 match self.planned_capacity {
667 Some(cap) => {
668 // POSITIONS against the plan (the plan was made in
669 // positions), ROWS against the buffer (the buffer holds
670 // rows). Equal unless this cache evicts.
671 self.positions <= cap && self.k.capacity() >= self.rows() * self.k_width()
672 }
673 None => false,
674 }
675 }
676}
677
678/// The other half of PagedAttention that `KvBlockPool`/`KvCache::with_pool`
679/// deliberately don't implement (see this module's doc comment): real,
680/// *shared* physical block storage that many sequences' block tables can
681/// address into, instead of each `KvCache` still owning its own private,
682/// contiguous `Vec`. `KvBlockPool` only ever bounds a *count* of blocks
683/// each cache may grow to; `PagedKvStore` is the actual backing memory,
684/// and a sequence's `PagedKvCache` holds a block table (an ordered list
685/// of block IDs into this shared store) instead of owning K/V data
686/// directly. This is what makes non-contiguous-block reads during
687/// attention (`causal_gqa_attention_paged`, in `attention.rs`) possible
688/// at all -- `causal_gqa_attention`'s existing contiguous-slice read
689/// pattern has no way to express "position 37 lives in block 12, cached
690/// out of order relative to block 5."
691pub struct PagedKvStore {
692 block_size: usize,
693 n_kv_heads: usize,
694 /// K head width; see [`KvCache::head_dim`].
695 head_dim: usize,
696 /// V head width; see [`KvCache::v_head_dim`].
697 v_head_dim: usize,
698 k: Vec<f32>, // [total_blocks * block_size, n_kv_heads, head_dim], flattened
699 v: Vec<f32>, // [total_blocks * block_size, n_kv_heads, v_head_dim], flattened
700 free_block_ids: Vec<usize>,
701}
702
703impl PagedKvStore {
704 /// One width for K and V; [`Self::new_split`] is the general form.
705 pub fn new(block_size: usize, total_blocks: usize, n_kv_heads: usize, head_dim: usize) -> Self {
706 Self::new_split(block_size, total_blocks, n_kv_heads, head_dim, head_dim)
707 }
708
709 pub fn new_split(
710 block_size: usize,
711 total_blocks: usize,
712 n_kv_heads: usize,
713 head_dim: usize,
714 v_head_dim: usize,
715 ) -> Self {
716 assert!(block_size > 0, "block_size must be positive");
717 PagedKvStore {
718 block_size,
719 n_kv_heads,
720 head_dim,
721 v_head_dim,
722 k: vec![0.0; total_blocks * block_size * n_kv_heads * head_dim],
723 v: vec![0.0; total_blocks * block_size * n_kv_heads * v_head_dim],
724 // Pushed in descending order so `pop()` hands out ascending
725 // block IDs -- not load-bearing for correctness (any free ID
726 // works), just makes manual debugging/inspection saner.
727 free_block_ids: (0..total_blocks).rev().collect(),
728 }
729 }
730
731 pub fn block_size(&self) -> usize {
732 self.block_size
733 }
734
735 pub fn free_block_count(&self) -> usize {
736 self.free_block_ids.len()
737 }
738
739 pub fn n_kv_heads(&self) -> usize {
740 self.n_kv_heads
741 }
742
743 pub fn head_dim(&self) -> usize {
744 self.head_dim
745 }
746
747 pub fn v_head_dim(&self) -> usize {
748 self.v_head_dim
749 }
750
751 /// One position's K row: `n_kv_heads * head_dim` elements.
752 pub fn k_width(&self) -> usize {
753 self.n_kv_heads * self.head_dim
754 }
755
756 /// One position's V row: `n_kv_heads * v_head_dim` elements.
757 pub fn v_width(&self) -> usize {
758 self.n_kv_heads * self.v_head_dim
759 }
760
761 fn acquire_block(&mut self) -> Option<usize> {
762 self.free_block_ids.pop()
763 }
764
765 fn release_block(&mut self, id: usize) {
766 self.free_block_ids.push(id);
767 }
768
769 fn k_elems_per_block(&self) -> usize {
770 self.block_size * self.k_width()
771 }
772
773 fn v_elems_per_block(&self) -> usize {
774 self.block_size * self.v_width()
775 }
776
777 /// One position's K (or V) row within block `id` at `offset` (0-based
778 /// within the block) -- `[n_kv_heads * head_dim]` long. Used by
779 /// `causal_gqa_attention_paged` to read attention inputs directly out
780 /// of shared physical storage via a block table, and by
781 /// `PagedKvCache::push` to write a new position into it.
782 pub fn k_row(&self, id: usize, offset: usize) -> &[f32] {
783 let width = self.k_width();
784 let start = id * self.k_elems_per_block() + offset * width;
785 &self.k[start..start + width]
786 }
787
788 pub fn v_row(&self, id: usize, offset: usize) -> &[f32] {
789 let width = self.v_width();
790 let start = id * self.v_elems_per_block() + offset * width;
791 &self.v[start..start + width]
792 }
793
794 /// Copies every position of block `src` over block `dst`.
795 ///
796 /// The one operation copy-on-write needs and nothing else does. A
797 /// sequence that forks shares its full blocks and copies only the
798 /// PART-FULL tail, because that is the single block two forks would
799 /// both write into. Sharing it instead would let one fork's token
800 /// appear in the other's context, which is a wrong answer served
801 /// with a 200 rather than a crash.
802 ///
803 /// The whole block is copied rather than the written prefix of it:
804 /// the unwritten rows are within the block either way, and a
805 /// length-aware copy would need the caller's `seq_len`, which is
806 /// the one number this type deliberately does not hold.
807 pub fn copy_block(&mut self, src: usize, dst: usize) {
808 assert_ne!(src, dst, "copy_block onto itself");
809 let k_span = self.k_elems_per_block();
810 let (ks, kd) = (src * k_span, dst * k_span);
811 self.k.copy_within(ks..ks + k_span, kd);
812 let v_span = self.v_elems_per_block();
813 let (vs, vd) = (src * v_span, dst * v_span);
814 self.v.copy_within(vs..vs + v_span, vd);
815 }
816
817 fn k_row_mut(&mut self, id: usize, offset: usize) -> &mut [f32] {
818 let width = self.k_width();
819 let start = id * self.k_elems_per_block() + offset * width;
820 &mut self.k[start..start + width]
821 }
822
823 fn v_row_mut(&mut self, id: usize, offset: usize) -> &mut [f32] {
824 let width = self.v_width();
825 let start = id * self.v_elems_per_block() + offset * width;
826 &mut self.v[start..start + width]
827 }
828}
829
830/// Returned when a `PagedKvCache` needs another block but its
831/// `PagedKvStore` has none free -- the paged-storage analog of
832/// `KvPoolExhausted`.
833#[derive(Debug, Clone, Copy, PartialEq, Eq)]
834pub struct PagedStoreExhausted;
835
836impl std::fmt::Display for PagedStoreExhausted {
837 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
838 write!(f, "paged KV store exhausted: no free blocks remain")
839 }
840}
841
842impl std::error::Error for PagedStoreExhausted {}
843
844/// One sequence's view into a shared `PagedKvStore`: a block table
845/// (which physical blocks this sequence's positions live in, in order)
846/// plus how many positions have been written so far. Unlike `KvCache`,
847/// this holds no K/V data itself -- every read and write goes through
848/// the shared store.
849#[derive(Debug, Clone, Default)]
850pub struct PagedKvCache {
851 block_table: Vec<usize>,
852 seq_len: usize,
853 /// A recurrent layer's state, per sequence and NOT paged: the store
854 /// holds rows and a Mamba layer has none. Same rules as
855 /// `KvCache::recurrent`.
856 pub recurrent: Option<crate::recurrent_state::RecurrentState>,
857}
858
859impl PagedKvCache {
860 pub fn new() -> Self {
861 PagedKvCache {
862 block_table: Vec::new(),
863 seq_len: 0,
864 recurrent: None,
865 }
866 }
867
868 /// See `KvCache::can_truncate_to`.
869 pub fn can_truncate_to(&self, new_seq_len: usize) -> bool {
870 self.recurrent.is_none() || new_seq_len == 0 || new_seq_len == self.seq_len
871 }
872
873 pub fn seq_len(&self) -> usize {
874 self.seq_len
875 }
876
877 pub fn block_table(&self) -> &[usize] {
878 &self.block_table
879 }
880
881 /// Appends one position's key/value vectors, acquiring a new block
882 /// from `store` first if the current tail block is full (or none
883 /// held yet). Mirrors `KvCache::push`'s signature/semantics exactly,
884 /// just against shared storage instead of a private buffer.
885 pub fn push(
886 &mut self,
887 store: &mut PagedKvStore,
888 k_step: &[f32],
889 v_step: &[f32],
890 ) -> Result<(), PagedStoreExhausted> {
891 let block_size = store.block_size();
892 let offset_in_block = self.seq_len % block_size;
893 // Index by position rather than taking the tail block: a
894 // sequence that pre-reserved (see `reserve`) already holds the
895 // block this position belongs in, and appending another would
896 // both leak a block and write the row in the wrong place.
897 let block_index = self.seq_len / block_size;
898 if block_index >= self.block_table.len() {
899 let id = store.acquire_block().ok_or(PagedStoreExhausted)?;
900 self.block_table.push(id);
901 }
902 let block_id = self.block_table[block_index];
903 store
904 .k_row_mut(block_id, offset_in_block)
905 .copy_from_slice(k_step);
906 store
907 .v_row_mut(block_id, offset_in_block)
908 .copy_from_slice(v_step);
909 self.seq_len += 1;
910 Ok(())
911 }
912
913 /// Appends a block the caller already owns, without taking one from
914 /// the store.
915 ///
916 /// This is how a sliding window recycles. A block whose positions
917 /// have fallen behind the window is never read again -- the paged
918 /// attention kernel indexes `block_table[t / block_size]` only for
919 /// `t >= seq_len - window` -- so its storage can back a *later*
920 /// position instead of being handed back and re-acquired. The table
921 /// keeps its absolute-position indexing and simply names the same
922 /// physical block at two indices: the stale one, which nothing
923 /// reads, and the live one.
924 ///
925 /// That aliasing is the reason this is a separate method rather than
926 /// a flag on [`Self::reserve`]. A caller that recycles owns the
927 /// obligation to release each distinct block exactly once, and to
928 /// have established that the donor index really is out of window --
929 /// neither of which this type can check for itself.
930 pub fn append_block(&mut self, block_id: usize) {
931 self.block_table.push(block_id);
932 }
933
934 /// Points this sequence at a different set of physical blocks,
935 /// keeping its length.
936 ///
937 /// What a FORK installs. The forked sequence is at the same
938 /// position with the same history, and the only thing that differs
939 /// is where some of those bytes live: the full blocks are the
940 /// original's, shared, and the tail is a copy. The caller owns
941 /// establishing that the new table really holds this sequence's
942 /// content, which is why this is a plain setter and not a `fork`
943 /// method here -- the copying happens a layer up, where the page
944 /// GROUP is the unit and one call covers every layer at once.
945 ///
946 /// The new table must be at least as long as the old, because a
947 /// shorter one would drop blocks the caller still accounts for.
948 pub fn retable(&mut self, block_table: Vec<usize>) {
949 assert!(
950 block_table.len() >= self.block_table.len(),
951 "a retable must not shorten the block table: {} to {}",
952 self.block_table.len(),
953 block_table.len()
954 );
955 self.block_table = block_table;
956 }
957
958 /// Releases every block this sequence holds back to `store`. Must be
959 /// called explicitly (there's no `Drop` here, since dropping needs a
960 /// `&mut PagedKvStore` this type doesn't own a reference to) --
961 /// mirrors `KvCache::release_to_pool`, just not automatic.
962 ///
963 /// Each *distinct* block once: a table that has recycled through
964 /// [`Self::append_block`] names one block at more than one index, and
965 /// releasing per index would put the same id on the free list twice,
966 /// after which two sequences are handed the same memory.
967 pub fn release(&mut self, store: &mut PagedKvStore) {
968 let mut seen: Vec<usize> = Vec::new();
969 for id in self.block_table.drain(..) {
970 if !seen.contains(&id) {
971 seen.push(id);
972 store.release_block(id);
973 }
974 }
975 self.seq_len = 0;
976 self.recurrent = None;
977 }
978
979 /// How many *additional* blocks appending `n_new` positions would
980 /// take from `store`, given what this sequence already holds.
981 ///
982 /// Counted against held CAPACITY rather than against `seq_len`, so
983 /// it is right in both cases. The tail block is usually part-full,
984 /// so the answer is never simply `n_new / block_size`: positions
985 /// that land in a block already held cost nothing. And a sequence
986 /// that pre-reserved (see [`Self::reserve`]) holds blocks beyond
987 /// its length, which a `seq_len`-only sum would ask for twice.
988 ///
989 /// Callers that must not fail part-way through a write check this
990 /// against [`PagedKvStore::free_block_count`] before touching
991 /// anything.
992 pub fn blocks_needed_for(&self, store: &PagedKvStore, n_new: usize) -> usize {
993 let held_capacity = self.block_table.len() * store.block_size();
994 let unused = held_capacity.saturating_sub(self.seq_len);
995 n_new.saturating_sub(unused).div_ceil(store.block_size())
996 }
997
998 /// Takes the blocks `n_new` more positions will need, without
999 /// advancing `seq_len`.
1000 ///
1001 /// This is what makes a multi-layer append all-or-nothing. The
1002 /// check and the taking happen together, so every later
1003 /// [`Self::push`] writes into a block this sequence already owns
1004 /// and cannot fail. Reserving and then not filling is harmless: the
1005 /// blocks are this sequence's until it releases, and `seq_len`
1006 /// still says how far it really got.
1007 pub fn reserve(
1008 &mut self,
1009 store: &mut PagedKvStore,
1010 n_new: usize,
1011 ) -> Result<(), PagedStoreExhausted> {
1012 let need = self.blocks_needed_for(store, n_new);
1013 if need > store.free_block_count() {
1014 return Err(PagedStoreExhausted);
1015 }
1016 for _ in 0..need {
1017 let id = store
1018 .acquire_block()
1019 .expect("checked against free_block_count immediately above");
1020 self.block_table.push(id);
1021 }
1022 Ok(())
1023 }
1024
1025 /// Installs a block table the caller allocated, with `seq_len`
1026 /// positions already computed in it.
1027 ///
1028 /// This is how a sequence starts life on top of a cached prefix:
1029 /// the blocks are somebody else's, already full, and this sequence
1030 /// appends past them.
1031 ///
1032 /// The `seq_len` installed here is therefore also the POSITION the
1033 /// caller's next forward pass must run at, and the caller has no
1034 /// second source for that number: [`Self::push`] writes at `seq_len`
1035 /// and ignores whatever position its caller believes it is at. A
1036 /// prefill that started from zero over an adopted prefix put the
1037 /// prompt in the rows *after* the prefix while carrying positions
1038 /// `0..n`, which is a wrong answer served with a 200.
1039 ///
1040 /// `seq_len` MUST be a whole number of blocks,
1041 /// because the first append writes at `seq_len` and a shared block
1042 /// must never be written -- another sequence is attending over it.
1043 /// A ragged length would put that write inside the last shared
1044 /// block, corrupting a prefix every other holder is reading.
1045 pub fn adopt_blocks(&mut self, block_table: Vec<usize>, seq_len: usize, block_size: usize) {
1046 assert_eq!(
1047 seq_len % block_size,
1048 0,
1049 "an adopted prefix must end on a block boundary, or the first \
1050 append writes into a block another sequence is reading"
1051 );
1052 assert!(
1053 seq_len / block_size <= block_table.len(),
1054 "block table too short for the adopted length"
1055 );
1056 self.block_table = block_table;
1057 self.seq_len = seq_len;
1058 }
1059
1060 /// Copies this sequence's KV out of the shared store into a plain
1061 /// contiguous [`KvCache`].
1062 ///
1063 /// This is what lets the batched prefill path run *unchanged* over
1064 /// paged storage. Its fast arm hands `cache.k` / `cache.v` to a
1065 /// blocked kernel that reads them as flat slices, and a block table
1066 /// cannot be expressed that way. Rather than maintain a second
1067 /// prefill kernel that reads through the table -- a copy that could
1068 /// drift from the one every other model path uses -- the pages are
1069 /// materialised once per layer, the existing kernel runs, and the
1070 /// new rows go back with [`Self::append_contiguous`].
1071 ///
1072 /// The cost is one `seq_len * n_kv_heads * head_dim` copy per layer
1073 /// per prefill call, against matmuls that dominate prefill. Decode
1074 /// still reads through the block table and copies nothing, which is
1075 /// where page sharing actually pays.
1076 pub fn to_contiguous(&self, store: &PagedKvStore) -> KvCache {
1077 let mut cache = KvCache::with_capacity_split(
1078 store.n_kv_heads,
1079 store.head_dim,
1080 store.v_head_dim,
1081 self.seq_len,
1082 );
1083 cache.k.reserve_exact(self.seq_len * store.k_width());
1084 cache.v.reserve_exact(self.seq_len * store.v_width());
1085 for pos in 0..self.seq_len {
1086 let block_id = self.block_table[pos / store.block_size];
1087 let offset = pos % store.block_size;
1088 cache.k.extend_from_slice(store.k_row(block_id, offset));
1089 cache.v.extend_from_slice(store.v_row(block_id, offset));
1090 }
1091 // The paged store does not evict either, so its positions and
1092 // its rows agree and this one assignment is both.
1093 cache.set_positions(self.seq_len);
1094 cache.recurrent = self.recurrent.clone();
1095 cache
1096 }
1097
1098 /// Appends `count` positions' worth of contiguous K/V rows, the
1099 /// inverse of [`Self::to_contiguous`].
1100 ///
1101 /// Blocks are reserved for the whole append *before* the first row
1102 /// is written, so a store that cannot hold the request refuses it
1103 /// having changed nothing. Writing rows until the store runs dry
1104 /// would leave the sequence with a `seq_len` that disagrees with
1105 /// the model's own idea of how far it has got, which is not a
1106 /// recoverable state.
1107 pub fn append_contiguous(
1108 &mut self,
1109 store: &mut PagedKvStore,
1110 k: &[f32],
1111 v: &[f32],
1112 count: usize,
1113 ) -> Result<(), PagedStoreExhausted> {
1114 let (k_width, v_width) = (store.k_width(), store.v_width());
1115 assert_eq!(k.len(), count * k_width, "k row count");
1116 assert_eq!(v.len(), count * v_width, "v row count");
1117 if self.blocks_needed_for(store, count) > store.free_block_count() {
1118 return Err(PagedStoreExhausted);
1119 }
1120 for i in 0..count {
1121 self.push(
1122 store,
1123 &k[i * k_width..(i + 1) * k_width],
1124 &v[i * v_width..(i + 1) * v_width],
1125 )
1126 .expect("blocks reserved above, so no push here can exhaust the store");
1127 }
1128 Ok(())
1129 }
1130}
1131
1132/// Per-layer [`PagedKvStore`]s that many concurrent requests share.
1133///
1134/// # Why a lock per layer, and why two phases
1135///
1136/// `frink-server` runs generation on `spawn_blocking` with, in its own
1137/// words, "no I/O and no shared lock". `KvBlockPool` survives that
1138/// because it only bounds a *count*: each `KvCache` owns a private
1139/// `Vec`, and the pool mutex is taken briefly at acquire and release,
1140/// never during a forward. A `PagedKvStore` is the opposite -- it IS
1141/// the backing memory -- so sharing one across concurrent requests
1142/// needs an answer to "who may touch these bytes when".
1143///
1144/// The answer the API already implies: attention takes
1145/// `&PagedKvStore` and only `push` takes `&mut`. So the accesses split
1146/// cleanly into many concurrent readers and one short exclusive write
1147/// per position, which is exactly an `RwLock` -- and one per LAYER
1148/// rather than one for the whole model, so two requests contend only
1149/// when both are writing the same layer at the same instant.
1150///
1151/// A caller must therefore take the write guard for the push alone and
1152/// drop it before attending under a read guard. Holding the write
1153/// guard across attention would serialise the expensive half and give
1154/// back a global lock with extra steps. Nothing breaks in the gap: a
1155/// sequence's block table and length are its own, and another
1156/// request's push in between only touches blocks it exclusively holds.
1157///
1158/// # Deadlock
1159///
1160/// [`Self::write_all`] is the one place several layers are held at
1161/// once, and it takes them in ascending layer order. Every caller
1162/// getting the same order is what makes that safe; there is no other
1163/// multi-layer acquisition in the codebase, and a new one must follow
1164/// the same rule.
1165///
1166/// # Poisoning
1167///
1168/// A panic while holding a store leaves the KV mid-write, which is not
1169/// recoverable state, but it is also not *unsound* -- the bytes are
1170/// plain `f32`. Poison is stepped over with `into_inner`, matching how
1171/// `frink-server` already treats its pool mutex: a poisoned lock
1172/// should not turn one request's panic into a permanently dead server.
1173pub struct SharedPagedKv {
1174 layers: Vec<RwLock<PagedKvStore>>,
1175 /// Guarded separately from the layers, and always taken BEFORE
1176 /// them, never while a layer guard is held. That one-way order is
1177 /// what keeps group allocation and the per-layer push paths from
1178 /// deadlocking against each other.
1179 groups: Mutex<GroupTable>,
1180}
1181
1182impl SharedPagedKv {
1183 /// One store per layer, each with `blocks_per_layer` blocks.
1184 pub fn new(
1185 n_layers: usize,
1186 block_size: usize,
1187 blocks_per_layer: usize,
1188 n_kv_heads: usize,
1189 head_dim: usize,
1190 ) -> Self {
1191 SharedPagedKv {
1192 layers: (0..n_layers)
1193 .map(|_| {
1194 RwLock::new(PagedKvStore::new(
1195 block_size,
1196 blocks_per_layer,
1197 n_kv_heads,
1198 head_dim,
1199 ))
1200 })
1201 .collect(),
1202 groups: Mutex::new(GroupTable::default()),
1203 }
1204 }
1205
1206 /// Wraps stores the caller built, for tests and for callers that
1207 /// size layers differently.
1208 pub fn from_stores(stores: Vec<PagedKvStore>) -> Self {
1209 SharedPagedKv {
1210 layers: stores.into_iter().map(RwLock::new).collect(),
1211 groups: Mutex::new(GroupTable::default()),
1212 }
1213 }
1214
1215 pub fn layer_count(&self) -> usize {
1216 self.layers.len()
1217 }
1218
1219 /// Shared access to one layer, for attention.
1220 pub fn read(&self, layer: usize) -> RwLockReadGuard<'_, PagedKvStore> {
1221 self.layers[layer]
1222 .read()
1223 .unwrap_or_else(|poisoned| poisoned.into_inner())
1224 }
1225
1226 /// Exclusive access to one layer, for a push. Hold it for the push
1227 /// and nothing else -- see the type docs.
1228 pub fn write(&self, layer: usize) -> RwLockWriteGuard<'_, PagedKvStore> {
1229 self.layers[layer]
1230 .write()
1231 .unwrap_or_else(|poisoned| poisoned.into_inner())
1232 }
1233
1234 /// Every layer at once, in ascending order, so a multi-layer append
1235 /// is atomic against other requests.
1236 ///
1237 /// This is what makes "all layers advance or none do" hold under
1238 /// concurrency rather than only single-threaded: checking free
1239 /// space and then appending are separate steps, and without the
1240 /// guards spanning both, another request can take the blocks in
1241 /// between and leave this one half-written.
1242 ///
1243 /// Ascending order is the deadlock rule; see the type docs.
1244 pub fn write_all(&self) -> Vec<RwLockWriteGuard<'_, PagedKvStore>> {
1245 self.layers
1246 .iter()
1247 .map(|l| l.write().unwrap_or_else(|poisoned| poisoned.into_inner()))
1248 .collect()
1249 }
1250
1251 /// Free blocks in one layer, for admission control. A snapshot: by
1252 /// the time a caller acts on it another request may have taken
1253 /// them, which is why the append itself re-checks under the guard.
1254 pub fn free_blocks(&self, layer: usize) -> usize {
1255 self.read(layer).free_block_count()
1256 }
1257
1258 /// Takes one block from EVERY layer as a single group, refcount 1.
1259 ///
1260 /// All layers or none: a group that existed in some layers and not
1261 /// others could not answer "which block holds position p in layer
1262 /// l", which is the only question it exists to answer.
1263 pub fn acquire_group(&self) -> Option<PageGroup> {
1264 let mut guards = self.write_all();
1265 if guards.iter().any(|s| s.free_block_count() == 0) {
1266 return None;
1267 }
1268 let blocks: Vec<usize> = guards
1269 .iter_mut()
1270 .map(|s| {
1271 s.acquire_block()
1272 .expect("checked every layer under these same guards")
1273 })
1274 .collect();
1275 let mut groups = self
1276 .groups
1277 .lock()
1278 .unwrap_or_else(|poisoned| poisoned.into_inner());
1279 Some(PageGroup(groups.insert(blocks)))
1280 }
1281
1282 /// One more holder of `group`.
1283 ///
1284 /// Called when a second sequence adopts a cached prefix. Without
1285 /// it, the first sequence to finish frees pages the second is
1286 /// still attending over -- a use-after-free that shows up as
1287 /// another conversation's tokens rather than as a crash.
1288 pub fn retain_group(&self, group: PageGroup) {
1289 let mut groups = self
1290 .groups
1291 .lock()
1292 .unwrap_or_else(|poisoned| poisoned.into_inner());
1293 groups.retain(group.0);
1294 }
1295
1296 /// One fewer holder. At zero the blocks go back to their layers.
1297 ///
1298 /// Returns whether this was the last holder, so a caller can assert
1299 /// on it rather than guess.
1300 pub fn release_group(&self, group: PageGroup) -> bool {
1301 let blocks = {
1302 let mut groups = self
1303 .groups
1304 .lock()
1305 .unwrap_or_else(|poisoned| poisoned.into_inner());
1306 match groups.release(group.0) {
1307 Some(blocks) => blocks,
1308 None => return false,
1309 }
1310 };
1311 // The groups lock is dropped before the layer guards are taken,
1312 // so the lock order is always groups-then-layers and never the
1313 // reverse. See the type docs on deadlock.
1314 let mut guards = self.write_all();
1315 for (store, block) in guards.iter_mut().zip(blocks) {
1316 store.release_block(block);
1317 }
1318 true
1319 }
1320
1321 /// Which block in each layer this group owns, indexed by layer.
1322 pub fn group_blocks(&self, group: PageGroup) -> Vec<usize> {
1323 let groups = self
1324 .groups
1325 .lock()
1326 .unwrap_or_else(|poisoned| poisoned.into_inner());
1327 groups.blocks(group.0).to_vec()
1328 }
1329
1330 /// How many holders `group` has. Zero means it does not exist.
1331 pub fn group_refs(&self, group: PageGroup) -> u32 {
1332 let groups = self
1333 .groups
1334 .lock()
1335 .unwrap_or_else(|poisoned| poisoned.into_inner());
1336 groups.refs(group.0)
1337 }
1338
1339 /// Copies every layer's block from `src` into `dst`.
1340 ///
1341 /// The group-level form of [`PagedKvStore::copy_block`], which is
1342 /// the unit a sequence forks at: a position's KV lives in one block
1343 /// per layer, and a fork that copied some layers and shared others
1344 /// would answer with half of each.
1345 pub fn copy_group(&self, src: PageGroup, dst: PageGroup) {
1346 let (from, to) = {
1347 let groups = self
1348 .groups
1349 .lock()
1350 .unwrap_or_else(|poisoned| poisoned.into_inner());
1351 (groups.blocks(src.0).to_vec(), groups.blocks(dst.0).to_vec())
1352 };
1353 // Groups lock released before the layer guards, as everywhere
1354 // else here: see the type docs on deadlock.
1355 let mut guards = self.write_all();
1356 for ((store, &s), &d) in guards.iter_mut().zip(&from).zip(&to) {
1357 store.copy_block(s, d);
1358 }
1359 }
1360
1361 /// Groups that could still be allocated, bounded by the layer with
1362 /// the fewest free blocks: a group needs one from each.
1363 pub fn free_groups(&self) -> usize {
1364 (0..self.layers.len())
1365 .map(|l| self.free_blocks(l))
1366 .min()
1367 .unwrap_or(0)
1368 }
1369}
1370
1371/// A handle to one block in every layer.
1372///
1373/// The unit of sharing between sequences, and the only thing small
1374/// enough to be what a radix prefix cache stores: that cache maps a
1375/// token prefix to ONE index per token, while a position's KV lives in
1376/// `n_layers` different blocks. A group is the name for all of them.
1377#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1378pub struct PageGroup(pub u32);
1379
1380/// Group ids, their per-layer blocks, and how many holders each has.
1381#[derive(Debug, Default)]
1382struct GroupTable {
1383 /// Indexed by group id. `None` for an id currently on the free list.
1384 blocks: Vec<Option<Vec<usize>>>,
1385 refs: Vec<u32>,
1386 free_ids: Vec<u32>,
1387}
1388
1389impl GroupTable {
1390 fn insert(&mut self, blocks: Vec<usize>) -> u32 {
1391 if let Some(id) = self.free_ids.pop() {
1392 self.blocks[id as usize] = Some(blocks);
1393 self.refs[id as usize] = 1;
1394 return id;
1395 }
1396 self.blocks.push(Some(blocks));
1397 self.refs.push(1);
1398 (self.blocks.len() - 1) as u32
1399 }
1400
1401 fn retain(&mut self, id: u32) {
1402 let refs = &mut self.refs[id as usize];
1403 assert!(*refs > 0, "cannot retain group {id}, which has no holders");
1404 *refs += 1;
1405 }
1406
1407 /// Drops one holder, returning the blocks to free only when the
1408 /// last one goes.
1409 fn release(&mut self, id: u32) -> Option<Vec<usize>> {
1410 let refs = &mut self.refs[id as usize];
1411 assert!(*refs > 0, "double free of group {id}");
1412 *refs -= 1;
1413 if *refs > 0 {
1414 return None;
1415 }
1416 // The id is reusable now, but only after the blocks are out:
1417 // handing the id back while it still named blocks would let a
1418 // later `acquire_group` believe it owns them too.
1419 let blocks = self.blocks[id as usize]
1420 .take()
1421 .expect("a group with holders always has blocks");
1422 self.free_ids.push(id);
1423 Some(blocks)
1424 }
1425
1426 fn blocks(&self, id: u32) -> &[usize] {
1427 self.blocks[id as usize]
1428 .as_deref()
1429 .expect("group has no blocks; it was already released")
1430 }
1431
1432 fn refs(&self, id: u32) -> u32 {
1433 self.refs.get(id as usize).copied().unwrap_or(0)
1434 }
1435}
1436
1437#[cfg(test)]
1438mod tests {
1439 use super::*;
1440
1441 /// `blocks_needed_for` is the reservation the whole no-partial-write
1442 /// guarantee rests on, and it is wrong in two opposite directions
1443 /// that fail very differently.
1444 ///
1445 /// UNDER-counting is the dangerous one: `append_contiguous` reserves
1446 /// on this answer and then pushes with an `expect`, so too small a
1447 /// number panics part-way through a layer -- exactly the corrupted
1448 /// state the reservation exists to prevent. Over-counting merely
1449 /// refuses a request that would have fitted.
1450 ///
1451 /// Both mistakes are one edit away. Flooring instead of ceiling
1452 /// under-counts whenever the append does not land on a block
1453 /// boundary; ignoring the part-full tail over-counts whenever a
1454 /// sequence is mid-block, which after the first token is almost
1455 /// always. Neither shows up when the numbers happen to divide
1456 /// evenly, so the cases here are chosen so that they do not.
1457 #[test]
1458 fn blocks_needed_for_accounts_for_the_part_full_tail_block() {
1459 let mut store = PagedKvStore::new(/* block_size = */ 4, 64, 1, 1);
1460 let mut cache = PagedKvCache::new();
1461 let row = [1.0f32];
1462 // Real pushes rather than poking `seq_len`: the count is
1463 // against blocks this sequence HOLDS, so a length with no
1464 // blocks behind it is a state that cannot occur and would only
1465 // let the test agree with an arithmetic nothing produces.
1466 let advance = |cache: &mut PagedKvCache, store: &mut PagedKvStore, n: usize| {
1467 for _ in 0..n {
1468 cache.push(store, &row, &row).unwrap();
1469 }
1470 };
1471
1472 // Empty: a whole-block boundary, and a remainder that a floor
1473 // would round away.
1474 assert_eq!(cache.blocks_needed_for(&store, 0), 0);
1475 assert_eq!(cache.blocks_needed_for(&store, 1), 1);
1476 assert_eq!(cache.blocks_needed_for(&store, 4), 1);
1477 assert_eq!(cache.blocks_needed_for(&store, 5), 2, "5 into 4s needs 2");
1478
1479 // One position in: three slots free in the tail, so appending up
1480 // to three costs NOTHING. Ignoring the tail would say 1.
1481 advance(&mut cache, &mut store, 1);
1482 assert_eq!(cache.blocks_needed_for(&store, 3), 0, "fits in the tail");
1483 assert_eq!(cache.blocks_needed_for(&store, 4), 1);
1484 assert_eq!(cache.blocks_needed_for(&store, 8), 2);
1485
1486 // The awkward case: 1 free in the tail, 6 to append. 5 spill
1487 // over 4-wide blocks, so 2. A floor gives 1 and a tail-blind
1488 // ceil gives 2 for the wrong reason, so this pins the shape.
1489 advance(&mut cache, &mut store, 2); // seq_len = 3
1490 assert_eq!(cache.blocks_needed_for(&store, 6), 2);
1491 assert_eq!(cache.blocks_needed_for(&store, 5), 1);
1492
1493 // Tail exactly full: no free slots, so this behaves like empty.
1494 advance(&mut cache, &mut store, 1); // seq_len = 4
1495 assert_eq!(cache.blocks_needed_for(&store, 1), 1);
1496 assert_eq!(cache.blocks_needed_for(&store, 4), 1);
1497
1498 // A RESERVED block is capacity this sequence already holds, so
1499 // it must not be asked for twice. Counting from `seq_len` alone
1500 // would say 1 here and take a second block for positions the
1501 // reservation already covers.
1502 cache.reserve(&mut store, 4).unwrap();
1503 assert_eq!(
1504 cache.blocks_needed_for(&store, 4),
1505 0,
1506 "a reserved block is already held"
1507 );
1508 assert_eq!(cache.blocks_needed_for(&store, 5), 1);
1509 }
1510
1511 /// A group takes one block from every layer, and gives them all
1512 /// back together.
1513 ///
1514 /// All-or-nothing is the point: a group holding blocks in some
1515 /// layers and not others cannot answer "which block holds position
1516 /// p in layer l", which is the only question it exists for.
1517 #[test]
1518 fn a_group_takes_one_block_from_every_layer_and_returns_them_together() {
1519 let kv = SharedPagedKv::new(3, 2, 4, 1, 1);
1520 assert_eq!(kv.free_groups(), 4);
1521
1522 let g = kv.acquire_group().expect("4 groups available");
1523 let blocks = kv.group_blocks(g);
1524 assert_eq!(blocks.len(), 3, "one block per layer");
1525 for l in 0..3 {
1526 assert_eq!(kv.free_blocks(l), 3, "layer {l} gave up exactly one");
1527 }
1528 assert_eq!(kv.free_groups(), 3);
1529
1530 assert!(kv.release_group(g), "sole holder, so this frees it");
1531 for l in 0..3 {
1532 assert_eq!(kv.free_blocks(l), 4, "layer {l} got its block back");
1533 }
1534 assert_eq!(kv.free_groups(), 4);
1535 }
1536
1537 /// A group survives until its LAST holder releases it.
1538 ///
1539 /// This is what makes prefix sharing safe. Two sequences off one
1540 /// system prompt hold the same pages; if the first to finish freed
1541 /// them, the second would keep attending over blocks the store had
1542 /// already handed to somebody else -- surfacing as another
1543 /// conversation's tokens, not as a crash.
1544 #[test]
1545 fn a_group_shared_by_two_holders_survives_the_first_release() {
1546 let kv = SharedPagedKv::new(2, 2, 2, 1, 1);
1547 let g = kv.acquire_group().unwrap();
1548 let blocks = kv.group_blocks(g);
1549 kv.retain_group(g);
1550 assert_eq!(kv.group_refs(g), 2);
1551
1552 assert!(
1553 !kv.release_group(g),
1554 "one holder remains, so nothing is freed"
1555 );
1556 assert_eq!(kv.group_refs(g), 1);
1557 assert_eq!(kv.free_blocks(0), 1, "the blocks are still held");
1558 assert_eq!(kv.group_blocks(g), blocks, "and still name the same blocks");
1559
1560 assert!(kv.release_group(g), "last holder frees it");
1561 assert_eq!(kv.group_refs(g), 0);
1562 assert_eq!(kv.free_blocks(0), 2);
1563 }
1564
1565 /// Exhaustion is per group, bounded by the tightest layer.
1566 ///
1567 /// A layer with one block left caps the whole pool at one more
1568 /// group however much room the others have, because a group needs
1569 /// one block from each.
1570 #[test]
1571 fn group_capacity_is_bounded_by_the_layer_with_the_fewest_blocks() {
1572 let kv = SharedPagedKv::from_stores(vec![
1573 PagedKvStore::new(2, 5, 1, 1),
1574 PagedKvStore::new(2, 1, 1, 1),
1575 ]);
1576 assert_eq!(kv.free_groups(), 1, "layer 1 has only one block");
1577
1578 let g = kv.acquire_group().expect("one group fits");
1579 assert_eq!(kv.free_groups(), 0);
1580 assert!(
1581 kv.acquire_group().is_none(),
1582 "layer 1 is empty, so no group can be formed"
1583 );
1584 // The refused attempt must not have taken layer 0's block.
1585 assert_eq!(kv.free_blocks(0), 4, "a refused group leaks nothing");
1586 kv.release_group(g);
1587 assert_eq!(kv.free_blocks(0), 5);
1588 }
1589
1590 /// A released id is reused, with a refcount that starts over.
1591 #[test]
1592 fn a_released_group_id_is_reused_with_a_fresh_refcount() {
1593 let kv = SharedPagedKv::new(1, 2, 2, 1, 1);
1594 let first = kv.acquire_group().unwrap();
1595 kv.retain_group(first);
1596 assert_eq!(kv.group_refs(first), 2);
1597 kv.release_group(first);
1598 kv.release_group(first);
1599 assert_eq!(kv.group_refs(first), 0, "gone, not merely decremented");
1600
1601 let second = kv.acquire_group().unwrap();
1602 assert_eq!(second, first, "the id is reused");
1603 assert_eq!(
1604 kv.group_refs(second),
1605 1,
1606 "a reused id must not inherit the old count"
1607 );
1608 assert_eq!(kv.group_blocks(second).len(), 1);
1609 assert_eq!(kv.free_blocks(0), 1);
1610 }
1611
1612 /// Reading a group after its last holder released it PANICS rather
1613 /// than answering with stale blocks.
1614 ///
1615 /// This is the observable half of clearing the entry on release,
1616 /// and the reason it is `take` rather than `clone`: a caller still
1617 /// holding a `PageGroup` after releasing it is exactly the bug
1618 /// refcounting exists to prevent, and blocks that now belong to
1619 /// somebody else are the worst possible answer -- the caller reads
1620 /// another sequence's KV and nothing says so.
1621 ///
1622 /// Written after sabotage showed the previous test here passed with
1623 /// `clone` in place of `take`: `insert` overwrites the entry on
1624 /// reuse, so a stale entry was never reachable through the path
1625 /// that test took. This one reaches it.
1626 #[test]
1627 #[should_panic(expected = "already released")]
1628 fn reading_a_released_group_panics_rather_than_returning_stale_blocks() {
1629 let kv = SharedPagedKv::new(2, 2, 2, 1, 1);
1630 let g = kv.acquire_group().unwrap();
1631 assert!(kv.release_group(g));
1632 let _ = kv.group_blocks(g);
1633 }
1634
1635 /// Releasing a group nobody holds is a bug, not a no-op.
1636 ///
1637 /// Silently ignoring it would let a double release return the same
1638 /// blocks to the store twice, after which two sequences are handed
1639 /// the same page and both write it.
1640 #[test]
1641 #[should_panic(expected = "double free of group")]
1642 fn releasing_a_group_twice_panics_rather_than_freeing_it_twice() {
1643 let kv = SharedPagedKv::new(1, 2, 2, 1, 1);
1644 let g = kv.acquire_group().unwrap();
1645 assert!(kv.release_group(g));
1646 kv.release_group(g);
1647 }
1648
1649 /// A recycled block backs a later position without the store ever
1650 /// being asked for another one, and the later position's writes are
1651 /// what a read at that position returns.
1652 ///
1653 /// This is the whole sliding-window mechanism in miniature. Blocks
1654 /// of two, four positions, and only two blocks in the store: without
1655 /// recycling, position 2 has nowhere to go.
1656 #[test]
1657 fn a_recycled_block_backs_a_later_position_without_touching_the_store() {
1658 let mut store = PagedKvStore::new(2, 2, 1, 2);
1659 let mut cache = PagedKvCache::new();
1660 cache.push(&mut store, &[1.0, 1.0], &[1.0, 1.0]).unwrap();
1661 cache.push(&mut store, &[2.0, 2.0], &[2.0, 2.0]).unwrap();
1662 assert_eq!(store.free_block_count(), 1, "one block per position pair");
1663
1664 // Positions 0..2 have fallen behind a window of two. Their block
1665 // backs positions 2..4 instead, and the store is untouched.
1666 let recycled = cache.block_table()[0];
1667 cache.append_block(recycled);
1668 assert_eq!(
1669 store.free_block_count(),
1670 1,
1671 "recycling must not take a block from the store"
1672 );
1673 cache.push(&mut store, &[3.0, 3.0], &[3.0, 3.0]).unwrap();
1674 assert_eq!(cache.seq_len(), 3);
1675 assert_eq!(
1676 cache.block_table(),
1677 &[recycled, recycled],
1678 "the same block at the stale index and the live one"
1679 );
1680
1681 // Reading position 2 sees the new row. Position 0's row is gone,
1682 // which is exactly what "behind the window" means -- the kernel
1683 // never indexes it.
1684 let flat = cache.to_contiguous(&store);
1685 assert_eq!(&flat.k[4..6], &[3.0, 3.0], "position 2 reads its own row");
1686 assert_eq!(
1687 &flat.k[0..2],
1688 &[3.0, 3.0],
1689 "position 0 now reads the recycled row, and nothing may read it"
1690 );
1691 }
1692
1693 /// Releasing an aliased table hands each block back ONCE.
1694 ///
1695 /// Per index instead of per distinct block would put the recycled id
1696 /// on the free list twice, and the next two acquisitions would hand
1697 /// two sequences the same memory -- which does not fail, it
1698 /// interleaves two conversations' KV.
1699 #[test]
1700 fn releasing_a_recycled_table_gives_each_block_back_once() {
1701 // Exactly one block in the store, so "handed back twice" is
1702 // observable as a second acquisition succeeding.
1703 let mut store = PagedKvStore::new(2, 1, 1, 2);
1704 let mut cache = PagedKvCache::new();
1705 cache.push(&mut store, &[1.0, 1.0], &[1.0, 1.0]).unwrap();
1706 let held = cache.block_table()[0];
1707 cache.append_block(held);
1708 cache.append_block(held);
1709
1710 let free_before = store.free_block_count();
1711 cache.release(&mut store);
1712 assert_eq!(
1713 store.free_block_count(),
1714 free_before + 1,
1715 "three table entries naming one block are one block back"
1716 );
1717 // And the store agrees: it can hand out that block once.
1718 assert!(store.acquire_block().is_some());
1719 assert!(store.acquire_block().is_none());
1720 }
1721
1722 #[test]
1723 fn a_gathered_sequence_round_trips_through_the_store() {
1724 let mut store = PagedKvStore::new(2, 8, 2, 2);
1725 let mut cache = PagedKvCache::new();
1726 // Five positions over blocks of two: the tail block is half
1727 // full, which is where an off-by-one in the gather shows up.
1728 let rows: Vec<[f32; 4]> = (0..5)
1729 .map(|i| {
1730 let b = i as f32 * 10.0;
1731 [b + 1.0, b + 2.0, b + 3.0, b + 4.0]
1732 })
1733 .collect();
1734 for r in &rows {
1735 cache.push(&mut store, r, r).unwrap();
1736 }
1737
1738 let flat = cache.to_contiguous(&store);
1739 assert_eq!(flat.positions(), 5);
1740 assert_eq!(flat.k.len(), 5 * 4);
1741 for (i, r) in rows.iter().enumerate() {
1742 assert_eq!(&flat.k[i * 4..(i + 1) * 4], r, "position {i} k");
1743 assert_eq!(&flat.v[i * 4..(i + 1) * 4], r, "position {i} v");
1744 }
1745
1746 // And appending those same rows back onto a fresh sequence
1747 // reproduces the store's view of them exactly.
1748 let mut rebuilt = PagedKvCache::new();
1749 let mut store2 = PagedKvStore::new(2, 8, 2, 2);
1750 rebuilt
1751 .append_contiguous(&mut store2, &flat.k, &flat.v, 5)
1752 .unwrap();
1753 let again = rebuilt.to_contiguous(&store2);
1754 assert_eq!(again.k, flat.k);
1755 assert_eq!(again.v, flat.v);
1756 assert_eq!(again.positions(), flat.positions());
1757 }
1758
1759 #[test]
1760 fn push_grows_seq_len_and_stores_values() {
1761 let mut cache = KvCache::new(2, 2);
1762 cache
1763 .push(&[1.0, 2.0, 3.0, 4.0], &[5.0, 6.0, 7.0, 8.0])
1764 .unwrap();
1765 assert_eq!(cache.positions(), 1);
1766 cache
1767 .push(&[9.0, 10.0, 11.0, 12.0], &[13.0, 14.0, 15.0, 16.0])
1768 .unwrap();
1769 assert_eq!(cache.positions(), 2);
1770 assert_eq!(cache.k.len(), 2 * 2 * 2);
1771 assert_eq!(cache.k[4], 9.0);
1772 }
1773
1774 #[test]
1775 #[should_panic]
1776 fn push_wrong_size_panics() {
1777 let mut cache = KvCache::new(2, 2);
1778 let _ = cache.push(&[1.0, 2.0], &[1.0, 2.0]); // too short
1779 }
1780
1781 #[test]
1782 fn clear_resets_state() {
1783 let mut cache = KvCache::new(1, 1);
1784 cache.push(&[1.0], &[2.0]).unwrap();
1785 cache.clear();
1786 assert_eq!(cache.positions(), 0);
1787 assert!(cache.k.is_empty());
1788 }
1789
1790 #[test]
1791 fn truncate_rolls_back_to_exact_length_preserving_earlier_data() {
1792 let mut cache = KvCache::new(2, 2);
1793 cache
1794 .push(&[1.0, 2.0, 3.0, 4.0], &[10.0, 20.0, 30.0, 40.0])
1795 .unwrap();
1796 cache
1797 .push(&[5.0, 6.0, 7.0, 8.0], &[50.0, 60.0, 70.0, 80.0])
1798 .unwrap();
1799 cache
1800 .push(&[9.0, 9.0, 9.0, 9.0], &[90.0, 90.0, 90.0, 90.0])
1801 .unwrap();
1802 assert_eq!(cache.positions(), 3);
1803
1804 cache.truncate(1);
1805 assert_eq!(cache.positions(), 1);
1806 assert_eq!(cache.k, vec![1.0, 2.0, 3.0, 4.0]);
1807 assert_eq!(cache.v, vec![10.0, 20.0, 30.0, 40.0]);
1808 }
1809
1810 #[test]
1811 fn truncate_to_current_length_is_a_no_op() {
1812 let mut cache = KvCache::new(1, 2);
1813 cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
1814 cache.truncate(1);
1815 assert_eq!(cache.positions(), 1);
1816 assert_eq!(cache.k, vec![1.0, 2.0]);
1817 }
1818
1819 #[test]
1820 fn truncate_to_zero_empties_the_cache() {
1821 let mut cache = KvCache::new(1, 2);
1822 cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
1823 cache.truncate(0);
1824 assert_eq!(cache.positions(), 0);
1825 assert!(cache.k.is_empty());
1826 assert!(cache.v.is_empty());
1827 }
1828
1829 #[test]
1830 #[should_panic]
1831 fn truncate_beyond_current_length_panics() {
1832 let mut cache = KvCache::new(1, 2);
1833 cache.push(&[1.0, 2.0], &[3.0, 4.0]).unwrap();
1834 cache.truncate(5);
1835 }
1836
1837 #[test]
1838 fn push_after_truncate_continues_correctly() {
1839 let mut cache = KvCache::new(1, 1);
1840 cache.push(&[1.0], &[10.0]).unwrap();
1841 cache.push(&[2.0], &[20.0]).unwrap();
1842 cache.push(&[3.0], &[30.0]).unwrap(); // this one will be "rejected"
1843 cache.truncate(2);
1844 cache.push(&[99.0], &[990.0]).unwrap(); // real continuation after rejection
1845 assert_eq!(cache.positions(), 3);
1846 assert_eq!(cache.k, vec![1.0, 2.0, 99.0]);
1847 assert_eq!(cache.v, vec![10.0, 20.0, 990.0]);
1848 }
1849
1850 #[test]
1851 fn with_capacity_preallocates_and_never_reallocates_within_plan() {
1852 let n_kv_heads = 4;
1853 let head_dim = 8;
1854 let max_seq_len = 16;
1855 let mut cache = KvCache::with_capacity(n_kv_heads, head_dim, max_seq_len);
1856
1857 let expected_elems = max_seq_len * n_kv_heads * head_dim;
1858 assert!(cache.k.capacity() >= expected_elems);
1859 assert!(cache.v.capacity() >= expected_elems);
1860
1861 let step = vec![0.5f32; n_kv_heads * head_dim];
1862 let k_ptr_before = cache.k.as_ptr();
1863 for _ in 0..max_seq_len {
1864 cache.push(&step, &step).unwrap();
1865 }
1866 let k_ptr_after = cache.k.as_ptr();
1867 assert_eq!(
1868 k_ptr_before, k_ptr_after,
1869 "pushing exactly up to the planned capacity must not reallocate"
1870 );
1871 assert!(cache.is_within_planned_capacity());
1872 }
1873
1874 #[test]
1875 fn allocated_bytes_reflects_preallocated_capacity_not_just_used_length() {
1876 let cache = KvCache::with_capacity(4, 8, 100);
1877 // 100 positions * 4 kv_heads * 8 head_dim * 2 (k+v) * 4 bytes/f32
1878 let expected_min = 100 * 4 * 8 * 2 * 4;
1879 assert!(
1880 cache.allocated_bytes() >= expected_min,
1881 "allocated_bytes={} expected_min={expected_min}",
1882 cache.allocated_bytes()
1883 );
1884 // Nothing has been pushed yet, but the memory is already reserved.
1885 assert_eq!(cache.positions(), 0);
1886 }
1887
1888 #[test]
1889 fn grow_as_you_go_cache_reports_not_within_planned_capacity() {
1890 let mut cache = KvCache::new(2, 2);
1891 cache
1892 .push(&[1.0, 2.0, 3.0, 4.0], &[5.0, 6.0, 7.0, 8.0])
1893 .unwrap();
1894 assert!(
1895 !cache.is_within_planned_capacity(),
1896 "a cache built with `new` has no plan to be within"
1897 );
1898 }
1899
1900 #[test]
1901 fn with_pool_acquires_one_block_and_reports_it_in_free_blocks() {
1902 let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 10)));
1903 let cache = KvCache::with_pool(2, 2, pool.clone(), 0).unwrap();
1904 assert_eq!(pool.lock().unwrap().free_blocks(), 9);
1905 assert_eq!(cache.positions(), 0);
1906 }
1907
1908 #[test]
1909 fn with_pool_fails_without_mutating_the_pool_when_exhausted() {
1910 let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 0)));
1911 let result = KvCache::with_pool(2, 2, pool.clone(), 0);
1912 assert!(result.is_err());
1913 assert_eq!(pool.lock().unwrap().free_blocks(), 0);
1914 }
1915
1916 #[test]
1917 fn push_acquires_additional_blocks_as_the_cache_crosses_block_boundaries() {
1918 let block_size = 2;
1919 let pool = Arc::new(Mutex::new(KvBlockPool::new(block_size, 10)));
1920 let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1921 assert_eq!(pool.lock().unwrap().free_blocks(), 9);
1922
1923 // First block holds `block_size` = 2 positions; pushing them
1924 // must not need a second block.
1925 cache.push(&[1.0], &[1.0]).unwrap();
1926 cache.push(&[2.0], &[2.0]).unwrap();
1927 assert_eq!(
1928 pool.lock().unwrap().free_blocks(),
1929 9,
1930 "filling exactly the first block must not acquire a second one"
1931 );
1932
1933 // The third position crosses into a second block.
1934 cache.push(&[3.0], &[3.0]).unwrap();
1935 assert_eq!(pool.lock().unwrap().free_blocks(), 8);
1936 assert_eq!(cache.positions(), 3);
1937 assert_eq!(cache.k, vec![1.0, 2.0, 3.0]);
1938 }
1939
1940 #[test]
1941 fn push_returns_pool_exhausted_and_leaves_state_unchanged_when_no_blocks_remain() {
1942 let block_size = 1;
1943 let pool = Arc::new(Mutex::new(KvBlockPool::new(block_size, 1)));
1944 let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1945 assert_eq!(pool.lock().unwrap().free_blocks(), 0);
1946
1947 cache.push(&[1.0], &[1.0]).unwrap(); // fills the one held block
1948
1949 let before_k = cache.k.clone();
1950 let result = cache.push(&[2.0], &[2.0]);
1951 assert_eq!(result, Err(KvPoolExhausted));
1952 assert_eq!(
1953 cache.positions(),
1954 1,
1955 "a failed push must not change seq_len"
1956 );
1957 assert_eq!(cache.k, before_k, "a failed push must not append data");
1958 }
1959
1960 #[test]
1961 fn dropping_a_pooled_cache_returns_its_blocks_to_the_pool() {
1962 let pool = Arc::new(Mutex::new(KvBlockPool::new(1, 2)));
1963 {
1964 let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1965 cache.push(&[1.0], &[1.0]).unwrap(); // fills the first (only held) block
1966 cache.push(&[2.0], &[2.0]).unwrap(); // crosses into a second block
1967 assert_eq!(pool.lock().unwrap().free_blocks(), 0);
1968 }
1969 assert_eq!(
1970 pool.lock().unwrap().free_blocks(),
1971 2,
1972 "both blocks held by the dropped cache must return to the pool"
1973 );
1974 }
1975
1976 #[test]
1977 fn release_to_pool_is_explicit_and_idempotent() {
1978 let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 5)));
1979 let mut cache = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1980 assert_eq!(pool.lock().unwrap().free_blocks(), 4);
1981
1982 cache.release_to_pool();
1983 assert_eq!(pool.lock().unwrap().free_blocks(), 5);
1984
1985 cache.release_to_pool(); // no-op, must not over-release
1986 assert_eq!(pool.lock().unwrap().free_blocks(), 5);
1987
1988 drop(cache); // must not release again either
1989 assert_eq!(pool.lock().unwrap().free_blocks(), 5);
1990 }
1991
1992 #[test]
1993 fn two_pooled_caches_share_one_bounded_budget() {
1994 let pool = Arc::new(Mutex::new(KvBlockPool::new(1, 1)));
1995 let cache_a = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
1996 let cache_b = KvCache::with_pool(1, 1, pool.clone(), 0);
1997 assert!(
1998 cache_b.is_err(),
1999 "a second concurrent request must not be admitted when the shared budget is full"
2000 );
2001
2002 drop(cache_a);
2003 let cache_c = KvCache::with_pool(1, 1, pool, 0);
2004 assert!(
2005 cache_c.is_ok(),
2006 "once the first request's cache is dropped, its budget must become available again"
2007 );
2008 }
2009
2010 #[test]
2011 fn cloning_a_pooled_cache_detaches_the_clone_from_pool_accounting() {
2012 let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 3)));
2013 let original = KvCache::with_pool(1, 1, pool.clone(), 0).unwrap();
2014 assert_eq!(pool.lock().unwrap().free_blocks(), 2);
2015
2016 let clone = original.clone();
2017 assert_eq!(
2018 pool.lock().unwrap().free_blocks(),
2019 2,
2020 "cloning must not acquire additional blocks"
2021 );
2022 assert_eq!(clone.k, original.k);
2023
2024 drop(clone);
2025 assert_eq!(
2026 pool.lock().unwrap().free_blocks(),
2027 2,
2028 "dropping a detached clone must not release the original's blocks"
2029 );
2030
2031 drop(original);
2032 assert_eq!(
2033 pool.lock().unwrap().free_blocks(),
2034 3,
2035 "dropping the original must release its blocks exactly once"
2036 );
2037 }
2038
2039 /// A resize is arithmetic, and the one rule that is not: shrinking
2040 /// past what is held is refused, and the pool is left exactly as it
2041 /// was.
2042 ///
2043 /// Clamping to zero instead would silently over-promise -- the
2044 /// caches holding those blocks do not give them back, so every
2045 /// later acquire would decide against a budget that does not
2046 /// describe the memory in use. This test fails under that clamp.
2047 #[test]
2048 fn a_pool_refuses_to_shrink_below_what_is_already_held() {
2049 let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 10)));
2050 let held = KvCache::with_pool(2, 4, Arc::clone(&pool), 24).expect("blocks");
2051 let in_use = {
2052 let p = pool.lock().unwrap();
2053 p.total_blocks() - p.free_blocks()
2054 };
2055 assert!(in_use > 0, "the fixture must actually hold blocks");
2056
2057 let mut p = pool.lock().unwrap();
2058 assert_eq!(p.resize(in_use - 1), Err(in_use));
2059 assert_eq!(p.total_blocks(), 10, "a refused resize changes nothing");
2060 assert_eq!(p.free_blocks(), 10 - in_use);
2061
2062 // Down to exactly what is held is legal, and leaves nothing free.
2063 assert_eq!(p.resize(in_use), Ok(()));
2064 assert_eq!(p.free_blocks(), 0);
2065 drop(p);
2066 drop(held);
2067 }
2068
2069 /// Growing hands the new blocks to the free list without disturbing
2070 /// what is held, which is the whole point of a live re-split.
2071 #[test]
2072 fn growing_a_pool_adds_to_what_is_free_and_not_to_what_is_held() {
2073 let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 8)));
2074 let held = KvCache::with_pool(2, 4, Arc::clone(&pool), 16).expect("blocks");
2075 let mut p = pool.lock().unwrap();
2076 let in_use = p.total_blocks() - p.free_blocks();
2077
2078 assert_eq!(p.resize(32), Ok(()));
2079 assert_eq!(p.total_blocks(), 32);
2080 assert_eq!(p.free_blocks(), 32 - in_use);
2081 drop(p);
2082 drop(held);
2083 }
2084
2085 /// Positions and rows agree for every store that does not evict,
2086 /// and this pins that they are DERIVED separately rather than one
2087 /// being an alias for the other.
2088 ///
2089 /// The point of the split is #61: a windowed layer will drop rows
2090 /// behind its window while its position count keeps climbing. Until
2091 /// then the two are equal, so this test cannot prove much about
2092 /// eviction. What it CAN prove, and what matters, is that `rows`
2093 /// reads the buffer rather than the counter: set the counter to a
2094 /// lie and `rows` still reports the truth.
2095 #[test]
2096 fn rows_is_read_from_the_buffer_and_positions_from_the_counter() {
2097 let mut cache = KvCache::new(2, 4);
2098 for i in 0..5 {
2099 let step = vec![i as f32; 8];
2100 cache.push(&step, &step).expect("unbounded growth");
2101 }
2102 assert_eq!(cache.positions(), 5);
2103 assert_eq!(cache.rows(), 5, "nothing evicts, so they agree");
2104
2105 // The counter lies; the buffer does not.
2106 cache.force_positions_for_test(99);
2107 assert_eq!(cache.positions(), 99);
2108 assert_eq!(
2109 cache.rows(),
2110 5,
2111 "rows must come from k/v, or it is just a second name for the counter"
2112 );
2113 }
2114
2115 /// `truncate` is measured in POSITIONS, and it moves both, because
2116 /// nothing evicts yet. Written down because it is the first thing
2117 /// eviction changes: a truncate target will stop being a row index.
2118 #[test]
2119 fn truncate_moves_positions_and_rows_together_while_nothing_evicts() {
2120 let mut cache = KvCache::new(1, 2);
2121 for i in 0..4 {
2122 let step = vec![i as f32; 2];
2123 cache.push(&step, &step).expect("unbounded growth");
2124 }
2125 cache.truncate(2);
2126 assert_eq!(cache.positions(), 2);
2127 assert_eq!(cache.rows(), 2);
2128 assert_eq!(cache.k.len(), 4, "two positions of two elements each");
2129 }
2130
2131 /// The store's rule must be *the* rule, not a second copy of it.
2132 ///
2133 /// `KvWindow::rows_after` is what `frink_models::kv_budget` prices
2134 /// against. If the store drained to anything else the budget would
2135 /// be describing a cache that does not exist, which is #33 again.
2136 #[test]
2137 fn a_windowed_cache_holds_exactly_what_the_rule_says_it_holds() {
2138 let window = KvWindow::new(8, 3).expect("positive window");
2139 let mut cache = KvCache::new(2, 4);
2140 cache.arm_window(window);
2141 let step = vec![1.0f32; 8];
2142 for p in 1..=200usize {
2143 cache.push(&step, &step).expect("unbounded growth");
2144 cache.evict_behind_window();
2145 assert_eq!(cache.positions(), p);
2146 assert_eq!(
2147 cache.rows(),
2148 window.rows_after(p),
2149 "at {p} positions the store and the rule disagree"
2150 );
2151 }
2152 }
2153
2154 /// The point of the issue: positions keep counting, rows do not.
2155 #[test]
2156 fn a_windowed_layers_resident_rows_stop_growing() {
2157 let window = KvWindow::with_default_slack(16).expect("positive window");
2158 let mut cache = KvCache::new(2, 4);
2159 cache.arm_window(window);
2160 let step = vec![0.5f32; 8];
2161 for _ in 0..2000 {
2162 cache.push(&step, &step).expect("unbounded growth");
2163 cache.evict_behind_window();
2164 }
2165 assert_eq!(cache.positions(), 2000);
2166 assert!(
2167 cache.rows() <= window.max_rows(),
2168 "{} rows resident after 2000 positions",
2169 cache.rows()
2170 );
2171 // And the bytes really went back, not just the length: `drain`
2172 // frees nothing, and memory is the whole point.
2173 assert!(
2174 cache.allocated_bytes() <= window.max_rows() * 8 * 2 * 4 * 2,
2175 "capacity was never handed back: {} bytes",
2176 cache.allocated_bytes()
2177 );
2178 }
2179
2180 /// **The correctness argument, measured.**
2181 ///
2182 /// Eviction is only allowed to be token-identical because the rows a
2183 /// windowed kernel reads -- the last `window` of them -- are the
2184 /// same bytes whether or not anything behind them was dropped. This
2185 /// pushes distinguishable rows into an evicting cache and a plain
2186 /// one and compares exactly that slice.
2187 #[test]
2188 fn the_rows_a_windowed_kernel_reads_are_identical_with_and_without_eviction() {
2189 let window = KvWindow::new(6, 2).expect("positive window");
2190 let (n_kv_heads, head_dim) = (2usize, 4usize);
2191 let per = n_kv_heads * head_dim;
2192 let mut evicting = KvCache::new(n_kv_heads, head_dim);
2193 evicting.arm_window(window);
2194 let mut plain = KvCache::new(n_kv_heads, head_dim);
2195
2196 for p in 0..120usize {
2197 // A row nothing else could produce, so a misplaced row is
2198 // not merely a different number but an identifiable one.
2199 let k: Vec<f32> = (0..per).map(|i| (p * 100 + i) as f32).collect();
2200 let v: Vec<f32> = k.iter().map(|x| -x).collect();
2201 evicting.push(&k, &v).expect("unbounded growth");
2202 evicting.evict_behind_window();
2203 plain.push(&k, &v).expect("unbounded growth");
2204
2205 let read = window.window().min(p + 1);
2206 let e_start = (evicting.rows() - read) * per;
2207 let p_start = (plain.rows() - read) * per;
2208 assert_eq!(
2209 &evicting.k[e_start..],
2210 &plain.k[p_start..],
2211 "K read set diverged at position {p}"
2212 );
2213 assert_eq!(
2214 &evicting.v[e_start..],
2215 &plain.v[p_start..],
2216 "V read set diverged at position {p}"
2217 );
2218 assert_eq!(evicting.positions(), plain.positions());
2219 }
2220 }
2221
2222 /// An unarmed cache is the cache this engine has always had.
2223 #[test]
2224 fn an_unarmed_cache_never_drops_a_row() {
2225 let mut cache = KvCache::new(2, 4);
2226 let step = vec![1.0f32; 8];
2227 for _ in 0..64 {
2228 cache.push(&step, &step).expect("unbounded growth");
2229 assert_eq!(cache.evict_behind_window(), 0);
2230 }
2231 assert_eq!(cache.rows(), 64);
2232 assert_eq!(cache.positions(), 64);
2233 assert!(cache.window().is_none());
2234 }
2235
2236 /// Speculative decoding rolls the cache back by the number of
2237 /// rejected draft tokens. That is representable while the target is
2238 /// still resident.
2239 #[test]
2240 fn truncate_still_works_inside_the_resident_window() {
2241 let window = KvWindow::new(8, 3).expect("positive window");
2242 let mut cache = KvCache::new(1, 2);
2243 cache.arm_window(window);
2244 let step = vec![1.0f32; 2];
2245 for _ in 0..50 {
2246 cache.push(&step, &step).expect("unbounded growth");
2247 cache.evict_behind_window();
2248 }
2249 let rows_before = cache.rows();
2250 cache.truncate(46);
2251 assert_eq!(cache.positions(), 46);
2252 assert_eq!(cache.rows(), rows_before - 4);
2253 }
2254
2255 /// ...and stops, loudly, when it is not. A cache that rolled back to
2256 /// the oldest row it happened to have would answer the next token
2257 /// out of a history with a hole in it.
2258 #[test]
2259 #[should_panic(expected = "rows are resident")]
2260 fn truncate_refuses_to_roll_back_past_what_the_window_kept() {
2261 let window = KvWindow::new(4, 1).expect("positive window");
2262 let mut cache = KvCache::new(1, 2);
2263 cache.arm_window(window);
2264 let step = vec![1.0f32; 2];
2265 for _ in 0..50 {
2266 cache.push(&step, &step).expect("unbounded growth");
2267 cache.evict_behind_window();
2268 }
2269 cache.truncate(3);
2270 }
2271
2272 /// A clone of an evicting cache is still an evicting cache. Reset
2273 /// the window on clone and `positions` becomes a row count again in
2274 /// whatever reads the clone.
2275 #[test]
2276 fn a_clone_carries_the_window() {
2277 let window = KvWindow::new(4, 1).expect("positive window");
2278 let mut cache = KvCache::new(1, 2);
2279 cache.arm_window(window);
2280 let step = vec![1.0f32; 2];
2281 for _ in 0..20 {
2282 cache.push(&step, &step).expect("unbounded growth");
2283 cache.evict_behind_window();
2284 }
2285 let copy = cache.clone();
2286 assert_eq!(copy.window(), Some(window));
2287 assert_eq!(copy.rows(), cache.rows());
2288 assert_eq!(copy.positions(), cache.positions());
2289 }
2290
2291 /// A pool-backed cache grows when its BUFFER is full, not when its
2292 /// position counter reaches the buffer's size. Those are the same
2293 /// number until something evicts, and an evicting pooled cache keyed
2294 /// on positions would acquire a block per token forever and exhaust
2295 /// the pool.
2296 #[test]
2297 fn a_pooled_windowed_cache_stops_acquiring_blocks() {
2298 let pool = Arc::new(Mutex::new(KvBlockPool::new(4, 8)));
2299 let mut cache =
2300 KvCache::with_pool(1, 2, Arc::clone(&pool), 8).expect("the pool was sized for this");
2301 cache.arm_window(KvWindow::new(4, 1).expect("positive window"));
2302 let step = vec![1.0f32; 2];
2303 for _ in 0..64 {
2304 cache
2305 .push(&step, &step)
2306 .expect("the buffer never fills again");
2307 cache.evict_behind_window();
2308 }
2309 assert_eq!(cache.positions(), 64);
2310 assert!(cache.rows() <= 5);
2311 }
2312
2313 /// **The sibling above passes for the wrong reason on its own
2314 /// sizing, so this is the same property where the trap is armed.**
2315 ///
2316 /// Eviction hands surplus CAPACITY back with `shrink_to`, which is
2317 /// where its memory saving actually comes from. For a pool-backed
2318 /// cache that is a bug rather than a saving: the pool has already
2319 /// promised these blocks and does not take them back, while `push`
2320 /// decides it needs another block by comparing rows against
2321 /// `k.capacity()`. Shrink that capacity to a window and the
2322 /// condition is true again every `slack + 1` tokens, forever -- so
2323 /// the cache draws a fresh block from the shared pool on a cadence,
2324 /// never releases one, and runs the pool dry underneath every OTHER
2325 /// request in the process. It surfaces where `push` is documented
2326 /// infallible, so the caller panics mid-answer.
2327 ///
2328 /// The sibling's cache is 8 positions against a 5-row ceiling,
2329 /// which is under the 2x threshold `shrink_to` is gated on, so it
2330 /// never reaches the shrink at all. This one is 64 positions
2331 /// against the same ceiling, and the pool is given spare blocks so
2332 /// the leak shows up as blocks quietly gone rather than only as the
2333 /// error at the end of them.
2334 #[test]
2335 fn an_evicting_pooled_cache_never_hands_its_capacity_back_to_reacquire_it() {
2336 let pool = Arc::new(Mutex::new(KvBlockPool::new(8, 24)));
2337 let mut cache =
2338 KvCache::with_pool(1, 2, Arc::clone(&pool), 64).expect("8 of the 24 blocks");
2339 let free_after_construction = pool.lock().unwrap().free_blocks();
2340 assert_eq!(free_after_construction, 16, "8 blocks cover 64 positions");
2341
2342 cache.arm_window(KvWindow::new(4, 1).expect("positive window"));
2343 let step = vec![1.0f32; 2];
2344 for _ in 0..512 {
2345 cache
2346 .push(&step, &step)
2347 .expect("a reservation made up front must not be re-made per token");
2348 cache.evict_behind_window();
2349 }
2350
2351 assert_eq!(
2352 pool.lock().unwrap().free_blocks(),
2353 free_after_construction,
2354 "the cache drew more blocks from the shared pool while evicting"
2355 );
2356 assert_eq!(cache.positions(), 512);
2357 assert!(cache.rows() <= 5);
2358 }
2359}