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}