Skip to main content

ghostlink_core/
kv_cache.rs

1//! KV cache primitives for a single sequence within a single transformer layer.
2//!
3//! Stores one contiguous, pre-allocated buffer for all tokens' keys and one
4//! for all values (shape: `max_tokens_per_seq * num_heads * hidden_dim_per_head`
5//! floats each), instead of one small heap allocation per token. The previous
6//! design called `resize_with` on a `Vec<KVCacheEntry>` where every entry
7//! independently allocated its own `keys`/`values` `Vec<f32>` — for the
8//! default 8192-token max sequence length, a full-length `initialize()`
9//! performed up to 16,384 separate small heap allocations. A single upfront
10//! allocation eliminates that, keeps a sequence's KV data in one contiguous
11//! cache-friendly region, and enables `read_range` — a single batched read
12//! over a token span, the shape attention actually needs, instead of N
13//! separate per-token reads.
14//!
15//! Reads and writes are guarded by an `RwLock` rather than a `Mutex`: real
16//! attention reads happen far more often than writes (every position reads
17//! all previous positions' KV every decode step; only one write happens per
18//! step), so letting reads proceed concurrently with each other matters.
19//!
20//! This module has no current caller in `runtime.rs` — Ghostlink delegates
21//! actual model execution to an external inference engine (llama-server /
22//! Ollama, see `ghost-link::native_engine`) rather than computing attention
23//! itself. It stays self-contained as a ready-to-use primitive for a future
24//! local execution path, so it can evolve without affecting the stable
25//! public API exported from `lib.rs`.
26
27use std::sync::{Arc, RwLock};
28
29pub const DEFAULT_MAX_TOKENS_PER_SEQ: usize = 8192;
30pub const DEFAULT_NUM_HEADS: usize = 4;
31pub const DEFAULT_HIDDEN_DIM_PER_HEAD: usize = 64;
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub struct KVCacheConfig {
35    pub max_tokens_per_seq: usize,
36    pub num_heads: usize,
37    pub hidden_dim_per_head: usize,
38}
39
40impl KVCacheConfig {
41    /// Number of `f32` elements one token's keys (or values) occupy.
42    pub const fn token_width(&self) -> usize {
43        self.num_heads * self.hidden_dim_per_head
44    }
45}
46
47impl Default for KVCacheConfig {
48    fn default() -> Self {
49        Self {
50            max_tokens_per_seq: DEFAULT_MAX_TOKENS_PER_SEQ,
51            num_heads: DEFAULT_NUM_HEADS,
52            hidden_dim_per_head: DEFAULT_HIDDEN_DIM_PER_HEAD,
53        }
54    }
55}
56
57/// Owned copy of one token's cached key/value vectors, returned by `read_kv`.
58/// Small and fixed-size (`config.token_width()` floats each) — not a copy of
59/// the whole cache.
60#[derive(Clone, Debug, PartialEq)]
61pub struct KVCacheEntry {
62    pub keys: Vec<f32>,
63    pub values: Vec<f32>,
64}
65
66struct KvCacheState {
67    /// Empty until the first `initialize()` call; allocated exactly once
68    /// after that, sized for `config.max_tokens_per_seq` tokens.
69    keys: Vec<f32>,
70    values: Vec<f32>,
71    /// Number of tokens actually written so far (<= config.max_tokens_per_seq).
72    current_len: usize,
73}
74
75/// Cheap to clone (an `Arc` around the shared, lock-guarded buffer) — matches
76/// the shared-state pattern used elsewhere in this crate (e.g. `ClusterState`).
77#[derive(Clone)]
78pub struct LayerKvCache {
79    pub config: KVCacheConfig,
80    state: Arc<RwLock<KvCacheState>>,
81}
82
83impl std::fmt::Debug for LayerKvCache {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        // Deliberately does not print the backing buffers — they can be
86        // megabytes (max_tokens_per_seq * token_width * 2 floats).
87        f.debug_struct("LayerKvCache")
88            .field("config", &self.config)
89            .field("current_len", &self.len())
90            .finish()
91    }
92}
93
94impl Default for LayerKvCache {
95    fn default() -> Self {
96        Self::new(KVCacheConfig::default())
97    }
98}
99
100impl LayerKvCache {
101    /// Creates an empty cache. Does not allocate the backing buffer yet —
102    /// `initialize()` performs the single upfront allocation, sized once for
103    /// `config.max_tokens_per_seq`.
104    pub fn new(config: KVCacheConfig) -> Self {
105        Self {
106            config,
107            state: Arc::new(RwLock::new(KvCacheState {
108                keys: Vec::new(),
109                values: Vec::new(),
110                current_len: 0,
111            })),
112        }
113    }
114
115    /// Ensures the backing buffer exists (allocating it on the first call
116    /// only) and marks the first `seq_len` tokens (clamped to
117    /// `config.max_tokens_per_seq`) as valid.
118    ///
119    /// Safe to call again on the same cache — e.g. to grow `current_len` as
120    /// more tokens are generated — without losing previously written data:
121    /// the buffer is allocated once and never reallocated by this method.
122    pub fn initialize(&self, seq_len: usize) -> Result<(), String> {
123        if seq_len == 0 {
124            return Err("sequence length must be greater than zero".to_string());
125        }
126
127        let bounded_len = seq_len.min(self.config.max_tokens_per_seq);
128        let width = self.config.token_width();
129        let mut state = self
130            .state
131            .write()
132            .map_err(|_| "kv cache lock poisoned".to_string())?;
133
134        if state.keys.is_empty() {
135            let capacity = self.config.max_tokens_per_seq * width;
136            state.keys = vec![0.0; capacity];
137            state.values = vec![0.0; capacity];
138        }
139
140        state.current_len = state.current_len.max(bounded_len);
141        Ok(())
142    }
143
144    /// Writes one token's key/value vectors into the cache at `token_idx`.
145    /// Copies directly into the pre-allocated buffer slice — no allocation.
146    pub fn write_kv(&self, token_idx: usize, keys: &[f32], values: &[f32]) -> Result<(), String> {
147        if keys.len() != values.len() {
148            return Err("keys/values length mismatch".to_string());
149        }
150        let width = self.config.token_width();
151        if keys.len() != width {
152            return Err(format!(
153                "expected {width} elements per token (num_heads * hidden_dim_per_head), got {}",
154                keys.len()
155            ));
156        }
157        if token_idx >= self.config.max_tokens_per_seq {
158            return Err(format!(
159                "token index {token_idx} out of bounds (max {})",
160                self.config.max_tokens_per_seq
161            ));
162        }
163
164        // Pre-compute slice range bounds once upfront to reduce arithmetic
165        // calculations inside the lock-holding section.
166        let start = token_idx * width;
167        let end = start + width;
168
169        let mut state = self
170            .state
171            .write()
172            .map_err(|_| "kv cache lock poisoned".to_string())?;
173        if state.keys.is_empty() {
174            return Err("cache not initialized — call initialize() first".to_string());
175        }
176
177        state.keys[start..end].copy_from_slice(keys);
178        state.values[start..end].copy_from_slice(values);
179        state.current_len = state.current_len.max(token_idx + 1);
180        Ok(())
181    }
182
183    /// Writes multiple tokens' key/value vectors under a single write-lock
184    /// acquisition, validating every entry upfront (before taking the lock)
185    /// so a bad entry anywhere in the batch fails the whole call without
186    /// partially writing it. Same per-token semantics as calling `write_kv`
187    /// in a loop — this only changes how many times the lock is acquired.
188    pub fn write_kv_batch(&self, entries: &[(usize, &[f32], &[f32])]) -> Result<(), String> {
189        if entries.is_empty() {
190            return Ok(());
191        }
192
193        let width = self.config.token_width();
194
195        // Validate all entries upfront before taking write lock
196        for (token_idx, keys, values) in entries {
197            if keys.len() != values.len() {
198                return Err("keys/values length mismatch".to_string());
199            }
200            if keys.len() != width {
201                return Err(format!("expected {width} elements, got {}", keys.len()));
202            }
203            if *token_idx >= self.config.max_tokens_per_seq {
204                return Err(format!("token index {token_idx} out of bounds"));
205            }
206        }
207
208        // Single write lock acquisition
209        let mut state = self
210            .state
211            .write()
212            .map_err(|_| "kv cache lock poisoned".to_string())?;
213        if state.keys.is_empty() {
214            return Err("cache not initialized".to_string());
215        }
216
217        let mut max_token_idx = 0;
218        // Direct reference destructuring and upfront slice range calculation
219        // avoid redundant arithmetic and pointer dereferences in the hot loop.
220        for &(token_idx, keys, values) in entries {
221            let start = token_idx * width;
222            let end = start + width;
223            state.keys[start..end].copy_from_slice(keys);
224            state.values[start..end].copy_from_slice(values);
225            if token_idx > max_token_idx {
226                max_token_idx = token_idx;
227            }
228        }
229
230        state.current_len = state.current_len.max(max_token_idx + 1);
231        Ok(())
232    }
233
234    /// Reads one token's cached key/value vectors as an owned, fixed-size
235    /// copy (`config.token_width()` floats each — not the whole cache).
236    pub fn read_kv(&self, token_idx: usize) -> Result<KVCacheEntry, String> {
237        let width = self.config.token_width();
238        let state = self
239            .state
240            .read()
241            .map_err(|_| "kv cache lock poisoned".to_string())?;
242
243        if token_idx >= state.current_len {
244            return Err(format!(
245                "token index {token_idx} out of bounds (current length {})",
246                state.current_len
247            ));
248        }
249
250        let start = token_idx * width;
251        Ok(KVCacheEntry {
252            keys: state.keys[start..start + width].to_vec(),
253            values: state.values[start..start + width].to_vec(),
254        })
255    }
256
257    /// Reads keys/values for a contiguous token range (`[start_token,
258    /// end_token)`) in one call — the shape attention actually wants (all
259    /// prior positions at once), instead of `end_token - start_token`
260    /// separate lock acquisitions and small allocations.
261    pub fn read_range(
262        &self,
263        start_token: usize,
264        end_token: usize,
265    ) -> Result<(Vec<f32>, Vec<f32>), String> {
266        if start_token > end_token {
267            return Err("start_token must be <= end_token".to_string());
268        }
269        let width = self.config.token_width();
270        let state = self
271            .state
272            .read()
273            .map_err(|_| "kv cache lock poisoned".to_string())?;
274
275        if end_token > state.current_len {
276            return Err(format!(
277                "end_token {end_token} exceeds current length {}",
278                state.current_len
279            ));
280        }
281
282        let start = start_token * width;
283        let end = end_token * width;
284        Ok((
285            state.keys[start..end].to_vec(),
286            state.values[start..end].to_vec(),
287        ))
288    }
289
290    pub fn len(&self) -> usize {
291        self.state.read().map(|s| s.current_len).unwrap_or(0)
292    }
293
294    pub fn is_empty(&self) -> bool {
295        self.len() == 0
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn small_config() -> KVCacheConfig {
304        // Small width keeps test assertions easy to read; 2 heads * 4 dims = 8.
305        KVCacheConfig {
306            max_tokens_per_seq: 16,
307            num_heads: 2,
308            hidden_dim_per_head: 4,
309        }
310    }
311
312    #[test]
313    fn initialize_rejects_zero_len() {
314        let cache = LayerKvCache::default();
315        assert!(cache.initialize(0).is_err());
316    }
317
318    #[test]
319    fn initialize_and_round_trip() {
320        let cache = LayerKvCache::new(small_config());
321        cache.initialize(8).expect("cache should initialize");
322        assert_eq!(cache.len(), 8);
323
324        let width = cache.config.token_width();
325        let keys = vec![1.0_f32; width];
326        let values = vec![2.0_f32; width];
327        cache
328            .write_kv(2, &keys, &values)
329            .expect("write should succeed");
330
331        let entry = cache.read_kv(2).expect("entry should exist");
332        assert_eq!(entry.keys, keys);
333        assert_eq!(entry.values, values);
334    }
335
336    #[test]
337    fn growing_seq_len_preserves_prior_writes() {
338        let cache = LayerKvCache::new(small_config());
339        cache.initialize(4).unwrap();
340
341        let width = cache.config.token_width();
342        let keys = vec![7.0_f32; width];
343        let values = vec![9.0_f32; width];
344        cache.write_kv(1, &keys, &values).unwrap();
345
346        // Growing current_len must not touch the already-allocated buffer,
347        // so token 1's data must survive.
348        cache.initialize(10).unwrap();
349        assert_eq!(cache.len(), 10);
350
351        let entry = cache.read_kv(1).unwrap();
352        assert_eq!(entry.keys, keys);
353        assert_eq!(entry.values, values);
354    }
355
356    #[test]
357    fn write_kv_rejects_wrong_width() {
358        let cache = LayerKvCache::new(small_config());
359        cache.initialize(4).unwrap();
360        let wrong_width = vec![0.0_f32; cache.config.token_width() + 1];
361        assert!(cache.write_kv(0, &wrong_width, &wrong_width).is_err());
362    }
363
364    #[test]
365    fn write_kv_rejects_out_of_bounds_token() {
366        let cache = LayerKvCache::new(small_config());
367        cache.initialize(4).unwrap();
368        let width = cache.config.token_width();
369        let data = vec![0.0_f32; width];
370        assert!(cache.write_kv(999, &data, &data).is_err());
371    }
372
373    #[test]
374    fn write_kv_before_initialize_is_rejected() {
375        let cache = LayerKvCache::new(small_config());
376        let width = cache.config.token_width();
377        let data = vec![0.0_f32; width];
378        assert!(cache.write_kv(0, &data, &data).is_err());
379    }
380
381    #[test]
382    fn read_range_returns_contiguous_data() {
383        let cache = LayerKvCache::new(small_config());
384        cache.initialize(4).unwrap();
385        let width = cache.config.token_width();
386
387        for token_idx in 0..4 {
388            let keys = vec![token_idx as f32; width];
389            let values = vec![(token_idx * 10) as f32; width];
390            cache.write_kv(token_idx, &keys, &values).unwrap();
391        }
392
393        let (keys, values) = cache.read_range(1, 3).unwrap();
394        assert_eq!(keys.len(), 2 * width);
395        assert_eq!(values.len(), 2 * width);
396        // First token in the range is token_idx 1.
397        assert_eq!(keys[0], 1.0);
398        assert_eq!(values[0], 10.0);
399        // Second token in the range is token_idx 2.
400        assert_eq!(keys[width], 2.0);
401        assert_eq!(values[width], 20.0);
402    }
403
404    #[test]
405    fn read_range_rejects_end_beyond_current_len() {
406        let cache = LayerKvCache::new(small_config());
407        cache.initialize(4).unwrap();
408        assert!(cache.read_range(0, 100).is_err());
409    }
410
411    #[test]
412    fn is_empty_and_len_track_initialization() {
413        let cache = LayerKvCache::new(small_config());
414        assert!(cache.is_empty());
415        cache.initialize(5).unwrap();
416        assert!(!cache.is_empty());
417        assert_eq!(cache.len(), 5);
418    }
419
420    #[test]
421    fn concurrent_reads_do_not_corrupt_or_deadlock() {
422        let cache = LayerKvCache::new(small_config());
423        cache.initialize(8).unwrap();
424        let width = cache.config.token_width();
425        for token_idx in 0..8 {
426            let v = vec![token_idx as f32; width];
427            cache.write_kv(token_idx, &v, &v).unwrap();
428        }
429
430        let mut handles = Vec::new();
431        for _ in 0..8 {
432            let cache = cache.clone();
433            handles.push(std::thread::spawn(move || {
434                for token_idx in 0..8 {
435                    let entry = cache.read_kv(token_idx).unwrap();
436                    assert_eq!(entry.keys[0], token_idx as f32);
437                }
438            }));
439        }
440        for h in handles {
441            h.join().unwrap();
442        }
443    }
444
445    #[test]
446    fn clone_shares_the_same_underlying_cache() {
447        let cache = LayerKvCache::new(small_config());
448        cache.initialize(4).unwrap();
449        let width = cache.config.token_width();
450        let data = vec![3.0_f32; width];
451
452        let cache_clone = cache.clone();
453        cache_clone.write_kv(0, &data, &data).unwrap();
454
455        // Written through the clone, visible through the original — same
456        // underlying Arc<RwLock<..>>, not a deep copy.
457        assert_eq!(cache.read_kv(0).unwrap().keys, data);
458    }
459
460    #[test]
461    fn write_kv_batch_multiple() {
462        let cache = LayerKvCache::new(small_config());
463        cache.initialize(8).unwrap();
464        let width = cache.config.token_width();
465
466        let k0 = vec![0.0_f32; width];
467        let v0 = vec![0.0_f32; width];
468        let k1 = vec![1.0_f32; width];
469        let v1 = vec![10.0_f32; width];
470
471        let entries = [
472            (0, k0.as_slice(), v0.as_slice()),
473            (1, k1.as_slice(), v1.as_slice()),
474        ];
475
476        cache.write_kv_batch(&entries).unwrap();
477        assert_eq!(cache.read_kv(0).unwrap().keys[0], 0.0);
478        assert_eq!(cache.read_kv(1).unwrap().values[0], 10.0);
479    }
480
481    #[test]
482    fn write_kv_batch_empty() {
483        let cache = LayerKvCache::new(small_config());
484        cache.initialize(4).unwrap();
485        assert!(cache.write_kv_batch(&[]).is_ok());
486    }
487
488    #[test]
489    fn write_kv_batch_validation_fails() {
490        let cache = LayerKvCache::new(small_config());
491        cache.initialize(4).unwrap();
492        let width = cache.config.token_width();
493
494        let k0 = vec![1.0_f32; width];
495        let v0 = vec![2.0_f32; width];
496        let bad_k = vec![0.0_f32; width + 1];
497
498        let entries = [
499            (0, k0.as_slice(), v0.as_slice()),
500            (1, bad_k.as_slice(), v0.as_slice()),
501        ];
502
503        assert!(cache.write_kv_batch(&entries).is_err());
504    }
505
506    #[test]
507    fn write_kv_batch_is_atomic_on_validation_failure() {
508        let cache = LayerKvCache::new(small_config());
509        cache.initialize(4).unwrap();
510        let width = cache.config.token_width();
511
512        let good_keys = vec![5.0_f32; width];
513        let bad_keys = vec![0.0_f32; width + 1];
514
515        let entries = [
516            (0, good_keys.as_slice(), good_keys.as_slice()),
517            (1, bad_keys.as_slice(), good_keys.as_slice()),
518        ];
519
520        assert!(cache.write_kv_batch(&entries).is_err());
521        // Upfront validation must reject the whole batch before the write
522        // lock is even taken, so entry 0 must still hold its zero-initialized
523        // default rather than the batch's (never-applied) value.
524        assert_eq!(cache.read_kv(0).unwrap().keys[0], 0.0);
525    }
526}