use crate::registry::{
BackwardKind, FusedOpEntry, FusedOpFamily, FusedOpParams, FusedOps, PatternMatch, Reduction,
SubgraphPattern, decompose_via_recipe,
};
use crate::{Graph, NodeId};
use fuel_ir::{DType, Shape};
use fuel_kernel_seam_types::{OpAttrs, OpTag, PatternNode};
pub fn entry() -> FusedOpEntry {
FusedOpEntry {
destructive_input: None,
id: FusedOps::FUSED_SOFTMAX_CROSS_ENTROPY,
name: "FusedSoftmaxCrossEntropy",
family: FusedOpFamily::Forward,
pattern: SubgraphPattern::Callable(canonical_pattern),
decompose,
backward: BackwardKind::Decompose,
shape_rule,
dtype_rule,
output_views: None,
}
}
fn shape_rule(input_shapes: &[Shape], params: &FusedOpParams) -> Shape {
debug_assert_eq!(
input_shapes.len(),
2,
"FusedSoftmaxCrossEntropy takes 2 inputs (logits, targets)",
);
let reduction = match params {
FusedOpParams::FusedSoftmaxCrossEntropy { reduction, .. } => *reduction,
other => panic!("fused_softmax_cross_entropy::shape_rule got non-FSCE params: {other:?}"),
};
match reduction {
Reduction::Mean | Reduction::Sum => Shape::from_dims(&[]),
Reduction::None => input_shapes[1].clone(),
}
}
fn dtype_rule(input_dtypes: &[DType], _params: &FusedOpParams) -> DType {
debug_assert_eq!(
input_dtypes.len(),
2,
"FusedSoftmaxCrossEntropy takes 2 inputs",
);
DType::F32
}
fn recipe(
logits_shape: &Shape,
_targets_shape: &Shape,
work_dtype: DType,
targets_dtype: DType,
reduction: Reduction,
) -> PatternNode {
use OpTag as T;
let logits_dims = logits_shape.dims();
let rank = logits_dims.len();
let last = rank - 1; let mut keepdim_dims = logits_dims.to_vec();
keepdim_dims[last] = 1;
let as_ts = |dims: &[usize]| -> Vec<i64> { dims.iter().map(|&d| d as i64).collect() };
let logits_ts = as_ts(logits_dims);
let keepdim_ts = as_ts(&keepdim_dims);
let op = |op, attrs, operands| PatternNode::Op {
op,
attrs,
operands,
};
let bind = |i: u8| PatternNode::Bind { index: i };
let shape_attr = |ts: Vec<i64>| OpAttrs {
target_shape: ts,
..OpAttrs::default()
};
let cast_attr = |dt: DType| OpAttrs {
cast_dtype: Some(dt.as_str().to_string()),
..OpAttrs::default()
};
let scalar_attr = |v: f64| OpAttrs {
scalars: vec![v],
..OpAttrs::default()
};
let m = op(
T::ReduceMaxTo,
shape_attr(keepdim_ts.clone()),
vec![bind(0)],
);
let mb = op(T::BroadcastTo, shape_attr(logits_ts.clone()), vec![m]);
let shifted = op(T::Sub, OpAttrs::default(), vec![bind(0), mb]);
let exp_shift = op(T::Exp, OpAttrs::default(), vec![shifted.clone()]);
let sum_exp = op(T::ReduceSumTo, shape_attr(keepdim_ts), vec![exp_shift]);
let log_sum = op(T::Log, OpAttrs::default(), vec![sum_exp]);
let log_sum_bcst = op(T::BroadcastTo, shape_attr(logits_ts), vec![log_sum]);
let log_softmax = op(T::Sub, OpAttrs::default(), vec![shifted, log_sum_bcst]);
let targets_u32 = if targets_dtype == DType::U32 {
bind(1)
} else {
op(T::Cast, cast_attr(DType::U32), vec![bind(1)])
};
let targets_keepdim = op(
T::Unsqueeze,
OpAttrs {
dims: vec![last as u8],
..OpAttrs::default()
},
vec![targets_u32],
);
let gathered = op(
T::Gather,
OpAttrs {
axis: Some(last as i64),
..OpAttrs::default()
},
vec![log_softmax, targets_keepdim],
);
let per_row_log_lik = op(
T::Squeeze,
OpAttrs {
dims: vec![last as u8],
..OpAttrs::default()
},
vec![gathered],
);
let per_row_nll = op(T::MulScalar, scalar_attr(-1.0), vec![per_row_log_lik]);
let needs_cast = work_dtype != DType::F32;
let cast_f32 = |src| op(T::Cast, cast_attr(DType::F32), vec![src]);
let rank0 = || OpAttrs {
rank0_target: true,
..OpAttrs::default()
};
match reduction {
Reduction::None => {
if needs_cast {
cast_f32(per_row_nll)
} else {
per_row_nll
}
}
Reduction::Sum => {
let sum = op(T::ReduceSumTo, rank0(), vec![per_row_nll]);
if needs_cast { cast_f32(sum) } else { sum }
}
Reduction::Mean => {
let n_rows: usize = logits_dims[..last].iter().product::<usize>().max(1);
let sum = op(T::ReduceSumTo, rank0(), vec![per_row_nll]);
let mean = op(T::MulScalar, scalar_attr(1.0 / n_rows as f64), vec![sum]);
if needs_cast { cast_f32(mean) } else { mean }
}
}
}
pub fn decompose(graph: &mut Graph, id: NodeId, params: &FusedOpParams) -> NodeId {
let reduction = match params {
FusedOpParams::FusedSoftmaxCrossEntropy { reduction, .. } => *reduction,
_ => return id,
};
let (logits_shape, targets_shape, work_dtype, targets_dtype) = {
let n = graph.node(id);
if n.inputs.len() != 2 {
return id;
}
let logits = graph.node(n.inputs[0]);
let targets = graph.node(n.inputs[1]);
(
logits.shape.clone(),
targets.shape.clone(),
logits.dtype,
targets.dtype,
)
};
if logits_shape.rank() < 1 {
return id;
}
let recipe_node = recipe(
&logits_shape,
&targets_shape,
work_dtype,
targets_dtype,
reduction,
);
decompose_via_recipe(graph, id, &recipe_node, Some(Vec::new()))
}
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};
fn frozen_legacy_decompose(graph: &mut Graph, id: NodeId, params: &FusedOpParams) -> NodeId {
let (reduction, _ignore_index) = match params {
FusedOpParams::FusedSoftmaxCrossEntropy {
reduction,
ignore_index,
} => (*reduction, *ignore_index),
_ => return id,
};
let (logits_id, targets_id, logits_shape, targets_shape, _logits_dtype, targets_dtype) = {
let n = graph.node(id);
debug_assert_eq!(n.inputs.len(), 2, "FSCE node must have 2 inputs");
let logits = graph.node(n.inputs[0]);
let targets = graph.node(n.inputs[1]);
(
n.inputs[0],
n.inputs[1],
logits.shape.clone(),
targets.shape.clone(),
logits.dtype,
targets.dtype,
)
};
let logits_dims = logits_shape.dims().to_vec();
let rank = logits_dims.len();
let last = rank - 1;
let vocab = logits_dims[last];
let work_dtype = graph.node(logits_id).dtype;
let mut keepdim_dims = logits_dims.clone();
keepdim_dims[last] = 1;
let keepdim_shape = Shape::from_dims(&keepdim_dims);
let m_id = graph.push(Node {
op: Op::ReduceMaxTo(keepdim_shape.clone()),
inputs: vec![logits_id],
shape: keepdim_shape.clone(),
dtype: work_dtype,
});
let mb_id = graph.push(Node {
op: Op::BroadcastTo(logits_shape.clone()),
inputs: vec![m_id],
shape: logits_shape.clone(),
dtype: work_dtype,
});
let shifted_id = graph.push(Node {
op: Op::Sub,
inputs: vec![logits_id, mb_id],
shape: logits_shape.clone(),
dtype: work_dtype,
});
let exp_shift_id = graph.push(Node {
op: Op::Exp,
inputs: vec![shifted_id],
shape: logits_shape.clone(),
dtype: work_dtype,
});
let sum_exp_id = graph.push(Node {
op: Op::ReduceSumTo(keepdim_shape.clone()),
inputs: vec![exp_shift_id],
shape: keepdim_shape.clone(),
dtype: work_dtype,
});
let log_sum_id = graph.push(Node {
op: Op::Log,
inputs: vec![sum_exp_id],
shape: keepdim_shape.clone(),
dtype: work_dtype,
});
let log_sum_bcst_id = graph.push(Node {
op: Op::BroadcastTo(logits_shape.clone()),
inputs: vec![log_sum_id],
shape: logits_shape.clone(),
dtype: work_dtype,
});
let log_softmax_id = graph.push(Node {
op: Op::Sub,
inputs: vec![shifted_id, log_sum_bcst_id],
shape: logits_shape.clone(),
dtype: work_dtype,
});
let targets_u32_id = if targets_dtype == DType::U32 {
targets_id
} else {
graph.push(Node {
op: Op::Cast(DType::U32),
inputs: vec![targets_id],
shape: targets_shape.clone(),
dtype: DType::U32,
})
};
let targets_keepdim_id = graph.push(Node {
op: Op::Unsqueeze { dim: last },
inputs: vec![targets_u32_id],
shape: keepdim_shape.clone(),
dtype: DType::U32,
});
let gathered_id = graph.push(Node {
op: Op::Gather { dim: last },
inputs: vec![log_softmax_id, targets_keepdim_id],
shape: keepdim_shape.clone(),
dtype: work_dtype,
});
let per_row_log_lik_id = graph.push(Node {
op: Op::Squeeze { dim: last },
inputs: vec![gathered_id],
shape: targets_shape.clone(),
dtype: work_dtype,
});
let per_row_nll_id = graph.push(Node {
op: Op::MulScalar(-1.0),
inputs: vec![per_row_log_lik_id],
shape: targets_shape.clone(),
dtype: work_dtype,
});
let needs_cast = work_dtype != DType::F32;
match reduction {
Reduction::None => {
if needs_cast {
graph.push(Node {
op: Op::Cast(DType::F32),
inputs: vec![per_row_nll_id],
shape: targets_shape,
dtype: DType::F32,
})
} else {
per_row_nll_id
}
}
Reduction::Sum => {
let sum_id = graph.push(Node {
op: Op::ReduceSumTo(Shape::from_dims(&[])),
inputs: vec![per_row_nll_id],
shape: Shape::from_dims(&[]),
dtype: work_dtype,
});
if needs_cast {
graph.push(Node {
op: Op::Cast(DType::F32),
inputs: vec![sum_id],
shape: Shape::from_dims(&[]),
dtype: DType::F32,
})
} else {
sum_id
}
}
Reduction::Mean => {
let n_rows: usize = logits_dims[..last].iter().product::<usize>().max(1);
let _ = vocab;
let sum_id = graph.push(Node {
op: Op::ReduceSumTo(Shape::from_dims(&[])),
inputs: vec![per_row_nll_id],
shape: Shape::from_dims(&[]),
dtype: work_dtype,
});
let mean_id = graph.push(Node {
op: Op::MulScalar(1.0 / n_rows as f64),
inputs: vec![sum_id],
shape: Shape::from_dims(&[]),
dtype: work_dtype,
});
if needs_cast {
graph.push(Node {
op: Op::Cast(DType::F32),
inputs: vec![mean_id],
shape: Shape::from_dims(&[]),
dtype: DType::F32,
})
} else {
mean_id
}
}
}
}
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,
leading: &[usize],
vocab: usize,
work: DType,
tgt: DType,
reduction: Reduction,
) -> NodeId {
let mut logits_dims = leading.to_vec();
logits_dims.push(vocab);
let logits = g.push(Node {
op: Op::Const,
inputs: vec![],
shape: Shape::from_dims(&logits_dims),
dtype: work,
});
let targets = g.push(Node {
op: Op::Const,
inputs: vec![],
shape: Shape::from_dims(leading),
dtype: tgt,
});
let out_shape = match reduction {
Reduction::Mean | Reduction::Sum => Shape::from_dims(&[]),
Reduction::None => Shape::from_dims(leading),
};
g.push(Node {
op: Op::Fused(
FusedOps::FUSED_SOFTMAX_CROSS_ENTROPY,
FusedOpParams::FusedSoftmaxCrossEntropy {
reduction,
ignore_index: -100,
},
),
inputs: vec![logits, targets],
shape: out_shape,
dtype: DType::F32,
})
}
#[test]
fn fsce_recipe_decompose_matches_frozen_legacy_for_every_config() {
for reduction in [Reduction::None, Reduction::Sum, Reduction::Mean] {
for tgt in [DType::I64, DType::U32] {
for leading in [vec![4usize], vec![2usize, 3]] {
let params = FusedOpParams::FusedSoftmaxCrossEntropy {
reduction,
ignore_index: -100,
};
let mut g = Graph::new();
let fused = fused_node(&mut g, &leading, 5, DType::F32, tgt, reduction);
let out_sh = g.node(fused).shape.clone();
let new_root = decompose(&mut g, fused, ¶ms);
assert_ne!(
new_root, fused,
"recipe decompose fires (reduction={reduction:?}, tgt={tgt:?}, leading={leading:?})"
);
assert_eq!(
g.node(new_root).shape,
out_sh,
"output shape matches shape_rule (reduction={reduction:?})"
);
assert_eq!(
g.node(new_root).dtype,
DType::F32,
"FSCE output dtype is F32"
);
let legacy_root = frozen_legacy_decompose(&mut g, fused, ¶ms);
assert_structural_eq(&g, new_root, legacy_root);
}
}
}
}
#[test]
fn fsce_recipe_non_f32_work_dtype_cast_tail_matches_frozen_legacy() {
for reduction in [Reduction::None, Reduction::Sum, Reduction::Mean] {
let params = FusedOpParams::FusedSoftmaxCrossEntropy {
reduction,
ignore_index: -100,
};
let mut g = Graph::new();
let fused = fused_node(&mut g, &[2, 3], 5, DType::F16, DType::I64, reduction);
let new_root = decompose(&mut g, fused, ¶ms);
assert_ne!(
new_root, fused,
"recipe decompose fires (F16 work, reduction={reduction:?})"
);
assert_eq!(g.node(new_root).dtype, DType::F32, "Cast(F32) output tail");
let legacy_root = frozen_legacy_decompose(&mut g, fused, ¶ms);
assert_structural_eq(&g, new_root, legacy_root);
}
}
#[test]
fn fsce_recipe_wrong_params_is_a_fixpoint_not_a_crash() {
let mut g = Graph::new();
let fused = fused_node(&mut g, &[2, 3], 5, DType::F32, DType::I64, Reduction::Mean);
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");
}
}