use tract_nnef::internal::*;
use tract_nnef::tract_core::ops::{FrozenOpState, OpStateFreeze};
use tract_nnef::tract_core::transform::ModelTransform;
use tract_nnef::tract_ndarray::Ix4;
use crate::ops::dyn_kv_cache::DynKeyValueCache;
use crate::ops::flash_sdpa::FlashSdpaOp;
use crate::ops::sdpa::Sdpa;
#[derive(Clone, Debug)]
pub struct InPlaceKvCache {
pub axis: usize,
buffer: Option<Tensor>,
len: usize,
reallocs: usize,
}
impl InPlaceKvCache {
pub fn new(axis: usize) -> Self {
InPlaceKvCache { axis, buffer: None, len: 0, reallocs: 0 }
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
pub fn reallocs(&self) -> usize {
self.reallocs
}
pub fn capacity(&self) -> usize {
self.buffer.as_ref().map(|b| b.shape()[self.axis]).unwrap_or(0)
}
pub fn push(&mut self, input: &Tensor) -> TractResult<()> {
let new = input.shape()[self.axis];
if new == 0 {
return Ok(());
}
match self.buffer.take() {
None => {
self.buffer = Some(input.clone());
self.len = new;
}
Some(mut buf) => {
ensure!(buf.rank() == input.rank(), "rank mismatch in kv-cache push");
let cap = buf.shape()[self.axis];
if self.len + new <= cap {
buf.assign_slice(self.len..self.len + new, input, 0..new, self.axis)?;
self.len += new;
self.buffer = Some(buf);
} else {
let new_cap = (cap * 2).max(self.len + new);
let mut grown = grow(&buf, self.len, new_cap, self.axis)?;
grown.assign_slice(self.len..self.len + new, input, 0..new, self.axis)?;
self.len += new;
self.reallocs += 1;
self.buffer = Some(grown);
}
}
}
Ok(())
}
pub fn valid_view<T: Datum>(&self) -> TractResult<tract_ndarray::ArrayViewD<'_, T>> {
let buf = self.buffer.as_ref().context("empty kv-cache")?;
let mut full = buf.to_plain_array_view::<T>()?;
full.slice_axis_inplace(tract_ndarray::Axis(self.axis), (0..self.len).into());
Ok(full)
}
pub fn valid_contiguous(&self) -> TractResult<Tensor> {
let buf = self.buffer.as_ref().context("empty kv-cache")?;
buf.slice(self.axis, 0, self.len)
}
}
fn grow(src: &Tensor, len: usize, new_cap: usize, axis: usize) -> TractResult<Tensor> {
let mut shape: TVec<usize> = src.shape().into();
shape[axis] = new_cap;
let mut out = unsafe { Tensor::uninitialized_dt(src.datum_type(), &shape)? };
out.assign_slice(0..len, src, 0..len, axis)?;
Ok(out)
}
#[derive(Clone, Debug, PartialEq)]
pub struct InPlaceKvSdpa {
pub axis: usize,
pub causal: bool,
pub scale: Option<f32>,
}
impl Eq for InPlaceKvSdpa {}
impl Op for InPlaceKvSdpa {
fn name(&self) -> StaticName {
"InPlaceKvSdpa".into()
}
fn info(&self) -> TractResult<Vec<String>> {
Ok(vec![format!("axis={}, causal={}, scale={:?}", self.axis, self.causal, self.scale)])
}
op_as_typed_op!();
}
impl EvalOp for InPlaceKvSdpa {
fn is_stateless(&self) -> bool {
false
}
fn state(
&self,
_session: &TurnState,
_node_id: usize,
) -> TractResult<Option<Box<dyn OpState>>> {
Ok(Some(Box::new(InPlaceKvSdpaState {
axis: self.axis,
causal: self.causal,
scale: self.scale,
k: InPlaceKvCache::new(self.axis),
v: InPlaceKvCache::new(self.axis),
})))
}
}
impl TypedOp for InPlaceKvSdpa {
fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
ensure!(inputs.len() == 3, "InPlaceKvSdpa expects [Q, K_new, V_new]");
Ok(tvec!(inputs[0].without_value()))
}
as_op!();
}
#[derive(Clone, Debug)]
pub struct InPlaceKvSdpaState {
axis: usize,
causal: bool,
scale: Option<f32>,
k: InPlaceKvCache,
v: InPlaceKvCache,
}
impl OpState for InPlaceKvSdpaState {
fn eval(
&mut self,
_state: &mut TurnState,
_op: &dyn Op,
inputs: TVec<TValue>,
) -> TractResult<TVec<TValue>> {
ensure!(inputs.len() == 3, "InPlaceKvSdpa expects [Q, K_new, V_new]");
let input_dt = inputs[0].datum_type();
let k_new = inputs[1].cast_to::<f32>()?;
let v_new = inputs[2].cast_to::<f32>()?;
self.k.push(k_new.as_ref())?;
self.v.push(v_new.as_ref())?;
let q = inputs[0].cast_to::<f32>()?;
let qv = q.to_plain_array_view::<f32>()?.into_dimensionality::<Ix4>()?;
let kview = self.k.valid_view::<f32>()?.into_dimensionality::<Ix4>()?;
let vview = self.v.valid_view::<f32>()?.into_dimensionality::<Ix4>()?;
let flash = FlashSdpaOp { causal: self.causal, scale: self.scale };
let o = flash.flash_attention_gqa(qv, kview, vview, None);
Ok(tvec!(o.into_tensor().cast_to_dt(input_dt)?.into_owned().into_tvalue()))
}
fn save_to(&self, states: &mut Vec<TValue>) -> TractResult<()> {
if !self.k.is_empty() {
states.push(self.k.valid_contiguous()?.into_tvalue());
states.push(self.v.valid_contiguous()?.into_tvalue());
}
Ok(())
}
fn load_from(
&mut self,
_state: &mut TurnState,
states: &mut dyn Iterator<Item = TValue>,
) -> TractResult<()> {
if let Some(k) = states.next() {
let v = states.next().context("InPlaceKvSdpa load_from: expected V state after K")?;
self.k = InPlaceKvCache::new(self.axis);
self.v = InPlaceKvCache::new(self.axis);
self.k.push(k.cast_to::<f32>()?.as_ref())?;
self.v.push(v.cast_to::<f32>()?.as_ref())?;
}
Ok(())
}
}
#[derive(Clone, Debug)]
struct FrozenInPlaceKvSdpaState {
axis: usize,
causal: bool,
scale: Option<f32>,
k: InPlaceKvCache,
v: InPlaceKvCache,
}
impl OpStateFreeze for InPlaceKvSdpaState {
fn freeze(&self) -> Box<dyn FrozenOpState> {
Box::new(FrozenInPlaceKvSdpaState {
axis: self.axis,
causal: self.causal,
scale: self.scale,
k: self.k.clone(),
v: self.v.clone(),
})
}
}
impl FrozenOpState for FrozenInPlaceKvSdpaState {
fn unfreeze(&self) -> Box<dyn OpState> {
Box::new(InPlaceKvSdpaState {
axis: self.axis,
causal: self.causal,
scale: self.scale,
k: self.k.clone(),
v: self.v.clone(),
})
}
}
pub fn fuse_inplace_kv_sdpa_rule(
_ctx: &(),
model: &TypedModel,
node: &TypedNode,
node_name: &str,
op: &Sdpa,
) -> TractResult<Option<TypedModelPatch>> {
if node.inputs.len() != 3 {
return Ok(None);
}
let k_node = model.node(node.inputs[1].node);
let v_node = model.node(node.inputs[2].node);
let (Some(kc), Some(vc)) =
(k_node.op_as::<DynKeyValueCache>(), v_node.op_as::<DynKeyValueCache>())
else {
return Ok(None);
};
if kc.axis != vc.axis {
return Ok(None);
}
if k_node.outputs[0].successors.len() != 1 || v_node.outputs[0].successors.len() != 1 {
return Ok(None);
}
let scale = op.scale.as_ref().map(|t| t.cast_to_scalar::<f32>()).transpose()?;
let q_outlet = node.inputs[0];
let k_new = k_node.inputs[0];
let v_new = v_node.inputs[0];
let mut patch = TypedModelPatch::default();
let taps = patch.taps(model, &[q_outlet, k_new, v_new])?;
let fused = patch.wire_node(
format!("{node_name}.inplace_kv_sdpa"),
InPlaceKvSdpa { axis: kc.axis, causal: op.is_causal, scale },
&taps,
)?;
patch.shunt_outside(model, node.id.into(), fused[0])?;
Ok(Some(patch))
}
#[derive(Debug, Default)]
pub struct InPlaceKvSdpaTransform;
impl ModelTransform for InPlaceKvSdpaTransform {
fn name(&self) -> StaticName {
"fuse_inplace_kv_sdpa".into()
}
fn transform(&self, model: &mut TypedModel) -> TractResult<()> {
Rewriter::default()
.with_rule_for("fuse-kv-broadcast", crate::ops::sdpa::fuse_kv_cache_broadcast_rule)
.with_rule_for("fuse-inplace-kv-sdpa", fuse_inplace_kv_sdpa_rule)
.rewrite(&(), model)?;
model.compact()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tract_nnef::tract_core::ops::array::TypedConcat;
use tract_nnef::tract_ndarray::{Array4, ArrayView4, s};
fn seq_tensor(shape: &[usize], start: f32) -> Tensor {
let n: usize = shape.iter().product();
let data: Vec<f32> = (0..n).map(|i| start + i as f32 * 0.5).collect();
Tensor::from_shape(shape, &data).unwrap()
}
fn check_matches_concat(chunk_shapes: &[Vec<usize>], axis: usize) -> TractResult<()> {
let mut cache = InPlaceKvCache::new(axis);
let mut concat: Option<Tensor> = None;
for (i, sh) in chunk_shapes.iter().enumerate() {
let chunk = seq_tensor(sh, i as f32 * 100.0);
cache.push(&chunk)?;
concat = Some(match concat.take() {
None => chunk,
Some(c) => TypedConcat { axis }
.eval(tvec![c.into(), chunk.into()])?
.remove(0)
.into_tensor(),
});
}
let reference = concat.unwrap();
let got = cache.valid_contiguous()?;
got.close_enough(&reference, Approximation::Exact)?;
ensure!(cache.len() == reference.shape()[axis]);
Ok(())
}
#[test]
fn inplace_matches_concat_decode() -> TractResult<()> {
let mut shapes = vec![vec![1, 2, 5, 4]];
for _ in 0..20 {
shapes.push(vec![1, 2, 1, 4]);
}
check_matches_concat(&shapes, 2)
}
#[test]
fn inplace_matches_concat_axes_and_chunks() -> TractResult<()> {
check_matches_concat(&[vec![2, 2], vec![4, 2], vec![1, 2], vec![7, 2]], 0)?;
check_matches_concat(&[vec![2, 2], vec![2, 1], vec![2, 3]], 1)?;
check_matches_concat(&[vec![1, 3, 2, 8], vec![1, 3, 3, 8], vec![1, 3, 1, 8]], 2)?;
Ok(())
}
#[test]
fn geometric_growth_is_amortized() -> TractResult<()> {
let mut cache = InPlaceKvCache::new(2);
cache.push(&seq_tensor(&[1, 2, 1, 4], 0.0))?;
for _ in 0..1023 {
cache.push(&seq_tensor(&[1, 2, 1, 4], 1.0))?;
}
ensure!(cache.len() == 1024);
ensure!(cache.reallocs() <= 12, "expected ~log2(1024) reallocs, got {}", cache.reallocs());
Ok(())
}
fn attention(
q: ArrayView4<f32>,
k: ArrayView4<f32>,
v: ArrayView4<f32>,
scale: f32,
) -> Array4<f32> {
let (b, h, sq, d) = q.dim();
let mut out = Array4::<f32>::zeros((b, h, sq, d));
for bi in 0..b {
for hi in 0..h {
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;
for mut row in scores.rows_mut() {
let max = row.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let mut sum = 0.0f32;
row.iter_mut().for_each(|x| {
*x = (*x - max).exp();
sum += *x;
});
let inv = 1.0 / sum;
row.iter_mut().for_each(|x| *x *= inv);
}
let o = scores.dot(&vm); out.slice_mut(s![bi, hi, .., ..]).assign(&o);
}
}
out
}
#[test]
fn consumer_over_view_matches_concat_baseline() -> TractResult<()> {
let (b, h, d) = (1usize, 2usize, 8usize);
let scale = 1.0 / (d as f32).sqrt();
let mut cache = InPlaceKvCache::new(2);
let mut concat: Option<Tensor> = None;
for t in 0..16 {
let kv = seq_tensor(&[b, h, 1, d], t as f32);
cache.push(&kv)?;
concat = Some(match concat.take() {
None => kv.clone(),
Some(c) => TypedConcat { axis: 2 }
.eval(tvec![c.into(), kv.clone().into()])?
.remove(0)
.into_tensor(),
});
let q = seq_tensor(&[b, h, 1, d], 1000.0 + t as f32);
let qv = q.to_plain_array_view::<f32>()?.into_dimensionality()?;
let kview = cache.valid_view::<f32>()?.into_dimensionality()?;
let out_inplace = attention(qv, kview, kview, scale);
let cbuf = concat.as_ref().unwrap();
let cv = cbuf.to_plain_array_view::<f32>()?.into_dimensionality()?;
let out_concat = attention(qv, cv, cv, scale);
let a = Tensor::from(out_inplace);
let bt = Tensor::from(out_concat);
a.close_enough(&bt, Approximation::Approximate)
.with_context(|| format!("mismatch at decode step {t}"))?;
}
Ok(())
}
#[test]
#[ignore]
fn bench_update() -> TractResult<()> {
use std::time::Instant;
let (b, h, d) = (1usize, 8usize, 128usize);
println!("\n cache-UPDATE only (B={b} H={h} D={d}), concat-grow vs in-place:");
println!(" T concat(ms) inplace(ms) speedup reallocs");
for &t in &[256usize, 512, 1024, 2048, 4096] {
let step = seq_tensor(&[b, h, 1, d], 1.0);
let t_concat = {
let start = Instant::now();
let mut concat: Option<Tensor> = None;
for _ in 0..t {
concat = Some(match concat.take() {
None => step.clone(),
Some(c) => TypedConcat { axis: 2 }
.eval(tvec![c.into(), step.clone().into()])?
.remove(0)
.into_tensor(),
});
}
start.elapsed().as_secs_f64() * 1e3
};
let (t_inplace, reallocs) = {
let start = Instant::now();
let mut cache = InPlaceKvCache::new(2);
for _ in 0..t {
cache.push(&step)?;
}
(start.elapsed().as_secs_f64() * 1e3, cache.reallocs())
};
println!(
" {t:>5} {t_concat:>9.3} {t_inplace:>10.3} {:>6.2}x {reallocs:>4}",
t_concat / t_inplace
);
}
Ok(())
}
#[test]
#[ignore]
fn bench_decode() -> TractResult<()> {
use std::time::Instant;
let (b, h, d) = (1usize, 8usize, 128usize);
let scale = 1.0 / (d as f32).sqrt();
println!("\n END-TO-END decode (update + attention, B={b} H={h} D={d}):");
println!(" T concat(ms) inplace(ms) speedup");
for &t in &[256usize, 512, 1024, 2048] {
let q = seq_tensor(&[b, h, 1, d], 7.0);
let qv = q.to_plain_array_view::<f32>()?.into_dimensionality()?;
let step = seq_tensor(&[b, h, 1, d], 1.0);
let t_concat = {
let start = Instant::now();
let mut concat: Option<Tensor> = None;
for _ in 0..t {
concat = Some(match concat.take() {
None => step.clone(),
Some(c) => TypedConcat { axis: 2 }
.eval(tvec![c.into(), step.clone().into()])?
.remove(0)
.into_tensor(),
});
let cbuf = concat.as_ref().unwrap();
let cv = cbuf.to_plain_array_view::<f32>()?.into_dimensionality()?;
std::hint::black_box(attention(qv, cv, cv, scale));
}
start.elapsed().as_secs_f64() * 1e3
};
let t_inplace = {
let start = Instant::now();
let mut cache = InPlaceKvCache::new(2);
for _ in 0..t {
cache.push(&step)?;
let kview = cache.valid_view::<f32>()?.into_dimensionality()?;
std::hint::black_box(attention(qv, kview, kview, scale));
}
start.elapsed().as_secs_f64() * 1e3
};
println!(
" {t:>5} {t_concat:>9.3} {t_inplace:>10.3} {:>6.2}x",
t_concat / t_inplace
);
}
Ok(())
}
fn drive_fused_vs_baseline(causal: bool) -> TractResult<()> {
let (bsz, hq, hkv, d) = (1usize, 4usize, 2usize, 16usize); let op = InPlaceKvSdpa { axis: 2, causal, scale: None };
let session = TurnState::default();
let mut state = op.state(&session, 0)?.unwrap();
let mut session = session;
let flash = FlashSdpaOp { causal, scale: None };
let mut kc: Option<Tensor> = None;
let mut vc: Option<Tensor> = None;
let mut snews = vec![3usize];
snews.extend(std::iter::repeat_n(1usize, 12));
for (t, &snew) in snews.iter().enumerate() {
let q = seq_tensor(&[bsz, hq, snew, d], 1.0 + t as f32);
let knew = seq_tensor(&[bsz, hkv, snew, d], 5.0 + t as f32 * 0.3);
let vnew = seq_tensor(&[bsz, hkv, snew, d], 9.0 - t as f32 * 0.2);
let o_fused = state
.eval(
&mut session,
&op,
tvec![q.clone().into(), knew.clone().into(), vnew.clone().into()],
)?
.remove(0)
.into_tensor();
kc = Some(match kc.take() {
None => knew.clone(),
Some(c) => TypedConcat { axis: 2 }
.eval(tvec![c.into(), knew.clone().into()])?
.remove(0)
.into_tensor(),
});
vc = Some(match vc.take() {
None => vnew.clone(),
Some(c) => TypedConcat { axis: 2 }
.eval(tvec![c.into(), vnew.clone().into()])?
.remove(0)
.into_tensor(),
});
let o_base = flash
.eval(tvec![q.into(), kc.clone().unwrap().into(), vc.clone().unwrap().into()])?
.remove(0)
.into_tensor();
o_fused
.close_enough(&o_base, Approximation::Approximate)
.with_context(|| format!("fused != baseline at step {t} (causal={causal})"))?;
}
Ok(())
}
#[test]
fn fused_op_matches_cache_plus_flash_noncausal() -> TractResult<()> {
drive_fused_vs_baseline(false)
}
#[test]
fn fused_op_matches_cache_plus_flash_causal() -> TractResult<()> {
drive_fused_vs_baseline(true)
}
#[test]
fn fused_op_runs_in_model() -> TractResult<()> {
let (b, hq, hkv, d) = (1usize, 4usize, 2usize, 16usize);
let mut model = TypedModel::default();
let s = model.sym("S");
let dim = |x: usize| x.to_dim();
let qshape: TVec<TDim> = tvec![dim(b), dim(hq), s.clone().into(), dim(d)];
let kshape: TVec<TDim> = tvec![dim(b), dim(hkv), s.into(), dim(d)];
let q = model.add_source("q", f32::fact(&qshape))?;
let k = model.add_source("k", f32::fact(&kshape))?;
let v = model.add_source("v", f32::fact(&kshape))?;
let o = model.wire_node(
"fused_attn",
InPlaceKvSdpa { axis: 2, causal: false, scale: None },
&[q, k, v],
)?;
model.select_output_outlets(&o)?;
let runnable = model.into_runnable()?;
let mut rt = runnable.spawn()?;
let flash = FlashSdpaOp { causal: false, scale: None };
let mut kc: Option<Tensor> = None;
let mut vc: Option<Tensor> = None;
let mut snews = vec![3usize];
snews.extend(std::iter::repeat_n(1usize, 8));
for (t, &snew) in snews.iter().enumerate() {
let q = seq_tensor(&[b, hq, snew, d], 2.0 + t as f32);
let knew = seq_tensor(&[b, hkv, snew, d], 4.0 + t as f32 * 0.4);
let vnew = seq_tensor(&[b, hkv, snew, d], 6.0 - t as f32 * 0.1);
let o_model = rt
.run(tvec![q.clone().into(), knew.clone().into(), vnew.clone().into()])?
.remove(0)
.into_tensor();
kc = Some(match kc.take() {
None => knew.clone(),
Some(c) => TypedConcat { axis: 2 }
.eval(tvec![c.into(), knew.clone().into()])?
.remove(0)
.into_tensor(),
});
vc = Some(match vc.take() {
None => vnew.clone(),
Some(c) => TypedConcat { axis: 2 }
.eval(tvec![c.into(), vnew.clone().into()])?
.remove(0)
.into_tensor(),
});
let o_base = flash
.eval(tvec![q.into(), kc.clone().unwrap().into(), vc.clone().unwrap().into()])?
.remove(0)
.into_tensor();
o_model
.close_enough(&o_base, Approximation::Approximate)
.with_context(|| format!("model-run != baseline at step {t}"))?;
}
Ok(())
}
#[test]
fn rewrite_fuses_cache_sdpa() -> TractResult<()> {
let (b, hq, hkv, d) = (1usize, 4usize, 2usize, 16usize);
let mut model = TypedModel::default();
let s = model.sym("S");
let p = model.sym("P");
let dim = |x: usize| x.to_dim();
let qf: TVec<TDim> = tvec![dim(b), dim(hq), s.clone().into(), dim(d)];
let newf: TVec<TDim> = tvec![dim(b), dim(hkv), s.into(), dim(d)];
let pastf: TVec<TDim> = tvec![dim(b), dim(hkv), p.into(), dim(d)];
let q = model.add_source("q", f32::fact(&qf))?;
let knew = model.add_source("k", f32::fact(&newf))?;
let vnew = model.add_source("v", f32::fact(&newf))?;
let mkcache = |nm: &str| DynKeyValueCache {
name: nm.to_string(),
axis: 2,
past_sequence_fact: f32::fact(&pastf),
input_sequence_fact: f32::fact(&newf),
};
let kc = model.wire_node("kc", mkcache("kc"), &[knew])?;
let vc = model.wire_node("vc", mkcache("vc"), &[vnew])?;
let o = model.wire_node(
"sdpa",
Sdpa {
scale: None,
datum_type: f32::datum_type(),
acc_datum_type: f32::datum_type(),
is_causal: false,
},
&[q, kc[0], vc[0]],
)?;
model.select_output_outlets(&o)?;
assert!(model.nodes().iter().any(|n| n.op_is::<DynKeyValueCache>()));
InPlaceKvSdpaTransform.transform(&mut model)?;
assert!(model.nodes().iter().any(|n| n.op_is::<InPlaceKvSdpa>()), "fused op present");
assert!(!model.nodes().iter().any(|n| n.op_is::<DynKeyValueCache>()), "caches removed");
assert!(!model.nodes().iter().any(|n| n.op_is::<Sdpa>()), "sdpa removed");
let fused = model.nodes().iter().find(|n| n.op_is::<InPlaceKvSdpa>()).unwrap();
assert_eq!(fused.inputs.len(), 3, "fused op takes [Q, K_new, V_new]");
let runnable = model.into_runnable()?;
let mut rt = runnable.spawn()?;
let flash = FlashSdpaOp { causal: false, scale: None };
let mut kacc: Option<Tensor> = None;
let mut vacc: Option<Tensor> = None;
let mut snews = vec![3usize];
snews.extend(std::iter::repeat_n(1usize, 6));
for (t, &snew) in snews.iter().enumerate() {
let qi = seq_tensor(&[b, hq, snew, d], 2.0 + t as f32);
let ki = seq_tensor(&[b, hkv, snew, d], 3.0 + t as f32 * 0.2);
let vi = seq_tensor(&[b, hkv, snew, d], 8.0 - t as f32 * 0.1);
let o_model = rt
.run(tvec![qi.clone().into(), ki.clone().into(), vi.clone().into()])?
.remove(0)
.into_tensor();
kacc = Some(match kacc.take() {
None => ki.clone(),
Some(c) => TypedConcat { axis: 2 }
.eval(tvec![c.into(), ki.clone().into()])?
.remove(0)
.into_tensor(),
});
vacc = Some(match vacc.take() {
None => vi.clone(),
Some(c) => TypedConcat { axis: 2 }
.eval(tvec![c.into(), vi.clone().into()])?
.remove(0)
.into_tensor(),
});
let o_base = flash
.eval(tvec![qi.into(), kacc.clone().unwrap().into(), vacc.clone().unwrap().into()])?
.remove(0)
.into_tensor();
o_model
.close_enough(&o_base, Approximation::Approximate)
.with_context(|| format!("rewritten model != baseline at step {t}"))?;
}
Ok(())
}
#[test]
#[ignore]
fn bench_fused_decode() -> TractResult<()> {
use std::time::Instant;
let (bsz, hq, hkv, d) = (1usize, 8usize, 8usize, 128usize);
let q = seq_tensor(&[bsz, hq, 1, d], 7.0);
let knew = seq_tensor(&[bsz, hkv, 1, d], 1.0);
let vnew = seq_tensor(&[bsz, hkv, 1, d], 2.0);
println!(
"\n INTEGRATED decode via op (fused InPlaceKvSdpa vs concat-cache + FlashSdpaOp):"
);
println!(" T baseline(ms) fused(ms) speedup");
for &steps in &[256usize, 512, 1024, 2048] {
let op = InPlaceKvSdpa { axis: 2, causal: false, scale: None };
let session = TurnState::default();
let mut state = op.state(&session, 0)?.unwrap();
let mut session = session;
let t_fused = {
let start = Instant::now();
for _ in 0..steps {
std::hint::black_box(state.eval(
&mut session,
&op,
tvec![q.clone().into(), knew.clone().into(), vnew.clone().into()],
)?);
}
start.elapsed().as_secs_f64() * 1e3
};
let flash = FlashSdpaOp { causal: false, scale: None };
let t_base = {
let mut kc: Option<Tensor> = None;
let mut vc: Option<Tensor> = None;
let start = Instant::now();
for _ in 0..steps {
kc = Some(match kc.take() {
None => knew.clone(),
Some(c) => TypedConcat { axis: 2 }
.eval(tvec![c.into(), knew.clone().into()])?
.remove(0)
.into_tensor(),
});
vc = Some(match vc.take() {
None => vnew.clone(),
Some(c) => TypedConcat { axis: 2 }
.eval(tvec![c.into(), vnew.clone().into()])?
.remove(0)
.into_tensor(),
});
std::hint::black_box(flash.eval(tvec![
q.clone().into(),
kc.clone().unwrap().into(),
vc.clone().unwrap().into()
])?);
}
start.elapsed().as_secs_f64() * 1e3
};
println!(" {steps:>5} {t_base:>11.3} {t_fused:>9.3} {:>6.2}x", t_base / t_fused);
}
Ok(())
}
fn decode_inputs(n_decode: usize) -> Vec<(Tensor, Tensor, Tensor)> {
let (b, hq, hkv, d) = (1usize, 4usize, 2usize, 16usize);
let mut shapes = vec![3usize];
shapes.extend(std::iter::repeat_n(1usize, n_decode));
shapes
.iter()
.enumerate()
.map(|(t, &snew)| {
(
seq_tensor(&[b, hq, snew, d], 1.0 + t as f32),
seq_tensor(&[b, hkv, snew, d], 5.0 + t as f32 * 0.3),
seq_tensor(&[b, hkv, snew, d], 9.0 - t as f32 * 0.2),
)
})
.collect()
}
#[test]
fn resume_via_freeze_unfreeze() -> TractResult<()> {
let op = InPlaceKvSdpa { axis: 2, causal: true, scale: None };
let mut session = TurnState::default();
let mut straight = op.state(&session, 0)?.unwrap();
let mut split = op.state(&session, 0)?.unwrap();
for (t, (q, k, v)) in decode_inputs(9).into_iter().enumerate() {
let ins = tvec![q.into(), k.into(), v.into()];
let os = straight.eval(&mut session, &op, ins.clone())?.remove(0).into_tensor();
if t == 5 {
let frozen = split.freeze();
split = frozen.unfreeze();
}
let op2 = split.eval(&mut session, &op, ins)?.remove(0).into_tensor();
os.close_enough(&op2, Approximation::Exact)
.with_context(|| format!("freeze/unfreeze resume mismatch at step {t}"))?;
}
Ok(())
}
#[test]
fn resume_via_save_load() -> TractResult<()> {
let op = InPlaceKvSdpa { axis: 2, causal: true, scale: None };
let mut session = TurnState::default();
let mut straight = op.state(&session, 0)?.unwrap();
let mut split = op.state(&session, 0)?.unwrap();
for (t, (q, k, v)) in decode_inputs(9).into_iter().enumerate() {
let ins = tvec![q.into(), k.into(), v.into()];
let os = straight.eval(&mut session, &op, ins.clone())?.remove(0).into_tensor();
if t == 5 {
let mut saved = vec![];
split.save_to(&mut saved)?;
ensure!(saved.len() == 2, "save_to should emit [K, V]");
let mut fresh = op.state(&session, 0)?.unwrap();
fresh.load_from(&mut session, &mut saved.into_iter())?;
split = fresh;
}
let op2 = split.eval(&mut session, &op, ins)?.remove(0).into_tensor();
os.close_enough(&op2, Approximation::Exact)
.with_context(|| format!("save/load resume mismatch at step {t}"))?;
}
Ok(())
}
#[test]
#[ignore]
fn bench_resume_snapshot() -> TractResult<()> {
use std::time::Instant;
let (b, hq, hkv, d) = (1usize, 8usize, 8usize, 128usize);
let op = InPlaceKvSdpa { axis: 2, causal: false, scale: None };
let session = TurnState::default();
let q = seq_tensor(&[b, hq, 1, d], 7.0);
let kv = seq_tensor(&[b, hkv, 1, d], 1.0);
println!("\n resume checkpoint (save_to) — one-time O(len):");
println!(" len save_to(ms)");
for &len in &[256usize, 1024, 4096] {
let mut sess = TurnState::default();
let mut state = op.state(&session, 0)?.unwrap();
for _ in 0..len {
state.eval(
&mut sess,
&op,
tvec![q.clone().into(), kv.clone().into(), kv.clone().into()],
)?;
}
let reps = 100;
let start = Instant::now();
for _ in 0..reps {
let mut saved = vec![];
state.save_to(&mut saved)?;
std::hint::black_box(saved);
}
println!(" {len:>5} {:>10.4}", start.elapsed().as_secs_f64() * 1e3 / reps as f64);
}
Ok(())
}
}