use crate::registry::{
BackwardKind, FusedOpEntry, FusedOpFamily, FusedOpParams, FusedOps, PatternMatch,
SubgraphPattern, decompose_via_recipe,
};
use crate::{Graph, NodeId, Op};
use fuel_ir::{DType, Shape};
use fuel_kernel_seam_types::shape_expr::{Dim, LAST, ShapeExpr};
use fuel_kernel_seam_types::{OpAttrs, OpTag, PatternNode};
use std::collections::HashMap;
use std::sync::OnceLock;
pub fn entry() -> FusedOpEntry {
FusedOpEntry {
destructive_input: None,
id: FusedOps::FUSED_LINEAR,
name: "FusedLinear",
family: FusedOpFamily::Forward,
pattern: SubgraphPattern::Callable(canonical_pattern),
decompose,
backward: BackwardKind::NotDifferentiable,
shape_rule: matmul_output_shape,
dtype_rule: dtype_passthrough,
output_views: None,
}
}
fn matmul_output_shape(input_shapes: &[Shape], _params: &FusedOpParams) -> Shape {
debug_assert_eq!(
input_shapes.len(),
3,
"FusedLinear takes 3 inputs (a, b, bias)"
);
let a = &input_shapes[0];
let b = &input_shapes[1];
let a_rank = a.rank();
let b_rank = b.rank();
debug_assert!(a_rank >= 2 && b_rank >= 2);
debug_assert_eq!(a_rank, b_rank, "FusedLinear: a/b ranks must match");
let mut dims: Vec<usize> = a.dims()[..a_rank - 2].to_vec();
dims.push(a.dims()[a_rank - 2]); dims.push(b.dims()[b_rank - 1]); Shape::from_dims(&dims)
}
fn dtype_passthrough(input_dtypes: &[DType], _params: &FusedOpParams) -> DType {
debug_assert_eq!(input_dtypes.len(), 3, "FusedLinear takes 3 inputs");
input_dtypes[0]
}
fn recipe() -> &'static PatternNode {
static RECIPE: OnceLock<PatternNode> = OnceLock::new();
RECIPE.get_or_init(|| {
let op = |op, attrs, operands| PatternNode::Op {
op,
attrs,
operands,
};
let a = || PatternNode::Bind { index: 0 };
let b = || PatternNode::Bind { index: 1 };
let bias = || PatternNode::Bind { index: 2 };
let bias_target = OpAttrs {
target_shape_rel: Some(ShapeExpr::WithDim {
operand: 0,
axis: LAST,
dim: Box::new(Dim::Extent {
operand: 1,
axis: LAST,
}),
}),
..OpAttrs::default()
};
op(
OpTag::Add,
OpAttrs::default(),
vec![
op(OpTag::MatMul, OpAttrs::default(), vec![a(), b()]),
op(OpTag::BroadcastTo, bias_target, vec![bias()]),
],
)
})
}
fn scalars(params: &FusedOpParams) -> Option<Vec<f64>> {
match params {
FusedOpParams::FusedLinear => Some(Vec::new()),
_ => None,
}
}
pub fn decompose(graph: &mut Graph, id: NodeId, params: &FusedOpParams) -> NodeId {
decompose_via_recipe(graph, id, recipe(), scalars(params))
}
pub fn canonical_pattern(graph: &Graph, add_id: NodeId) -> Option<PatternMatch> {
let add = graph.node(add_id);
if !matches!(add.op, Op::Add) {
return None;
}
if add.inputs.len() != 2 {
return None;
}
let mm_id = add.inputs[0];
let rhs_id = add.inputs[1];
let mm = graph.node(mm_id);
if !matches!(mm.op, Op::MatMul) {
return None;
}
if mm.inputs.len() != 2 {
return None;
}
let a_id = mm.inputs[0];
let b_id = mm.inputs[1];
let mm_dims = mm.shape.dims();
if mm_dims.is_empty() {
return None;
}
let last_dim = mm_dims[mm_dims.len() - 1];
let rhs = graph.node(rhs_id);
let bcst_operand = if matches!(rhs.op, Op::BroadcastTo(_)) && rhs.inputs.len() == 1 {
rhs.inputs[0]
} else {
rhs_id
};
let mut pad_reshape_id: Option<NodeId> = None;
let bias_src_id = {
let n = graph.node(bcst_operand);
if matches!(n.op, Op::Reshape(_)) && n.inputs.len() == 1 {
let inner = n.inputs[0];
let inner_dims = graph.node(inner).shape.dims();
let pad_dims = n.shape.dims();
let is_leading_1_pad = inner_dims.len() == 1
&& !pad_dims.is_empty()
&& pad_dims[pad_dims.len() - 1] == inner_dims[0]
&& pad_dims[..pad_dims.len() - 1].iter().all(|&d| d == 1);
if is_leading_1_pad {
pad_reshape_id = Some(bcst_operand);
inner
} else {
bcst_operand
}
} else {
bcst_operand
}
};
let bias_dims = graph.node(bias_src_id).shape.dims();
if bias_dims.len() != 1 || bias_dims[0] != last_dim {
return None;
}
let consumer_counts = count_consumers(graph);
if consumer_counts.get(&mm_id).copied().unwrap_or(0) != 1 {
return None;
}
if let Some(pad) = pad_reshape_id
&& consumer_counts.get(&pad).copied().unwrap_or(0) != 1
{
return None;
}
Some(PatternMatch {
bindings: vec![(0, a_id), (1, b_id), (2, bias_src_id)],
params: FusedOpParams::FusedLinear,
})
}
fn count_consumers(graph: &Graph) -> HashMap<NodeId, usize> {
let mut counts: HashMap<NodeId, usize> = HashMap::new();
let n = graph.len();
for nid in 0..n {
let node = graph.node(NodeId(nid));
for &input in &node.inputs {
*counts.entry(input).or_insert(0) += 1;
}
}
counts
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Node;
fn fused_node(
g: &mut Graph,
a_dims: &[usize],
b_dims: &[usize],
) -> (NodeId, NodeId, NodeId, NodeId) {
let ar = a_dims.len();
let n = b_dims[b_dims.len() - 1];
let mut out_dims = a_dims[..ar - 2].to_vec();
out_dims.push(a_dims[ar - 2]);
out_dims.push(n);
let a = g.push(Node {
op: Op::Const,
inputs: vec![],
shape: Shape::from_dims(a_dims),
dtype: DType::F32,
});
let b = g.push(Node {
op: Op::Const,
inputs: vec![],
shape: Shape::from_dims(b_dims),
dtype: DType::F32,
});
let bias = g.push(Node {
op: Op::Const,
inputs: vec![],
shape: Shape::from_dims(&[n]),
dtype: DType::F32,
});
let fused = g.push(Node {
op: Op::Fused(FusedOps::FUSED_LINEAR, FusedOpParams::FusedLinear),
inputs: vec![a, b, bias],
shape: Shape::from_dims(&out_dims),
dtype: DType::F32,
});
(a, b, bias, fused)
}
#[test]
fn canonical_pattern_matches_the_recipe_spelling() {
for (a_dims, b_dims) in [
(vec![2usize, 3], vec![3usize, 4]), (vec![5, 2, 3], vec![5, 3, 4]), (vec![8, 16, 64], vec![8, 64, 128]), ] {
let mut g = Graph::new();
let (a, b, bias, fused) = fused_node(&mut g, &a_dims, &b_dims);
let root = decompose(&mut g, fused, &FusedOpParams::FusedLinear);
assert_ne!(root, fused, "recipe decompose fires at a={a_dims:?}");
let reachable = crate::topo_order_multi(&g, &[root]);
assert!(
reachable
.iter()
.any(|&n| matches!(g.node(n).op, Op::Reshape(_))),
"the WithDim rank-raise materializes a D4 pad Reshape at a={a_dims:?}",
);
let m = canonical_pattern(&g, root)
.expect("the recipe emission must re-fuse (see-through the D4 pad)");
assert_eq!(
m.bindings,
vec![(0, a), (1, b), (2, bias)],
"binds recovered = [a, b, bias] at a={a_dims:?}",
);
assert!(matches!(m.params, FusedOpParams::FusedLinear));
}
}
#[test]
fn canonical_pattern_still_matches_the_legacy_spelling() {
let mut g = Graph::new();
let f32_node = |op, inputs, shape| Node {
op,
inputs,
shape,
dtype: DType::F32,
};
let a = g.push(f32_node(Op::Const, vec![], Shape::from_dims(&[2, 3])));
let b = g.push(f32_node(Op::Const, vec![], Shape::from_dims(&[3, 4])));
let bias = g.push(f32_node(Op::Const, vec![], Shape::from_dims(&[4])));
let mm = g.push(f32_node(Op::MatMul, vec![a, b], Shape::from_dims(&[2, 4])));
let bcst = g.push(f32_node(
Op::BroadcastTo(Shape::from_dims(&[2, 4])),
vec![bias],
Shape::from_dims(&[2, 4]),
));
let add = g.push(f32_node(Op::Add, vec![mm, bcst], Shape::from_dims(&[2, 4])));
let m = canonical_pattern(&g, add).expect("legacy spelling must still match");
assert_eq!(m.bindings, vec![(0, a), (1, b), (2, bias)]);
assert!(matches!(m.params, FusedOpParams::FusedLinear));
}
}