Skip to main content

rlx_ir/ops/
conv3d.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! NCDHW 3-D convolution builders (`conv3d`, `conv_transpose3d`) and a
17//! separable 3-D resize (`interpolate3d`) for MONAI-style 3-D U-Net decoders
18//! (UNETR / SwinUNETR).
19//!
20//! `conv3d` / `conv_transpose3d` add new `Op::Conv3d` / `Op::ConvTranspose3d`
21//! nodes (dedicated CPU kernels). `interpolate3d` is a PURE DECOMPOSITION — no
22//! new op — that resamples D, H, W one axis at a time by moving that axis last
23//! (`transpose_`), applying a host-built `[L_in, L_out]` matrix via `mm`, and
24//! moving it back. So it lowers entirely to existing kernels and runs on every
25//! backend, matching the 1-D idea in [`super::upsample::Graph::interpolate1d`].
26
27use crate::infer::GraphExt as _;
28use crate::ops::upsample::InterpMode;
29use crate::{Graph, NodeId, Op};
30
31impl Graph {
32    /// 3-D convolution on NCDHW tensors (`Op::Conv3d`).
33    ///
34    /// * `input`  — `[N, C_in, D, H, W]`.
35    /// * `weight` — `[C_out, C_in/groups, kD, kH, kW]` (PyTorch `Conv3d` layout).
36    ///
37    /// Kernel size is read from the weight. Returns `[N, C_out, D_out, H_out,
38    /// W_out]` with `X_out = floor((X + 2·p − dil·(K−1) − 1) / stride) + 1`.
39    pub fn conv3d(
40        &mut self,
41        input: NodeId,
42        weight: NodeId,
43        stride: [usize; 3],
44        padding: [usize; 3],
45        dilation: [usize; 3],
46        groups: usize,
47    ) -> NodeId {
48        let in_s = self.node(input).shape.clone();
49        let w_s = self.node(weight).shape.clone();
50        assert_eq!(in_s.rank(), 5, "conv3d: input must be [N, C_in, D, H, W]");
51        assert_eq!(
52            w_s.rank(),
53            5,
54            "conv3d: weight must be [C_out, C_in/groups, kD, kH, kW]"
55        );
56        let ks = [
57            w_s.dim(2).unwrap_static(),
58            w_s.dim(3).unwrap_static(),
59            w_s.dim(4).unwrap_static(),
60        ];
61        let out =
62            crate::shape::conv3d_output_shape(&in_s, &w_s, ks, stride, padding, dilation, groups)
63                .expect("conv3d shape inference");
64        self.push(
65            Op::Conv3d {
66                stride,
67                padding,
68                dilation,
69                groups,
70            },
71            vec![input, weight],
72            out,
73            None,
74        )
75    }
76
77    /// 3-D transposed convolution on NCDHW (`Op::ConvTranspose3d`), the learned
78    /// upsampler in MONAI 3-D U-Net decoders.
79    ///
80    /// * `input`  — `[N, C_in, D, H, W]`.
81    /// * `weight` — `[C_in, C_out/groups, kD, kH, kW]` (PyTorch `ConvTranspose3d`).
82    ///
83    /// Returns `[N, C_out, D_out, H_out, W_out]` with
84    /// `X_out = (X−1)·stride − 2·p + dil·(K−1) + output_padding + 1`.
85    #[allow(clippy::too_many_arguments)]
86    pub fn conv_transpose3d(
87        &mut self,
88        input: NodeId,
89        weight: NodeId,
90        stride: [usize; 3],
91        padding: [usize; 3],
92        dilation: [usize; 3],
93        output_padding: [usize; 3],
94        groups: usize,
95    ) -> NodeId {
96        let in_s = self.node(input).shape.clone();
97        let w_s = self.node(weight).shape.clone();
98        assert_eq!(
99            in_s.rank(),
100            5,
101            "conv_transpose3d: input must be [N, C_in, D, H, W]"
102        );
103        assert_eq!(
104            w_s.rank(),
105            5,
106            "conv_transpose3d: weight must be [C_in, C_out/groups, kD, kH, kW]"
107        );
108        let ks = [
109            w_s.dim(2).unwrap_static(),
110            w_s.dim(3).unwrap_static(),
111            w_s.dim(4).unwrap_static(),
112        ];
113        let out = crate::shape::conv_transpose3d_output_shape(
114            &in_s,
115            &w_s,
116            ks,
117            stride,
118            padding,
119            dilation,
120            output_padding,
121            groups,
122        )
123        .expect("conv_transpose3d shape inference");
124        self.push(
125            Op::ConvTranspose3d {
126                stride,
127                padding,
128                dilation,
129                output_padding,
130                groups,
131            },
132            vec![input, weight],
133            out,
134            None,
135        )
136    }
137
138    /// Resample the spatial `[D, H, W]` axes of a `[N, C, D, H, W]` tensor to
139    /// `out_dhw`, as a pure decomposition into `transpose_`/`reshape_`/`mm`
140    /// (no new op). `InterpMode::Linear` gives separable trilinear;
141    /// `InterpMode::Nearest` replicates the nearest source voxel.
142    ///
143    /// `align_corners` selects the sampling grid (exposed so a decoder can
144    /// match its reference):
145    /// * `true`  — endpoint mapping `pos = j·(L_in−1)/(L_out−1)`
146    ///   (`torch.nn.functional.interpolate(..., align_corners=True)`).
147    /// * `false` — half-pixel mapping `pos = (j+0.5)·L_in/L_out − 0.5`, clamped
148    ///   to `[0, L_in−1]` — what MONAI UNETR's input branch uses
149    ///   (`trilinear, align_corners=False`).
150    pub fn interpolate3d(
151        &mut self,
152        x: NodeId,
153        out_dhw: [usize; 3],
154        mode: InterpMode,
155        align_corners: bool,
156    ) -> NodeId {
157        let xs = self.shape(x).clone();
158        assert_eq!(xs.rank(), 5, "interpolate3d: input must be [N, C, D, H, W]");
159        assert!(
160            out_dhw.iter().all(|&l| l > 0),
161            "interpolate3d: out_dhw must be positive"
162        );
163        // Resample D (axis 2), then H (axis 3), then W (axis 4). Each pass is a
164        // 1-D separable resample of one axis — order does not matter.
165        let mut y = x;
166        for (i, &out_len) in out_dhw.iter().enumerate() {
167            y = self.resample_axis(y, 2 + i, out_len, mode, align_corners);
168        }
169        y
170    }
171
172    /// Resample a single `axis` of `x` from its current length to `out_len` via
173    /// a `[L_in, L_out]` matmul, moving `axis` to the last position first.
174    fn resample_axis(
175        &mut self,
176        x: NodeId,
177        axis: usize,
178        out_len: usize,
179        mode: InterpMode,
180        align_corners: bool,
181    ) -> NodeId {
182        let xs = self.shape(x).clone();
183        let rank = xs.rank();
184        let in_len = xs.dim(axis).unwrap_static();
185        if in_len == out_len {
186            return x;
187        }
188        // Move `axis` to last: perm = [all other axes in order] ++ [axis].
189        let mut perm: Vec<usize> = (0..rank).filter(|&d| d != axis).collect();
190        perm.push(axis);
191        let xp = self.transpose_(x, perm.clone());
192        let ps = self.shape(xp).clone();
193        let batch: usize = (0..rank - 1).map(|d| ps.dim(d).unwrap_static()).product();
194
195        let w = interp_matrix(in_len, out_len, mode, align_corners);
196        let w_node = self.const_f32_tensor(w, &[in_len, out_len]);
197        let x2 = self.reshape_(xp, vec![batch as i64, in_len as i64]);
198        let y2 = self.mm(x2, w_node); // [batch, out_len]
199
200        let mut out_perm_dims: Vec<i64> = (0..rank - 1)
201            .map(|d| ps.dim(d).unwrap_static() as i64)
202            .collect();
203        out_perm_dims.push(out_len as i64);
204        let yp = self.reshape_(y2, out_perm_dims);
205
206        // Inverse permutation: inv[original_axis] = position_in_perm.
207        let mut inv = vec![0usize; rank];
208        for (new_pos, &old_axis) in perm.iter().enumerate() {
209            inv[old_axis] = new_pos;
210        }
211        self.transpose_(yp, inv)
212    }
213}
214
215/// Host-build the `[L_in, L_out]` resample matrix `W` so that
216/// `out[.., j] = Σ_i x[.., i]·W[i, j]`.
217fn interp_matrix(l_in: usize, l_out: usize, mode: InterpMode, align_corners: bool) -> Vec<f32> {
218    let mut w = vec![0f32; l_in * l_out];
219    for j in 0..l_out {
220        // Source position for output index j.
221        let pos = if align_corners {
222            if l_out == 1 {
223                0.0
224            } else {
225                j as f32 * (l_in as f32 - 1.0) / (l_out as f32 - 1.0)
226            }
227        } else {
228            // Half-pixel (MONAI / PyTorch align_corners=False), clamped so the
229            // boundary output taps land exactly on the first/last source sample.
230            let p = (j as f32 + 0.5) * (l_in as f32) / (l_out as f32) - 0.5;
231            p.clamp(0.0, l_in as f32 - 1.0)
232        };
233        match mode {
234            InterpMode::Nearest => {
235                let i = (pos + 0.5).floor() as usize;
236                let i = i.min(l_in - 1);
237                w[i * l_out + j] = 1.0;
238            }
239            InterpMode::Linear => {
240                let i0 = pos.floor() as usize;
241                let i0 = i0.min(l_in - 1);
242                let i1 = (i0 + 1).min(l_in - 1);
243                let frac = pos - i0 as f32;
244                w[i0 * l_out + j] += 1.0 - frac;
245                w[i1 * l_out + j] += frac;
246            }
247        }
248    }
249    w
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::{DType, Shape};
256
257    fn dims(g: &Graph, id: NodeId) -> Vec<usize> {
258        g.shape(id)
259            .dims()
260            .iter()
261            .map(|d| d.unwrap_static())
262            .collect()
263    }
264
265    #[test]
266    fn conv3d_output_shape_matches_formula() {
267        let mut g = Graph::new("conv3d");
268        let x = g.input("x", Shape::new(&[1, 2, 8, 8, 8], DType::F32));
269        let w = g.param("w", Shape::new(&[4, 2, 3, 3, 3], DType::F32));
270        let y = g.conv3d(x, w, [1, 1, 1], [1, 1, 1], [1, 1, 1], 1);
271        // X_out = (8 + 2 - 2 - 1)/1 + 1 = 8 (same padding for k=3, s=1, p=1).
272        assert_eq!(dims(&g, y), vec![1, 4, 8, 8, 8]);
273    }
274
275    #[test]
276    fn conv_transpose3d_upsamples_by_stride() {
277        let mut g = Graph::new("ct3d");
278        let x = g.input("x", Shape::new(&[1, 3, 4, 4, 4], DType::F32));
279        let w = g.param("w", Shape::new(&[3, 5, 2, 2, 2], DType::F32));
280        let y = g.conv_transpose3d(x, w, [2, 2, 2], [0, 0, 0], [1, 1, 1], [0, 0, 0], 1);
281        // X_out = (4-1)*2 - 0 + 1*(2-1) + 0 + 1 = 6 + 1 + 1 = 8
282        assert_eq!(dims(&g, y), vec![1, 5, 8, 8, 8]);
283    }
284
285    #[test]
286    fn interpolate3d_shape() {
287        let mut g = Graph::new("interp3d");
288        let x = g.input("x", Shape::new(&[1, 2, 2, 3, 4], DType::F32));
289        let y = g.interpolate3d(x, [4, 6, 8], InterpMode::Linear, false);
290        assert_eq!(dims(&g, y), vec![1, 2, 4, 6, 8]);
291    }
292}