1use 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
29pub 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#[derive(Clone, Debug)]
77pub struct WindowKvCache {
78 pub axis: usize,
79 pub window: usize,
80 buf: Option<Tensor>, len: usize, cursor: usize, }
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 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 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 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 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 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#[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 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
267pub 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#[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 #[test]
344 fn window_holds_last_w_as_a_set() -> TractResult<()> {
345 let w = 4;
346 let mut c = WindowKvCache::new(2, w); 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 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 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 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 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 let o_win = attention(
445 qv,
446 kc.valid_view::<f32>()?.into_dimensionality()?,
447 vc.valid_view::<f32>()?.into_dimensionality()?,
448 scale,
449 );
450 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 #[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 #[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 #[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}