Skip to main content

fuel_tensor/
lazy_latent_cache.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `LatentCache` — a general **N-slot** decode cache backed by
3//! [`Tensor`], the structural generalization of [`fuel_transformers::models::lazy_kv_cache::
4//! LazyKvCache`] that unblocks latent / pruned KV compression.
5//!
6//! `LazyKvCache` hardwires a symmetric **K/V pair** — two buffers per
7//! layer, both shaped `[max_seq, n_kv_heads, head_dim]`. That shape is
8//! wrong for the compression architectures the roadmap targets:
9//!
10//!   - **Multi-head Latent Attention (DeepSeek-V2 MLA)** caches, per layer,
11//!     a low-rank latent `compressed_kv [max_seq, kv_lora_rank]` **and** a
12//!     single-head rope key `k_pe [max_seq, qk_rope_head_dim]` — two slots
13//!     of *different* trailing shapes, neither a `[n_kv_heads, head_dim]`
14//!     K/V. (This is the ~93% cache reduction: cache `kv_lora_rank +
15//!     qk_rope_head_dim` per token instead of `2 · n_heads · head_dim`.)
16//!   - **Two-projection attention / QKV pruning** caches a *single*
17//!     retained projection per layer — one slot.
18//!
19//! So the container's real degree of freedom is: **per layer, an ordered
20//! list of latent buffers, each `[max_seq, …arbitrary trailing dims]`,
21//! sharing the sequence axis (dim 0).** The standard K/V cache is then just
22//! the two-equal-slots special case; MLA is two-unequal-slots; two-projection
23//! is one slot. This type owns that general shape; `LazyKvCache`'s K/V
24//! surface can later be re-expressed on top of it — deferred for now because
25//! its unit-test surface and the Phase-B eager-retirement program still
26//! reference it directly.
27//!
28//! # Shape contract
29//!
30//! Slot `s` of every layer is a buffer `[max_seq, …slot_trailing[s]]` with
31//! the sequence axis at dim 0 (matching `LazyKvCache`'s no-batch,
32//! per-sequence convention; the caller broadcasts/concats across batches).
33//! An [`LatentCache::append`] writes a `[seqlen_new, …slot_trailing[s]]` slab into
34//! every slot at the cache's current position; all slots in one append
35//! share the same `seqlen_new`.
36//!
37//! # Lifecycle
38//!
39//! Per-forward-pass and graph-anchored, exactly like `LazyKvCache`:
40//! every buffer is a node on one [`fuel_graph::Graph`]; cross-step decode
41//! either re-creates the cache on the new step's graph (rebinding realized
42//! latents via `const_*_like`) or holds realized latents host-side between
43//! steps. Persistent storage-backed cross-graph re-anchoring (the
44//! [`crate::inference_context::KvCache`] pattern) is the same documented
45//! follow-up it is for `LazyKvCache`.
46
47#[cfg(test)]
48use crate::Device;
49use crate::{DType, lazy::Tensor};
50use fuel_ir::Shape;
51
52/// Per-forward-pass, N-slot latent cache. See module docs for the shape
53/// contract and lifecycle.
54#[derive(Clone, Debug)]
55pub struct LatentCache {
56    /// `layers[l][s]` is slot `s`'s buffer for layer `l`, shaped
57    /// `[max_seq, …slot_trailing[s]]`.
58    layers: Vec<Vec<Tensor>>,
59    /// Trailing dims (past the leading seq axis) for each slot; its length
60    /// is the per-layer slot count.
61    slot_trailing: Vec<Vec<usize>>,
62    /// Sequence positions filled so far. [`Self::slot`] narrows to
63    /// `[..current_seq_len]` on dim 0.
64    current_seq_len: usize,
65    /// Pre-allocated per-slot capacity along the sequence axis.
66    max_seq_len: usize,
67}
68
69impl LatentCache {
70    /// Allocate a zero-filled cache on the same graph as `anchor`.
71    ///
72    /// `slot_trailing` gives the trailing shape (past the seq axis) of each
73    /// slot; its length is the number of slots per layer and must be ≥ 1.
74    /// A slot's buffer is `[max_seq_len, …trailing]` (an empty trailing ⇒
75    /// a `[max_seq_len]` per-token-scalar slot). `dtype` selects the element
76    /// type (typically the latent's — F32 / BF16 / F16 for inference).
77    pub fn new(
78        anchor: &Tensor,
79        n_layers: usize,
80        max_seq_len: usize,
81        slot_trailing: Vec<Vec<usize>>,
82        dtype: DType,
83    ) -> std::result::Result<Self, fuel_ir::Error> {
84        if n_layers == 0 {
85            return Err(fuel_ir::Error::Msg("LatentCache::new: n_layers must be ≥ 1".into()).bt());
86        }
87        if max_seq_len == 0 {
88            return Err(
89                fuel_ir::Error::Msg("LatentCache::new: max_seq_len must be ≥ 1".into()).bt(),
90            );
91        }
92        if slot_trailing.is_empty() {
93            return Err(fuel_ir::Error::Msg(
94                "LatentCache::new: need at least one slot (slot_trailing empty)".into(),
95            )
96            .bt());
97        }
98        let mut layers = Vec::with_capacity(n_layers);
99        for _ in 0..n_layers {
100            let mut slots = Vec::with_capacity(slot_trailing.len());
101            for trailing in &slot_trailing {
102                let mut dims = Vec::with_capacity(1 + trailing.len());
103                dims.push(max_seq_len);
104                dims.extend_from_slice(trailing);
105                let shape = Shape::from_dims(&dims);
106                let elems: usize = dims.iter().product();
107                slots.push(zero_const_on(anchor, dtype, shape, elems)?);
108            }
109            layers.push(slots);
110        }
111        Ok(Self {
112            layers,
113            slot_trailing,
114            current_seq_len: 0,
115            max_seq_len,
116        })
117    }
118
119    /// Append fresh latents for `layer` at the cache's current position.
120    /// **Consumes `self`** and returns the updated cache (option (b), the
121    /// same functional shape as `LazyKvCache::append`).
122    ///
123    /// `new_slots` must have exactly `n_slots` entries, in slot order; entry
124    /// `s` must be `[seqlen_new, …slot_trailing[s]]`, and all entries must
125    /// agree on `seqlen_new` (the number of tokens appended this step: 1 for
126    /// a decode step, `prefill_len` for prefill). Advances the cache's
127    /// position implicitly is **not** done here — call [`Self::advance_by`]
128    /// after the last layer's append in each step, matching `LazyKvCache`.
129    ///
130    /// Errors (typed, at build time): `layer` out of bounds, wrong slot
131    /// count, a slot shape mismatch, mismatched `seqlen_new` across slots,
132    /// or an append that would exceed `max_seq_len`.
133    pub fn append(mut self, layer: usize, new_slots: &[&Tensor]) -> crate::Result<Self> {
134        if layer >= self.layers.len() {
135            crate::bail!(
136                "LatentCache::append: layer {layer} out of bounds (n_layers={})",
137                self.layers.len(),
138            );
139        }
140        let n_slots = self.slot_trailing.len();
141        if new_slots.len() != n_slots {
142            crate::bail!(
143                "LatentCache::append: expected {n_slots} slot tensors, got {}",
144                new_slots.len(),
145            );
146        }
147        // Derive seqlen_new from slot 0 and validate every slot against its
148        // declared trailing shape + that shared seqlen_new.
149        let seqlen_new = {
150            let d = new_slots[0].shape().dims().to_vec();
151            if d.is_empty() {
152                crate::bail!(
153                    "LatentCache::append: slot 0 must be rank ≥ 1 (leading seq axis), got {d:?}",
154                );
155            }
156            d[0]
157        };
158        for (s, tensor) in new_slots.iter().enumerate() {
159            let got = tensor.shape().dims().to_vec();
160            let mut want = Vec::with_capacity(1 + self.slot_trailing[s].len());
161            want.push(seqlen_new);
162            want.extend_from_slice(&self.slot_trailing[s]);
163            if got != want {
164                crate::bail!(
165                    "LatentCache::append: slot {s} shape {got:?} != expected {want:?} \
166                     (seqlen_new={seqlen_new}, trailing={:?})",
167                    self.slot_trailing[s],
168                );
169            }
170        }
171        let new_end = self.current_seq_len + seqlen_new;
172        if new_end > self.max_seq_len {
173            crate::bail!(
174                "LatentCache::append: appending {seqlen_new} tokens at position {} would \
175                 exceed max_seq_len {}",
176                self.current_seq_len,
177                self.max_seq_len,
178            );
179        }
180        // Write each slot's slab at [current_seq_len, new_end) on dim 0,
181        // full extent on the trailing dims.
182        let mut updated = Vec::with_capacity(n_slots);
183        for (s, tensor) in new_slots.iter().enumerate() {
184            let mut ranges = Vec::with_capacity(1 + self.slot_trailing[s].len());
185            ranges.push((self.current_seq_len, new_end));
186            for &d in &self.slot_trailing[s] {
187                ranges.push((0, d));
188            }
189            let buffer = self.layers[layer][s].clone();
190            updated.push(buffer.write_slice(tensor, ranges)?);
191        }
192        self.layers[layer] = updated;
193        Ok(self)
194    }
195
196    /// Advance `current_seq_len` by `n`. Call after the last layer's
197    /// [`Self::append`] in each generation step (mirrors
198    /// `LazyKvCache::advance_by`).
199    pub fn advance_by(mut self, n: usize) -> Self {
200        self.current_seq_len = (self.current_seq_len + n).min(self.max_seq_len);
201        self
202    }
203
204    /// Active slice of `layer`'s slot `s`: `[0..current_seq_len]` on dim 0
205    /// (clamped to ≥ 1 so a fresh cache still yields a valid rank).
206    pub fn slot(&self, layer: usize, slot: usize) -> Tensor {
207        self.layers[layer][slot]
208            .slice(0_usize, 0, self.current_seq_len.max(1))
209            .unwrap()
210    }
211
212    /// Full-capacity buffer for `layer`'s slot `s` (`[max_seq, …trailing]`)
213    /// — escape hatch mirroring `LazyKvCache::k_buffer_full`.
214    pub fn slot_buffer_full(&self, layer: usize, slot: usize) -> Tensor {
215        self.layers[layer][slot].clone()
216    }
217
218    /// Tokens written so far.
219    pub fn current_seq_len(&self) -> usize {
220        self.current_seq_len
221    }
222    /// Pre-allocated per-slot capacity along the sequence axis.
223    pub fn max_seq_len(&self) -> usize {
224        self.max_seq_len
225    }
226    /// Number of layers.
227    pub fn n_layers(&self) -> usize {
228        self.layers.len()
229    }
230    /// Number of slots per layer.
231    pub fn n_slots(&self) -> usize {
232        self.slot_trailing.len()
233    }
234    /// Trailing shape of slot `s` (past the seq axis).
235    pub fn slot_trailing(&self, slot: usize) -> &[usize] {
236        &self.slot_trailing[slot]
237    }
238}
239
240/// Zero-initialized [`Tensor`] of the given shape/dtype on `anchor`'s
241/// graph. Same helper `LazyKvCache` uses (kept private per-module to avoid
242/// coupling the two caches while the K/V one still exists independently).
243fn zero_const_on(
244    anchor: &Tensor,
245    dtype: DType,
246    shape: Shape,
247    elems: usize,
248) -> std::result::Result<Tensor, fuel_ir::Error> {
249    match dtype {
250        DType::F32 => anchor.const_f32_like(vec![0.0_f32; elems], shape),
251        DType::F64 => anchor.const_f64_like(vec![0.0_f64; elems], shape),
252        DType::BF16 => anchor.const_bf16_like(vec![half::bf16::ZERO; elems], shape),
253        DType::F16 => anchor.const_f16_like(vec![half::f16::ZERO; elems], shape),
254        other => {
255            Err(fuel_ir::Error::Msg(format!("LatentCache: unsupported dtype {other:?}",)).bt())
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn cpu_f32(data: Vec<f32>, shape: &[usize]) -> Tensor {
265        Tensor::from_f32(data, shape.to_vec(), &Device::cpu()).unwrap()
266    }
267
268    #[test]
269    fn new_latent_cache_is_empty() {
270        let anchor = cpu_f32(vec![0.0], &[1]);
271        // MLA-shaped: slot 0 = compressed_kv trailing [kv_lora_rank=5],
272        // slot 1 = k_pe trailing [qk_rope_head_dim=2].
273        let cache = LatentCache::new(&anchor, 3, 8, vec![vec![5], vec![2]], DType::F32).unwrap();
274        assert_eq!(cache.n_layers(), 3);
275        assert_eq!(cache.n_slots(), 2);
276        assert_eq!(cache.max_seq_len(), 8);
277        assert_eq!(cache.current_seq_len(), 0);
278        assert_eq!(cache.slot_trailing(0), &[5]);
279        assert_eq!(cache.slot_trailing(1), &[2]);
280        assert_eq!(cache.slot_buffer_full(0, 0).shape().dims(), &[8, 5]);
281        assert_eq!(cache.slot_buffer_full(0, 1).shape().dims(), &[8, 2]);
282    }
283
284    /// The MLA payoff the generalization exists for: two slots of
285    /// **different** trailing shapes (`[kv_lora_rank]` and
286    /// `[qk_rope_head_dim]`), appended across two decode steps, sliced back
287    /// as the logically-concatenated latents. `LazyKvCache` cannot express
288    /// this — it forces both buffers to `[n_kv_heads, head_dim]`.
289    #[test]
290    fn mla_two_unequal_slots_append_and_slice() {
291        let anchor = cpu_f32(vec![0.0], &[1]);
292        // slot 0 latent trailing [3], slot 1 rope-key trailing [2].
293        let mut cache =
294            LatentCache::new(&anchor, 1, 4, vec![vec![3], vec![2]], DType::F32).unwrap();
295
296        // Step 1: append 2 tokens.
297        let c1 = anchor
298            .const_f32_like(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![2, 3])
299            .unwrap();
300        let p1 = anchor
301            .const_f32_like(vec![10.0, 11.0, 12.0, 13.0], vec![2, 2])
302            .unwrap();
303        cache = cache.append(0, &[&c1, &p1]).unwrap().advance_by(2);
304        assert_eq!(cache.current_seq_len(), 2);
305
306        // Step 2: append 1 token.
307        let c2 = anchor
308            .const_f32_like(vec![7.0, 8.0, 9.0], vec![1, 3])
309            .unwrap();
310        let p2 = anchor.const_f32_like(vec![14.0, 15.0], vec![1, 2]).unwrap();
311        cache = cache.append(0, &[&c2, &p2]).unwrap().advance_by(1);
312        assert_eq!(cache.current_seq_len(), 3);
313
314        // slot 0 (latent) = the three tokens' [3]-vectors, end to end.
315        let latent = cache.slot(0, 0);
316        assert_eq!(latent.shape().dims(), &[3, 3]);
317        assert_eq!(
318            latent.realize_f32(),
319            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0],
320        );
321        // slot 1 (rope key) = the three tokens' [2]-vectors.
322        let kpe = cache.slot(0, 1);
323        assert_eq!(kpe.shape().dims(), &[3, 2]);
324        assert_eq!(kpe.realize_f32(), vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0],);
325    }
326
327    /// Two-projection / QKV-pruning shape: a single retained latent per
328    /// layer (one slot), with a rank-3 trailing shape to exercise
329    /// higher-rank slots.
330    #[test]
331    fn single_slot_rank3_trailing() {
332        let anchor = cpu_f32(vec![0.0], &[1]);
333        // one slot, trailing [2, 2] → buffer [max_seq, 2, 2].
334        let cache = LatentCache::new(&anchor, 1, 4, vec![vec![2, 2]], DType::F32).unwrap();
335        assert_eq!(cache.n_slots(), 1);
336        let t = anchor
337            .const_f32_like(vec![1.0, 2.0, 3.0, 4.0], vec![1, 2, 2])
338            .unwrap();
339        let cache = cache.append(0, &[&t]).unwrap().advance_by(1);
340        let got = cache.slot(0, 0);
341        assert_eq!(got.shape().dims(), &[1, 2, 2]);
342        assert_eq!(got.realize_f32(), vec![1.0, 2.0, 3.0, 4.0]);
343    }
344
345    #[test]
346    fn multi_layer_isolates_slots() {
347        let anchor = cpu_f32(vec![0.0], &[1]);
348        let cache = LatentCache::new(&anchor, 2, 4, vec![vec![2], vec![1]], DType::F32).unwrap();
349        // Both layers appended within one step → same position → advance once.
350        let a0 = anchor.const_f32_like(vec![1.0, 1.0], vec![1, 2]).unwrap();
351        let b0 = anchor.const_f32_like(vec![9.0], vec![1, 1]).unwrap();
352        let cache = cache.append(0, &[&a0, &b0]).unwrap();
353        let a1 = anchor.const_f32_like(vec![2.0, 2.0], vec![1, 2]).unwrap();
354        let b1 = anchor.const_f32_like(vec![8.0], vec![1, 1]).unwrap();
355        let cache = cache.append(1, &[&a1, &b1]).unwrap().advance_by(1);
356        assert_eq!(cache.current_seq_len(), 1);
357        assert_eq!(cache.slot(0, 0).realize_f32(), vec![1.0, 1.0]);
358        assert_eq!(cache.slot(0, 1).realize_f32(), vec![9.0]);
359        assert_eq!(cache.slot(1, 0).realize_f32(), vec![2.0, 2.0]);
360        assert_eq!(cache.slot(1, 1).realize_f32(), vec![8.0]);
361    }
362
363    #[test]
364    fn append_rejects_oob_layer() {
365        let anchor = cpu_f32(vec![0.0], &[1]);
366        let cache = LatentCache::new(&anchor, 1, 4, vec![vec![2]], DType::F32).unwrap();
367        let t = anchor.const_f32_like(vec![0.0, 0.0], vec![1, 2]).unwrap();
368        assert!(cache.append(5, &[&t]).is_err());
369    }
370
371    #[test]
372    fn append_rejects_wrong_slot_count() {
373        let anchor = cpu_f32(vec![0.0], &[1]);
374        let cache = LatentCache::new(&anchor, 1, 4, vec![vec![2], vec![2]], DType::F32).unwrap();
375        let t = anchor.const_f32_like(vec![0.0, 0.0], vec![1, 2]).unwrap();
376        // 2 slots declared, only 1 tensor supplied.
377        assert!(cache.append(0, &[&t]).is_err());
378    }
379
380    #[test]
381    fn append_rejects_slot_shape_mismatch() {
382        let anchor = cpu_f32(vec![0.0], &[1]);
383        let cache = LatentCache::new(&anchor, 1, 4, vec![vec![3]], DType::F32).unwrap();
384        // trailing should be [3], supply [5].
385        let t = anchor.const_f32_like(vec![0.0; 5], vec![1, 5]).unwrap();
386        assert!(cache.append(0, &[&t]).is_err());
387    }
388
389    #[test]
390    fn append_rejects_mismatched_seqlen_across_slots() {
391        let anchor = cpu_f32(vec![0.0], &[1]);
392        let cache = LatentCache::new(&anchor, 1, 4, vec![vec![2], vec![2]], DType::F32).unwrap();
393        // slot 0 has seqlen 2, slot 1 has seqlen 1 → inconsistent.
394        let a = anchor.const_f32_like(vec![0.0; 4], vec![2, 2]).unwrap();
395        let b = anchor.const_f32_like(vec![0.0; 2], vec![1, 2]).unwrap();
396        assert!(cache.append(0, &[&a, &b]).is_err());
397    }
398
399    #[test]
400    fn append_rejects_capacity_overflow() {
401        let anchor = cpu_f32(vec![0.0], &[1]);
402        let cache = LatentCache::new(&anchor, 1, 2, vec![vec![1]], DType::F32).unwrap();
403        let t = anchor.const_f32_like(vec![0.0; 3], vec![3, 1]).unwrap();
404        assert!(cache.append(0, &[&t]).is_err()); // 3 tokens > max_seq_len 2
405    }
406}