Skip to main content

nuvec_core/
conv.rs

1//! Convolutions, as MLX's `conv1d`.
2
3use crate::ops::{add, matmul, multiply, reshape, slice, transpose};
4use crate::{
5    Array, Category, DType, Error, Result, SliceArg, concatenate, promote_types, stack, zeros,
6};
7
8/// The 1-D convolution (cross-correlation) of `input` (`[N, L, C_in]`) with `weight`
9/// (`[C_out, K, C_in / groups]`), as MLX's `conv1d`: output `[N, L_out, C_out]` with
10/// `L_out = (L + 2·padding − dilation·(K − 1) − 1) / stride + 1`. Channels split into
11/// `groups` independent groups (`groups = C_in = C_out` is a depthwise convolution).
12/// Products accumulate in float32 and round once, as MLX's kernels.
13///
14/// ```
15/// use nuvec_core::{Array, Result, conv1d};
16///
17/// # fn main() -> Result<()> {
18/// // A moving sum of three, one channel.
19/// let x = Array::from_slice(&[1.0f32, 2.0, 3.0, 4.0, 5.0], &[1, 5, 1])?;
20/// let w = Array::from_slice(&[1.0f32, 1.0, 1.0], &[1, 3, 1])?;
21/// assert_eq!(conv1d(&x, &w, 1, 0, 1, 1)?.to_vec::<f32>()?, [6.0, 9.0, 12.0]);
22/// assert_eq!(conv1d(&x, &w, 2, 1, 1, 1)?.to_vec::<f32>()?, [3.0, 9.0, 9.0]);
23/// # Ok(())
24/// # }
25/// ```
26pub fn conv1d(
27    input: &Array,
28    weight: &Array,
29    stride: usize,
30    padding: usize,
31    dilation: usize,
32    groups: usize,
33) -> Result<Array> {
34    let invalid = |msg: String| Err(Error::InvalidArgument(format!("conv1d: {msg}")));
35    if input.ndim() != 3 || weight.ndim() != 3 {
36        return invalid(format!(
37            "input must be [N, L, C_in] and weight [C_out, K, C_in / groups], got {:?} and {:?}",
38            input.shape(),
39            weight.shape()
40        ));
41    }
42    let (l, c_in) = (input.shape()[1], input.shape()[2]);
43    let (c_out, k, c_group) = (weight.shape()[0], weight.shape()[1], weight.shape()[2]);
44    if stride == 0 || dilation == 0 || groups == 0 {
45        return invalid("stride, dilation and groups must be positive".into());
46    }
47    if c_in % groups != 0 || c_out % groups != 0 || c_group * groups != c_in {
48        return invalid(format!(
49            "{c_in} input and {c_out} output channels in {groups} groups need weights \
50             [C_out, K, {}], got {:?}",
51            c_in / groups.max(1),
52            weight.shape()
53        ));
54    }
55    let span = dilation * (k.max(1) - 1) + 1;
56    if k == 0 || l + 2 * padding < span {
57        return invalid(format!(
58            "a kernel spanning {span} positions does not fit {l} positions padded by {padding}"
59        ));
60    }
61    let dtype = promote_types(input.dtype(), weight.dtype());
62    if dtype.category() != Category::Float {
63        return Err(Error::UnsupportedDType {
64            op: "conv1d",
65            dtype,
66        });
67    }
68    if groups == c_in && c_out == c_in {
69        // Depthwise: one GPU kernel, with `general` as its fallback.
70        let op = crate::fast::FastOp::DepthwiseConv1d {
71            stride,
72            padding,
73            dilation,
74        };
75        let inputs = vec![input.astype(dtype)?, weight.astype(dtype)?];
76        return Ok(crate::fast::record_or_fallback(op, inputs)?.swap_remove(0));
77    }
78    general(input, weight, (stride, padding, dilation, groups))
79}
80
81/// [`conv1d`] composed of ordinary operations (the fallback of the depthwise kernel, and
82/// every grouped convolution): taps of the padded input in float32, weighted and summed
83/// in tap order, rounded once.
84pub(crate) fn general(
85    input: &Array,
86    weight: &Array,
87    (stride, padding, dilation, groups): (usize, usize, usize, usize),
88) -> Result<Array> {
89    let (n, l, c_in) = (input.shape()[0], input.shape()[1], input.shape()[2]);
90    let (c_out, k, c_group) = (weight.shape()[0], weight.shape()[1], weight.shape()[2]);
91    let span = dilation * (k - 1) + 1;
92    let dtype = promote_types(input.dtype(), weight.dtype());
93    let acc = if dtype == DType::F64 {
94        DType::F64
95    } else {
96        DType::F32
97    };
98    let l_out = (l + 2 * padding - span) / stride + 1;
99    let mut x = input.astype(acc)?;
100    if padding > 0 {
101        let pad = zeros(&[n, padding, c_in], acc)?;
102        x = concatenate(&[pad.clone(), x, pad], 1)?;
103    }
104    // Tap `j` of every output position: positions `j·dilation + i·stride`.
105    let tap = |j: usize| -> Result<Array> {
106        let start = (j * dilation) as isize;
107        let stop = start + ((l_out - 1) * stride + 1) as isize;
108        slice(
109            &x,
110            &[
111                SliceArg::full(),
112                SliceArg::new(Some(start), Some(stop), stride as isize),
113                SliceArg::full(),
114            ],
115        )
116    };
117    let w = weight.astype(acc)?;
118    let out = if groups == c_in && c_out == c_in {
119        // Depthwise: each channel's taps weighted by its own kernel.
120        let mut sum: Option<Array> = None;
121        for j in 0..k {
122            let wj = reshape(
123                &slice(
124                    &w,
125                    &[
126                        SliceArg::full(),
127                        SliceArg::new(Some(j as isize), Some(j as isize + 1), 1),
128                        SliceArg::full(),
129                    ],
130                )?,
131                &[c_in as isize],
132            )?;
133            let term = multiply(&tap(j)?, &wj)?;
134            sum = Some(match sum {
135                Some(s) => add(&s, &term)?,
136                None => term,
137            });
138        }
139        sum.expect("at least one tap")
140    } else {
141        // `[N·L_out, K·C_in]` columns, one product per group.
142        let cols = stack(&(0..k).map(tap).collect::<Result<Vec<_>>>()?, 2)?;
143        let per_out = c_out / groups;
144        let mut outs = Vec::with_capacity(groups);
145        for g in 0..groups {
146            let cg = slice(
147                &cols,
148                &[
149                    SliceArg::full(),
150                    SliceArg::full(),
151                    SliceArg::full(),
152                    SliceArg::new(
153                        Some((g * c_group) as isize),
154                        Some(((g + 1) * c_group) as isize),
155                        1,
156                    ),
157                ],
158            )?;
159            let cg = reshape(&cg, &[(n * l_out) as isize, (k * c_group) as isize])?;
160            let wg = slice(
161                &w,
162                &[
163                    SliceArg::new(
164                        Some((g * per_out) as isize),
165                        Some(((g + 1) * per_out) as isize),
166                        1,
167                    ),
168                    SliceArg::full(),
169                    SliceArg::full(),
170                ],
171            )?;
172            let wg = reshape(&wg, &[per_out as isize, (k * c_group) as isize])?;
173            outs.push(matmul(&cg, &transpose(&wg, &[1, 0])?)?);
174        }
175        let y = match groups {
176            1 => outs.pop().expect("one group"),
177            _ => concatenate(&outs, 1)?,
178        };
179        reshape(&y, &[n as isize, l_out as isize, c_out as isize])?
180    };
181    out.astype(dtype)
182}