Skip to main content

tract_transformers/ops/
window_kv_cache.rs

1//! Bounded (sliding-window) KV cache — a fixed-capacity ring buffer for models
2//! trained with sliding-window attention (Mistral, Gemma-style local/global, …).
3//!
4//! Instead of growing the cache with the sequence (O(T) memory + O(T) per-step
5//! attention), keep a fixed `window` slots and overwrite the oldest on append. So a
6//! decode of arbitrary length runs at **constant** memory and per-step cost — and it's
7//! **lossless**, because the model was trained to attend only within the window.
8//!
9//! Key trick that makes the ring buffer cheap: **decode attention is order-invariant
10//! over keys** — `O = Σ_j softmax_j · V_j` is unchanged if you permute (K,V) together
11//! (the set of scores, hence the softmax weights, is identical). So we never have to
12//! un-rotate the buffer: the consumer attends over the W physical slots in whatever
13//! order they sit, and the result equals attending over the ordered last-W (up to
14//! floating-point summation order). Validated in the tests.
15//!
16//! Companion to the in-place cache (#2321): this is "the in-place cache with a cap and
17//! wraparound." For prefill (multi-query) the window also needs a banded causal mask;
18//! that lives on the attention op, not here.
19
20use tract_nnef::internal::*;
21use tract_nnef::tract_core::transform::ModelTransform;
22use tract_nnef::tract_ndarray::Ix4;
23
24use crate::ops::dyn_kv_cache::DynKeyValueCache;
25use crate::ops::flash_sdpa::FlashSdpaOp;
26use crate::ops::sdpa::Sdpa;
27
28/// NNEF (de)serialization for the fused `WindowKvSdpa` op.
29pub fn register(registry: &mut Registry) {
30    registry.register_dumper(ser_window_kv_sdpa);
31    registry.register_primitive(
32        "tract_transformers_window_kv_sdpa",
33        &[
34            TypeName::Scalar.tensor().named("q"),
35            TypeName::Scalar.tensor().named("k"),
36            TypeName::Scalar.tensor().named("v"),
37            TypeName::Integer.named("axis"),
38            TypeName::Integer.named("window"),
39            TypeName::Scalar.named("scale"),
40        ],
41        &[("output", TypeName::Scalar.tensor())],
42        de_window_kv_sdpa,
43    );
44}
45
46fn ser_window_kv_sdpa(
47    ast: &mut IntoAst,
48    node: &TypedNode,
49    op: &WindowKvSdpa,
50) -> TractResult<Option<Arc<RValue>>> {
51    let q = ast.mapping[&node.inputs[0]].clone();
52    let k = ast.mapping[&node.inputs[1]].clone();
53    let v = ast.mapping[&node.inputs[2]].clone();
54    let mut attrs = vec![("axis", numeric(op.axis)), ("window", numeric(op.window))];
55    if let Some(scale) = op.scale {
56        attrs.push(("scale", numeric(scale)));
57    }
58    Ok(Some(invocation("tract_transformers_window_kv_sdpa", &[q, k, v], &attrs)))
59}
60
61fn de_window_kv_sdpa(
62    builder: &mut ModelBuilder,
63    invocation: &ResolvedInvocation,
64) -> TractResult<Value> {
65    let q = invocation.named_arg_as(builder, "q")?;
66    let k = invocation.named_arg_as(builder, "k")?;
67    let v = invocation.named_arg_as(builder, "v")?;
68    let axis: usize = invocation.named_arg_as(builder, "axis")?;
69    let window: usize = invocation.named_arg_as(builder, "window")?;
70    let scale: Option<f32> = invocation.get_named_arg_as(builder, "scale")?;
71    builder.wire(WindowKvSdpa { axis, window, scale }, &[q, k, v])
72}
73
74/// Fixed-capacity sliding-window KV cache (ring buffer) along `axis`.
75#[derive(Clone, Debug)]
76pub struct WindowKvCache {
77    pub axis: usize,
78    pub window: usize,
79    buf: Option<Tensor>, // capacity `window` along `axis`, allocated on first push
80    len: usize,          // valid slots, ≤ window
81    cursor: usize,       // next write position, in 0..window
82}
83
84impl WindowKvCache {
85    pub fn new(axis: usize, window: usize) -> Self {
86        assert!(window > 0, "window must be > 0");
87        WindowKvCache { axis, window, buf: None, len: 0, cursor: 0 }
88    }
89
90    pub fn len(&self) -> usize {
91        self.len
92    }
93    pub fn is_empty(&self) -> bool {
94        self.len == 0
95    }
96    /// Always the window capacity once allocated — memory is bounded regardless of T.
97    pub fn capacity(&self) -> usize {
98        self.buf.as_ref().map(|b| b.shape()[self.axis]).unwrap_or(0)
99    }
100
101    fn ensure_buf(&mut self, like: &Tensor) -> TractResult<()> {
102        if self.buf.is_none() {
103            let mut shape: TVec<usize> = like.shape().into();
104            shape[self.axis] = self.window;
105            self.buf = Some(unsafe { Tensor::uninitialized_dt(like.datum_type(), &shape)? });
106        }
107        Ok(())
108    }
109
110    /// Append `input` along `axis`, overwriting the oldest slots once full. O(min(new, window)).
111    pub fn push(&mut self, input: &Tensor) -> TractResult<()> {
112        let new = input.shape()[self.axis];
113        if new == 0 {
114            return Ok(());
115        }
116        self.ensure_buf(input)?;
117        let w = self.window;
118
119        if new >= w {
120            // Only the last `w` of the input survive; lay them out [0..w], cursor resets.
121            let buf = self.buf.as_mut().unwrap();
122            buf.assign_slice(0..w, input, (new - w)..new, self.axis)?;
123            self.cursor = 0;
124            self.len = w;
125            return Ok(());
126        }
127
128        // new < w: write at the cursor, wrapping around the end.
129        let end = self.cursor + new;
130        let buf = self.buf.as_mut().unwrap();
131        if end <= w {
132            buf.assign_slice(self.cursor..end, input, 0..new, self.axis)?;
133            self.cursor = if end == w { 0 } else { end };
134        } else {
135            let first = w - self.cursor;
136            buf.assign_slice(self.cursor..w, input, 0..first, self.axis)?;
137            buf.assign_slice(0..(new - first), input, first..new, self.axis)?;
138            self.cursor = new - first;
139        }
140        self.len = (self.len + new).min(w);
141        Ok(())
142    }
143
144    /// Zero-copy view of the `len` valid slots. Once full this is the whole buffer in
145    /// *physical* (rotated) order — correct for decode attention by order-invariance.
146    pub fn valid_view<T: Datum>(&self) -> TractResult<tract_ndarray::ArrayViewD<'_, T>> {
147        let buf = self.buf.as_ref().context("empty window cache")?;
148        let mut v = buf.to_plain_array_view::<T>()?;
149        v.slice_axis_inplace(tract_ndarray::Axis(self.axis), (0..self.len).into());
150        Ok(v)
151    }
152}
153
154/// Fused sliding-window KV-cache + attention (decode). Owns K/V ring buffers of size
155/// `window`; each step appends `K_new`/`V_new` and attends `Q` over the (≤window) cache.
156/// The bounded cache *is* the sliding window — attending over it equals windowed
157/// attention — so decode runs at constant memory + per-step cost, losslessly. Inputs
158/// `[Q, K_new, V_new]`, each `[B, H, S, D]`; output has Q's shape.
159#[derive(Clone, Debug, PartialEq)]
160pub struct WindowKvSdpa {
161    pub axis: usize,
162    pub window: usize,
163    pub scale: Option<f32>,
164}
165impl Eq for WindowKvSdpa {}
166
167impl Op for WindowKvSdpa {
168    fn name(&self) -> StaticName {
169        "WindowKvSdpa".into()
170    }
171    fn info(&self) -> TractResult<Vec<String>> {
172        Ok(vec![format!("axis={}, window={}, scale={:?}", self.axis, self.window, self.scale)])
173    }
174    op_as_typed_op!();
175}
176
177impl EvalOp for WindowKvSdpa {
178    not_out_of_plan!();
179    fn state(&self, _ctx: &EvalContext) -> TractResult<Option<Box<dyn OpState>>> {
180        Ok(Some(Box::new(WindowKvSdpaState {
181            scale: self.scale,
182            k: WindowKvCache::new(self.axis, self.window),
183            v: WindowKvCache::new(self.axis, self.window),
184        })))
185    }
186}
187
188impl TypedOp for WindowKvSdpa {
189    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
190        ensure!(inputs.len() == 3, "WindowKvSdpa expects [Q, K_new, V_new]");
191        Ok(tvec!(inputs[0].without_value()))
192    }
193    as_op!();
194}
195
196#[derive(Clone, Debug)]
197pub struct WindowKvSdpaState {
198    scale: Option<f32>,
199    k: WindowKvCache,
200    v: WindowKvCache,
201}
202
203impl OpState for WindowKvSdpaState {
204    fn eval(
205        &mut self,
206        _ctx: &EvalContext,
207        _op: &dyn Op,
208        inputs: TVec<TValue>,
209    ) -> TractResult<TVec<TValue>> {
210        ensure!(inputs.len() == 3, "WindowKvSdpa expects [Q, K_new, V_new]");
211        let input_dt = inputs[0].datum_type();
212        let k_new = inputs[1].cast_to::<f32>()?;
213        let v_new = inputs[2].cast_to::<f32>()?;
214        self.k.push(k_new.as_ref())?;
215        self.v.push(v_new.as_ref())?;
216
217        let q = inputs[0].cast_to::<f32>()?;
218        let qv = q.to_plain_array_view::<f32>()?.into_dimensionality::<Ix4>()?;
219        let kview = self.k.valid_view::<f32>()?.into_dimensionality::<Ix4>()?;
220        let vview = self.v.valid_view::<f32>()?.into_dimensionality::<Ix4>()?;
221
222        // The ring buffer already bounds which keys are visible (the last `window`), so
223        // attention is "attend all" — every cached key is within the current query's window.
224        let flash = FlashSdpaOp { causal: false, scale: self.scale };
225        let o = flash.flash_attention_gqa(qv, kview, vview, None);
226        Ok(tvec!(o.into_tensor().cast_to_dt(input_dt)?.into_owned().into_tvalue()))
227    }
228
229    fn reset_lanes(&mut self, _lanes: &[LaneId]) -> TractResult<()> {
230        bail!("WindowKvSdpa is not lane-aware: the ring buffers have no lane axis")
231    }
232}
233
234/// Rewrite rule: fuse `{DynKeyValueCache(K), DynKeyValueCache(V), Sdpa(Q,K,V)}` into a
235/// `WindowKvSdpa` with the window supplied via the Rewriter context — so an imported
236/// decode model uses a bounded sliding-window cache. The window comes from the model
237/// (the GQA `local_window_size` / config), passed to `WindowKvSdpaTransform`.
238pub fn fuse_window_kv_sdpa_rule(
239    window: &usize,
240    model: &TypedModel,
241    node: &TypedNode,
242    node_name: &str,
243    op: &Sdpa,
244) -> TractResult<Option<TypedModelPatch>> {
245    if node.inputs.len() != 3 {
246        return Ok(None);
247    }
248    let k_node = model.node(node.inputs[1].node);
249    let v_node = model.node(node.inputs[2].node);
250    let (Some(kc), Some(vc)) =
251        (k_node.op_as::<DynKeyValueCache>(), v_node.op_as::<DynKeyValueCache>())
252    else {
253        return Ok(None);
254    };
255    if kc.axis != vc.axis {
256        return Ok(None);
257    }
258    if k_node.outputs[0].successors.len() != 1 || v_node.outputs[0].successors.len() != 1 {
259        return Ok(None);
260    }
261    let scale = op.scale.as_ref().map(|t| t.cast_to_scalar::<f32>()).transpose()?;
262    let q_outlet = node.inputs[0];
263    let k_new = k_node.inputs[0];
264    let v_new = v_node.inputs[0];
265
266    let mut patch = TypedModelPatch::default();
267    let taps = patch.taps(model, &[q_outlet, k_new, v_new])?;
268    let fused = patch.wire_node(
269        format!("{node_name}.window_kv_sdpa"),
270        WindowKvSdpa { axis: kc.axis, window: *window, scale },
271        &taps,
272    )?;
273    patch.shunt_outside(model, node.id.into(), fused[0])?;
274    Ok(Some(patch))
275}
276
277/// Strip the GQA broadcast chain, then fuse `cache -> Sdpa` into `WindowKvSdpa` with
278/// `window` — making an imported decode model use the bounded sliding-window cache.
279#[derive(Debug, Clone)]
280pub struct WindowKvSdpaTransform {
281    pub window: usize,
282}
283
284impl ModelTransform for WindowKvSdpaTransform {
285    fn name(&self) -> StaticName {
286        "fuse_window_kv_sdpa".into()
287    }
288    fn transform(&self, model: &mut TypedModel) -> TractResult<()> {
289        Rewriter::default()
290            .with_rule_for("fuse-kv-broadcast", crate::ops::sdpa::fuse_kv_cache_broadcast_rule)
291            .rewrite(&(), model)?;
292        Rewriter::default()
293            .with_rule_for("fuse-window-kv-sdpa", fuse_window_kv_sdpa_rule)
294            .rewrite(&self.window, model)?;
295        model.compact()
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use tract_nnef::tract_ndarray::{Array4, ArrayView4, s};
303
304    fn tok(shape: &[usize], v: f32) -> Tensor {
305        let n: usize = shape.iter().product();
306        Tensor::from_shape(shape, &vec![v; n]).unwrap()
307    }
308
309    // ---- ring-buffer mechanics: holds exactly the last `window` items (as a set) ----
310    #[test]
311    fn window_holds_last_w_as_a_set() -> TractResult<()> {
312        let w = 4;
313        let mut c = WindowKvCache::new(2, w); // [B,H,S,D], seq axis
314        let mut full: Vec<f32> = vec![];
315        for t in 0..10 {
316            c.push(&tok(&[1, 1, 1, 1], t as f32))?;
317            full.push(t as f32);
318            assert!(c.len() <= w, "len bounded by window");
319            // valid_view set == last min(t+1,w) tokens
320            let view = c.valid_view::<f32>()?;
321            let mut got: Vec<f32> = view.iter().copied().collect();
322            got.sort_by(|a, b| a.partial_cmp(b).unwrap());
323            let mut want: Vec<f32> = full.iter().rev().take(w).copied().collect();
324            want.sort_by(|a, b| a.partial_cmp(b).unwrap());
325            assert_eq!(got, want, "step {t}: window must hold the last {w} as a set");
326        }
327        assert_eq!(c.capacity(), w, "memory stays bounded at the window");
328        Ok(())
329    }
330
331    #[test]
332    fn prefill_chunk_larger_than_window_keeps_last_w() -> TractResult<()> {
333        let w = 3;
334        let mut c = WindowKvCache::new(2, w);
335        // one chunk of 7 tokens with distinct values, axis=2
336        let chunk = Tensor::from_shape(&[1, 1, 7, 1], &[0f32, 1., 2., 3., 4., 5., 6.])?;
337        c.push(&chunk)?;
338        let mut got: Vec<f32> = c.valid_view::<f32>()?.iter().copied().collect();
339        got.sort_by(|a, b| a.partial_cmp(b).unwrap());
340        assert_eq!(got, vec![4.0, 5.0, 6.0], "keeps the last 3");
341        Ok(())
342    }
343
344    // ---- the correctness property: decode attention over the (rotated) window ==
345    //      attention over the ordered last-W slice, by order-invariance ----
346    fn attention(
347        q: ArrayView4<f32>,
348        k: ArrayView4<f32>,
349        v: ArrayView4<f32>,
350        scale: f32,
351    ) -> Array4<f32> {
352        let (b, h, sq, d) = q.dim();
353        let mut out = Array4::<f32>::zeros((b, h, sq, d));
354        for bi in 0..b {
355            for hi in 0..h {
356                let qm = q.slice(s![bi, hi, .., ..]);
357                let km = k.slice(s![bi, hi, .., ..]);
358                let vm = v.slice(s![bi, hi, .., ..]);
359                let mut sc = qm.dot(&km.t());
360                sc *= scale;
361                for mut row in sc.rows_mut() {
362                    let m = row.iter().copied().fold(f32::NEG_INFINITY, f32::max);
363                    let mut s = 0.0;
364                    row.iter_mut().for_each(|x| {
365                        *x = (*x - m).exp();
366                        s += *x;
367                    });
368                    row.iter_mut().for_each(|x| *x /= s);
369                }
370                out.slice_mut(s![bi, hi, .., ..]).assign(&sc.dot(&vm));
371            }
372        }
373        out
374    }
375
376    #[test]
377    fn windowed_attention_matches_last_w_full() -> TractResult<()> {
378        let (h, d, w) = (2usize, 8usize, 6usize);
379        let scale = 1.0 / (d as f32).sqrt();
380        // deterministic varied K/V per token so order actually differs after wrap
381        let seq = |s: usize, base: f32| -> Tensor {
382            let data: Vec<f32> = (0..h * s * d).map(|i| base + (i as f32 * 0.013).sin()).collect();
383            Tensor::from_shape(&[1, h, s, d], &data).unwrap()
384        };
385        let mut kc = WindowKvCache::new(2, w);
386        let mut vc = WindowKvCache::new(2, w);
387        let mut kfull: Option<Tensor> = None;
388        let mut vfull: Option<Tensor> = None;
389        use tract_nnef::tract_core::ops::array::TypedConcat;
390        for t in 0..20 {
391            let knew = seq(1, 1.0 + t as f32 * 0.1);
392            let vnew = seq(1, 5.0 - t as f32 * 0.07);
393            kc.push(&knew)?;
394            vc.push(&vnew)?;
395            let grow = |acc: Option<Tensor>, x: Tensor| -> TractResult<Tensor> {
396                Ok(match acc {
397                    None => x,
398                    Some(a) => TypedConcat { axis: 2 }
399                        .eval(&EvalContext::out_of_plan(), tvec![a.into(), x.into()])?
400                        .remove(0)
401                        .into_tensor(),
402                })
403            };
404            kfull = Some(grow(kfull.take(), knew)?);
405            vfull = Some(grow(vfull.take(), vnew)?);
406
407            let q = seq(1, 9.0 + t as f32 * 0.05);
408            let qv = q.to_plain_array_view::<f32>()?.into_dimensionality()?;
409
410            // windowed (rotated physical order)
411            let o_win = attention(
412                qv,
413                kc.valid_view::<f32>()?.into_dimensionality()?,
414                vc.valid_view::<f32>()?.into_dimensionality()?,
415                scale,
416            );
417            // reference: ordered last-W of the full cache
418            let len = kc.len();
419            let kf = kfull.as_ref().unwrap();
420            let s = kf.shape()[2];
421            let kslice = kf.slice(2, s - len, s)?;
422            let vslice = vfull.as_ref().unwrap().slice(2, s - len, s)?;
423            let o_ref = attention(
424                qv,
425                kslice.to_plain_array_view::<f32>()?.into_dimensionality()?,
426                vslice.to_plain_array_view::<f32>()?.into_dimensionality()?,
427                scale,
428            );
429            let a = Tensor::from(o_win);
430            let b = Tensor::from(o_ref);
431            a.close_enough(&b, Approximation::Approximate)
432                .with_context(|| format!("windowed != last-W at step {t}"))?;
433        }
434        Ok(())
435    }
436
437    // The fused decode op, run through tract's engine over a long sequence with a small
438    // window, equals full attention over the last-W each step — i.e. correct sliding-window
439    // decode, with the cache bounded to `window` regardless of how long we decode.
440    #[test]
441    fn window_sdpa_decode_matches_last_w_in_model() -> TractResult<()> {
442        use tract_nnef::tract_core::ops::array::TypedConcat;
443        let (b, h, d, w) = (1usize, 2usize, 16usize, 5usize);
444        let scale = 1.0 / (d as f32).sqrt();
445        let mut model = TypedModel::default();
446        let s = model.sym("S");
447        let dim = |x: usize| x.to_dim();
448        let f: TVec<TDim> = tvec![dim(b), dim(h), s.into(), dim(d)];
449        let q = model.add_source("q", f32::fact(&f))?;
450        let k = model.add_source("k", f32::fact(&f))?;
451        let v = model.add_source("v", f32::fact(&f))?;
452        let o =
453            model.wire_node("win", WindowKvSdpa { axis: 2, window: w, scale: None }, &[q, k, v])?;
454        model.select_output_outlets(&o)?;
455        let mut rt = model.into_runnable()?.spawn()?;
456
457        let mk = |base: f32| -> Tensor {
458            let data: Vec<f32> = (0..b * h * d).map(|i| base + (i as f32 * 0.013).sin()).collect();
459            Tensor::from_shape(&[b, h, 1, d], &data).unwrap()
460        };
461        let grow = |acc: Option<Tensor>, x: Tensor| -> TractResult<Tensor> {
462            Ok(match acc {
463                None => x,
464                Some(a) => TypedConcat { axis: 2 }
465                    .eval(&EvalContext::out_of_plan(), tvec![a.into(), x.into()])?
466                    .remove(0)
467                    .into_tensor(),
468            })
469        };
470        let (mut kf, mut vf): (Option<Tensor>, Option<Tensor>) = (None, None);
471        for t in 0..15 {
472            let qi = mk(9.0 + t as f32 * 0.1);
473            let ki = mk(1.0 + t as f32 * 0.07);
474            let vi = mk(5.0 - t as f32 * 0.05);
475            let o_model = rt
476                .run(tvec![qi.clone().into(), ki.clone().into(), vi.clone().into()])?
477                .remove(0)
478                .into_tensor();
479            kf = Some(grow(kf.take(), ki)?);
480            vf = Some(grow(vf.take(), vi)?);
481            let fk = kf.as_ref().unwrap();
482            let sk = fk.shape()[2];
483            let len = sk.min(w);
484            let kslice = fk.slice(2, sk - len, sk)?;
485            let vslice = vf.as_ref().unwrap().slice(2, sk - len, sk)?;
486            let qv = qi.to_plain_array_view::<f32>()?.into_dimensionality()?;
487            let o_ref = attention(
488                qv,
489                kslice.to_plain_array_view::<f32>()?.into_dimensionality()?,
490                vslice.to_plain_array_view::<f32>()?.into_dimensionality()?,
491                scale,
492            );
493            o_model
494                .close_enough(&Tensor::from(o_ref), Approximation::Approximate)
495                .with_context(|| format!("window decode != last-{w} at step {t}"))?;
496        }
497        Ok(())
498    }
499
500    // Auto-wiring: a {DynKeyValueCache(K), DynKeyValueCache(V), Sdpa} decode subgraph is
501    // fused to WindowKvSdpa{window} by the transform, and the rewritten model then does
502    // correct windowed decode (vs the un-windowed full attention it had before).
503    #[test]
504    fn transform_fuses_cache_sdpa_to_windowed_decode() -> TractResult<()> {
505        use crate::ops::dyn_kv_cache::DynKeyValueCache;
506        use crate::ops::sdpa::Sdpa;
507        use tract_nnef::tract_core::ops::array::TypedConcat;
508        let (b, h, d, w) = (1usize, 2usize, 16usize, 5usize);
509        let scale = 1.0 / (d as f32).sqrt();
510        let mut model = TypedModel::default();
511        let s = model.sym("S");
512        let p = model.sym("P");
513        let dim = |x: usize| x.to_dim();
514        let newf: TVec<TDim> = tvec![dim(b), dim(h), s.clone().into(), dim(d)];
515        let qf: TVec<TDim> = tvec![dim(b), dim(h), s.into(), dim(d)];
516        let pastf: TVec<TDim> = tvec![dim(b), dim(h), p.into(), dim(d)];
517        let q = model.add_source("q", f32::fact(&qf))?;
518        let knew = model.add_source("k", f32::fact(&newf))?;
519        let vnew = model.add_source("v", f32::fact(&newf))?;
520        let mkc = |nm: &str| DynKeyValueCache {
521            name: nm.to_string(),
522            axis: 2,
523            past_sequence_fact: f32::fact(&pastf),
524            input_sequence_fact: f32::fact(&newf),
525        };
526        let kc = model.wire_node("kc", mkc("kc"), &[knew])?;
527        let vc = model.wire_node("vc", mkc("vc"), &[vnew])?;
528        let o = model.wire_node(
529            "sdpa",
530            Sdpa {
531                scale: None,
532                datum_type: f32::datum_type(),
533                acc_datum_type: f32::datum_type(),
534                is_causal: true,
535            },
536            &[q, kc[0], vc[0]],
537        )?;
538        model.select_output_outlets(&o)?;
539
540        WindowKvSdpaTransform { window: w }.transform(&mut model)?;
541
542        assert!(model.nodes().iter().any(|n| n.op_is::<WindowKvSdpa>()), "fused to WindowKvSdpa");
543        assert!(!model.nodes().iter().any(|n| n.op_is::<DynKeyValueCache>()), "caches removed");
544        assert!(!model.nodes().iter().any(|n| n.op_is::<Sdpa>()), "sdpa removed");
545
546        let mut rt = model.into_runnable()?.spawn()?;
547        let mk = |base: f32| -> Tensor {
548            let data: Vec<f32> = (0..b * h * d).map(|i| base + (i as f32 * 0.013).sin()).collect();
549            Tensor::from_shape(&[b, h, 1, d], &data).unwrap()
550        };
551        let grow = |acc: Option<Tensor>, x: Tensor| -> TractResult<Tensor> {
552            Ok(match acc {
553                None => x,
554                Some(a) => TypedConcat { axis: 2 }
555                    .eval(&EvalContext::out_of_plan(), tvec![a.into(), x.into()])?
556                    .remove(0)
557                    .into_tensor(),
558            })
559        };
560        let (mut kf, mut vf): (Option<Tensor>, Option<Tensor>) = (None, None);
561        for t in 0..15 {
562            let qi = mk(9.0 + t as f32 * 0.1);
563            let ki = mk(1.0 + t as f32 * 0.07);
564            let vi = mk(5.0 - t as f32 * 0.05);
565            let o_model = rt
566                .run(tvec![qi.clone().into(), ki.clone().into(), vi.clone().into()])?
567                .remove(0)
568                .into_tensor();
569            kf = Some(grow(kf.take(), ki)?);
570            vf = Some(grow(vf.take(), vi)?);
571            let fk = kf.as_ref().unwrap();
572            let sk = fk.shape()[2];
573            let len = sk.min(w);
574            let kslice = fk.slice(2, sk - len, sk)?;
575            let vslice = vf.as_ref().unwrap().slice(2, sk - len, sk)?;
576            let qv = qi.to_plain_array_view::<f32>()?.into_dimensionality()?;
577            let o_ref = attention(
578                qv,
579                kslice.to_plain_array_view::<f32>()?.into_dimensionality()?,
580                vslice.to_plain_array_view::<f32>()?.into_dimensionality()?,
581                scale,
582            );
583            o_model
584                .close_enough(&Tensor::from(o_ref), Approximation::Approximate)
585                .with_context(|| format!("rewritten windowed decode != last-{w} at step {t}"))?;
586        }
587        Ok(())
588    }
589
590    // NNEF ser/de round-trip: WindowKvSdpa survives write_to_tar -> model_for_read.
591    #[test]
592    fn window_kv_sdpa_nnef_round_trip() -> TractResult<()> {
593        use crate::WithTractTransformers;
594        let (b, h, d) = (1usize, 2usize, 16usize);
595        let mut model = TypedModel::default();
596        let s = model.sym("S");
597        let dim = |x: usize| x.to_dim();
598        let f: TVec<TDim> = tvec![dim(b), dim(h), s.into(), dim(d)];
599        let q = model.add_source("q", f32::fact(&f))?;
600        let k = model.add_source("k", f32::fact(&f))?;
601        let v = model.add_source("v", f32::fact(&f))?;
602        let o = model.wire_node(
603            "win",
604            WindowKvSdpa { axis: 2, window: 4096, scale: Some(0.125) },
605            &[q, k, v],
606        )?;
607        model.select_output_outlets(&o)?;
608
609        let nnef = tract_nnef::nnef().with_tract_transformers();
610        let mut buffer = vec![];
611        nnef.write_to_tar(&model, &mut buffer)?;
612        let reloaded = nnef.model_for_read(&mut &*buffer)?;
613
614        let n = reloaded
615            .nodes()
616            .iter()
617            .find(|n| n.op_is::<WindowKvSdpa>())
618            .context("WindowKvSdpa survived the round-trip")?;
619        let op = n.op_as::<WindowKvSdpa>().unwrap();
620        assert_eq!(op.axis, 2);
621        assert_eq!(op.window, 4096);
622        assert_eq!(op.scale, Some(0.125));
623        Ok(())
624    }
625}