use super::PaddingType;
use crate::error::Error;
use crate::math::matmul::dot_par;
use crate::neural_network::Tensor;
use gemmkit_ndarray::{Bias, Parallelism};
use ndarray::{Array2, Array3, ArrayD, ArrayView2, ArrayViewMut2, Axis, IxDyn};
use rayon::prelude::*;
tunable_gate! {
pub(crate) CONV_PARALLEL_MIN_FLOPS => conv_parallel_min_flops / set_conv_parallel_min_flops = 4_000_000
}
const CONV_MIN_CHUNK_POSITIONS: usize = 64;
pub(super) struct ConvGradients {
pub weight_grad: Vec<f32>,
pub bias_grad: Vec<f32>,
pub input_grad: Tensor,
}
fn spatial_strides(sp: &[usize], cin: usize) -> Vec<usize> {
let mut strides = vec![cin; sp.len()];
for d in (0..sp.len().saturating_sub(1)).rev() {
strides[d] = strides[d + 1] * sp[d + 1];
}
strides
}
#[inline]
fn increment_index(idx: &mut [usize], dims: &[usize]) -> bool {
for k in (0..idx.len()).rev() {
idx[k] += 1;
if idx[k] < dims[k] {
return true;
}
idx[k] = 0;
}
false
}
fn map_indexed<R, F>(n: usize, parallel: bool, f: F) -> Vec<R>
where
R: Send,
F: Fn(usize) -> R + Sync + Send,
{
if parallel {
(0..n).into_par_iter().map(f).collect()
} else {
(0..n).map(f).collect()
}
}
type ConvGeometry = (Vec<usize>, Vec<usize>, Vec<usize>);
fn conv_geometry(
sp: &[usize],
k_dims: &[usize],
strides: &[usize],
padding: PaddingType,
) -> Result<ConvGeometry, Error> {
let r = sp.len();
match padding {
PaddingType::Valid => {
if let Some(d) = (0..r).find(|&d| sp[d] < k_dims[d]) {
return Err(Error::invalid_input(format!(
"Valid-padding convolution requires every input spatial dimension to be at \
least the kernel size: axis {d} has input size {} < kernel size {}",
sp[d], k_dims[d]
)));
}
let out_sp: Vec<usize> = (0..r)
.map(|d| (sp[d] - k_dims[d]) / strides[d] + 1)
.collect();
Ok((out_sp, vec![0; r], sp.to_vec()))
}
PaddingType::Same => {
let out_sp: Vec<usize> = (0..r).map(|d| sp[d].div_ceil(strides[d])).collect();
let pad_before: Vec<usize> = (0..r)
.map(|d| (((out_sp[d] - 1) * strides[d] + k_dims[d]).saturating_sub(sp[d])) / 2)
.collect();
let padded_sp: Vec<usize> = (0..r)
.map(|d| ((out_sp[d] - 1) * strides[d] + k_dims[d]).max(sp[d]))
.collect();
Ok((out_sp, pad_before, padded_sp))
}
}
}
fn build_padded(
in_flat: &[f32],
items: usize,
sp: &[usize],
padded_sp: &[usize],
pad_before: &[usize],
cin: usize,
) -> Vec<f32> {
let r = sp.len();
let in_item: usize = sp.iter().product::<usize>() * cin;
let padded_item: usize = padded_sp.iter().product::<usize>() * cin;
let padded_strides = spatial_strides(padded_sp, cin);
let mut out = vec![0.0f32; items * padded_item];
for item in 0..items {
let in_base = item * in_item;
let pad_base = item * padded_item;
let mut si = vec![0usize; r];
let mut si_flat = 0usize;
loop {
let mut pidx = pad_base;
for d in 0..r {
pidx += (si[d] + pad_before[d]) * padded_strides[d];
}
out[pidx..pidx + cin]
.copy_from_slice(&in_flat[in_base + si_flat..in_base + si_flat + cin]);
si_flat += cin;
if !increment_index(&mut si, sp) {
break;
}
}
}
out
}
fn crop_padded(
padded: &[f32],
items: usize,
sp: &[usize],
padded_sp: &[usize],
pad_before: &[usize],
cin: usize,
) -> Vec<f32> {
let r = sp.len();
let in_item: usize = sp.iter().product::<usize>() * cin;
let padded_item: usize = padded_sp.iter().product::<usize>() * cin;
let padded_strides = spatial_strides(padded_sp, cin);
let mut out = vec![0.0f32; items * in_item];
for item in 0..items {
let in_base = item * in_item;
let pad_base = item * padded_item;
let mut si = vec![0usize; r];
let mut si_flat = 0usize;
loop {
let mut pidx = pad_base;
for d in 0..r {
pidx += (si[d] + pad_before[d]) * padded_strides[d];
}
out[in_base + si_flat..in_base + si_flat + cin]
.copy_from_slice(&padded[pidx..pidx + cin]);
si_flat += cin;
if !increment_index(&mut si, sp) {
break;
}
}
}
out
}
fn im2col_offsets(
out_sp: &[usize],
k_dims: &[usize],
strides: &[usize],
padded_strides: &[usize],
) -> Vec<usize> {
let r = out_sp.len();
let out_plane: usize = out_sp.iter().product();
let k_plane: usize = k_dims.iter().product();
let mut offsets = vec![0usize; k_plane * out_plane];
let mut o = vec![0usize; r];
let mut o_flat = 0usize;
loop {
let mut kk = vec![0usize; r];
let mut kk_flat = 0usize;
loop {
let mut pidx = 0usize;
for d in 0..r {
pidx += (o[d] * strides[d] + kk[d]) * padded_strides[d];
}
offsets[kk_flat * out_plane + o_flat] = pidx;
kk_flat += 1;
if !increment_index(&mut kk, k_dims) {
break;
}
}
o_flat += 1;
if !increment_index(&mut o, out_sp) {
break;
}
}
offsets
}
struct ColContext<'a> {
padded: &'a [f32],
cin: usize,
padded_item: usize,
k_plane: usize,
out_plane: usize,
offsets: &'a [usize],
}
fn build_col_range(ctx: &ColContext, b: usize, c0: usize, c1: usize) -> Vec<f32> {
let rows = c1 - c0;
let k_total = ctx.k_plane * ctx.cin;
let mut col = vec![0.0f32; rows * k_total];
let b_base = b * ctx.padded_item;
for kk in 0..ctx.k_plane {
let off = kk * ctx.out_plane;
let kbase = kk * ctx.cin;
for (i, o) in (c0..c1).enumerate() {
let src = b_base + ctx.offsets[off + o];
let dst = i * k_total + kbase;
col[dst..dst + ctx.cin].copy_from_slice(&ctx.padded[src..src + ctx.cin]);
}
}
col
}
pub(super) fn conv_forward(
input: &Tensor,
weights: &[f32],
weight_shape: &[usize],
bias: &[f32],
strides: &[usize],
padding: PaddingType,
) -> Result<Tensor, Error> {
conv_forward_impl(input, weights, weight_shape, bias, strides, padding, None)
}
pub fn conv_forward_impl(
input: &Tensor,
weights: &[f32],
weight_shape: &[usize],
bias: &[f32],
strides: &[usize],
padding: PaddingType,
force_parallel: Option<bool>,
) -> Result<Tensor, Error> {
let in_shape = input.shape();
let r = in_shape.len() - 2;
let batch = in_shape[0];
let sp = &in_shape[1..1 + r];
let cin = in_shape[1 + r];
let filters = weight_shape[r + 1];
let k_dims = &weight_shape[..r];
let k_plane: usize = k_dims.iter().product();
let (out_sp, pad_before, padded_sp) = conv_geometry(sp, k_dims, strides, padding)?;
let out_plane: usize = out_sp.iter().product();
let padded_item: usize = padded_sp.iter().product::<usize>() * cin;
let padded_strides = spatial_strides(&padded_sp, cin);
let input_std = input.as_standard_layout();
let in_flat = input_std
.as_slice()
.expect("standard-layout array is contiguous");
let padded_storage = if padded_sp.as_slice() != sp {
Some(build_padded(
in_flat,
batch,
sp,
&padded_sp,
&pad_before,
cin,
))
} else {
None
};
let padded: &[f32] = padded_storage.as_deref().unwrap_or(in_flat);
let k_total = k_plane * cin;
let offsets = im2col_offsets(&out_sp, k_dims, strides, &padded_strides);
let w_mat = ArrayView2::from_shape((k_total, filters), weights)
.expect("weights length matches [k*Cin, F]");
let ctx = ColContext {
padded,
cin,
padded_item,
k_plane,
out_plane,
offsets: &offsets,
};
let fill_block = |b: usize, c0: usize, mut blk: ArrayViewMut2<f32>| {
let rows = blk.nrows();
let col = build_col_range(&ctx, b, c0, c0 + rows);
let col_mat = ArrayView2::from_shape((rows, k_total), &col)
.expect("col block length matches [positions, k*Cin]");
gemmkit_ndarray::gemm_fused(
1.0,
&col_mat,
&w_mat,
0.0,
&mut blk,
Some(Bias::PerCol(bias)),
None,
Parallelism::Serial,
);
};
let gemm_flops = 2usize
.saturating_mul(batch)
.saturating_mul(filters)
.saturating_mul(out_plane)
.saturating_mul(k_total);
let parallel = force_parallel.unwrap_or(gemm_flops >= conv_parallel_min_flops());
let mut out3 = Array3::<f32>::zeros((batch, out_plane, filters));
if parallel {
let chunks_per_item = rayon::current_num_threads().div_ceil(batch);
let chunk_rows = out_plane
.div_ceil(chunks_per_item)
.max(CONV_MIN_CHUNK_POSITIONS);
out3.axis_iter_mut(Axis(0))
.into_par_iter()
.enumerate()
.for_each(|(b, mut out_b)| {
out_b
.axis_chunks_iter_mut(Axis(0), chunk_rows)
.into_par_iter()
.enumerate()
.for_each(|(ci, blk)| fill_block(b, ci * chunk_rows, blk));
});
} else {
for (b, mut out_b) in out3.axis_iter_mut(Axis(0)).enumerate() {
fill_block(b, 0, out_b.view_mut());
}
}
let mut out_shape = Vec::with_capacity(2 + r);
out_shape.push(batch);
out_shape.extend_from_slice(&out_sp);
out_shape.push(filters);
Ok(out3
.into_shape_with_order(IxDyn(&out_shape))
.expect("conv output length matches shape"))
}
pub(super) fn conv_backward(
grad_output: &Tensor,
input: &Tensor,
weights: &[f32],
weight_shape: &[usize],
strides: &[usize],
padding: PaddingType,
) -> Result<ConvGradients, Error> {
let in_shape = input.shape();
let r = in_shape.len() - 2;
let batch = in_shape[0];
let sp = &in_shape[1..1 + r];
let cin = in_shape[1 + r];
let filters = weight_shape[r + 1];
let k_dims = &weight_shape[..r];
let k_plane: usize = k_dims.iter().product();
let (out_sp, pad_before, padded_sp) = conv_geometry(sp, k_dims, strides, padding)?;
let out_plane: usize = out_sp.iter().product();
let in_item: usize = sp.iter().product::<usize>() * cin;
let padded_item: usize = padded_sp.iter().product::<usize>() * cin;
let padded_strides = spatial_strides(&padded_sp, cin);
let input_std = input.as_standard_layout();
let in_flat = input_std
.as_slice()
.expect("standard-layout array is contiguous");
let padded_storage = if padded_sp.as_slice() != sp {
Some(build_padded(
in_flat,
batch,
sp,
&padded_sp,
&pad_before,
cin,
))
} else {
None
};
let padded: &[f32] = padded_storage.as_deref().unwrap_or(in_flat);
let grad_std = grad_output.as_standard_layout();
let grad_flat = grad_std
.as_slice()
.expect("standard-layout array is contiguous");
let k_total = k_plane * cin;
let offsets = im2col_offsets(&out_sp, k_dims, strides, &padded_strides);
let w_mat = ArrayView2::from_shape((k_total, filters), weights)
.expect("weights length matches [k*Cin, F]");
let ctx = ColContext {
padded,
cin,
padded_item,
k_plane,
out_plane,
offsets: &offsets,
};
let process_b = |b: usize, gemm_par: Parallelism| -> (Array2<f32>, Vec<f32>, Vec<f32>) {
let col = build_col_range(&ctx, b, 0, out_plane);
let col_mat = ArrayView2::from_shape((out_plane, k_total), &col)
.expect("col length matches [out_plane, k*Cin]");
let g_slice = &grad_flat[b * out_plane * filters..(b + 1) * out_plane * filters];
let g_mat = ArrayView2::from_shape((out_plane, filters), g_slice)
.expect("grad slice matches [out_plane, F]");
let wg = dot_par(&col_mat.t(), &g_mat, gemm_par);
let dcol = dot_par(&g_mat, &w_mat.t(), gemm_par);
let bias_p: Vec<f32> = g_mat.sum_axis(Axis(0)).to_vec();
let dcol = dcol.as_slice().expect("matmul result is standard layout");
let mut pad_grad = vec![0.0f32; padded_item];
for kk in 0..k_plane {
let off = kk * out_plane;
let kbase = kk * cin;
for o in 0..out_plane {
let dst = offsets[off + o];
let src = o * k_total + kbase;
for c in 0..cin {
pad_grad[dst + c] += dcol[src + c];
}
}
}
let input_grad_b = if padded_sp.as_slice() != sp {
crop_padded(&pad_grad, 1, sp, &padded_sp, &pad_before, cin)
} else {
pad_grad
};
(wg, bias_p, input_grad_b)
};
let gemm_flops = 4usize
.saturating_mul(batch)
.saturating_mul(filters)
.saturating_mul(out_plane)
.saturating_mul(k_total);
let parallel = gemm_flops >= conv_parallel_min_flops();
let gemm_par = if parallel && batch >= rayon::current_num_threads() {
Parallelism::Serial
} else {
Parallelism::Rayon(0)
};
let per_b = map_indexed(batch, parallel, |b| process_b(b, gemm_par));
let mut weight_grad_arr = Array2::<f32>::zeros((k_total, filters));
let mut bias_grad = vec![0.0f32; filters];
let mut in_grad_flat = Vec::with_capacity(batch * in_item);
for (wg, bias_p, ig_b) in per_b {
weight_grad_arr += &wg;
for (acc, v) in bias_grad.iter_mut().zip(bias_p) {
*acc += v;
}
in_grad_flat.extend(ig_b);
}
let weight_grad = weight_grad_arr.into_raw_vec_and_offset().0;
let mut ig_shape = Vec::with_capacity(2 + r);
ig_shape.push(batch);
ig_shape.extend_from_slice(sp);
ig_shape.push(cin);
let input_grad =
ArrayD::from_shape_vec(IxDyn(&ig_shape), in_grad_flat).expect("input grad matches shape");
Ok(ConvGradients {
weight_grad,
bias_grad,
input_grad,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spatial_strides_3d() {
let got = spatial_strides(&[2, 3, 4], 8);
assert_eq!(got, vec![96, 32, 8]);
}
#[test]
fn test_spatial_strides_single_channel() {
let got = spatial_strides(&[2, 3, 4], 1);
assert_eq!(got, vec![12, 4, 1]);
}
#[test]
fn test_spatial_strides_1d() {
let got = spatial_strides(&[5], 3);
assert_eq!(got, vec![3]);
}
#[test]
fn test_spatial_strides_empty() {
let got = spatial_strides(&[], 4);
assert_eq!(got, Vec::<usize>::new());
}
#[test]
fn test_increment_index_2d() {
let dims = [2usize, 3];
let mut idx = vec![0usize, 0];
assert!(increment_index(&mut idx, &dims));
assert_eq!(idx, vec![0, 1]);
assert!(increment_index(&mut idx, &dims));
assert_eq!(idx, vec![0, 2]);
assert!(increment_index(&mut idx, &dims));
assert_eq!(idx, vec![1, 0]);
assert!(increment_index(&mut idx, &dims));
assert_eq!(idx, vec![1, 1]);
assert!(increment_index(&mut idx, &dims));
assert_eq!(idx, vec![1, 2]);
assert!(!increment_index(&mut idx, &dims));
assert_eq!(idx, vec![0, 0]);
}
#[test]
fn test_increment_index_1d() {
let dims = [3usize];
let mut idx = vec![0usize];
assert!(increment_index(&mut idx, &dims));
assert_eq!(idx, vec![1]);
assert!(increment_index(&mut idx, &dims));
assert_eq!(idx, vec![2]);
assert!(!increment_index(&mut idx, &dims));
}
#[test]
fn test_conv_geometry_valid_1d() {
let (out_sp, pad_before, padded_sp) =
conv_geometry(&[5], &[3], &[1], PaddingType::Valid).unwrap();
assert_eq!(out_sp, vec![3]);
assert_eq!(pad_before, vec![0]);
assert_eq!(padded_sp, vec![5]);
}
#[test]
fn test_conv_geometry_valid_input_smaller_than_kernel_errors() {
let result = conv_geometry(&[2], &[3], &[1], PaddingType::Valid);
assert!(
matches!(result, Err(Error::InvalidInput(_))),
"expected InvalidInput, got {:?}",
result
);
}
#[test]
fn test_conv_geometry_same_1d() {
let (out_sp, pad_before, padded_sp) =
conv_geometry(&[7], &[3], &[2], PaddingType::Same).unwrap();
assert_eq!(out_sp, vec![4]);
assert_eq!(pad_before, vec![1]);
assert_eq!(padded_sp, vec![9]);
}
#[test]
fn test_conv_geometry_same_2d() {
let (out_sp, pad_before, padded_sp) =
conv_geometry(&[4, 4], &[3, 3], &[1, 1], PaddingType::Same).unwrap();
assert_eq!(out_sp, vec![4, 4]);
assert_eq!(pad_before, vec![1, 1]);
assert_eq!(padded_sp, vec![6, 6]);
}
#[test]
fn test_build_padded_2x2_into_4x4() {
let in_flat = [1.0f32, 2.0, 3.0, 4.0];
let got = build_padded(&in_flat, 1, &[2, 2], &[4, 4], &[1, 1], 1);
assert_eq!(got.len(), 16, "padded buffer should have 16 elements");
assert_eq!(got[5], 1.0, "padded[5] should be in[0,0]=1.0");
assert_eq!(got[6], 2.0, "padded[6] should be in[0,1]=2.0");
assert_eq!(got[9], 3.0, "padded[9] should be in[1,0]=3.0");
assert_eq!(got[10], 4.0, "padded[10] should be in[1,1]=4.0");
let non_zero_positions = [5usize, 6, 9, 10];
for (i, &val) in got.iter().enumerate() {
if !non_zero_positions.contains(&i) {
assert_eq!(val, 0.0, "padded[{i}] should be 0.0 (border), got {val}");
}
}
}
#[test]
fn test_build_padded_two_items() {
let in_flat = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
let got = build_padded(&in_flat, 2, &[2, 2], &[4, 4], &[1, 1], 1);
assert_eq!(got.len(), 32);
assert_eq!(got[5], 1.0);
assert_eq!(got[6], 2.0);
assert_eq!(got[9], 3.0);
assert_eq!(got[10], 4.0);
assert_eq!(got[16 + 5], 5.0);
assert_eq!(got[16 + 6], 6.0);
assert_eq!(got[16 + 9], 7.0);
assert_eq!(got[16 + 10], 8.0);
}
#[test]
fn test_build_padded_keeps_channels_contiguous() {
let in_flat = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
let got = build_padded(&in_flat, 1, &[2, 2], &[4, 4], &[1, 1], 2);
assert_eq!(got.len(), 32, "4*4 positions x 2 channels");
assert_eq!(&got[10..12], &[1.0, 2.0], "padded (1,1)");
assert_eq!(&got[12..14], &[3.0, 4.0], "padded (1,2)");
assert_eq!(&got[18..20], &[5.0, 6.0], "padded (2,1)");
assert_eq!(&got[20..22], &[7.0, 8.0], "padded (2,2)");
for (i, &val) in got.iter().enumerate() {
if !(10..14).contains(&i) && !(18..22).contains(&i) {
assert_eq!(val, 0.0, "padded[{i}] should be 0.0 (border), got {val}");
}
}
}
#[test]
fn test_build_padded_1d() {
let in_flat = [10.0f32, 20.0, 30.0];
let got = build_padded(&in_flat, 1, &[3], &[5], &[1], 1);
assert_eq!(got.len(), 5);
assert_eq!(got[0], 0.0, "leading pad must be 0");
assert_eq!(got[1], 10.0);
assert_eq!(got[2], 20.0);
assert_eq!(got[3], 30.0);
assert_eq!(got[4], 0.0, "trailing pad must be 0");
}
#[test]
fn test_build_padded_no_padding() {
let in_flat = [5.0f32, 6.0, 7.0, 8.0];
let got = build_padded(&in_flat, 1, &[2, 2], &[2, 2], &[0, 0], 1);
assert_eq!(got, vec![5.0f32, 6.0, 7.0, 8.0]);
}
#[test]
fn test_crop_padded_roundtrip() {
let in_flat = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
let padded = build_padded(&in_flat, 2, &[2, 2], &[4, 4], &[1, 1], 1);
let got = crop_padded(&padded, 2, &[2, 2], &[4, 4], &[1, 1], 1);
assert_eq!(got, in_flat.to_vec());
}
#[test]
fn test_crop_padded_roundtrip_multichannel() {
let in_flat: Vec<f32> = (0..24).map(|v| v as f32).collect(); let padded = build_padded(&in_flat, 2, &[2, 2], &[4, 4], &[1, 1], 3);
let got = crop_padded(&padded, 2, &[2, 2], &[4, 4], &[1, 1], 3);
assert_eq!(got, in_flat);
}
#[test]
fn test_crop_padded_1d() {
let padded = [0.0f32, 10.0, 20.0, 30.0, 0.0];
let got = crop_padded(&padded, 1, &[3], &[5], &[1], 1);
assert_eq!(got, vec![10.0f32, 20.0, 30.0]);
}
#[test]
fn test_im2col_offsets_1d() {
let offsets = im2col_offsets(&[3], &[3], &[1], &spatial_strides(&[5], 1));
assert_eq!(
offsets,
vec![
0, 1, 2, 1, 2, 3, 2, 3, 4
]
);
}
#[test]
fn test_im2col_offsets_1d_multichannel() {
let offsets = im2col_offsets(&[3], &[3], &[1], &spatial_strides(&[5], 2));
assert_eq!(
offsets,
vec![
0, 2, 4, 2, 4, 6, 4, 6, 8
]
);
}
#[test]
fn test_conv_forward_2d_two_channels_hand_derived() {
let input = ArrayD::from_shape_vec(
IxDyn(&[1, 3, 3, 2]),
vec![
1.0, 10.0, 2.0, 20.0, 3.0, 30.0, 4.0, 40.0, 5.0, 50.0, 6.0, 60.0, 7.0, 70.0, 8.0, 80.0, 9.0, 90.0, ],
)
.unwrap();
let w_shape = [2usize, 2, 2, 1];
let weights = vec![1.0f32; w_shape.iter().product()];
let bias = [0.5f32];
let out = conv_forward(
&input,
&weights,
&w_shape,
&bias,
&[1, 1],
PaddingType::Valid,
)
.unwrap();
assert_eq!(out.shape(), &[1, 2, 2, 1]);
let got = out.iter().copied().collect::<Vec<f32>>();
assert_eq!(got, vec![132.5, 176.5, 264.5, 308.5]);
}
#[test]
fn test_conv_forward_2d_filter_axis_is_innermost() {
let input = ArrayD::from_shape_vec(IxDyn(&[1, 2, 2, 1]), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
let weights = vec![1.0f32, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0];
let bias = [0.0f32, 0.0];
let out = conv_forward(
&input,
&weights,
&[2, 2, 1, 2],
&bias,
&[1, 1],
PaddingType::Valid,
)
.unwrap();
assert_eq!(out.shape(), &[1, 1, 1, 2]);
assert_eq!(out.iter().copied().collect::<Vec<f32>>(), vec![10.0, 20.0]);
}
#[test]
fn test_conv_forward_1d_same_padding_hand_derived() {
let input = ArrayD::from_shape_vec(IxDyn(&[1, 4, 1]), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
let weights = vec![1.0f32; 3];
let bias = [0.0f32];
let out =
conv_forward(&input, &weights, &[3, 1, 1], &bias, &[1], PaddingType::Same).unwrap();
assert_eq!(out.shape(), &[1, 4, 1]);
assert_eq!(
out.iter().copied().collect::<Vec<f32>>(),
vec![3.0, 6.0, 9.0, 7.0]
);
}
}