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