use crate::registry::{
BackwardKind, FusedOpEntry, FusedOpFamily, FusedOpParams, FusedOps, PatternMatch,
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::CONV2D,
name: "Conv2D",
family: FusedOpFamily::Forward,
pattern: SubgraphPattern::Callable(canonical_pattern),
decompose,
backward: BackwardKind::NotDifferentiable,
shape_rule,
dtype_rule: dtype_passthrough,
output_views: None,
}
}
fn shape_rule(input_shapes: &[Shape], params: &FusedOpParams) -> Shape {
debug_assert!(
input_shapes.len() == 2 || input_shapes.len() == 3,
"Conv2D takes 2 or 3 inputs (x, weight, [bias])",
);
let (stride, padding) = match params {
FusedOpParams::Conv2D {
stride, padding, ..
} => (*stride, *padding),
_ => panic!("conv2d::shape_rule got non-Conv2D params: {params:?}"),
};
let x_dims = input_shapes[0].dims();
let w_dims = input_shapes[1].dims();
debug_assert_eq!(x_dims.len(), 4, "Conv2D x must be rank 4");
debug_assert_eq!(w_dims.len(), 4, "Conv2D weight must be rank 4");
let (n, _cin, h_in, w_in) = (x_dims[0], x_dims[1], x_dims[2], x_dims[3]);
let (cout, _cin_per_g, kh, kw) = (w_dims[0], w_dims[1], w_dims[2], w_dims[3]);
let (sh, sw) = stride;
let (ph, pw) = padding;
let h_out = (h_in + 2 * ph - kh) / sh + 1;
let w_out = (w_in + 2 * pw - kw) / sw + 1;
Shape::from_dims(&[n, cout, h_out, w_out])
}
fn dtype_passthrough(input_dtypes: &[DType], _params: &FusedOpParams) -> DType {
debug_assert!(
input_dtypes.len() == 2 || input_dtypes.len() == 3,
"Conv2D takes 2 or 3 inputs",
);
input_dtypes[0]
}
fn recipe(
n: usize,
cin: usize,
hpad: usize,
wpad: usize,
kh: usize,
kw: usize,
hout: usize,
wout: usize,
cout: usize,
sh: usize,
sw: usize,
ph: usize,
pw: usize,
has_bias: bool,
) -> PatternNode {
use OpTag as T;
let op = |op, attrs, operands| PatternNode::Op {
op,
attrs,
operands,
};
let bind = |i| PatternNode::Bind { index: i };
let shape_attr = |dims: Vec<i64>| OpAttrs {
target_shape: dims,
..OpAttrs::default()
};
let k = cin * kh * kw; let spatial = hout * wout;
let xp = op(
T::Pad,
OpAttrs {
pad_amounts: vec![
(0, 0),
(0, 0),
(ph as u64, ph as u64),
(pw as u64, pw as u64),
],
pad_mode: Some(0), pad_value: Some(0.0),
..OpAttrs::default()
},
vec![bind(0)],
);
let xf = op(
T::Reshape,
shape_attr(vec![n as i64, cin as i64, (hpad * wpad) as i64]),
vec![xp],
);
let idx = flat_gather_index(kh, kw, hout, wout, sh, sw, wpad);
let cols = op(
T::IndexSelect,
OpAttrs {
axis: Some(2),
..OpAttrs::default()
},
vec![xf, idx],
);
let patch = op(
T::Reshape,
shape_attr(vec![n as i64, k as i64, spatial as i64]),
vec![cols],
);
let wm = op(
T::Reshape,
shape_attr(vec![1, cout as i64, k as i64]),
vec![bind(1)],
);
let wb = op(
T::BroadcastTo,
shape_attr(vec![n as i64, cout as i64, k as i64]),
vec![wm],
);
let ymat = op(T::MatMul, OpAttrs::default(), vec![wb, patch]);
let out = op(
T::Reshape,
shape_attr(vec![n as i64, cout as i64, hout as i64, wout as i64]),
vec![ymat],
);
if has_bias {
let bm = op(
T::Reshape,
shape_attr(vec![1, cout as i64, 1, 1]),
vec![bind(2)],
);
let bb = op(
T::BroadcastTo,
shape_attr(vec![n as i64, cout as i64, hout as i64, wout as i64]),
vec![bm],
);
op(T::Add, OpAttrs::default(), vec![out, bb])
} else {
out
}
}
fn flat_gather_index(
kh: usize,
kw: usize,
hout: usize,
wout: usize,
sh: usize,
sw: usize,
wpad: usize,
) -> PatternNode {
use OpTag as T;
let op = |op, attrs, operands| PatternNode::Op {
op,
attrs,
operands,
};
let shape_attr = |dims: Vec<i64>| OpAttrs {
target_shape: dims,
..OpAttrs::default()
};
let l = kh * kw * hout * wout;
let idx4_shape = vec![kh as i64, kw as i64, hout as i64, wout as i64];
let term = |axis_len: usize, reshape_dims: Vec<i64>, factor: f64| -> PatternNode {
let iota = op(
T::Iota,
OpAttrs {
target_shape: vec![axis_len as i64],
..OpAttrs::default()
},
vec![],
);
let re = op(T::Reshape, shape_attr(reshape_dims), vec![iota]);
let scaled = op(
T::MulScalar,
OpAttrs {
scalars: vec![factor],
..OpAttrs::default()
},
vec![re],
);
op(T::BroadcastTo, shape_attr(idx4_shape.clone()), vec![scaled])
};
let term_ky = term(kh, vec![kh as i64, 1, 1, 1], wpad as f64);
let term_kx = term(kw, vec![1, kw as i64, 1, 1], 1.0);
let term_oh = term(hout, vec![1, 1, hout as i64, 1], (sh * wpad) as f64);
let term_ow = term(wout, vec![1, 1, 1, wout as i64], sw as f64);
let add = |a, b| op(T::Add, OpAttrs::default(), vec![a, b]);
let idx4 = add(add(add(term_ky, term_kx), term_oh), term_ow);
let idx1 = op(T::Reshape, shape_attr(vec![l as i64]), vec![idx4]);
op(
T::Cast,
OpAttrs {
cast_dtype: Some("u32".to_string()),
..OpAttrs::default()
},
vec![idx1],
)
}
fn recipe_grouped(
n: usize,
cin: usize,
hpad: usize,
wpad: usize,
kh: usize,
kw: usize,
hout: usize,
wout: usize,
cout: usize,
groups: usize,
sh: usize,
sw: usize,
ph: usize,
pw: usize,
has_bias: bool,
) -> PatternNode {
use OpTag as T;
let op = |op, attrs, operands| PatternNode::Op {
op,
attrs,
operands,
};
let bind = |i| PatternNode::Bind { index: i };
let shape_attr = |dims: Vec<i64>| OpAttrs {
target_shape: dims,
..OpAttrs::default()
};
let cin_per_g = cin / groups;
let cout_per_g = cout / groups;
let k_per_g = cin_per_g * kh * kw; let spatial = hout * wout;
let xp = op(
T::Pad,
OpAttrs {
pad_amounts: vec![
(0, 0),
(0, 0),
(ph as u64, ph as u64),
(pw as u64, pw as u64),
],
pad_mode: Some(0), pad_value: Some(0.0),
..OpAttrs::default()
},
vec![bind(0)],
);
let xf = op(
T::Reshape,
shape_attr(vec![
n as i64,
groups as i64,
cin_per_g as i64,
(hpad * wpad) as i64,
]),
vec![xp],
);
let idx = flat_gather_index(kh, kw, hout, wout, sh, sw, wpad);
let cols = op(
T::IndexSelect,
OpAttrs {
axis: Some(3),
..OpAttrs::default()
},
vec![xf, idx],
);
let patch = op(
T::Reshape,
shape_attr(vec![
n as i64,
groups as i64,
k_per_g as i64,
spatial as i64,
]),
vec![cols],
);
let wm = op(
T::Reshape,
shape_attr(vec![1, groups as i64, cout_per_g as i64, k_per_g as i64]),
vec![bind(1)],
);
let wb = op(
T::BroadcastTo,
shape_attr(vec![
n as i64,
groups as i64,
cout_per_g as i64,
k_per_g as i64,
]),
vec![wm],
);
let ymat = op(T::MatMul, OpAttrs::default(), vec![wb, patch]);
let out = op(
T::Reshape,
shape_attr(vec![n as i64, cout as i64, hout as i64, wout as i64]),
vec![ymat],
);
if has_bias {
let bm = op(
T::Reshape,
shape_attr(vec![1, cout as i64, 1, 1]),
vec![bind(2)],
);
let bb = op(
T::BroadcastTo,
shape_attr(vec![n as i64, cout as i64, hout as i64, wout as i64]),
vec![bm],
);
op(T::Add, OpAttrs::default(), vec![out, bb])
} else {
out
}
}
pub fn decompose(graph: &mut Graph, id: NodeId, params: &FusedOpParams) -> NodeId {
let (stride, padding, groups) = match params {
FusedOpParams::Conv2D {
stride,
padding,
groups,
} => (*stride, *padding, *groups),
_ => return id,
};
if groups == 0 {
return id;
}
let (x_shape, w_shape, n_inputs) = {
let node = graph.node(id);
if node.inputs.len() != 2 && node.inputs.len() != 3 {
return id;
}
(
graph.node(node.inputs[0]).shape.clone(),
graph.node(node.inputs[1]).shape.clone(),
node.inputs.len(),
)
};
let x_dims = x_shape.dims();
let w_dims = w_shape.dims();
if x_dims.len() != 4 || w_dims.len() != 4 {
return id;
}
let (n, cin, hin, win) = (x_dims[0], x_dims[1], x_dims[2], x_dims[3]);
let (cout, cin_per_g, kh, kw) = (w_dims[0], w_dims[1], w_dims[2], w_dims[3]);
if cin % groups != 0 || cout % groups != 0 || cin_per_g != cin / groups {
return id;
}
let (sh, sw) = stride;
let (ph, pw) = padding;
if sh == 0 || sw == 0 {
return id;
}
let hpad = hin + 2 * ph;
let wpad = win + 2 * pw;
if hpad < kh || wpad < kw {
return id;
}
let hout = (hpad - kh) / sh + 1;
let wout = (wpad - kw) / sw + 1;
let has_bias = n_inputs == 3;
let recipe_node = if groups == 1 {
recipe(
n, cin, hpad, wpad, kh, kw, hout, wout, cout, sh, sw, ph, pw, has_bias,
)
} else {
recipe_grouped(
n, cin, hpad, wpad, kh, kw, hout, wout, cout, groups, sh, sw, ph, pw, has_bias,
)
};
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::{Node, Op};
fn mk_const(g: &mut Graph, dims: &[usize]) -> NodeId {
g.push(Node {
op: Op::Const,
inputs: vec![],
shape: Shape::from_dims(dims),
dtype: DType::F32,
})
}
fn fused_conv(
g: &mut Graph,
n: usize,
cin: usize,
hin: usize,
win: usize,
cout: usize,
kh: usize,
kw: usize,
stride: (usize, usize),
padding: (usize, usize),
groups: usize,
bias: bool,
) -> NodeId {
let x = mk_const(g, &[n, cin, hin, win]);
let w = mk_const(g, &[cout, cin / groups, kh, kw]);
let mut inputs = vec![x, w];
if bias {
inputs.push(mk_const(g, &[cout]));
}
let (sh, sw) = stride;
let (ph, pw) = padding;
let hout = (hin + 2 * ph - kh) / sh + 1;
let wout = (win + 2 * pw - kw) / sw + 1;
g.push(Node {
op: Op::Fused(
FusedOps::CONV2D,
FusedOpParams::Conv2D {
stride,
padding,
groups,
},
),
inputs,
shape: Shape::from_dims(&[n, cout, hout, wout]),
dtype: DType::F32,
})
}
fn reachable_ops(g: &Graph, root: NodeId) -> Vec<Op> {
let mut stack = vec![root];
let mut seen = std::collections::HashSet::new();
let mut ops = Vec::new();
while let Some(id) = stack.pop() {
if !seen.insert(id) {
continue;
}
let node = g.node(id);
ops.push(node.op.clone());
for &inp in &node.inputs {
stack.push(inp);
}
}
ops
}
#[test]
fn conv2d_groups1_decompose_lowers_to_im2col_matmul() {
for bias in [false, true] {
let mut g = Graph::new();
let fused = fused_conv(&mut g, 1, 2, 5, 5, 3, 2, 2, (1, 1), (0, 0), 1, bias);
let out_shape = g.node(fused).shape.clone();
let params = match &g.node(fused).op {
Op::Fused(_, p) => p.clone(),
other => panic!("expected fused node, got {other:?}"),
};
let root = decompose(&mut g, fused, ¶ms);
assert_ne!(root, fused, "conv2d groups=1 must lower (bias={bias})");
assert_eq!(
g.node(root).shape,
out_shape,
"lowered root keeps [N,Cout,Hout,Wout] (bias={bias})",
);
assert_eq!(g.node(root).dtype, DType::F32);
let ops = reachable_ops(&g, root);
assert!(
!ops.iter()
.any(|o| matches!(o, Op::Fused(fid, _) if *fid == FusedOps::CONV2D)),
"no Op::Fused(CONV2D) remains after decompose (bias={bias})",
);
assert!(
ops.iter().any(|o| matches!(o, Op::MatMul)),
"recipe contains MatMul"
);
assert!(
ops.iter().any(|o| matches!(o, Op::IndexSelect { .. })),
"recipe gathers patches via IndexSelect",
);
assert!(
ops.iter().any(|o| matches!(o, Op::Iota { .. })),
"index built from Iota"
);
assert!(
ops.iter().any(|o| matches!(o, Op::Pad { .. })),
"input padded via Op::Pad"
);
assert!(
ops.iter()
.any(|o| matches!(o, Op::Cast(dt) if *dt == DType::U32)),
"index cast to U32 for the gather",
);
if bias {
assert!(
ops.iter().any(|o| matches!(o, Op::Add)),
"bias added via broadcast Add tail"
);
}
}
}
#[test]
fn conv2d_wrong_params_is_a_fixpoint() {
let mut g = Graph::new();
let fused = fused_conv(&mut g, 1, 2, 5, 5, 3, 2, 2, (1, 1), (0, 0), 1, false);
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");
}
#[test]
fn conv2d_groups_gt_1_decompose_lowers_to_im2col_matmul() {
#[allow(clippy::type_complexity)]
let cases: [(
usize,
usize,
usize,
usize,
usize,
usize,
usize,
(usize, usize),
(usize, usize),
usize,
bool,
); 3] = [
(1, 4, 5, 5, 6, 3, 3, (1, 1), (1, 1), 2, false), (2, 4, 5, 5, 4, 2, 2, (1, 1), (0, 0), 4, true), (1, 8, 6, 6, 16, 3, 3, (1, 1), (1, 1), 8, false), ];
for (n, cin, hin, win, cout, kh, kw, stride, padding, groups, bias) in cases {
let mut g = Graph::new();
let fused = fused_conv(
&mut g, n, cin, hin, win, cout, kh, kw, stride, padding, groups, bias,
);
let out_shape = g.node(fused).shape.clone();
let params = match &g.node(fused).op {
Op::Fused(_, p) => p.clone(),
other => panic!("expected fused node, got {other:?}"),
};
let root = decompose(&mut g, fused, ¶ms);
assert_ne!(root, fused, "groups={groups} must lower (bias={bias})");
assert_eq!(
g.node(root).shape,
out_shape,
"lowered root keeps [N,Cout,Hout,Wout] (groups={groups}, bias={bias})",
);
assert_eq!(g.node(root).dtype, DType::F32);
let ops = reachable_ops(&g, root);
assert!(
!ops.iter()
.any(|o| matches!(o, Op::Fused(fid, _) if *fid == FusedOps::CONV2D)),
"no Op::Fused(CONV2D) remains after decompose (groups={groups}, bias={bias})",
);
assert!(
ops.iter().any(|o| matches!(o, Op::MatMul)),
"recipe contains MatMul"
);
assert!(
ops.iter().any(|o| matches!(o, Op::IndexSelect { .. })),
"recipe gathers patches via IndexSelect",
);
assert!(
ops.iter().any(|o| matches!(o, Op::Iota { .. })),
"index built from Iota"
);
assert!(
ops.iter().any(|o| matches!(o, Op::Pad { .. })),
"input padded via Op::Pad"
);
assert!(
ops.iter()
.any(|o| matches!(o, Op::Cast(dt) if *dt == DType::U32)),
"index cast to U32 for the gather",
);
if bias {
assert!(
ops.iter().any(|o| matches!(o, Op::Add)),
"bias added via broadcast Add tail"
);
}
}
}
#[test]
fn conv2d_bad_groups_is_a_fixpoint() {
let mut g = Graph::new();
let x = mk_const(&mut g, &[1, 6, 5, 5]);
let w = mk_const(&mut g, &[4, 2, 2, 2]);
let params = FusedOpParams::Conv2D {
stride: (1, 1),
padding: (0, 0),
groups: 3,
};
let fused = g.push(Node {
op: Op::Fused(FusedOps::CONV2D, params.clone()),
inputs: vec![x, w],
shape: Shape::from_dims(&[1, 4, 4, 4]),
dtype: DType::F32,
});
let before = g.len();
let out = decompose(&mut g, fused, ¶ms);
assert_eq!(out, fused, "Cout not divisible by groups => fixpoint");
assert_eq!(g.len(), before, "declined before any emission");
}
}