use crate::registry::{
BackwardKind, FusedOpEntry, FusedOpFamily, FusedOpParams, FusedOps, PatternMatch,
SubgraphPattern, decompose_via_recipe,
};
use crate::{Graph, NodeId};
use fuel_ir::storage::OutputViewSpec;
use fuel_ir::{DType, Layout, Shape};
use fuel_kernel_seam_types::shape_expr::{Dim, ShapeExpr};
use fuel_kernel_seam_types::{OpAttrs, OpTag, PatternNode, SCAN_ROLE_CARRY, SCAN_ROLE_ELEM};
use std::sync::Arc;
pub fn entry() -> FusedOpEntry {
FusedOpEntry {
destructive_input: None,
id: FusedOps::SSD_CHUNK_SCAN,
name: "SsdChunkScan",
family: FusedOpFamily::Forward,
pattern: SubgraphPattern::Callable(canonical_pattern),
decompose,
backward: BackwardKind::Decompose,
shape_rule,
dtype_rule,
output_views: Some(output_views),
}
}
fn shape_rule(input_shapes: &[Shape], _params: &FusedOpParams) -> Shape {
debug_assert_eq!(
input_shapes.len(),
5,
"SsdChunkScan takes 5 inputs (x, dt, a, b, c)",
);
input_shapes[0].clone()
}
fn output_views(
input_shapes: &[Shape],
input_dtypes: &[DType],
_params: &FusedOpParams,
) -> Vec<OutputViewSpec> {
debug_assert_eq!(
input_shapes.len(),
5,
"SsdChunkScan output_views: takes 5 inputs (x, dt, a, b, c)",
);
debug_assert_eq!(
input_dtypes.len(),
5,
"SsdChunkScan output_views: takes 5 input dtypes",
);
let x_dims = input_shapes[0].dims();
let b_dims = input_shapes[3].dims();
debug_assert!(
x_dims.len() == 4 && b_dims.len() == 4,
"SsdChunkScan output_views: x rank=4, b rank=4 expected",
);
let batch = x_dims[0];
let seqlen = x_dims[1];
let heads = x_dims[2];
let head_dim = x_dims[3];
let state_dim = b_dims[3];
let dtype = input_dtypes[0];
let y_shape = Shape::from_dims(&[batch, seqlen, heads, head_dim]);
let last_state_shape = Shape::from_dims(&[batch, heads, head_dim, state_dim]);
vec![
OutputViewSpec {
dtype,
shape: y_shape.clone(),
layout: Layout::contiguous(y_shape),
name: Some("y"),
},
OutputViewSpec {
dtype,
shape: last_state_shape.clone(),
layout: Layout::contiguous(last_state_shape),
name: Some("last_state"),
},
]
}
fn dtype_rule(input_dtypes: &[DType], _params: &FusedOpParams) -> DType {
debug_assert_eq!(input_dtypes.len(), 5, "SsdChunkScan takes 5 inputs",);
input_dtypes[0]
}
fn recipe(seqlen: usize) -> PatternNode {
use OpTag as T;
let ext = |operand: u8, axis: u8| Dim::Extent { operand, axis };
let one = || Dim::Const(1);
let batch = || ext(0, 0);
let heads = || ext(0, 2);
let head_dim = || ext(0, 3);
let state_dim = || ext(3, 3);
let dims = |ds: Vec<Dim>| OpAttrs {
target_shape_rel: Some(ShapeExpr::Dims(ds)),
..OpAttrs::default()
};
let op = |op, attrs, operands| PatternNode::Op {
op,
attrs,
operands,
};
let bind = |i| PatternNode::Bind { index: i };
let carry = || vec![batch(), heads(), head_dim(), state_dim()]; let elem_x = || vec![batch(), heads(), head_dim()]; let elem_dt = || vec![batch(), heads()]; let elem_state = || vec![batch(), heads(), state_dim()]; let reshape = |src, ds: Vec<Dim>| op(T::Reshape, dims(ds), vec![src]);
let bcast = |src, ds: Vec<Dim>| op(T::BroadcastTo, dims(ds), vec![src]);
let bc_dt = |src| bcast(reshape(src, vec![batch(), heads(), one(), one()]), carry());
let bc_x = |src| {
bcast(
reshape(src, vec![batch(), heads(), head_dim(), one()]),
carry(),
)
};
let bc_state = |src| {
bcast(
reshape(src, vec![batch(), heads(), one(), state_dim()]),
carry(),
)
};
let bc_a = || {
bcast(
reshape(bind(2), vec![one(), heads(), one(), one()]),
carry(),
)
};
let permute4 = |src| {
op(
T::Permute,
OpAttrs {
perm: vec![1, 0, 2, 3],
..OpAttrs::default()
},
vec![src],
)
};
let permute3 = |src| {
op(
T::Permute,
OpAttrs {
perm: vec![1, 0, 2],
..OpAttrs::default()
},
vec![src],
)
};
let ph = |role: u8, index: u32, shape: Vec<Dim>| PatternNode::Op {
op: T::ScanPlaceholder,
attrs: OpAttrs {
scan_role: Some(role),
scan_index: Some(index),
target_shape_rel: Some(ShapeExpr::Dims(shape)),
..OpAttrs::default()
},
operands: vec![],
};
let x_ser = permute4(bind(0));
let dt_ser = permute3(bind(1));
let b_ser = permute4(bind(3));
let c_ser = permute4(bind(4));
let init_carry = op(
T::MulScalar,
OpAttrs {
scalars: vec![0.0],
..OpAttrs::default()
},
vec![bc_a()],
);
let h = ph(SCAN_ROLE_CARRY, 0, carry());
let x_t = ph(SCAN_ROLE_ELEM, 0, elem_x());
let dt_t = ph(SCAN_ROLE_ELEM, 1, elem_dt());
let b_t = ph(SCAN_ROLE_ELEM, 2, elem_state());
let c_t = ph(SCAN_ROLE_ELEM, 3, elem_state());
let da = op(
T::Mul,
OpAttrs::default(),
vec![bc_dt(dt_t.clone()), bc_a()],
);
let gate = op(T::Exp, OpAttrs::default(), vec![da]);
let db = op(T::Mul, OpAttrs::default(), vec![bc_dt(dt_t), bc_state(b_t)]);
let dbx = op(T::Mul, OpAttrs::default(), vec![db, bc_x(x_t)]);
let gh = op(T::Mul, OpAttrs::default(), vec![gate, h]);
let h_new = op(T::Add, OpAttrs::default(), vec![gh, dbx]);
let hc = op(
T::Mul,
OpAttrs::default(),
vec![h_new.clone(), bc_state(c_t)],
);
let y_keep = op(
T::ReduceSumTo,
dims(vec![batch(), heads(), head_dim(), one()]),
vec![hc],
);
let y_t = op(T::Reshape, dims(elem_x()), vec![y_keep]);
let scan = PatternNode::Op {
op: T::Scan,
attrs: OpAttrs {
scan_n_carries: Some(1),
scan_n_xs: Some(4),
scan_bound: Some(seqlen as u32),
scan_emit: Some(0), scan_early_exit: None,
..OpAttrs::default()
},
operands: vec![init_carry, x_ser, dt_ser, b_ser, c_ser, bind(2), h_new, y_t],
};
let ys_raw = op(
T::View,
OpAttrs {
view_slot: Some(0),
..OpAttrs::default()
},
vec![scan],
);
op(
T::Permute,
OpAttrs {
perm: vec![1, 0, 2, 3],
..OpAttrs::default()
},
vec![ys_raw],
)
}
pub fn decompose(graph: &mut Graph, id: NodeId, params: &FusedOpParams) -> NodeId {
match params {
FusedOpParams::SsdChunkScan { .. } => {}
_ => return id,
}
let (x_shape, dt_shape, a_shape, b_shape, c_shape, dtype) = {
let n = graph.node(id);
if n.inputs.len() != 5 {
return id;
}
(
graph.node(n.inputs[0]).shape.clone(),
graph.node(n.inputs[1]).shape.clone(),
graph.node(n.inputs[2]).shape.clone(),
graph.node(n.inputs[3]).shape.clone(),
graph.node(n.inputs[4]).shape.clone(),
n.dtype,
)
};
let x_dims = x_shape.dims();
let b_dims = b_shape.dims();
if x_dims.len() != 4 || b_dims.len() != 4 {
return id;
}
let seqlen = x_dims[1];
let recipe_node = recipe(seqlen);
let root = decompose_via_recipe(graph, id, &recipe_node, Some(Vec::new()));
if root == id {
return id;
}
let input_shapes = [x_shape, dt_shape, a_shape, b_shape, c_shape];
let input_dtypes = [dtype, dtype, dtype, dtype, dtype];
let ss_specs = output_views(&input_shapes, &input_dtypes, params);
if let Ok((_bytes, views)) = fuel_ir::storage::compose_bundle(&ss_specs) {
let _ = graph.set_output_views(root, Arc::from(views.into_boxed_slice()));
}
root
}
pub fn canonical_pattern(_graph: &Graph, _root: NodeId) -> Option<PatternMatch> {
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::FusedOps;
use crate::{Node, Op, ScanEmit, ScanRole};
fn frozen_legacy_decompose(graph: &mut Graph, id: NodeId, params: &FusedOpParams) -> NodeId {
match params {
FusedOpParams::SsdChunkScan { .. } => {}
_ => return id,
}
let (x_id, dt_id, a_id, b_id, c_id, x_shape, dt_shape, a_shape, b_shape, c_shape, dtype) = {
let n = graph.node(id);
if n.inputs.len() != 5 {
return id;
}
let x_shape = graph.node(n.inputs[0]).shape.clone();
let dt_shape = graph.node(n.inputs[1]).shape.clone();
let a_shape = graph.node(n.inputs[2]).shape.clone();
let b_shape = graph.node(n.inputs[3]).shape.clone();
let c_shape = graph.node(n.inputs[4]).shape.clone();
(
n.inputs[0],
n.inputs[1],
n.inputs[2],
n.inputs[3],
n.inputs[4],
x_shape,
dt_shape,
a_shape,
b_shape,
c_shape,
n.dtype,
)
};
let x_dims = x_shape.dims();
let b_dims = b_shape.dims();
if x_dims.len() != 4 || b_dims.len() != 4 {
return id;
}
let batch = x_dims[0];
let seqlen = x_dims[1];
let heads = x_dims[2];
let head_dim = x_dims[3];
let state_dim = b_dims[3];
let carry_shape = Shape::from_dims(&[batch, heads, head_dim, state_dim]);
let elem_x = Shape::from_dims(&[batch, heads, head_dim]);
let elem_dt = Shape::from_dims(&[batch, heads]);
let elem_state = Shape::from_dims(&[batch, heads, state_dim]);
let bc_dt = |graph: &mut Graph, src: NodeId| -> NodeId {
let mid = Shape::from_dims(&[batch, heads, 1, 1]);
let full = Shape::from_dims(&[batch, heads, head_dim, state_dim]);
let re = graph.push(Node {
op: Op::Reshape(mid.clone()),
inputs: vec![src],
shape: mid,
dtype,
});
graph.push(Node {
op: Op::BroadcastTo(full.clone()),
inputs: vec![re],
shape: full,
dtype,
})
};
let bc_x = |graph: &mut Graph, src: NodeId| -> NodeId {
let mid = Shape::from_dims(&[batch, heads, head_dim, 1]);
let full = Shape::from_dims(&[batch, heads, head_dim, state_dim]);
let re = graph.push(Node {
op: Op::Reshape(mid.clone()),
inputs: vec![src],
shape: mid,
dtype,
});
graph.push(Node {
op: Op::BroadcastTo(full.clone()),
inputs: vec![re],
shape: full,
dtype,
})
};
let bc_state = |graph: &mut Graph, src: NodeId| -> NodeId {
let mid = Shape::from_dims(&[batch, heads, 1, state_dim]);
let full = Shape::from_dims(&[batch, heads, head_dim, state_dim]);
let re = graph.push(Node {
op: Op::Reshape(mid.clone()),
inputs: vec![src],
shape: mid,
dtype,
});
graph.push(Node {
op: Op::BroadcastTo(full.clone()),
inputs: vec![re],
shape: full,
dtype,
})
};
let bc_a = |graph: &mut Graph, src: NodeId| -> NodeId {
let mid = Shape::from_dims(&[1, heads, 1, 1]);
let full = Shape::from_dims(&[batch, heads, head_dim, state_dim]);
let re = graph.push(Node {
op: Op::Reshape(mid.clone()),
inputs: vec![src],
shape: mid,
dtype,
});
graph.push(Node {
op: Op::BroadcastTo(full.clone()),
inputs: vec![re],
shape: full,
dtype,
})
};
let x_ser_shape = Shape::from_dims(&[seqlen, batch, heads, head_dim]);
let dt_ser_shape = Shape::from_dims(&[seqlen, batch, heads]);
let bc_ser_shape = Shape::from_dims(&[seqlen, batch, heads, state_dim]);
let x_ser = graph.push(Node {
op: Op::Permute(vec![1, 0, 2, 3]),
inputs: vec![x_id],
shape: x_ser_shape.clone(),
dtype,
});
let dt_ser = graph.push(Node {
op: Op::Permute(vec![1, 0, 2]),
inputs: vec![dt_id],
shape: dt_ser_shape,
dtype,
});
let b_ser = graph.push(Node {
op: Op::Permute(vec![1, 0, 2, 3]),
inputs: vec![b_id],
shape: bc_ser_shape.clone(),
dtype,
});
let c_ser = graph.push(Node {
op: Op::Permute(vec![1, 0, 2, 3]),
inputs: vec![c_id],
shape: bc_ser_shape,
dtype,
});
let a_bc0 = bc_a(graph, a_id);
let init_carry = graph.push(Node {
op: Op::MulScalar(0.0),
inputs: vec![a_bc0],
shape: carry_shape.clone(),
dtype,
});
let h = graph.push(Node {
op: Op::ScanPlaceholder {
role: ScanRole::Carry,
index: 0,
},
inputs: vec![],
shape: carry_shape.clone(),
dtype,
});
let x_t = graph.push(Node {
op: Op::ScanPlaceholder {
role: ScanRole::Elem,
index: 0,
},
inputs: vec![],
shape: elem_x.clone(),
dtype,
});
let dt_t = graph.push(Node {
op: Op::ScanPlaceholder {
role: ScanRole::Elem,
index: 1,
},
inputs: vec![],
shape: elem_dt.clone(),
dtype,
});
let b_t = graph.push(Node {
op: Op::ScanPlaceholder {
role: ScanRole::Elem,
index: 2,
},
inputs: vec![],
shape: elem_state.clone(),
dtype,
});
let c_t = graph.push(Node {
op: Op::ScanPlaceholder {
role: ScanRole::Elem,
index: 3,
},
inputs: vec![],
shape: elem_state.clone(),
dtype,
});
let dt_g = bc_dt(graph, dt_t);
let a_g = bc_a(graph, a_id);
let da = graph.push(Node {
op: Op::Mul,
inputs: vec![dt_g, a_g],
shape: carry_shape.clone(),
dtype,
});
let gate = graph.push(Node {
op: Op::Exp,
inputs: vec![da],
shape: carry_shape.clone(),
dtype,
});
let dt_b = bc_dt(graph, dt_t);
let b_body = bc_state(graph, b_t);
let x_body = bc_x(graph, x_t);
let db = graph.push(Node {
op: Op::Mul,
inputs: vec![dt_b, b_body],
shape: carry_shape.clone(),
dtype,
});
let dbx = graph.push(Node {
op: Op::Mul,
inputs: vec![db, x_body],
shape: carry_shape.clone(),
dtype,
});
let gh = graph.push(Node {
op: Op::Mul,
inputs: vec![gate, h],
shape: carry_shape.clone(),
dtype,
});
let h_new = graph.push(Node {
op: Op::Add,
inputs: vec![gh, dbx],
shape: carry_shape.clone(),
dtype,
});
let c_body = bc_state(graph, c_t);
let hc = graph.push(Node {
op: Op::Mul,
inputs: vec![h_new, c_body],
shape: carry_shape.clone(),
dtype,
});
let y_keep_shape = Shape::from_dims(&[batch, heads, head_dim, 1]);
let y_keep = graph.push(Node {
op: Op::ReduceSumTo(y_keep_shape.clone()),
inputs: vec![hc],
shape: y_keep_shape,
dtype,
});
let y_t = graph.push(Node {
op: Op::Reshape(elem_x.clone()),
inputs: vec![y_keep],
shape: elem_x.clone(),
dtype,
});
let ys_stacked_shape = Shape::from_dims(&[seqlen, batch, heads, head_dim]);
let scan = graph.push(Node {
op: Op::Scan {
n_carries: 1,
n_xs: 4,
bound: seqlen,
emit: ScanEmit::All,
early_exit: None,
},
inputs: vec![init_carry, x_ser, dt_ser, b_ser, c_ser, a_id, h_new, y_t],
shape: ys_stacked_shape.clone(),
dtype,
});
let scan_specs = vec![
OutputViewSpec::contiguous(dtype, ys_stacked_shape.clone()),
OutputViewSpec::contiguous(dtype, carry_shape.clone()),
];
if let Ok((_bytes, views)) = fuel_ir::storage::compose_bundle(&scan_specs) {
let _ = graph.set_output_views(scan, Arc::from(views.into_boxed_slice()));
}
let ys_raw = graph.push(Node {
op: Op::View { slot: 0 },
inputs: vec![scan],
shape: ys_stacked_shape,
dtype,
});
let y_shape = Shape::from_dims(&[batch, seqlen, heads, head_dim]);
let y = graph.push(Node {
op: Op::Permute(vec![1, 0, 2, 3]),
inputs: vec![ys_raw],
shape: y_shape,
dtype,
});
let input_shapes = [x_shape, dt_shape, a_shape, b_shape, c_shape];
let input_dtypes = [dtype, dtype, dtype, dtype, dtype];
let ss_specs = output_views(&input_shapes, &input_dtypes, params);
if let Ok((_bytes, views)) = fuel_ir::storage::compose_bundle(&ss_specs) {
let _ = graph.set_output_views(y, Arc::from(views.into_boxed_slice()));
}
y
}
fn assert_structural_eq(g: &Graph, a: NodeId, b: NodeId) {
if a == b {
return;
}
let na = g.node(a);
let nb = g.node(b);
assert_eq!(na.op, nb.op, "op mismatch: {:?} vs {:?}", na.op, nb.op);
assert_eq!(
na.shape, nb.shape,
"shape mismatch at {:?}: {:?} vs {:?}",
na.op, na.shape, nb.shape
);
assert_eq!(na.dtype, nb.dtype, "dtype mismatch at {:?}", na.op);
assert_eq!(
na.inputs.len(),
nb.inputs.len(),
"arity mismatch at {:?}",
na.op
);
for (&ia, &ib) in na.inputs.iter().zip(nb.inputs.iter()) {
assert_structural_eq(g, ia, ib);
}
}
fn fused_node(
g: &mut Graph,
batch: usize,
seqlen: usize,
heads: usize,
head_dim: usize,
state_dim: usize,
) -> NodeId {
let mk = |g: &mut Graph, dims: &[usize]| {
g.push(Node {
op: Op::Const,
inputs: vec![],
shape: Shape::from_dims(dims),
dtype: DType::F32,
})
};
let x = mk(g, &[batch, seqlen, heads, head_dim]);
let dt = mk(g, &[batch, seqlen, heads]);
let a = mk(g, &[heads]);
let b = mk(g, &[batch, seqlen, heads, state_dim]);
let c = mk(g, &[batch, seqlen, heads, state_dim]);
g.push(Node {
op: Op::Fused(
FusedOps::SSD_CHUNK_SCAN,
FusedOpParams::SsdChunkScan { chunk_size: 1 },
),
inputs: vec![x, dt, a, b, c],
shape: Shape::from_dims(&[batch, seqlen, heads, head_dim]),
dtype: DType::F32,
})
}
#[test]
fn ssd_chunk_scan_recipe_decompose_is_polymorphic_and_matches_frozen_legacy() {
for (batch, seqlen, heads, head_dim, state_dim) in [(1usize, 3, 2, 2, 4), (2, 4, 1, 3, 2)] {
let params = FusedOpParams::SsdChunkScan { chunk_size: 1 };
let mut g = Graph::new();
let fused = fused_node(&mut g, batch, seqlen, heads, head_dim, state_dim);
let out_sh = g.node(fused).shape.clone();
let new_root = decompose(&mut g, fused, ¶ms);
assert_ne!(
new_root, fused,
"recipe decompose fires ([{batch},{seqlen},{heads},{head_dim},{state_dim}])"
);
assert_eq!(
g.node(new_root).shape,
out_sh,
"output y = [batch,seqlen,heads,head_dim]"
);
assert_eq!(g.node(new_root).dtype, DType::F32);
assert_eq!(
g.output_views(new_root).map(|v| v.len()),
Some(2),
"SsdChunkScan 2-slot output_views re-attached on the recipe root",
);
let legacy_root = frozen_legacy_decompose(&mut g, fused, ¶ms);
assert_structural_eq(&g, new_root, legacy_root);
}
}
#[test]
fn ssd_chunk_scan_recipe_ignores_chunk_size() {
use crate::opt::base_map_hash;
let mut g = Graph::new();
let f1 = fused_node(&mut g, 1, 4, 1, 2, 2);
let n = g.node(f1);
let inputs = n.inputs.clone();
let shape = n.shape.clone();
let f2 = g.push(Node {
op: Op::Fused(
FusedOps::SSD_CHUNK_SCAN,
FusedOpParams::SsdChunkScan { chunk_size: 4 },
),
inputs,
shape,
dtype: DType::F32,
});
let r1 = decompose(&mut g, f1, &FusedOpParams::SsdChunkScan { chunk_size: 1 });
let r2 = decompose(&mut g, f2, &FusedOpParams::SsdChunkScan { chunk_size: 4 });
assert_eq!(
base_map_hash(&g, r1),
base_map_hash(&g, r2),
"chunk_size does not change the recipe base map"
);
}
#[test]
fn ssd_chunk_scan_recipe_wrong_params_is_a_fixpoint_not_a_crash() {
let mut g = Graph::new();
let fused = fused_node(&mut g, 1, 3, 2, 2, 4);
let before = g.len();
let out = decompose(&mut g, fused, &FusedOpParams::Rope);
assert_eq!(out, fused, "wrong params => typed decline => fixpoint");
assert_eq!(g.len(), before, "declined before any emission");
}
}