Skip to main content

tract_transformers/ops/
inplace_kv_cache.rs

1//! In-place, geometric-growth KV cache (Apple Core ML "stateful in-place KV"
2//! lever, arXiv: Core ML Llama 3.1 — the ~13x throughput win on long decodes).
3//!
4//! tract's current `DynKeyValueCache` grows the cache by `TypedConcat([past, new])`
5//! every step: step `t` copies the whole `t`-token past into a fresh buffer, so a
6//! `T`-token decode does O(T^2) total copy work. This keeps a buffer with spare
7//! capacity along `axis` and writes each new chunk at the cursor, doubling only
8//! when capacity is exceeded — O(T) amortized copy work (classic Vec growth).
9//!
10//! IMPORTANT (the subtlety that made a naive attempt a wash): the saving only
11//! materializes if the CONSUMER reads the valid `[0..len]` region as a *view*
12//! (`valid_view`) and not as a contiguous copy (`valid_contiguous`) — otherwise
13//! the per-step slice-to-valid copy reintroduces the O(T^2). The module tests
14//! A/B exactly this, and the consumer-via-view path is what a fused attention
15//! kernel (reading buffer + length) would use.
16
17use tract_nnef::internal::*;
18use tract_nnef::tract_core::ops::{FrozenOpState, OpStateFreeze};
19use tract_nnef::tract_core::transform::ModelTransform;
20use tract_nnef::tract_ndarray::Ix4;
21
22use crate::ops::dyn_kv_cache::DynKeyValueCache;
23use crate::ops::flash_sdpa::FlashSdpaOp;
24use crate::ops::sdpa::Sdpa;
25
26/// Geometric-growth in-place cache along `axis`.
27#[derive(Clone, Debug)]
28pub struct InPlaceKvCache {
29    pub axis: usize,
30    buffer: Option<Tensor>,
31    len: usize,
32    reallocs: usize,
33}
34
35impl InPlaceKvCache {
36    pub fn new(axis: usize) -> Self {
37        InPlaceKvCache { axis, buffer: None, len: 0, reallocs: 0 }
38    }
39
40    pub fn len(&self) -> usize {
41        self.len
42    }
43    pub fn is_empty(&self) -> bool {
44        self.len == 0
45    }
46    /// Number of reallocations so far (should be O(log T) over a decode).
47    pub fn reallocs(&self) -> usize {
48        self.reallocs
49    }
50    pub fn capacity(&self) -> usize {
51        self.buffer.as_ref().map(|b| b.shape()[self.axis]).unwrap_or(0)
52    }
53
54    /// Append `input` along `axis`, in place. O(input) amortized.
55    pub fn push(&mut self, input: &Tensor) -> TractResult<()> {
56        let new = input.shape()[self.axis];
57        if new == 0 {
58            return Ok(());
59        }
60        match self.buffer.take() {
61            None => {
62                // Seed with the first chunk (capacity = its length); subsequent
63                // pushes grow geometrically from here.
64                self.buffer = Some(input.clone());
65                self.len = new;
66            }
67            Some(mut buf) => {
68                ensure!(buf.rank() == input.rank(), "rank mismatch in kv-cache push");
69                let cap = buf.shape()[self.axis];
70                if self.len + new <= cap {
71                    buf.assign_slice(self.len..self.len + new, input, 0..new, self.axis)?;
72                    self.len += new;
73                    self.buffer = Some(buf);
74                } else {
75                    let new_cap = (cap * 2).max(self.len + new);
76                    let mut grown = grow(&buf, self.len, new_cap, self.axis)?;
77                    grown.assign_slice(self.len..self.len + new, input, 0..new, self.axis)?;
78                    self.len += new;
79                    self.reallocs += 1;
80                    self.buffer = Some(grown);
81                }
82            }
83        }
84        Ok(())
85    }
86
87    /// Zero-copy ndarray view of the valid `[0..len]` region. This is the path
88    /// that realizes the win — a length-aware consumer attends over this without
89    /// ever copying the past.
90    pub fn valid_view<T: Datum>(&self) -> TractResult<tract_ndarray::ArrayViewD<'_, T>> {
91        let buf = self.buffer.as_ref().context("empty kv-cache")?;
92        let mut full = buf.to_plain_array_view::<T>()?;
93        full.slice_axis_inplace(tract_ndarray::Axis(self.axis), (0..self.len).into());
94        Ok(full)
95    }
96
97    /// Contiguous `[0..len]` copy. This is what a consumer needing an owned,
98    /// packed tensor forces — it re-introduces the per-step O(len) copy, so it is
99    /// only kept to A/B against `valid_view`.
100    pub fn valid_contiguous(&self) -> TractResult<Tensor> {
101        let buf = self.buffer.as_ref().context("empty kv-cache")?;
102        buf.slice(self.axis, 0, self.len)
103    }
104}
105
106/// Allocate a buffer of capacity `new_cap` along `axis` and copy the live
107/// `[0..len]` region from `src` (the tail is left uninitialized — never read).
108fn grow(src: &Tensor, len: usize, new_cap: usize, axis: usize) -> TractResult<Tensor> {
109    let mut shape: TVec<usize> = src.shape().into();
110    shape[axis] = new_cap;
111    let mut out = unsafe { Tensor::uninitialized_dt(src.datum_type(), &shape)? };
112    out.assign_slice(0..len, src, 0..len, axis)?;
113    Ok(out)
114}
115
116// ===================================================================================
117// Integration: a stateful fused KV-cache + attention op.
118//
119// tract `Tensor`s cannot be zero-copy views across an op boundary (`Tensor::slice`
120// copies), so the only way to make the consumer read the cache without re-copying is
121// to keep the K/V buffers INSIDE the op that consumes them. `InPlaceKvSdpa` owns the
122// two in-place caches as op-state, appends K/V in place each step, and runs the CPU
123// SDPA (`FlashSdpaOp::flash_attention_gqa`) over the zero-copy `[0..len]` views — so
124// it is a drop-in for the `{kv_cache(K), kv_cache(V), Sdpa}` subgraph that produces
125// the same output by construction (same attention kernel, same K/V values) while
126// eliminating the per-step O(T) concat copy. This is the "couple #1 with the
127// attention consumer" design; the same shape works on the GPU side by handing the
128// fused MFA kernel the capacity buffer + length.
129// ===================================================================================
130
131/// Fused in-place KV-cache + scaled-dot-product-attention (decode-oriented).
132/// Inputs: `[Q, K_new, V_new]`, each `[B, H, S, D]` (K/V at `num_kv_heads`).
133/// Output: attention of `Q` over the whole accumulated cache, shape of `Q`.
134#[derive(Clone, Debug, PartialEq)]
135pub struct InPlaceKvSdpa {
136    /// Sequence axis to grow (2 for `[B, H, S, D]`).
137    pub axis: usize,
138    pub causal: bool,
139    pub scale: Option<f32>,
140}
141impl Eq for InPlaceKvSdpa {}
142
143impl Op for InPlaceKvSdpa {
144    fn name(&self) -> StaticName {
145        "InPlaceKvSdpa".into()
146    }
147    fn info(&self) -> TractResult<Vec<String>> {
148        Ok(vec![format!("axis={}, causal={}, scale={:?}", self.axis, self.causal, self.scale)])
149    }
150    op_as_typed_op!();
151}
152
153impl EvalOp for InPlaceKvSdpa {
154    fn is_stateless(&self) -> bool {
155        false
156    }
157    fn state(
158        &self,
159        _session: &TurnState,
160        _node_id: usize,
161    ) -> TractResult<Option<Box<dyn OpState>>> {
162        Ok(Some(Box::new(InPlaceKvSdpaState {
163            axis: self.axis,
164            causal: self.causal,
165            scale: self.scale,
166            k: InPlaceKvCache::new(self.axis),
167            v: InPlaceKvCache::new(self.axis),
168        })))
169    }
170}
171
172impl TypedOp for InPlaceKvSdpa {
173    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
174        ensure!(inputs.len() == 3, "InPlaceKvSdpa expects [Q, K_new, V_new]");
175        // Attention output has Q's shape and dtype.
176        Ok(tvec!(inputs[0].without_value()))
177    }
178    as_op!();
179}
180
181#[derive(Clone, Debug)]
182pub struct InPlaceKvSdpaState {
183    axis: usize,
184    causal: bool,
185    scale: Option<f32>,
186    k: InPlaceKvCache,
187    v: InPlaceKvCache,
188}
189
190impl OpState for InPlaceKvSdpaState {
191    fn eval(
192        &mut self,
193        _state: &mut TurnState,
194        _op: &dyn Op,
195        inputs: TVec<TValue>,
196    ) -> TractResult<TVec<TValue>> {
197        ensure!(inputs.len() == 3, "InPlaceKvSdpa expects [Q, K_new, V_new]");
198        let input_dt = inputs[0].datum_type();
199
200        // Cache K/V in f32 (the dtype FlashSdpaOp computes in); append in place.
201        let k_new = inputs[1].cast_to::<f32>()?;
202        let v_new = inputs[2].cast_to::<f32>()?;
203        self.k.push(k_new.as_ref())?;
204        self.v.push(v_new.as_ref())?;
205
206        let q = inputs[0].cast_to::<f32>()?;
207        let qv = q.to_plain_array_view::<f32>()?.into_dimensionality::<Ix4>()?;
208        // Zero-copy views of the valid [0..len] region — no past is ever copied.
209        let kview = self.k.valid_view::<f32>()?.into_dimensionality::<Ix4>()?;
210        let vview = self.v.valid_view::<f32>()?.into_dimensionality::<Ix4>()?;
211
212        let flash = FlashSdpaOp { causal: self.causal, scale: self.scale };
213        let o = flash.flash_attention_gqa(qv, kview, vview, None);
214
215        Ok(tvec!(o.into_tensor().cast_to_dt(input_dt)?.into_owned().into_tvalue()))
216    }
217
218    /// Persist the accumulated cache as two state tensors `[K_valid, V_valid]`, so a
219    /// decode can be checkpointed and resumed (alongside `freeze`/`unfreeze`, which
220    /// snapshot the whole running state in-process). The valid `[0..len]` regions are
221    /// copied out once here — O(len), not per step.
222    fn save_to(&self, states: &mut Vec<TValue>) -> TractResult<()> {
223        if !self.k.is_empty() {
224            states.push(self.k.valid_contiguous()?.into_tvalue());
225            states.push(self.v.valid_contiguous()?.into_tvalue());
226        }
227        Ok(())
228    }
229
230    /// Seed the K/V caches from a previously `save_to`'d `[K, V]` pair (resume from a
231    /// saved cache). With no initializers the caches stay empty (fresh decode).
232    fn load_from(
233        &mut self,
234        _state: &mut TurnState,
235        states: &mut dyn Iterator<Item = TValue>,
236    ) -> TractResult<()> {
237        if let Some(k) = states.next() {
238            let v = states.next().context("InPlaceKvSdpa load_from: expected V state after K")?;
239            self.k = InPlaceKvCache::new(self.axis);
240            self.v = InPlaceKvCache::new(self.axis);
241            self.k.push(k.cast_to::<f32>()?.as_ref())?;
242            self.v.push(v.cast_to::<f32>()?.as_ref())?;
243        }
244        Ok(())
245    }
246}
247
248#[derive(Clone, Debug)]
249struct FrozenInPlaceKvSdpaState {
250    axis: usize,
251    causal: bool,
252    scale: Option<f32>,
253    k: InPlaceKvCache,
254    v: InPlaceKvCache,
255}
256
257impl OpStateFreeze for InPlaceKvSdpaState {
258    fn freeze(&self) -> Box<dyn FrozenOpState> {
259        Box::new(FrozenInPlaceKvSdpaState {
260            axis: self.axis,
261            causal: self.causal,
262            scale: self.scale,
263            k: self.k.clone(),
264            v: self.v.clone(),
265        })
266    }
267}
268
269impl FrozenOpState for FrozenInPlaceKvSdpaState {
270    fn unfreeze(&self) -> Box<dyn OpState> {
271        Box::new(InPlaceKvSdpaState {
272            axis: self.axis,
273            causal: self.causal,
274            scale: self.scale,
275            k: self.k.clone(),
276            v: self.v.clone(),
277        })
278    }
279}
280
281/// Rewrite rule: fuse `{DynKeyValueCache(K), DynKeyValueCache(V), Sdpa(Q,K,V)}` into a
282/// single stateful `InPlaceKvSdpa`, so existing decode models adopt the in-place cache
283/// transparently. Fires on the 3-input pattern where each of Sdpa's K/V inputs is the
284/// output of a single-consumer `DynKeyValueCache` on the same axis. Intended to run
285/// AFTER `fuse_kv_cache_broadcast_rule` strips the GQA unsqueeze/broadcast/reshape chain
286/// (so the cache feeds Sdpa directly) — `InPlaceKvSdpa` does the GQA expansion itself, so
287/// the fusion also removes those broadcast ops.
288///
289/// Note: the fused op self-initializes an empty cache, so this replaces the
290/// `DynKeyValueCache` state-init/restore contract with the op's own state — equivalent
291/// for fresh inference; a model that restores a pre-seeded cache would need that state
292/// threaded into the op (follow-up).
293pub fn fuse_inplace_kv_sdpa_rule(
294    _ctx: &(),
295    model: &TypedModel,
296    node: &TypedNode,
297    node_name: &str,
298    op: &Sdpa,
299) -> TractResult<Option<TypedModelPatch>> {
300    // plain (Q, K, V) — no explicit mask input
301    if node.inputs.len() != 3 {
302        return Ok(None);
303    }
304    let k_node = model.node(node.inputs[1].node);
305    let v_node = model.node(node.inputs[2].node);
306    let (Some(kc), Some(vc)) =
307        (k_node.op_as::<DynKeyValueCache>(), v_node.op_as::<DynKeyValueCache>())
308    else {
309        return Ok(None);
310    };
311    if kc.axis != vc.axis {
312        return Ok(None);
313    }
314    // Each cache must feed only this Sdpa, else removing it would drop a live value.
315    if k_node.outputs[0].successors.len() != 1 || v_node.outputs[0].successors.len() != 1 {
316        return Ok(None);
317    }
318
319    let scale = op.scale.as_ref().map(|t| t.cast_to_scalar::<f32>()).transpose()?;
320    let q_outlet = node.inputs[0];
321    let k_new = k_node.inputs[0];
322    let v_new = v_node.inputs[0];
323
324    let mut patch = TypedModelPatch::default();
325    let taps = patch.taps(model, &[q_outlet, k_new, v_new])?;
326    let fused = patch.wire_node(
327        format!("{node_name}.inplace_kv_sdpa"),
328        InPlaceKvSdpa { axis: kc.axis, causal: op.is_causal, scale },
329        &taps,
330    )?;
331    patch.shunt_outside(model, node.id.into(), fused[0])?;
332    Ok(Some(patch))
333}
334
335/// Strip the GQA broadcast chain, then fuse `cache -> Sdpa` into `InPlaceKvSdpa`.
336#[derive(Debug, Default)]
337pub struct InPlaceKvSdpaTransform;
338
339impl ModelTransform for InPlaceKvSdpaTransform {
340    fn name(&self) -> StaticName {
341        "fuse_inplace_kv_sdpa".into()
342    }
343    fn transform(&self, model: &mut TypedModel) -> TractResult<()> {
344        Rewriter::default()
345            .with_rule_for("fuse-kv-broadcast", crate::ops::sdpa::fuse_kv_cache_broadcast_rule)
346            .with_rule_for("fuse-inplace-kv-sdpa", fuse_inplace_kv_sdpa_rule)
347            .rewrite(&(), model)?;
348        model.compact()
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use tract_nnef::tract_core::ops::array::TypedConcat;
356    use tract_nnef::tract_ndarray::{Array4, ArrayView4, s};
357
358    // Deterministic filler so correctness checks are reproducible.
359    fn seq_tensor(shape: &[usize], start: f32) -> Tensor {
360        let n: usize = shape.iter().product();
361        let data: Vec<f32> = (0..n).map(|i| start + i as f32 * 0.5).collect();
362        Tensor::from_shape(shape, &data).unwrap()
363    }
364
365    // ---- Correctness: in-place buffer[0..len] == concat-grow, bit-exact ----
366    fn check_matches_concat(chunk_shapes: &[Vec<usize>], axis: usize) -> TractResult<()> {
367        let mut cache = InPlaceKvCache::new(axis);
368        let mut concat: Option<Tensor> = None;
369        for (i, sh) in chunk_shapes.iter().enumerate() {
370            let chunk = seq_tensor(sh, i as f32 * 100.0);
371            cache.push(&chunk)?;
372            concat = Some(match concat.take() {
373                None => chunk,
374                Some(c) => TypedConcat { axis }
375                    .eval(tvec![c.into(), chunk.into()])?
376                    .remove(0)
377                    .into_tensor(),
378            });
379        }
380        let reference = concat.unwrap();
381        // The view and the contiguous slice must both equal the concat result.
382        let got = cache.valid_contiguous()?;
383        got.close_enough(&reference, Approximation::Exact)?;
384        ensure!(cache.len() == reference.shape()[axis]);
385        Ok(())
386    }
387
388    #[test]
389    fn inplace_matches_concat_decode() -> TractResult<()> {
390        // prompt of 5, then 20 single-token decode steps, axis = seq (2) of [B,H,S,D]
391        let mut shapes = vec![vec![1, 2, 5, 4]];
392        for _ in 0..20 {
393            shapes.push(vec![1, 2, 1, 4]);
394        }
395        check_matches_concat(&shapes, 2)
396    }
397
398    #[test]
399    fn inplace_matches_concat_axes_and_chunks() -> TractResult<()> {
400        check_matches_concat(&[vec![2, 2], vec![4, 2], vec![1, 2], vec![7, 2]], 0)?;
401        check_matches_concat(&[vec![2, 2], vec![2, 1], vec![2, 3]], 1)?;
402        check_matches_concat(&[vec![1, 3, 2, 8], vec![1, 3, 3, 8], vec![1, 3, 1, 8]], 2)?;
403        Ok(())
404    }
405
406    #[test]
407    fn geometric_growth_is_amortized() -> TractResult<()> {
408        // 1024 single-token pushes from a 1-token seed → O(log T) reallocs, not O(T).
409        let mut cache = InPlaceKvCache::new(2);
410        cache.push(&seq_tensor(&[1, 2, 1, 4], 0.0))?;
411        for _ in 0..1023 {
412            cache.push(&seq_tensor(&[1, 2, 1, 4], 1.0))?;
413        }
414        ensure!(cache.len() == 1024);
415        // doubling from 1: 1,2,4,...,1024 → ~10-11 reallocs
416        ensure!(cache.reallocs() <= 12, "expected ~log2(1024) reallocs, got {}", cache.reallocs());
417        Ok(())
418    }
419
420    // ---- Length-aware attention readout (identical math for both strategies;
421    //      the ONLY difference benched is how K/V are obtained). ----
422    fn attention(
423        q: ArrayView4<f32>,
424        k: ArrayView4<f32>,
425        v: ArrayView4<f32>,
426        scale: f32,
427    ) -> Array4<f32> {
428        let (b, h, sq, d) = q.dim();
429        let mut out = Array4::<f32>::zeros((b, h, sq, d));
430        for bi in 0..b {
431            for hi in 0..h {
432                let qm = q.slice(s![bi, hi, .., ..]); // [Sq, D] contiguous
433                let km = k.slice(s![bi, hi, .., ..]); // [Skv, D] contiguous prefix
434                let vm = v.slice(s![bi, hi, .., ..]); // [Skv, D]
435                let mut scores = qm.dot(&km.t()); // [Sq, Skv]
436                scores *= scale;
437                for mut row in scores.rows_mut() {
438                    let max = row.iter().copied().fold(f32::NEG_INFINITY, f32::max);
439                    let mut sum = 0.0f32;
440                    row.iter_mut().for_each(|x| {
441                        *x = (*x - max).exp();
442                        sum += *x;
443                    });
444                    let inv = 1.0 / sum;
445                    row.iter_mut().for_each(|x| *x *= inv);
446                }
447                let o = scores.dot(&vm); // [Sq, D]
448                out.slice_mut(s![bi, hi, .., ..]).assign(&o);
449            }
450        }
451        out
452    }
453
454    // ---- End-to-end correctness: attention over the in-place view == attention
455    //      over the concat-grown contiguous cache, every decode step. ----
456    #[test]
457    fn consumer_over_view_matches_concat_baseline() -> TractResult<()> {
458        let (b, h, d) = (1usize, 2usize, 8usize);
459        let scale = 1.0 / (d as f32).sqrt();
460        let mut cache = InPlaceKvCache::new(2);
461        let mut concat: Option<Tensor> = None;
462        for t in 0..16 {
463            let kv = seq_tensor(&[b, h, 1, d], t as f32);
464            cache.push(&kv)?;
465            concat = Some(match concat.take() {
466                None => kv.clone(),
467                Some(c) => TypedConcat { axis: 2 }
468                    .eval(tvec![c.into(), kv.clone().into()])?
469                    .remove(0)
470                    .into_tensor(),
471            });
472
473            let q = seq_tensor(&[b, h, 1, d], 1000.0 + t as f32);
474            let qv = q.to_plain_array_view::<f32>()?.into_dimensionality()?;
475
476            // in-place: read the [0..len] VIEW (no copy)
477            let kview = cache.valid_view::<f32>()?.into_dimensionality()?;
478            let out_inplace = attention(qv, kview, kview, scale);
479
480            // baseline: read the concat-grown contiguous tensor
481            let cbuf = concat.as_ref().unwrap();
482            let cv = cbuf.to_plain_array_view::<f32>()?.into_dimensionality()?;
483            let out_concat = attention(qv, cv, cv, scale);
484
485            let a = Tensor::from(out_inplace);
486            let bt = Tensor::from(out_concat);
487            a.close_enough(&bt, Approximation::Approximate)
488                .with_context(|| format!("mismatch at decode step {t}"))?;
489        }
490        Ok(())
491    }
492
493    // ================= BENCHES (#[ignore]) =================
494
495    // Pure cache-update cost: concat-grow (O(T^2)) vs in-place (O(T)).
496    //   cargo test -p tract-transformers inplace_kv_cache::tests::bench_update -- --ignored --nocapture
497    #[test]
498    #[ignore]
499    fn bench_update() -> TractResult<()> {
500        use std::time::Instant;
501        let (b, h, d) = (1usize, 8usize, 128usize);
502        println!("\n  cache-UPDATE only (B={b} H={h} D={d}), concat-grow vs in-place:");
503        println!("   T     concat(ms)  inplace(ms)   speedup  reallocs");
504        for &t in &[256usize, 512, 1024, 2048, 4096] {
505            let step = seq_tensor(&[b, h, 1, d], 1.0);
506
507            let t_concat = {
508                let start = Instant::now();
509                let mut concat: Option<Tensor> = None;
510                for _ in 0..t {
511                    concat = Some(match concat.take() {
512                        None => step.clone(),
513                        Some(c) => TypedConcat { axis: 2 }
514                            .eval(tvec![c.into(), step.clone().into()])?
515                            .remove(0)
516                            .into_tensor(),
517                    });
518                }
519                start.elapsed().as_secs_f64() * 1e3
520            };
521
522            let (t_inplace, reallocs) = {
523                let start = Instant::now();
524                let mut cache = InPlaceKvCache::new(2);
525                for _ in 0..t {
526                    cache.push(&step)?;
527                }
528                (start.elapsed().as_secs_f64() * 1e3, cache.reallocs())
529            };
530
531            println!(
532                "  {t:>5}  {t_concat:>9.3}  {t_inplace:>10.3}   {:>6.2}x  {reallocs:>4}",
533                t_concat / t_inplace
534            );
535        }
536        Ok(())
537    }
538
539    // End-to-end decode: {update + attention readout} per step, both strategies.
540    //   cargo test -p tract-transformers inplace_kv_cache::tests::bench_decode -- --ignored --nocapture
541    #[test]
542    #[ignore]
543    fn bench_decode() -> TractResult<()> {
544        use std::time::Instant;
545        let (b, h, d) = (1usize, 8usize, 128usize);
546        let scale = 1.0 / (d as f32).sqrt();
547        println!("\n  END-TO-END decode (update + attention, B={b} H={h} D={d}):");
548        println!("   T     concat(ms)  inplace(ms)   speedup");
549        for &t in &[256usize, 512, 1024, 2048] {
550            let q = seq_tensor(&[b, h, 1, d], 7.0);
551            let qv = q.to_plain_array_view::<f32>()?.into_dimensionality()?;
552            let step = seq_tensor(&[b, h, 1, d], 1.0);
553
554            let t_concat = {
555                let start = Instant::now();
556                let mut concat: Option<Tensor> = None;
557                for _ in 0..t {
558                    concat = Some(match concat.take() {
559                        None => step.clone(),
560                        Some(c) => TypedConcat { axis: 2 }
561                            .eval(tvec![c.into(), step.clone().into()])?
562                            .remove(0)
563                            .into_tensor(),
564                    });
565                    let cbuf = concat.as_ref().unwrap();
566                    let cv = cbuf.to_plain_array_view::<f32>()?.into_dimensionality()?;
567                    std::hint::black_box(attention(qv, cv, cv, scale));
568                }
569                start.elapsed().as_secs_f64() * 1e3
570            };
571
572            let t_inplace = {
573                let start = Instant::now();
574                let mut cache = InPlaceKvCache::new(2);
575                for _ in 0..t {
576                    cache.push(&step)?;
577                    let kview = cache.valid_view::<f32>()?.into_dimensionality()?;
578                    std::hint::black_box(attention(qv, kview, kview, scale));
579                }
580                start.elapsed().as_secs_f64() * 1e3
581            };
582
583            println!(
584                "  {t:>5}  {t_concat:>9.3}  {t_inplace:>10.3}   {:>6.2}x",
585                t_concat / t_inplace
586            );
587        }
588        Ok(())
589    }
590
591    // ---- Integration: the fused stateful op vs the {concat-cache(K), concat-cache(V),
592    //      FlashSdpaOp} baseline subgraph, driven step-by-step (prefill + decode), GQA.
593    //      Same attention kernel + identical K/V => bit-identical, proving the fused op
594    //      is a correct drop-in for the cache->Sdpa path. ----
595    fn drive_fused_vs_baseline(causal: bool) -> TractResult<()> {
596        let (bsz, hq, hkv, d) = (1usize, 4usize, 2usize, 16usize); // GQA: 4 q-heads / 2 kv-heads
597        let op = InPlaceKvSdpa { axis: 2, causal, scale: None };
598        let session = TurnState::default();
599        let mut state = op.state(&session, 0)?.unwrap();
600        let mut session = session;
601        let flash = FlashSdpaOp { causal, scale: None };
602
603        let mut kc: Option<Tensor> = None;
604        let mut vc: Option<Tensor> = None;
605
606        // prefill 3 tokens, then 12 single-token decode steps
607        let mut snews = vec![3usize];
608        snews.extend(std::iter::repeat_n(1usize, 12));
609
610        for (t, &snew) in snews.iter().enumerate() {
611            let q = seq_tensor(&[bsz, hq, snew, d], 1.0 + t as f32);
612            let knew = seq_tensor(&[bsz, hkv, snew, d], 5.0 + t as f32 * 0.3);
613            let vnew = seq_tensor(&[bsz, hkv, snew, d], 9.0 - t as f32 * 0.2);
614
615            let o_fused = state
616                .eval(
617                    &mut session,
618                    &op,
619                    tvec![q.clone().into(), knew.clone().into(), vnew.clone().into()],
620                )?
621                .remove(0)
622                .into_tensor();
623
624            kc = Some(match kc.take() {
625                None => knew.clone(),
626                Some(c) => TypedConcat { axis: 2 }
627                    .eval(tvec![c.into(), knew.clone().into()])?
628                    .remove(0)
629                    .into_tensor(),
630            });
631            vc = Some(match vc.take() {
632                None => vnew.clone(),
633                Some(c) => TypedConcat { axis: 2 }
634                    .eval(tvec![c.into(), vnew.clone().into()])?
635                    .remove(0)
636                    .into_tensor(),
637            });
638            let o_base = flash
639                .eval(tvec![q.into(), kc.clone().unwrap().into(), vc.clone().unwrap().into()])?
640                .remove(0)
641                .into_tensor();
642
643            o_fused
644                .close_enough(&o_base, Approximation::Approximate)
645                .with_context(|| format!("fused != baseline at step {t} (causal={causal})"))?;
646        }
647        Ok(())
648    }
649
650    #[test]
651    fn fused_op_matches_cache_plus_flash_noncausal() -> TractResult<()> {
652        drive_fused_vs_baseline(false)
653    }
654
655    #[test]
656    fn fused_op_matches_cache_plus_flash_causal() -> TractResult<()> {
657        drive_fused_vs_baseline(true)
658    }
659
660    // True runtime integration: build a real TypedModel with the op, run it through
661    // tract's engine via a persistent SimpleState across prefill+decode steps (KV state
662    // carried by the op between runs), and match the concat-cache + FlashSdpaOp baseline.
663    #[test]
664    fn fused_op_runs_in_model() -> TractResult<()> {
665        let (b, hq, hkv, d) = (1usize, 4usize, 2usize, 16usize);
666        let mut model = TypedModel::default();
667        let s = model.sym("S");
668        let dim = |x: usize| x.to_dim();
669        let qshape: TVec<TDim> = tvec![dim(b), dim(hq), s.clone().into(), dim(d)];
670        let kshape: TVec<TDim> = tvec![dim(b), dim(hkv), s.into(), dim(d)];
671        let q = model.add_source("q", f32::fact(&qshape))?;
672        let k = model.add_source("k", f32::fact(&kshape))?;
673        let v = model.add_source("v", f32::fact(&kshape))?;
674        let o = model.wire_node(
675            "fused_attn",
676            InPlaceKvSdpa { axis: 2, causal: false, scale: None },
677            &[q, k, v],
678        )?;
679        model.select_output_outlets(&o)?;
680
681        let runnable = model.into_runnable()?;
682        let mut rt = runnable.spawn()?;
683
684        let flash = FlashSdpaOp { causal: false, scale: None };
685        let mut kc: Option<Tensor> = None;
686        let mut vc: Option<Tensor> = None;
687        let mut snews = vec![3usize];
688        snews.extend(std::iter::repeat_n(1usize, 8));
689
690        for (t, &snew) in snews.iter().enumerate() {
691            let q = seq_tensor(&[b, hq, snew, d], 2.0 + t as f32);
692            let knew = seq_tensor(&[b, hkv, snew, d], 4.0 + t as f32 * 0.4);
693            let vnew = seq_tensor(&[b, hkv, snew, d], 6.0 - t as f32 * 0.1);
694
695            let o_model = rt
696                .run(tvec![q.clone().into(), knew.clone().into(), vnew.clone().into()])?
697                .remove(0)
698                .into_tensor();
699
700            kc = Some(match kc.take() {
701                None => knew.clone(),
702                Some(c) => TypedConcat { axis: 2 }
703                    .eval(tvec![c.into(), knew.clone().into()])?
704                    .remove(0)
705                    .into_tensor(),
706            });
707            vc = Some(match vc.take() {
708                None => vnew.clone(),
709                Some(c) => TypedConcat { axis: 2 }
710                    .eval(tvec![c.into(), vnew.clone().into()])?
711                    .remove(0)
712                    .into_tensor(),
713            });
714            let o_base = flash
715                .eval(tvec![q.into(), kc.clone().unwrap().into(), vc.clone().unwrap().into()])?
716                .remove(0)
717                .into_tensor();
718
719            o_model
720                .close_enough(&o_base, Approximation::Approximate)
721                .with_context(|| format!("model-run != baseline at step {t}"))?;
722        }
723        Ok(())
724    }
725
726    // Auto-rewrite: a model with the {DynKeyValueCache(K), DynKeyValueCache(V), Sdpa}
727    // pattern is transparently fused to InPlaceKvSdpa, and the rewritten model runs and
728    // matches the concat-cache + FlashSdpaOp baseline.
729    #[test]
730    fn rewrite_fuses_cache_sdpa() -> TractResult<()> {
731        let (b, hq, hkv, d) = (1usize, 4usize, 2usize, 16usize);
732        let mut model = TypedModel::default();
733        let s = model.sym("S");
734        let p = model.sym("P");
735        let dim = |x: usize| x.to_dim();
736        let qf: TVec<TDim> = tvec![dim(b), dim(hq), s.clone().into(), dim(d)];
737        let newf: TVec<TDim> = tvec![dim(b), dim(hkv), s.into(), dim(d)];
738        let pastf: TVec<TDim> = tvec![dim(b), dim(hkv), p.into(), dim(d)];
739        let q = model.add_source("q", f32::fact(&qf))?;
740        let knew = model.add_source("k", f32::fact(&newf))?;
741        let vnew = model.add_source("v", f32::fact(&newf))?;
742        let mkcache = |nm: &str| DynKeyValueCache {
743            name: nm.to_string(),
744            axis: 2,
745            past_sequence_fact: f32::fact(&pastf),
746            input_sequence_fact: f32::fact(&newf),
747        };
748        let kc = model.wire_node("kc", mkcache("kc"), &[knew])?;
749        let vc = model.wire_node("vc", mkcache("vc"), &[vnew])?;
750        let o = model.wire_node(
751            "sdpa",
752            Sdpa {
753                scale: None,
754                datum_type: f32::datum_type(),
755                acc_datum_type: f32::datum_type(),
756                is_causal: false,
757            },
758            &[q, kc[0], vc[0]],
759        )?;
760        model.select_output_outlets(&o)?;
761
762        assert!(model.nodes().iter().any(|n| n.op_is::<DynKeyValueCache>()));
763        InPlaceKvSdpaTransform.transform(&mut model)?;
764
765        // Structural: fused op present, caches + sdpa gone.
766        assert!(model.nodes().iter().any(|n| n.op_is::<InPlaceKvSdpa>()), "fused op present");
767        assert!(!model.nodes().iter().any(|n| n.op_is::<DynKeyValueCache>()), "caches removed");
768        assert!(!model.nodes().iter().any(|n| n.op_is::<Sdpa>()), "sdpa removed");
769        let fused = model.nodes().iter().find(|n| n.op_is::<InPlaceKvSdpa>()).unwrap();
770        assert_eq!(fused.inputs.len(), 3, "fused op takes [Q, K_new, V_new]");
771
772        // Behavioral: the rewritten model runs and matches the baseline over decode.
773        let runnable = model.into_runnable()?;
774        let mut rt = runnable.spawn()?;
775        let flash = FlashSdpaOp { causal: false, scale: None };
776        let mut kacc: Option<Tensor> = None;
777        let mut vacc: Option<Tensor> = None;
778        let mut snews = vec![3usize];
779        snews.extend(std::iter::repeat_n(1usize, 6));
780        for (t, &snew) in snews.iter().enumerate() {
781            let qi = seq_tensor(&[b, hq, snew, d], 2.0 + t as f32);
782            let ki = seq_tensor(&[b, hkv, snew, d], 3.0 + t as f32 * 0.2);
783            let vi = seq_tensor(&[b, hkv, snew, d], 8.0 - t as f32 * 0.1);
784            let o_model = rt
785                .run(tvec![qi.clone().into(), ki.clone().into(), vi.clone().into()])?
786                .remove(0)
787                .into_tensor();
788            kacc = Some(match kacc.take() {
789                None => ki.clone(),
790                Some(c) => TypedConcat { axis: 2 }
791                    .eval(tvec![c.into(), ki.clone().into()])?
792                    .remove(0)
793                    .into_tensor(),
794            });
795            vacc = Some(match vacc.take() {
796                None => vi.clone(),
797                Some(c) => TypedConcat { axis: 2 }
798                    .eval(tvec![c.into(), vi.clone().into()])?
799                    .remove(0)
800                    .into_tensor(),
801            });
802            let o_base = flash
803                .eval(tvec![qi.into(), kacc.clone().unwrap().into(), vacc.clone().unwrap().into()])?
804                .remove(0)
805                .into_tensor();
806            o_model
807                .close_enough(&o_base, Approximation::Approximate)
808                .with_context(|| format!("rewritten model != baseline at step {t}"))?;
809        }
810        Ok(())
811    }
812
813    // End-to-end decode through the actual op path: fused InPlaceKvSdpa (in-place cache
814    // + attention over the view) vs concat-cache + FlashSdpaOp.
815    //   cargo test -p tract-transformers inplace_kv_cache::tests::bench_fused_decode -- --ignored --nocapture
816    #[test]
817    #[ignore]
818    fn bench_fused_decode() -> TractResult<()> {
819        use std::time::Instant;
820        let (bsz, hq, hkv, d) = (1usize, 8usize, 8usize, 128usize);
821        let q = seq_tensor(&[bsz, hq, 1, d], 7.0);
822        let knew = seq_tensor(&[bsz, hkv, 1, d], 1.0);
823        let vnew = seq_tensor(&[bsz, hkv, 1, d], 2.0);
824        println!(
825            "\n  INTEGRATED decode via op (fused InPlaceKvSdpa vs concat-cache + FlashSdpaOp):"
826        );
827        println!("   T     baseline(ms)  fused(ms)   speedup");
828        for &steps in &[256usize, 512, 1024, 2048] {
829            let op = InPlaceKvSdpa { axis: 2, causal: false, scale: None };
830            let session = TurnState::default();
831            let mut state = op.state(&session, 0)?.unwrap();
832            let mut session = session;
833            let t_fused = {
834                let start = Instant::now();
835                for _ in 0..steps {
836                    std::hint::black_box(state.eval(
837                        &mut session,
838                        &op,
839                        tvec![q.clone().into(), knew.clone().into(), vnew.clone().into()],
840                    )?);
841                }
842                start.elapsed().as_secs_f64() * 1e3
843            };
844
845            let flash = FlashSdpaOp { causal: false, scale: None };
846            let t_base = {
847                let mut kc: Option<Tensor> = None;
848                let mut vc: Option<Tensor> = None;
849                let start = Instant::now();
850                for _ in 0..steps {
851                    kc = Some(match kc.take() {
852                        None => knew.clone(),
853                        Some(c) => TypedConcat { axis: 2 }
854                            .eval(tvec![c.into(), knew.clone().into()])?
855                            .remove(0)
856                            .into_tensor(),
857                    });
858                    vc = Some(match vc.take() {
859                        None => vnew.clone(),
860                        Some(c) => TypedConcat { axis: 2 }
861                            .eval(tvec![c.into(), vnew.clone().into()])?
862                            .remove(0)
863                            .into_tensor(),
864                    });
865                    std::hint::black_box(flash.eval(tvec![
866                        q.clone().into(),
867                        kc.clone().unwrap().into(),
868                        vc.clone().unwrap().into()
869                    ])?);
870                }
871                start.elapsed().as_secs_f64() * 1e3
872            };
873            println!("  {steps:>5}  {t_base:>11.3}  {t_fused:>9.3}   {:>6.2}x", t_base / t_fused);
874        }
875        Ok(())
876    }
877
878    // Deterministic (Q, K_new, V_new) per step: prefill 3 + `n_decode` single tokens.
879    fn decode_inputs(n_decode: usize) -> Vec<(Tensor, Tensor, Tensor)> {
880        let (b, hq, hkv, d) = (1usize, 4usize, 2usize, 16usize);
881        let mut shapes = vec![3usize];
882        shapes.extend(std::iter::repeat_n(1usize, n_decode));
883        shapes
884            .iter()
885            .enumerate()
886            .map(|(t, &snew)| {
887                (
888                    seq_tensor(&[b, hq, snew, d], 1.0 + t as f32),
889                    seq_tensor(&[b, hkv, snew, d], 5.0 + t as f32 * 0.3),
890                    seq_tensor(&[b, hkv, snew, d], 9.0 - t as f32 * 0.2),
891                )
892            })
893            .collect()
894    }
895
896    // Resume #1: snapshot the running state mid-decode via freeze/unfreeze, then keep
897    // going — bit-identical to a straight run.
898    #[test]
899    fn resume_via_freeze_unfreeze() -> TractResult<()> {
900        let op = InPlaceKvSdpa { axis: 2, causal: true, scale: None };
901        let mut session = TurnState::default();
902        let mut straight = op.state(&session, 0)?.unwrap();
903        let mut split = op.state(&session, 0)?.unwrap();
904        for (t, (q, k, v)) in decode_inputs(9).into_iter().enumerate() {
905            let ins = tvec![q.into(), k.into(), v.into()];
906            let os = straight.eval(&mut session, &op, ins.clone())?.remove(0).into_tensor();
907            if t == 5 {
908                let frozen = split.freeze();
909                split = frozen.unfreeze();
910            }
911            let op2 = split.eval(&mut session, &op, ins)?.remove(0).into_tensor();
912            os.close_enough(&op2, Approximation::Exact)
913                .with_context(|| format!("freeze/unfreeze resume mismatch at step {t}"))?;
914        }
915        Ok(())
916    }
917
918    // Resume #2: checkpoint the cache to tensors (save_to), reload into a fresh state
919    // (load_from), then continue — bit-identical to a straight run.
920    #[test]
921    fn resume_via_save_load() -> TractResult<()> {
922        let op = InPlaceKvSdpa { axis: 2, causal: true, scale: None };
923        let mut session = TurnState::default();
924        let mut straight = op.state(&session, 0)?.unwrap();
925        let mut split = op.state(&session, 0)?.unwrap();
926        for (t, (q, k, v)) in decode_inputs(9).into_iter().enumerate() {
927            let ins = tvec![q.into(), k.into(), v.into()];
928            let os = straight.eval(&mut session, &op, ins.clone())?.remove(0).into_tensor();
929            if t == 5 {
930                let mut saved = vec![];
931                split.save_to(&mut saved)?;
932                ensure!(saved.len() == 2, "save_to should emit [K, V]");
933                let mut fresh = op.state(&session, 0)?.unwrap();
934                fresh.load_from(&mut session, &mut saved.into_iter())?;
935                split = fresh;
936            }
937            let op2 = split.eval(&mut session, &op, ins)?.remove(0).into_tensor();
938            os.close_enough(&op2, Approximation::Exact)
939                .with_context(|| format!("save/load resume mismatch at step {t}"))?;
940        }
941        Ok(())
942    }
943
944    // The resume checkpoint (save_to) is a one-time O(len) copy, not per-step O(T^2).
945    //   cargo test -p tract-transformers inplace_kv_cache::tests::bench_resume_snapshot -- --ignored --nocapture
946    #[test]
947    #[ignore]
948    fn bench_resume_snapshot() -> TractResult<()> {
949        use std::time::Instant;
950        let (b, hq, hkv, d) = (1usize, 8usize, 8usize, 128usize);
951        let op = InPlaceKvSdpa { axis: 2, causal: false, scale: None };
952        let session = TurnState::default();
953        let q = seq_tensor(&[b, hq, 1, d], 7.0);
954        let kv = seq_tensor(&[b, hkv, 1, d], 1.0);
955        println!("\n  resume checkpoint (save_to) — one-time O(len):");
956        println!("   len    save_to(ms)");
957        for &len in &[256usize, 1024, 4096] {
958            let mut sess = TurnState::default();
959            let mut state = op.state(&session, 0)?.unwrap();
960            for _ in 0..len {
961                state.eval(
962                    &mut sess,
963                    &op,
964                    tvec![q.clone().into(), kv.clone().into(), kv.clone().into()],
965                )?;
966            }
967            let reps = 100;
968            let start = Instant::now();
969            for _ in 0..reps {
970                let mut saved = vec![];
971                state.save_to(&mut saved)?;
972                std::hint::black_box(saved);
973            }
974            println!("  {len:>5}  {:>10.4}", start.elapsed().as_secs_f64() * 1e3 / reps as f64);
975        }
976        Ok(())
977    }
978}