Skip to main content

rlx_ir/ops/
conv3d.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! NCDHW 3-D convolution builders (`conv3d`, `conv_transpose3d`) and a
6//! separable 3-D resize (`interpolate3d`) for MONAI-style 3-D U-Net decoders
7//! (UNETR / SwinUNETR).
8//!
9//! `conv3d` / `conv_transpose3d` add new `Op::Conv3d` / `Op::ConvTranspose3d`
10//! nodes (dedicated CPU kernels). `interpolate3d` is a PURE DECOMPOSITION — no
11//! new op — that resamples D, H, W one axis at a time by moving that axis last
12//! (`transpose_`), applying a host-built `[L_in, L_out]` matrix via `mm`, and
13//! moving it back. So it lowers entirely to existing kernels and runs on every
14//! backend, matching the 1-D idea in `super::upsample::Graph::interpolate1d`.
15
16use crate::infer::GraphExt as _;
17use crate::ops::upsample::InterpMode;
18use crate::{Graph, NodeId, Op};
19
20impl Graph {
21    /// 3-D convolution on NCDHW tensors (`Op::Conv3d`).
22    ///
23    /// * `input`  — `[N, C_in, D, H, W]`.
24    /// * `weight` — `[C_out, C_in/groups, kD, kH, kW]` (PyTorch `Conv3d` layout).
25    ///
26    /// Kernel size is read from the weight. Returns `[N, C_out, D_out, H_out,
27    /// W_out]` with `X_out = floor((X + 2·p − dil·(K−1) − 1) / stride) + 1`.
28    pub fn conv3d(
29        &mut self,
30        input: NodeId,
31        weight: NodeId,
32        stride: [usize; 3],
33        padding: [usize; 3],
34        dilation: [usize; 3],
35        groups: usize,
36    ) -> NodeId {
37        let in_s = self.node(input).shape.clone();
38        let w_s = self.node(weight).shape.clone();
39        assert_eq!(in_s.rank(), 5, "conv3d: input must be [N, C_in, D, H, W]");
40        assert_eq!(
41            w_s.rank(),
42            5,
43            "conv3d: weight must be [C_out, C_in/groups, kD, kH, kW]"
44        );
45        let ks = [
46            w_s.dim(2).unwrap_static(),
47            w_s.dim(3).unwrap_static(),
48            w_s.dim(4).unwrap_static(),
49        ];
50        let out =
51            crate::shape::conv3d_output_shape(&in_s, &w_s, ks, stride, padding, dilation, groups)
52                .expect("conv3d shape inference");
53        self.push(
54            Op::Conv3d {
55                stride,
56                padding,
57                dilation,
58                groups,
59            },
60            vec![input, weight],
61            out,
62            None,
63        )
64    }
65
66    /// 3-D convolution computed as a PURE DECOMPOSITION into universal,
67    /// already-differentiable primitives (`reshape`/`concat`/`gather`/`matmul`/
68    /// `transpose`) — the same "runs on every backend, autodiff for free"
69    /// philosophy as [`Self::interpolate3d`]. Semantically identical to
70    /// [`Self::conv3d`] (cross-correlation, zero padding), so it can stand in
71    /// for `Op::Conv3d` on MLX / CUDA / Metal, which have no native 3-D conv
72    /// kernel, and it trains without a hand-written VJP.
73    ///
74    /// * `input`  — `[N, C_in, D, H, W]`.
75    /// * `weight` — `[C_out, C_in/groups, kD, kH, kW]` (PyTorch `Conv3d` layout).
76    ///
77    /// Returns `[N, C_out, D_out, H_out, W_out]` with the same output formula
78    /// as [`Self::conv3d`]. Only `groups == 1` is supported (panics otherwise).
79    ///
80    /// # Technique (im2col via axis-0 `gather`, no `Pad` op)
81    ///
82    /// The batch axis is carried as the gather **trailing** dim so the whole op
83    /// reduces to a single *axis-0, 1-D-index* gather — the one gather shape
84    /// whose autodiff (`Op::ScatterAdd`) is exact on the universal kernels
85    /// (a batched 2-D index would mis-shape both `GatherBackward` and
86    /// `ScatterAdd`).
87    ///
88    /// 1. Flatten `input` `[N, C_in, D, H, W]` → `[N, M]` (`M = C_in·D·H·W`),
89    ///    transpose to `[M, N]`, and append one **zero sentinel** row (`concat`
90    ///    with a `Constant` zero) → `table` `[M + 1, N]`; row `M` is guaranteed
91    ///    zero.
92    /// 2. Host-build a **1-D** `f32` index of length `P·K`
93    ///    (`P = D_out·H_out·W_out`, `K = C_in·kD·kH·kW`) mapping each
94    ///    (output-position, in-channel, kernel-tap) to its flattened input row;
95    ///    taps landing in the (virtual) padding map to the sentinel `M`.
96    /// 3. `gather(table, idx, axis=0)` → `[P·K, N]` (the im2col matrix for every
97    ///    batch at once). Reshape / transpose to `[P·N, K]`, `matmul` with
98    ///    `weight` reshaped to `[C_out, K]` and transposed to `[K, C_out]`, then
99    ///    reshape / transpose back to `[N, C_out, D_out, H_out, W_out]`.
100    ///
101    /// The index is `f32` because the axis-0 `Op::Gather` **and** its
102    /// `Op::ScatterAdd` VJP both read the index buffer as `f32` on CPU; index
103    /// magnitudes stay well under `2^24`, so they are represented exactly.
104    pub fn conv3d_im2col(
105        &mut self,
106        input: NodeId,
107        weight: NodeId,
108        stride: [usize; 3],
109        padding: [usize; 3],
110        dilation: [usize; 3],
111        groups: usize,
112    ) -> NodeId {
113        assert_eq!(
114            groups, 1,
115            "conv3d_im2col: only groups == 1 is supported (grouped 3-D conv is \
116             not needed by the codec); got groups = {groups}"
117        );
118        let in_s = self.node(input).shape.clone();
119        let w_s = self.node(weight).shape.clone();
120        assert_eq!(
121            in_s.rank(),
122            5,
123            "conv3d_im2col: input must be [N, C_in, D, H, W]"
124        );
125        assert_eq!(
126            w_s.rank(),
127            5,
128            "conv3d_im2col: weight must be [C_out, C_in, kD, kH, kW]"
129        );
130
131        let n = in_s.dim(0).unwrap_static();
132        let c_in = in_s.dim(1).unwrap_static();
133        let d = in_s.dim(2).unwrap_static();
134        let h = in_s.dim(3).unwrap_static();
135        let w = in_s.dim(4).unwrap_static();
136
137        let c_out = w_s.dim(0).unwrap_static();
138        assert_eq!(
139            w_s.dim(1).unwrap_static(),
140            c_in,
141            "conv3d_im2col: weight C_in dim must match input C_in for groups == 1"
142        );
143        let kd = w_s.dim(2).unwrap_static();
144        let kh = w_s.dim(3).unwrap_static();
145        let kw = w_s.dim(4).unwrap_static();
146        let ks = [kd, kh, kw];
147
148        let out =
149            crate::shape::conv3d_output_shape(&in_s, &w_s, ks, stride, padding, dilation, groups)
150                .expect("conv3d_im2col shape inference");
151        let d_out = out.dim(2).unwrap_static();
152        let h_out = out.dim(3).unwrap_static();
153        let w_out = out.dim(4).unwrap_static();
154
155        let [sd, sh, sw] = stride;
156        let [pd, ph, pw] = padding;
157        let [dd, dh, dw] = dilation;
158
159        // Flattened per-batch input length; index `m` is the zero sentinel.
160        let m = c_in * d * h * w;
161        let p = d_out * h_out * w_out; // output spatial positions (rows)
162        let k = c_in * kd * kh * kw; // in-channel × kernel taps (cols)
163
164        // Host-build the 1-D im2col gather index (length P·K, flattened [P, K]).
165        // Column order matches the weight reshaped to [C_out, K] = row-major
166        // flatten of [C_in, kD, kH, kW]:
167        //   kcol = ((ci·kD + kdi)·kH + ki)·kW + kj.
168        // Row order matches the NCDHW output spatial flatten:
169        //   prow = (od·H_out + ho)·W_out + wo.
170        // Semantics mirror the CPU `Op::Conv3d` kernel exactly (correlation,
171        // zero padding): out-of-bounds taps map to the sentinel row `m`.
172        let mut idx = vec![0f32; p * k];
173        for od in 0..d_out {
174            for ho in 0..h_out {
175                for wo in 0..w_out {
176                    let prow = (od * h_out + ho) * w_out + wo;
177                    for ci in 0..c_in {
178                        for kdi in 0..kd {
179                            // Padded (virtual) input coordinate for this tap.
180                            let dip = od * sd + kdi * dd;
181                            let di_ok = dip >= pd && (dip - pd) < d;
182                            let di = dip.wrapping_sub(pd);
183                            for ki in 0..kh {
184                                let hip = ho * sh + ki * dh;
185                                let hi_ok = hip >= ph && (hip - ph) < h;
186                                let hi = hip.wrapping_sub(ph);
187                                for kj in 0..kw {
188                                    let wip = wo * sw + kj * dw;
189                                    let wi_ok = wip >= pw && (wip - pw) < w;
190                                    let wi = wip.wrapping_sub(pw);
191                                    let kcol = ((ci * kd + kdi) * kh + ki) * kw + kj;
192                                    let flat = if di_ok && hi_ok && wi_ok {
193                                        ci * d * h * w + (di * h + hi) * w + wi
194                                    } else {
195                                        m // zero sentinel row
196                                    };
197                                    idx[prow * k + kcol] = flat as f32;
198                                }
199                            }
200                        }
201                    }
202                }
203            }
204        }
205        let idx_node = self.const_f32_tensor(idx, &[p * k]);
206
207        // table = [xᵀ ; 0] of shape [M + 1, N]: row j (j < M) holds input value
208        // at flat spatial-channel offset j for every batch; row M is the zero
209        // sentinel padding taps gather from.
210        let x_flat = self.reshape_(input, vec![n as i64, m as i64]); // [N, M]
211        let x_t = self.transpose_(x_flat, vec![1, 0]); // [M, N]
212        let zero_row = self.const_f32_tensor(vec![0.0f32; n], &[1, n]);
213        let table = self.concat_(vec![x_t, zero_row], 0); // [M + 1, N]
214
215        // im2col via a single axis-0 gather (1-D index): [P·K, N].
216        let col = self.gather_(table, idx_node, 0);
217        let col_pkn = self.reshape_(col, vec![p as i64, k as i64, n as i64]); // [P, K, N]
218        let col_pnk = self.transpose_(col_pkn, vec![0, 2, 1]); // [P, N, K]
219        let col2 = self.reshape_(col_pnk, vec![(p * n) as i64, k as i64]); // [P·N, K]
220
221        // weight [C_out, C_in, kD, kH, kW] → [C_out, K] → [K, C_out].
222        let w_flat = self.reshape_(weight, vec![c_out as i64, k as i64]);
223        let w_t = self.transpose_(w_flat, vec![1, 0]);
224
225        // [P·N, K] @ [K, C_out] = [P·N, C_out] → [P, N, C_out] → [N, C_out, P].
226        let y2 = self.mm(col2, w_t);
227        let y3 = self.reshape_(y2, vec![p as i64, n as i64, c_out as i64]);
228        let y4 = self.transpose_(y3, vec![1, 2, 0]); // [N, C_out, P]
229        self.reshape_(
230            y4,
231            vec![
232                n as i64,
233                c_out as i64,
234                d_out as i64,
235                h_out as i64,
236                w_out as i64,
237            ],
238        )
239    }
240
241    /// 3-D transposed convolution on NCDHW (`Op::ConvTranspose3d`), the learned
242    /// upsampler in MONAI 3-D U-Net decoders.
243    ///
244    /// * `input`  — `[N, C_in, D, H, W]`.
245    /// * `weight` — `[C_in, C_out/groups, kD, kH, kW]` (PyTorch `ConvTranspose3d`).
246    ///
247    /// Returns `[N, C_out, D_out, H_out, W_out]` with
248    /// `X_out = (X−1)·stride − 2·p + dil·(K−1) + output_padding + 1`.
249    #[allow(clippy::too_many_arguments)]
250    pub fn conv_transpose3d(
251        &mut self,
252        input: NodeId,
253        weight: NodeId,
254        stride: [usize; 3],
255        padding: [usize; 3],
256        dilation: [usize; 3],
257        output_padding: [usize; 3],
258        groups: usize,
259    ) -> NodeId {
260        let in_s = self.node(input).shape.clone();
261        let w_s = self.node(weight).shape.clone();
262        assert_eq!(
263            in_s.rank(),
264            5,
265            "conv_transpose3d: input must be [N, C_in, D, H, W]"
266        );
267        assert_eq!(
268            w_s.rank(),
269            5,
270            "conv_transpose3d: weight must be [C_in, C_out/groups, kD, kH, kW]"
271        );
272        let ks = [
273            w_s.dim(2).unwrap_static(),
274            w_s.dim(3).unwrap_static(),
275            w_s.dim(4).unwrap_static(),
276        ];
277        let out = crate::shape::conv_transpose3d_output_shape(
278            &in_s,
279            &w_s,
280            ks,
281            stride,
282            padding,
283            dilation,
284            output_padding,
285            groups,
286        )
287        .expect("conv_transpose3d shape inference");
288        self.push(
289            Op::ConvTranspose3d {
290                stride,
291                padding,
292                dilation,
293                output_padding,
294                groups,
295            },
296            vec![input, weight],
297            out,
298            None,
299        )
300    }
301
302    /// Resample the spatial `[D, H, W]` axes of a `[N, C, D, H, W]` tensor to
303    /// `out_dhw`, as a pure decomposition into `transpose_`/`reshape_`/`mm`
304    /// (no new op). `InterpMode::Linear` gives separable trilinear;
305    /// `InterpMode::Nearest` replicates the nearest source voxel.
306    ///
307    /// `align_corners` selects the sampling grid (exposed so a decoder can
308    /// match its reference):
309    /// * `true`  — endpoint mapping `pos = j·(L_in−1)/(L_out−1)`
310    ///   (`torch.nn.functional.interpolate(..., align_corners=True)`).
311    /// * `false` — half-pixel mapping `pos = (j+0.5)·L_in/L_out − 0.5`, clamped
312    ///   to `[0, L_in−1]` — what MONAI UNETR's input branch uses
313    ///   (`trilinear, align_corners=False`).
314    pub fn interpolate3d(
315        &mut self,
316        x: NodeId,
317        out_dhw: [usize; 3],
318        mode: InterpMode,
319        align_corners: bool,
320    ) -> NodeId {
321        let xs = self.shape(x).clone();
322        assert_eq!(xs.rank(), 5, "interpolate3d: input must be [N, C, D, H, W]");
323        assert!(
324            out_dhw.iter().all(|&l| l > 0),
325            "interpolate3d: out_dhw must be positive"
326        );
327        // Nearest at an integer upscale is voxel replication, which is a
328        // reshape, a broadcast, and a reshape — no arithmetic at all. The
329        // separable path below instead pays, per axis, a transpose, a matmul
330        // against a one-hot `[L_in, L_out]` matrix, and a transpose back:
331        // measured on Metal, six transposes and three GEMMs to replicate each
332        // volume, 3.9 ms of a 33 ms network.
333        //
334        // Splitting every axis into `(len, 1)` and broadcasting the singleton
335        // to `k` gives `[N, C, D, k, H, k, W, k]`, whose row-major order is
336        // `n, c, d, i, h, j, w, l` — exactly the order of the target
337        // `[N, C, kD, kH, kW]`. So both reshapes are free and the expand is
338        // the single unavoidable write of the output.
339        //
340        // The two index rules agree on exactly this case. With
341        // `align_corners = false` the separable path selects source
342        // `floor((j + 0.5) * in/out)`; for `out = k * in` that is `floor(j/k)`,
343        // which is what replication does. Downsampling, non-integer ratios,
344        // `align_corners`, and Linear all keep the general path, where the
345        // rules do *not* coincide.
346        if matches!(mode, InterpMode::Nearest) && !align_corners {
347            let factors: Option<Vec<usize>> = (0..3)
348                .map(|i| {
349                    let l_in = xs.dim(2 + i).unwrap_static();
350                    (out_dhw[i] >= l_in && out_dhw[i].is_multiple_of(l_in))
351                        .then_some(out_dhw[i] / l_in)
352                })
353                .collect();
354            if let Some(k) = factors {
355                let n = xs.dim(0).unwrap_static();
356                let c = xs.dim(1).unwrap_static();
357                let l: Vec<usize> = (0..3).map(|i| xs.dim(2 + i).unwrap_static()).collect();
358                let split = [n, c, l[0], 1, l[1], 1, l[2], 1];
359                let target = [n, c, l[0], k[0], l[1], k[1], l[2], k[2]];
360                let out = [n, c, out_dhw[0], out_dhw[1], out_dhw[2]];
361                let as_i64 = |v: &[usize]| v.iter().map(|&d| d as i64).collect::<Vec<i64>>();
362                let y = self.reshape_(x, as_i64(&split));
363                let y = self.add_node(
364                    crate::Op::Expand {
365                        target_shape: as_i64(&target),
366                    },
367                    vec![y],
368                    crate::Shape::new(&target, xs.dtype()),
369                );
370                return self.reshape_(y, as_i64(&out));
371            }
372        }
373        // Resample D (axis 2), then H (axis 3), then W (axis 4). Each pass is a
374        // 1-D separable resample of one axis — order does not matter.
375        let mut y = x;
376        for (i, &out_len) in out_dhw.iter().enumerate() {
377            y = self.resample_axis(y, 2 + i, out_len, mode, align_corners);
378        }
379        y
380    }
381
382    /// Resample a single `axis` of `x` from its current length to `out_len` via
383    /// a `[L_in, L_out]` matmul, moving `axis` to the last position first.
384    fn resample_axis(
385        &mut self,
386        x: NodeId,
387        axis: usize,
388        out_len: usize,
389        mode: InterpMode,
390        align_corners: bool,
391    ) -> NodeId {
392        let xs = self.shape(x).clone();
393        let rank = xs.rank();
394        let in_len = xs.dim(axis).unwrap_static();
395        if in_len == out_len {
396            return x;
397        }
398        // Move `axis` to last: perm = [all other axes in order] ++ [axis].
399        let mut perm: Vec<usize> = (0..rank).filter(|&d| d != axis).collect();
400        perm.push(axis);
401        let xp = self.transpose_(x, perm.clone());
402        let ps = self.shape(xp).clone();
403        let batch: usize = (0..rank - 1).map(|d| ps.dim(d).unwrap_static()).product();
404
405        let w = interp_matrix(in_len, out_len, mode, align_corners);
406        let w_node = self.const_f32_tensor(w, &[in_len, out_len]);
407        let x2 = self.reshape_(xp, vec![batch as i64, in_len as i64]);
408        let y2 = self.mm(x2, w_node); // [batch, out_len]
409
410        let mut out_perm_dims: Vec<i64> = (0..rank - 1)
411            .map(|d| ps.dim(d).unwrap_static() as i64)
412            .collect();
413        out_perm_dims.push(out_len as i64);
414        let yp = self.reshape_(y2, out_perm_dims);
415
416        // Inverse permutation: inv[original_axis] = position_in_perm.
417        let mut inv = vec![0usize; rank];
418        for (new_pos, &old_axis) in perm.iter().enumerate() {
419            inv[old_axis] = new_pos;
420        }
421        self.transpose_(yp, inv)
422    }
423}
424
425/// Host-build the `[L_in, L_out]` resample matrix `W` so that
426/// `out[.., j] = Σ_i x[.., i]·W[i, j]`.
427fn interp_matrix(l_in: usize, l_out: usize, mode: InterpMode, align_corners: bool) -> Vec<f32> {
428    let mut w = vec![0f32; l_in * l_out];
429    for j in 0..l_out {
430        // Source position for output index j.
431        let pos = if align_corners {
432            if l_out == 1 {
433                0.0
434            } else {
435                j as f32 * (l_in as f32 - 1.0) / (l_out as f32 - 1.0)
436            }
437        } else {
438            // Half-pixel (MONAI / PyTorch align_corners=False), clamped so the
439            // boundary output taps land exactly on the first/last source sample.
440            let p = (j as f32 + 0.5) * (l_in as f32) / (l_out as f32) - 0.5;
441            p.clamp(0.0, l_in as f32 - 1.0)
442        };
443        match mode {
444            InterpMode::Nearest => {
445                let i = (pos + 0.5).floor() as usize;
446                let i = i.min(l_in - 1);
447                w[i * l_out + j] = 1.0;
448            }
449            InterpMode::Linear => {
450                let i0 = pos.floor() as usize;
451                let i0 = i0.min(l_in - 1);
452                let i1 = (i0 + 1).min(l_in - 1);
453                let frac = pos - i0 as f32;
454                w[i0 * l_out + j] += 1.0 - frac;
455                w[i1 * l_out + j] += frac;
456            }
457        }
458    }
459    w
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use crate::{DType, Shape};
466
467    fn dims(g: &Graph, id: NodeId) -> Vec<usize> {
468        g.shape(id)
469            .dims()
470            .iter()
471            .map(|d| d.unwrap_static())
472            .collect()
473    }
474
475    #[test]
476    fn conv3d_output_shape_matches_formula() {
477        let mut g = Graph::new("conv3d");
478        let x = g.input("x", Shape::new(&[1, 2, 8, 8, 8], DType::F32));
479        let w = g.param("w", Shape::new(&[4, 2, 3, 3, 3], DType::F32));
480        let y = g.conv3d(x, w, [1, 1, 1], [1, 1, 1], [1, 1, 1], 1);
481        // X_out = (8 + 2 - 2 - 1)/1 + 1 = 8 (same padding for k=3, s=1, p=1).
482        assert_eq!(dims(&g, y), vec![1, 4, 8, 8, 8]);
483    }
484
485    #[test]
486    fn conv_transpose3d_upsamples_by_stride() {
487        let mut g = Graph::new("ct3d");
488        let x = g.input("x", Shape::new(&[1, 3, 4, 4, 4], DType::F32));
489        let w = g.param("w", Shape::new(&[3, 5, 2, 2, 2], DType::F32));
490        let y = g.conv_transpose3d(x, w, [2, 2, 2], [0, 0, 0], [1, 1, 1], [0, 0, 0], 1);
491        // X_out = (4-1)*2 - 0 + 1*(2-1) + 0 + 1 = 6 + 1 + 1 = 8
492        assert_eq!(dims(&g, y), vec![1, 5, 8, 8, 8]);
493    }
494
495    #[test]
496    fn interpolate3d_shape() {
497        let mut g = Graph::new("interp3d");
498        let x = g.input("x", Shape::new(&[1, 2, 2, 3, 4], DType::F32));
499        let y = g.interpolate3d(x, [4, 6, 8], InterpMode::Linear, false);
500        assert_eq!(dims(&g, y), vec![1, 2, 4, 6, 8]);
501    }
502}