1use 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#[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 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 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 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 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 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
106fn 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#[derive(Clone, Debug, PartialEq)]
135pub struct InPlaceKvSdpa {
136 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 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 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 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 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 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
281pub 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 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 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#[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 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 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 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 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 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 ensure!(cache.reallocs() <= 12, "expected ~log2(1024) reallocs, got {}", cache.reallocs());
417 Ok(())
418 }
419
420 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, .., ..]); let km = k.slice(s![bi, hi, .., ..]); let vm = v.slice(s![bi, hi, .., ..]); let mut scores = qm.dot(&km.t()); 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); out.slice_mut(s![bi, hi, .., ..]).assign(&o);
449 }
450 }
451 out
452 }
453
454 #[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 let kview = cache.valid_view::<f32>()?.into_dimensionality()?;
478 let out_inplace = attention(qv, kview, kview, scale);
479
480 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 #[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 #[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 fn drive_fused_vs_baseline(causal: bool) -> TractResult<()> {
596 let (bsz, hq, hkv, d) = (1usize, 4usize, 2usize, 16usize); 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 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 #[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 #[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 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 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 #[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 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 #[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 #[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 #[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}