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        // Resample D (axis 2), then H (axis 3), then W (axis 4). Each pass is a
328        // 1-D separable resample of one axis — order does not matter.
329        let mut y = x;
330        for (i, &out_len) in out_dhw.iter().enumerate() {
331            y = self.resample_axis(y, 2 + i, out_len, mode, align_corners);
332        }
333        y
334    }
335
336    /// Resample a single `axis` of `x` from its current length to `out_len` via
337    /// a `[L_in, L_out]` matmul, moving `axis` to the last position first.
338    fn resample_axis(
339        &mut self,
340        x: NodeId,
341        axis: usize,
342        out_len: usize,
343        mode: InterpMode,
344        align_corners: bool,
345    ) -> NodeId {
346        let xs = self.shape(x).clone();
347        let rank = xs.rank();
348        let in_len = xs.dim(axis).unwrap_static();
349        if in_len == out_len {
350            return x;
351        }
352        // Move `axis` to last: perm = [all other axes in order] ++ [axis].
353        let mut perm: Vec<usize> = (0..rank).filter(|&d| d != axis).collect();
354        perm.push(axis);
355        let xp = self.transpose_(x, perm.clone());
356        let ps = self.shape(xp).clone();
357        let batch: usize = (0..rank - 1).map(|d| ps.dim(d).unwrap_static()).product();
358
359        let w = interp_matrix(in_len, out_len, mode, align_corners);
360        let w_node = self.const_f32_tensor(w, &[in_len, out_len]);
361        let x2 = self.reshape_(xp, vec![batch as i64, in_len as i64]);
362        let y2 = self.mm(x2, w_node); // [batch, out_len]
363
364        let mut out_perm_dims: Vec<i64> = (0..rank - 1)
365            .map(|d| ps.dim(d).unwrap_static() as i64)
366            .collect();
367        out_perm_dims.push(out_len as i64);
368        let yp = self.reshape_(y2, out_perm_dims);
369
370        // Inverse permutation: inv[original_axis] = position_in_perm.
371        let mut inv = vec![0usize; rank];
372        for (new_pos, &old_axis) in perm.iter().enumerate() {
373            inv[old_axis] = new_pos;
374        }
375        self.transpose_(yp, inv)
376    }
377}
378
379/// Host-build the `[L_in, L_out]` resample matrix `W` so that
380/// `out[.., j] = Σ_i x[.., i]·W[i, j]`.
381fn interp_matrix(l_in: usize, l_out: usize, mode: InterpMode, align_corners: bool) -> Vec<f32> {
382    let mut w = vec![0f32; l_in * l_out];
383    for j in 0..l_out {
384        // Source position for output index j.
385        let pos = if align_corners {
386            if l_out == 1 {
387                0.0
388            } else {
389                j as f32 * (l_in as f32 - 1.0) / (l_out as f32 - 1.0)
390            }
391        } else {
392            // Half-pixel (MONAI / PyTorch align_corners=False), clamped so the
393            // boundary output taps land exactly on the first/last source sample.
394            let p = (j as f32 + 0.5) * (l_in as f32) / (l_out as f32) - 0.5;
395            p.clamp(0.0, l_in as f32 - 1.0)
396        };
397        match mode {
398            InterpMode::Nearest => {
399                let i = (pos + 0.5).floor() as usize;
400                let i = i.min(l_in - 1);
401                w[i * l_out + j] = 1.0;
402            }
403            InterpMode::Linear => {
404                let i0 = pos.floor() as usize;
405                let i0 = i0.min(l_in - 1);
406                let i1 = (i0 + 1).min(l_in - 1);
407                let frac = pos - i0 as f32;
408                w[i0 * l_out + j] += 1.0 - frac;
409                w[i1 * l_out + j] += frac;
410            }
411        }
412    }
413    w
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::{DType, Shape};
420
421    fn dims(g: &Graph, id: NodeId) -> Vec<usize> {
422        g.shape(id)
423            .dims()
424            .iter()
425            .map(|d| d.unwrap_static())
426            .collect()
427    }
428
429    #[test]
430    fn conv3d_output_shape_matches_formula() {
431        let mut g = Graph::new("conv3d");
432        let x = g.input("x", Shape::new(&[1, 2, 8, 8, 8], DType::F32));
433        let w = g.param("w", Shape::new(&[4, 2, 3, 3, 3], DType::F32));
434        let y = g.conv3d(x, w, [1, 1, 1], [1, 1, 1], [1, 1, 1], 1);
435        // X_out = (8 + 2 - 2 - 1)/1 + 1 = 8 (same padding for k=3, s=1, p=1).
436        assert_eq!(dims(&g, y), vec![1, 4, 8, 8, 8]);
437    }
438
439    #[test]
440    fn conv_transpose3d_upsamples_by_stride() {
441        let mut g = Graph::new("ct3d");
442        let x = g.input("x", Shape::new(&[1, 3, 4, 4, 4], DType::F32));
443        let w = g.param("w", Shape::new(&[3, 5, 2, 2, 2], DType::F32));
444        let y = g.conv_transpose3d(x, w, [2, 2, 2], [0, 0, 0], [1, 1, 1], [0, 0, 0], 1);
445        // X_out = (4-1)*2 - 0 + 1*(2-1) + 0 + 1 = 6 + 1 + 1 = 8
446        assert_eq!(dims(&g, y), vec![1, 5, 8, 8, 8]);
447    }
448
449    #[test]
450    fn interpolate3d_shape() {
451        let mut g = Graph::new("interp3d");
452        let x = g.input("x", Shape::new(&[1, 2, 2, 3, 4], DType::F32));
453        let y = g.interpolate3d(x, [4, 6, 8], InterpMode::Linear, false);
454        assert_eq!(dims(&g, y), vec![1, 2, 4, 6, 8]);
455    }
456}