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}