use crate::{Graph, NodeId, Op};
use std::collections::BTreeMap;
pub use fuel_kernel_seam_types::{OpAttrs, OpTag, PatternNode};
pub fn op_to_tag(op: &Op) -> Option<OpTag> {
Some(match op {
Op::Add => OpTag::Add,
Op::Sub => OpTag::Sub,
Op::Mul => OpTag::Mul,
Op::Div => OpTag::Div,
Op::Maximum => OpTag::Maximum,
Op::Minimum => OpTag::Minimum,
Op::Pow => OpTag::Pow,
Op::Rem => OpTag::Rem,
Op::Neg => OpTag::Neg,
Op::Abs => OpTag::Abs,
Op::Sqr => OpTag::Sqr,
Op::Sqrt => OpTag::Sqrt,
Op::Rsqrt => OpTag::Rsqrt,
Op::Recip => OpTag::Recip,
Op::Exp => OpTag::Exp,
Op::Log => OpTag::Log,
Op::Sin => OpTag::Sin,
Op::Cos => OpTag::Cos,
Op::Tanh => OpTag::Tanh,
Op::Sigmoid => OpTag::Sigmoid,
Op::Silu => OpTag::Silu,
Op::GeluTanh => OpTag::GeluTanh,
Op::Gelu => OpTag::Gelu,
Op::Relu => OpTag::Relu,
Op::Erf => OpTag::Erf,
Op::Step => OpTag::Step,
Op::Floor => OpTag::Floor,
Op::Ceil => OpTag::Ceil,
Op::Round => OpTag::Round,
Op::Sign => OpTag::Sign,
Op::AddScalar(_) => OpTag::AddScalar,
Op::MulScalar(_) => OpTag::MulScalar,
Op::PowI(_) => OpTag::PowI,
Op::Clamp { .. } => OpTag::Clamp,
Op::Equal => OpTag::Equal,
Op::Ne => OpTag::Ne,
Op::Lt => OpTag::Lt,
Op::Le => OpTag::Le,
Op::Gt => OpTag::Gt,
Op::Ge => OpTag::Ge,
Op::Where => OpTag::Where,
Op::MaskedFill { .. } => OpTag::MaskedFill,
Op::SumAll => OpTag::SumAll,
Op::MaxAll => OpTag::MaxAll,
Op::MinAll => OpTag::MinAll,
Op::MeanAll => OpTag::MeanAll,
Op::SumDim(_) => OpTag::SumDim,
Op::MaxDim(_) => OpTag::MaxDim,
Op::MeanDim(_) => OpTag::MeanDim,
Op::ReduceSumTo(_) => OpTag::ReduceSumTo,
Op::ReduceMaxTo(_) => OpTag::ReduceMaxTo,
Op::CumSum { .. } => OpTag::CumSum,
Op::MatMul => OpTag::MatMul,
Op::Transpose => OpTag::Transpose,
Op::Permute(_) => OpTag::Permute,
Op::Reshape(_) => OpTag::Reshape,
Op::BroadcastTo(_) => OpTag::BroadcastTo,
Op::Unsqueeze { .. } => OpTag::Unsqueeze,
Op::Squeeze { .. } => OpTag::Squeeze,
Op::Cast(_) => OpTag::Cast,
Op::Slice { .. } => OpTag::Slice,
Op::Concat { .. } => OpTag::Concat,
Op::Flip { .. } => OpTag::Flip,
Op::Roll { .. } => OpTag::Roll,
Op::Pad { .. } => OpTag::Pad,
Op::Triu { .. } => OpTag::Triu,
Op::Tril { .. } => OpTag::Tril,
Op::IndexSelect { .. } => OpTag::IndexSelect,
Op::Gather { .. } => OpTag::Gather,
Op::IndexAdd { .. } => OpTag::IndexAdd,
Op::ScatterAdd { .. } => OpTag::ScatterAdd,
Op::LogSoftmaxLastDim => OpTag::LogSoftmaxLastDim,
Op::Iota { .. } => OpTag::Iota,
_ => return None,
})
}
fn is_commutative(op: OpTag) -> bool {
matches!(op, OpTag::Add | OpTag::Mul)
}
fn op_to_attrs(op: &Op) -> OpAttrs {
let mut a = OpAttrs::default();
match op {
Op::AddScalar(v) | Op::MulScalar(v) => a.scalars = vec![*v],
Op::Clamp { min, max } => a.scalars = vec![*min, *max],
Op::PowI(n) => a.scalars = vec![*n as f64],
Op::SumDim(d) | Op::MaxDim(d) | Op::MeanDim(d) => a.axis = Some(*d as i64),
Op::Triu { diagonal } | Op::Tril { diagonal } => a.axis = Some(*diagonal),
Op::Permute(axes) => a.perm = axes.iter().map(|&x| x as u8).collect(),
Op::BroadcastTo(shape) | Op::Reshape(shape) => {
a.target_shape = shape.dims().iter().map(|&d| d as i64).collect()
}
Op::Unsqueeze { dim } | Op::Squeeze { dim } => a.dims = vec![*dim as u8],
Op::Slice { dim, start, len } => {
a.axis = Some(*dim as i64);
a.slice_start = Some(*start as u64);
a.slice_len = Some(*len as u64);
}
Op::Concat { dim } | Op::Flip { dim } => a.axis = Some(*dim as i64),
Op::Roll { dim, shift } => {
a.axis = Some(*dim as i64);
a.roll_shift = Some(*shift);
}
Op::Cast(dt) => a.cast_dtype = Some(dt.as_str().to_string()),
Op::Iota { len } => a.target_shape = vec![*len as i64],
Op::Pad {
padding,
mode,
value,
} => {
a.pad_amounts = padding.iter().map(|&(b, e)| (b as u64, e as u64)).collect();
a.pad_mode = Some(match mode {
crate::PadMode::Constant => 0,
crate::PadMode::Reflect => 1,
crate::PadMode::Replicate => 2,
});
a.pad_value = Some(*value);
}
Op::MaskedFill { value } => {
a.scalars = vec![value.to_f64()];
a.cast_dtype = Some(value.dtype().as_str().to_string());
}
_ => {}
}
a
}
fn attrs_match(pattern: &OpAttrs, node: &OpAttrs) -> bool {
(pattern.scalars.is_empty() || pattern.scalars == node.scalars)
&& (pattern.axis.is_none() || pattern.axis == node.axis)
&& (pattern.perm.is_empty() || pattern.perm == node.perm)
&& (pattern.target_shape.is_empty() || pattern.target_shape == node.target_shape)
&& (pattern.dims.is_empty() || pattern.dims == node.dims)
}
pub fn match_region(
graph: &Graph,
root: NodeId,
pattern: &PatternNode,
consumers: &dyn Fn(NodeId) -> usize,
) -> Option<Vec<NodeId>> {
match_region_extract(graph, root, pattern, consumers).map(|(binds, _)| binds)
}
pub fn match_region_extract(
graph: &Graph,
root: NodeId,
pattern: &PatternNode,
consumers: &dyn Fn(NodeId) -> usize,
) -> Option<(Vec<NodeId>, Vec<f64>)> {
let mut binds: BTreeMap<u8, NodeId> = BTreeMap::new();
let mut scalars: Vec<f64> = Vec::new();
match_node(
graph,
root,
pattern,
true,
consumers,
&mut binds,
&mut scalars,
)?;
let n = binds.len() as u8;
if (0..n).all(|i| binds.contains_key(&i)) {
Some(((0..n).map(|i| binds[&i]).collect(), scalars))
} else {
None
}
}
fn match_node(
graph: &Graph,
node_id: NodeId,
pattern: &PatternNode,
is_root: bool,
consumers: &dyn Fn(NodeId) -> usize,
binds: &mut BTreeMap<u8, NodeId>,
scalars: &mut Vec<f64>,
) -> Option<()> {
match pattern {
PatternNode::Any => Some(()),
PatternNode::Bind { index } => {
match binds.get(index) {
Some(&existing) if existing != node_id => None,
_ => {
binds.insert(*index, node_id);
Some(())
}
}
}
PatternNode::SeeThrough { then } => {
match_node(graph, node_id, then, is_root, consumers, binds, scalars)
}
PatternNode::Op {
op,
operands,
attrs,
} => {
let node = graph.node(node_id);
if op_to_tag(&node.op) != Some(*op) {
return None;
}
let node_attrs = op_to_attrs(&node.op);
if !attrs_match(attrs, &node_attrs) {
return None;
}
if !is_root && consumers(node_id) != 1 {
return None;
}
let inputs = &node.inputs;
if inputs.len() != operands.len() {
return None;
}
if attrs.scalars.is_empty() && !node_attrs.scalars.is_empty() {
scalars.extend_from_slice(&node_attrs.scalars);
}
if is_commutative(*op) && operands.len() == 2 {
for (a, b) in [(0usize, 1usize), (1, 0)] {
let mut trial = binds.clone();
let mut trial_scalars = scalars.clone();
if match_node(
graph,
inputs[a],
&operands[0],
false,
consumers,
&mut trial,
&mut trial_scalars,
)
.is_some()
&& match_node(
graph,
inputs[b],
&operands[1],
false,
consumers,
&mut trial,
&mut trial_scalars,
)
.is_some()
{
*binds = trial;
*scalars = trial_scalars;
return Some(());
}
}
return None;
}
for (child_pat, &child_id) in operands.iter().zip(inputs.iter()) {
match_node(graph, child_id, child_pat, false, consumers, binds, scalars)?;
}
Some(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use fuel_ir::DType;
#[test]
fn op_to_tag_projects_functional_ops_and_skips_structural() {
assert_eq!(op_to_tag(&Op::Add), Some(OpTag::Add));
assert_eq!(op_to_tag(&Op::Gelu), Some(OpTag::Gelu));
assert_eq!(op_to_tag(&Op::GeluTanh), Some(OpTag::GeluTanh)); assert_ne!(op_to_tag(&Op::GeluTanh), op_to_tag(&Op::Gelu));
assert_eq!(op_to_tag(&Op::AddScalar(1.0)), Some(OpTag::AddScalar));
assert_eq!(op_to_tag(&Op::MatMul), Some(OpTag::MatMul));
assert_eq!(op_to_tag(&Op::ReluInplace), None);
assert_eq!(op_to_tag(&Op::Const), None);
assert_eq!(op_to_tag(&Op::Release), None);
}
#[test]
fn op_to_attrs_projects_new_first_order_params() {
use fuel_ir::DType;
let a = op_to_attrs(&Op::Cast(DType::F16));
assert_eq!(a.cast_dtype.as_deref(), Some("f16"));
let a = op_to_attrs(&Op::Slice {
dim: 2,
start: 3,
len: 5,
});
assert_eq!(
(a.axis, a.slice_start, a.slice_len),
(Some(2), Some(3), Some(5))
);
assert_eq!(op_to_attrs(&Op::Concat { dim: 1 }).axis, Some(1));
let a = op_to_attrs(&Op::Roll { dim: 0, shift: -2 });
assert_eq!((a.axis, a.roll_shift), (Some(0), Some(-2)));
assert_eq!(op_to_attrs(&Op::Flip { dim: 1 }).axis, Some(1));
let a = op_to_attrs(&Op::Pad {
padding: vec![(1, 1), (0, 2)],
mode: crate::PadMode::Constant,
value: 0.5,
});
assert_eq!(a.pad_amounts, vec![(1, 1), (0, 2)]);
assert_eq!((a.pad_mode, a.pad_value), (Some(0), Some(0.5)));
assert_eq!(op_to_attrs(&Op::Iota { len: 7 }).target_shape, vec![7]);
assert_eq!(op_to_attrs(&Op::PowI(3)).scalars, vec![3.0]);
}
#[test]
fn max_dim_projects_tag_and_axis() {
assert_eq!(op_to_tag(&Op::MaxDim(1)), Some(OpTag::MaxDim));
assert_eq!(op_to_attrs(&Op::MaxDim(2)).axis, Some(2));
}
fn consumer_counts(g: &Graph) -> std::collections::HashMap<NodeId, usize> {
let mut c = std::collections::HashMap::new();
for i in 0..g.len() {
for &inp in &g.node(NodeId(i)).inputs {
*c.entry(inp).or_insert(0) += 1;
}
}
c
}
fn leaf(g: &mut Graph, s: &fuel_ir::Shape) -> NodeId {
g.push(crate::Node {
op: Op::Const,
inputs: vec![],
shape: s.clone(),
dtype: DType::F32,
})
}
fn op1(g: &mut Graph, op: Op, x: NodeId, s: &fuel_ir::Shape) -> NodeId {
g.push(crate::Node {
op,
inputs: vec![x],
shape: s.clone(),
dtype: DType::F32,
})
}
fn op2(g: &mut Graph, op: Op, x: NodeId, y: NodeId, s: &fuel_ir::Shape) -> NodeId {
g.push(crate::Node {
op,
inputs: vec![x, y],
shape: s.clone(),
dtype: DType::F32,
})
}
fn relu_add_pattern() -> PatternNode {
PatternNode::Op {
op: OpTag::Relu,
attrs: OpAttrs::default(),
operands: vec![PatternNode::Op {
op: OpTag::Add,
attrs: OpAttrs::default(),
operands: vec![
PatternNode::Bind { index: 0 },
PatternNode::Bind { index: 1 },
],
}],
}
}
#[test]
fn match_region_binds_relu_add() {
let mut g = Graph::new();
let s = fuel_ir::Shape::from_dims(&[2]);
let a = leaf(&mut g, &s);
let b = leaf(&mut g, &s);
let sum = op2(&mut g, Op::Add, a, b, &s);
let r = op1(&mut g, Op::Relu, sum, &s);
let counts = consumer_counts(&g);
let got = match_region(&g, r, &relu_add_pattern(), &|n| {
*counts.get(&n).unwrap_or(&0)
});
assert_eq!(got, Some(vec![a, b]));
}
#[test]
fn match_region_commutative_order_independent() {
let mut g = Graph::new();
let s = fuel_ir::Shape::from_dims(&[2]);
let c = leaf(&mut g, &s);
let d = leaf(&mut g, &s);
let rd = op1(&mut g, Op::Relu, d, &s);
let prod = op2(&mut g, Op::Mul, c, rd, &s);
let pat = PatternNode::Op {
op: OpTag::Mul,
attrs: OpAttrs::default(),
operands: vec![
PatternNode::Op {
op: OpTag::Relu,
attrs: OpAttrs::default(),
operands: vec![PatternNode::Bind { index: 0 }],
},
PatternNode::Bind { index: 1 },
],
};
let counts = consumer_counts(&g);
let got = match_region(&g, prod, &pat, &|n| *counts.get(&n).unwrap_or(&0));
assert_eq!(got, Some(vec![d, c]), "bind 0 = d (under relu), bind 1 = c");
}
#[test]
fn match_region_rejects_wrong_interior_op() {
let mut g = Graph::new();
let s = fuel_ir::Shape::from_dims(&[2]);
let a = leaf(&mut g, &s);
let b = leaf(&mut g, &s);
let prod = op2(&mut g, Op::Mul, a, b, &s);
let r = op1(&mut g, Op::Relu, prod, &s);
let counts = consumer_counts(&g);
assert_eq!(
match_region(&g, r, &relu_add_pattern(), &|n| *counts
.get(&n)
.unwrap_or(&0)),
None
);
}
#[test]
fn match_region_declines_shared_interior() {
let mut g = Graph::new();
let s = fuel_ir::Shape::from_dims(&[2]);
let a = leaf(&mut g, &s);
let b = leaf(&mut g, &s);
let sum = op2(&mut g, Op::Add, a, b, &s);
let r1 = op1(&mut g, Op::Relu, sum, &s);
let _r2 = op1(&mut g, Op::Neg, sum, &s); let counts = consumer_counts(&g);
assert_eq!(
match_region(&g, r1, &relu_add_pattern(), &|n| *counts
.get(&n)
.unwrap_or(&0)),
None
);
}
fn permute_pattern(perm: &[u8]) -> PatternNode {
PatternNode::Op {
op: OpTag::Permute,
attrs: OpAttrs {
perm: perm.to_vec(),
..OpAttrs::default()
},
operands: vec![PatternNode::Bind { index: 0 }],
}
}
#[test]
fn match_node_discriminates_on_perm_attr() {
let mut g = Graph::new();
let s = fuel_ir::Shape::from_dims(&[2, 3]);
let x = leaf(&mut g, &s);
let p = op1(
&mut g,
Op::Permute(vec![1, 0]),
x,
&fuel_ir::Shape::from_dims(&[3, 2]),
);
let counts = consumer_counts(&g);
let cf = |n: NodeId| *counts.get(&n).unwrap_or(&0);
assert_eq!(
match_region(&g, p, &permute_pattern(&[1, 0]), &cf),
Some(vec![x]),
"perm=[1,0] pattern must match a [1,0] graph node",
);
assert_eq!(
match_region(&g, p, &permute_pattern(&[0, 2, 1]), &cf),
None,
"perm=[0,2,1] pattern must NOT match a [1,0] graph node (attr discrimination)",
);
assert_eq!(
match_region(&g, p, &permute_pattern(&[]), &cf),
Some(vec![x]),
"empty-perm (wildcard) pattern must still match (no regression)",
);
}
#[test]
fn match_region_extract_reads_live_scalars_in_pattern_pre_order() {
let mut g = Graph::new();
let s = fuel_ir::Shape::from_dims(&[2]);
let x = leaf(&mut g, &s);
let ms = op1(&mut g, Op::MulScalar(2.5), x, &s);
let asn = op1(&mut g, Op::AddScalar(0.5), ms, &s);
let counts = consumer_counts(&g);
let cf = |n: NodeId| *counts.get(&n).unwrap_or(&0);
let pat = PatternNode::Op {
op: OpTag::AddScalar,
attrs: OpAttrs::default(),
operands: vec![PatternNode::Op {
op: OpTag::MulScalar,
attrs: OpAttrs::default(),
operands: vec![PatternNode::Bind { index: 0 }],
}],
};
let (binds, scalars) = match_region_extract(&g, asn, &pat, &cf).expect("matches");
assert_eq!(binds, vec![x]);
assert_eq!(scalars, vec![0.5, 2.5], "live values in pattern pre-order");
}
#[test]
fn baked_scalar_is_a_pattern_constant_not_a_slot() {
let mut g = Graph::new();
let s = fuel_ir::Shape::from_dims(&[2]);
let x = leaf(&mut g, &s);
let ms = op1(&mut g, Op::MulScalar(2.5), x, &s);
let counts = consumer_counts(&g);
let cf = |n: NodeId| *counts.get(&n).unwrap_or(&0);
let baked = |v: f64| PatternNode::Op {
op: OpTag::MulScalar,
attrs: OpAttrs {
scalars: vec![v],
..OpAttrs::default()
},
operands: vec![PatternNode::Bind { index: 0 }],
};
let (binds, scalars) = match_region_extract(&g, ms, &baked(2.5), &cf).expect("matches");
assert_eq!(binds, vec![x]);
assert!(
scalars.is_empty(),
"baked value is a constant of the pattern, not a slot"
);
assert!(match_region_extract(&g, ms, &baked(3.0), &cf).is_none());
}
#[derive(PartialEq)]
enum Soundness {
Projected,
Lossy,
DeadField,
}
struct Collapse {
op: Op,
field: &'static str,
soundness: Soundness,
is_set: fn(&OpAttrs) -> bool,
}
fn axis(a: &OpAttrs) -> bool {
a.axis.is_some()
}
fn keepdim(a: &OpAttrs) -> bool {
a.keepdim.is_some()
}
fn dtype(a: &OpAttrs) -> bool {
a.cast_dtype.is_some()
}
fn pad() -> Op {
Op::Pad {
padding: vec![(1, 1)],
mode: crate::PadMode::Constant,
value: 0.0,
}
}
fn slice() -> Op {
Op::Slice {
dim: 1,
start: 0,
len: 2,
}
}
fn row(
op: Op,
field: &'static str,
soundness: Soundness,
is_set: fn(&OpAttrs) -> bool,
) -> Collapse {
Collapse {
op,
field,
soundness,
is_set,
}
}
fn collapse_table() -> Vec<Collapse> {
use Soundness::{DeadField, Lossy, Projected};
vec![
row(slice(), "axis", Projected, axis),
row(slice(), "slice_start", Projected, |a| {
a.slice_start.is_some()
}),
row(slice(), "slice_len", Projected, |a| a.slice_len.is_some()),
row(Op::Roll { dim: 0, shift: 1 }, "axis", Projected, axis),
row(
Op::Roll { dim: 0, shift: 1 },
"roll_shift",
Projected,
|a| a.roll_shift.is_some(),
),
row(Op::Cast(DType::F32), "cast_dtype", Projected, dtype),
row(pad(), "pad_mode", Projected, |a| a.pad_mode.is_some()),
row(pad(), "pad_value", Projected, |a| a.pad_value.is_some()),
row(
Op::MaskedFill {
value: crate::Scalar::F32(0.0),
},
"cast_dtype",
Projected,
dtype,
),
row(Op::Concat { dim: 0 }, "axis", Projected, axis),
row(Op::Flip { dim: 0 }, "axis", Projected, axis),
row(Op::Triu { diagonal: 0 }, "axis", Projected, axis),
row(Op::Tril { diagonal: 0 }, "axis", Projected, axis),
row(Op::SumDim(0), "axis", Projected, axis),
row(Op::MaxDim(0), "axis", Projected, axis),
row(Op::MeanDim(0), "axis", Projected, axis),
row(Op::IndexSelect { dim: 1 }, "axis", Lossy, axis),
row(Op::Gather { dim: 1 }, "axis", Lossy, axis),
row(Op::IndexAdd { dim: 1 }, "axis", Lossy, axis),
row(Op::ScatterAdd { dim: 1 }, "axis", Lossy, axis),
row(Op::CumSum { dim: 1 }, "axis", Lossy, axis),
row(Op::SumDim(0), "keepdim", DeadField, keepdim),
row(Op::MaxDim(0), "keepdim", DeadField, keepdim),
row(Op::MeanDim(0), "keepdim", DeadField, keepdim),
row(Op::CumSum { dim: 1 }, "keepdim", DeadField, keepdim),
]
}
#[test]
fn collapse_table_rows_all_reach_a_serializer_arm() {
for c in collapse_table() {
assert!(
op_to_tag(&c.op).is_some(),
"row ({:?}, {}) does not project to any OpTag, so it exercises \
no to_canonical_bytes arm and asserts nothing",
c.op,
c.field
);
}
}
#[test]
fn canonical_bytes_projected_fields_are_actually_set() {
for c in collapse_table()
.iter()
.filter(|c| c.soundness == Soundness::Projected)
{
let a = op_to_attrs(&c.op);
assert!(
(c.is_set)(&a),
"REGRESSION: op_to_attrs no longer sets `{}` for {:?}, so \
to_canonical_bytes now emits its unwrap_or default and this \
op collides with every other op on that arm (GAP-287)",
c.field,
c.op
);
}
}
#[test]
fn canonical_bytes_lossy_collapses_are_pinned_gap287() {
for c in collapse_table()
.iter()
.filter(|c| c.soundness == Soundness::Lossy)
{
let a = op_to_attrs(&c.op);
assert!(
!(c.is_set)(&a),
"`{}` is now SET for {:?} -- the GAP-287 collapse was fixed. \
That is good news: move this row to `Soundness::Projected` and \
update the GAP-287 row and the to_canonical_bytes doc.",
c.field,
c.op
);
}
}
#[test]
fn keepdim_is_a_dead_field_not_a_lossy_collapse_gap287() {
for c in collapse_table()
.iter()
.filter(|c| c.soundness == Soundness::DeadField)
{
let a = op_to_attrs(&c.op);
assert!(
!(c.is_set)(&a),
"`{}` is now SET for {:?}. If a keepdim-varying reduce was added under this tag, unwrap_or(false) is no longer correct and this row moves to Soundness::Lossy.",
c.field,
c.op
);
assert!(
!matches!(
op_to_tag(&c.op),
Some(OpTag::ReduceSumTo) | Some(OpTag::ReduceMaxTo)
),
"{:?} now projects to a shape-target reduce tag, which is where keepdim=true lives -- the arm assumption has changed",
c.op
);
}
}
}