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::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::SOFTMAX_LAST_DIM,
name: "SoftmaxLastDim",
family: FusedOpFamily::Forward,
pattern: SubgraphPattern::Callable(canonical_pattern),
decompose,
backward: BackwardKind::Fused(FusedOps::SOFTMAX_LAST_DIM_BACKWARD),
shape_rule: shape_passthrough,
dtype_rule: dtype_passthrough,
output_views: None,
}
}
fn shape_passthrough(input_shapes: &[Shape], _params: &FusedOpParams) -> Shape {
debug_assert_eq!(input_shapes.len(), 1, "SoftmaxLastDim takes one input");
input_shapes[0].clone()
}
fn dtype_passthrough(input_dtypes: &[DType], _params: &FusedOpParams) -> DType {
debug_assert_eq!(input_dtypes.len(), 1, "SoftmaxLastDim takes one input");
input_dtypes[0]
}
fn recipe() -> &'static PatternNode {
static RECIPE: OnceLock<PatternNode> = OnceLock::new();
RECIPE.get_or_init(|| {
let axis_last = || OpAttrs {
axis_last: true,
..OpAttrs::default()
};
let same_as_x = || OpAttrs {
target_shape_rel: Some(ShapeExpr::SameAs { operand: 0 }),
..OpAttrs::default()
};
let op = |op, attrs, operands| PatternNode::Op {
op,
attrs,
operands,
};
let x = || PatternNode::Bind { index: 0 };
let e = op(
OpTag::Exp,
OpAttrs::default(),
vec![op(
OpTag::Sub,
OpAttrs::default(),
vec![
x(),
op(
OpTag::BroadcastTo,
same_as_x(),
vec![op(
OpTag::Unsqueeze,
axis_last(),
vec![op(OpTag::MaxDim, axis_last(), vec![x()])],
)],
),
],
)],
);
op(
OpTag::Div,
OpAttrs::default(),
vec![
e.clone(),
op(
OpTag::BroadcastTo,
same_as_x(),
vec![op(
OpTag::Unsqueeze,
axis_last(),
vec![op(OpTag::SumDim, axis_last(), vec![e])],
)],
),
],
)
})
}
fn scalars(params: &FusedOpParams) -> Option<Vec<f64>> {
match params {
FusedOpParams::SoftmaxLastDim => 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, div_id: NodeId) -> Option<PatternMatch> {
legacy_spelled_pattern(graph, div_id).or_else(|| recipe_spelled_pattern(graph, div_id))
}
fn legacy_spelled_pattern(graph: &Graph, div_id: NodeId) -> Option<PatternMatch> {
let div = graph.node(div_id);
if !matches!(div.op, Op::Div) {
return None;
}
if div.inputs.len() != 2 {
return None;
}
let e_id = div.inputs[0];
let db_id = div.inputs[1];
let db = graph.node(db_id);
if !matches!(db.op, Op::BroadcastTo(_)) {
return None;
}
if db.inputs.len() != 1 {
return None;
}
let d_id = db.inputs[0];
let d = graph.node(d_id);
if !matches!(d.op, Op::ReduceSumTo(_)) {
return None;
}
if d.inputs.len() != 1 || d.inputs[0] != e_id {
return None;
}
let e = graph.node(e_id);
if !matches!(e.op, Op::Exp) {
return None;
}
if e.inputs.len() != 1 {
return None;
}
let s_id = e.inputs[0];
let s = graph.node(s_id);
if !matches!(s.op, Op::Sub) {
return None;
}
if s.inputs.len() != 2 {
return None;
}
let x_id = s.inputs[0];
let mb_id = s.inputs[1];
let mb = graph.node(mb_id);
if !matches!(mb.op, Op::BroadcastTo(_)) {
return None;
}
if mb.inputs.len() != 1 {
return None;
}
let m_id = mb.inputs[0];
let m = graph.node(m_id);
if !matches!(m.op, Op::ReduceMaxTo(_)) {
return None;
}
if m.inputs.len() != 1 || m.inputs[0] != x_id {
return None;
}
let x_shape = &graph.node(x_id).shape;
if x_shape.rank() == 0 {
return None;
}
let m_shape = &graph.node(m_id).shape;
if m_shape.rank() != x_shape.rank() {
return None;
}
let last = x_shape.rank() - 1;
for axis in 0..x_shape.rank() {
let expected = if axis == last {
1
} else {
x_shape.dims()[axis]
};
if m_shape.dims()[axis] != expected {
return None;
}
}
let intermediates_with_expected_count = [
(m_id, 1),
(mb_id, 1),
(d_id, 1),
(db_id, 1),
(s_id, 1),
(e_id, 2),
];
let consumer_counts = count_consumers(graph);
for (nid, expected) in intermediates_with_expected_count {
if consumer_counts.get(&nid).copied().unwrap_or(0) != expected {
return None;
}
}
Some(PatternMatch {
bindings: vec![(0, x_id)],
params: FusedOpParams::SoftmaxLastDim,
})
}
fn recipe_spelled_pattern(graph: &Graph, div_id: NodeId) -> Option<PatternMatch> {
let div = graph.node(div_id);
if !matches!(div.op, Op::Div) {
return None;
}
if div.inputs.len() != 2 {
return None;
}
let e_id = div.inputs[0];
let db_id = div.inputs[1];
let db = graph.node(db_id);
if !matches!(db.op, Op::BroadcastTo(_)) {
return None;
}
if db.inputs.len() != 1 {
return None;
}
let u2_id = db.inputs[0];
let u2 = graph.node(u2_id);
let Op::Unsqueeze { dim: u2_dim } = u2.op else {
return None;
};
if u2.inputs.len() != 1 {
return None;
}
let d_id = u2.inputs[0];
let d = graph.node(d_id);
let Op::SumDim(sum_axis) = d.op else {
return None;
};
if d.inputs.len() != 1 || d.inputs[0] != e_id {
return None;
}
let e = graph.node(e_id);
if !matches!(e.op, Op::Exp) {
return None;
}
if e.inputs.len() != 1 {
return None;
}
let s_id = e.inputs[0];
let s = graph.node(s_id);
if !matches!(s.op, Op::Sub) {
return None;
}
if s.inputs.len() != 2 {
return None;
}
let x_id = s.inputs[0];
let mb_id = s.inputs[1];
let mb = graph.node(mb_id);
if !matches!(mb.op, Op::BroadcastTo(_)) {
return None;
}
if mb.inputs.len() != 1 {
return None;
}
let u1_id = mb.inputs[0];
let u1 = graph.node(u1_id);
let Op::Unsqueeze { dim: u1_dim } = u1.op else {
return None;
};
if u1.inputs.len() != 1 {
return None;
}
let m_id = u1.inputs[0];
let m = graph.node(m_id);
let Op::MaxDim(max_axis) = m.op else {
return None;
};
if m.inputs.len() != 1 || m.inputs[0] != x_id {
return None;
}
let x_shape = &graph.node(x_id).shape;
if x_shape.rank() == 0 {
return None;
}
let last = x_shape.rank() - 1;
if max_axis != last || sum_axis != last || u1_dim != last || u2_dim != last {
return None;
}
let full = x_shape.dims();
if graph.node(mb_id).shape.dims() != full {
return None;
}
if graph.node(db_id).shape.dims() != full {
return None;
}
let intermediates_with_expected_count = [
(m_id, 1),
(u1_id, 1),
(mb_id, 1),
(s_id, 1),
(e_id, 2),
(d_id, 1),
(u2_id, 1),
(db_id, 1),
];
let consumer_counts = count_consumers(graph);
for (nid, expected) in intermediates_with_expected_count {
if consumer_counts.get(&nid).copied().unwrap_or(0) != expected {
return None;
}
}
Some(PatternMatch {
bindings: vec![(0, x_id)],
params: FusedOpParams::SoftmaxLastDim,
})
}
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;
use fuel_ir::{DType, Shape};
#[test]
fn canonical_pattern_matches_the_recipe_spelling() {
let mut g = Graph::new();
let sh = Shape::from_dims(&[2, 4]);
let x = g.push(Node {
op: Op::Const,
inputs: vec![],
shape: sh.clone(),
dtype: DType::F32,
});
let fused = g.push(Node {
op: Op::Fused(FusedOps::SOFTMAX_LAST_DIM, FusedOpParams::SoftmaxLastDim),
inputs: vec![x],
shape: sh,
dtype: DType::F32,
});
let root = decompose(&mut g, fused, &FusedOpParams::SoftmaxLastDim);
assert_ne!(root, fused, "recipe decompose fires");
let m = canonical_pattern(&g, root).expect("the recipe emission must re-fuse");
assert_eq!(m.bindings, vec![(0, x)], "bound external input = x");
assert!(matches!(m.params, FusedOpParams::SoftmaxLastDim));
}
}