Skip to main content

rlx_ir/ops/
upsample.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//! 1-D upsampling helpers for time-series U-Net decoders and diffusion
6//! samplers (U-Sleep, EEGDM, …).
7//!
8//! `conv_transpose1d` wraps the existing 2-D transposed convolution with a
9//! singleton height axis. `interpolate1d` resamples the last axis by an
10//! arbitrary factor using a host-built `[L_in, L_out]` interpolation matrix
11//! applied as a matmul — so both lower entirely to existing kernels and run
12//! on every backend.
13
14use crate::infer::GraphExt as _;
15use crate::{Graph, NodeId};
16
17/// Interpolation mode for [`Graph::interpolate1d`].
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum InterpMode {
20    /// Nearest-neighbor (round to closest source sample).
21    Nearest,
22    /// Linear interpolation between the two neighboring source samples.
23    Linear,
24}
25
26impl Graph {
27    /// Transposed 1-D convolution (a.k.a. deconvolution / fractionally-strided
28    /// conv), the learned upsampler in Conv1d U-Net decoders.
29    ///
30    /// * `input`  — `[N, C_in, L]`.
31    /// * `weight` — `[C_in, C_out/groups, K]` (PyTorch `ConvTranspose1d` layout).
32    ///
33    /// Returns `[N, C_out, L_out]` with
34    /// `L_out = (L−1)·stride − 2·padding + dilation·(K−1) + output_padding + 1`.
35    #[allow(clippy::too_many_arguments)]
36    pub fn conv_transpose1d(
37        &mut self,
38        input: NodeId,
39        weight: NodeId,
40        kernel: usize,
41        stride: usize,
42        padding: usize,
43        dilation: usize,
44        output_padding: usize,
45        groups: usize,
46    ) -> NodeId {
47        let in_s = self.shape(input).clone();
48        let w_s = self.shape(weight).clone();
49        assert_eq!(
50            in_s.rank(),
51            3,
52            "conv_transpose1d: input must be [N, C_in, L]"
53        );
54        assert_eq!(
55            w_s.rank(),
56            3,
57            "conv_transpose1d: weight must be [C_in, C_out/groups, K]"
58        );
59        let n = in_s.dim(0).unwrap_static();
60        let c_in = in_s.dim(1).unwrap_static();
61        let l = in_s.dim(2).unwrap_static();
62        let c_out_g = w_s.dim(1).unwrap_static();
63
64        // Lift to NCHW with a singleton height: [N, C_in, 1, L], weight
65        // [C_in, C_out/g, 1, K], then run the 2-D transposed conv.
66        let input4 = self.reshape_(input, vec![n as i64, c_in as i64, 1, l as i64]);
67        let weight4 = self.reshape_(weight, vec![c_in as i64, c_out_g as i64, 1, kernel as i64]);
68        let out4 = self.conv_transpose2d(
69            input4,
70            weight4,
71            [1, kernel],
72            [1, stride],
73            [0, padding],
74            [1, dilation],
75            [0, output_padding],
76            groups,
77        );
78        let o = self.shape(out4).clone();
79        let c_out = o.dim(1).unwrap_static();
80        let l_out = o.dim(3).unwrap_static();
81        self.reshape_(out4, vec![n as i64, c_out as i64, l_out as i64])
82    }
83
84    /// Nearest-neighbor resample of an NCDHW volume to `size = [D, H, W]`.
85    ///
86    /// Emits native `Op::Interpolate3d`. Source index mapping:
87    /// `src = min(floor(dst * in / out), in - 1)`.
88    ///
89    /// Prefer [`Graph::interpolate3d`] (in `ops/conv3d.rs`) for mode/align_corners
90    /// control via a pure transpose/reshape/mm decomposition.
91    pub fn interpolate3d_nearest(&mut self, x: NodeId, size: [usize; 3]) -> NodeId {
92        let xs = self.shape(x).clone();
93        assert_eq!(xs.rank(), 5, "interpolate3d_nearest: NCDHW only");
94        assert!(
95            size.iter().all(|&s| s > 0),
96            "interpolate3d_nearest: size dims must be positive"
97        );
98        let out = crate::Shape::new(
99            &[
100                xs.dim(0).unwrap_static(),
101                xs.dim(1).unwrap_static(),
102                size[0],
103                size[1],
104                size[2],
105            ],
106            xs.dtype(),
107        );
108        self.push(
109            crate::Op::Interpolate3d {
110                size: size.to_vec(),
111            },
112            vec![x],
113            out,
114            None,
115        )
116    }
117
118    /// Resample the last axis of `x` from `L_in` to `l_out` samples.
119    ///
120    /// Works for any rank ≥ 1; leading axes are treated as batch. Uses
121    /// align-corners endpoint mapping (`pos = j·(L_in−1)/(l_out−1)`), matching
122    /// `torch.nn.functional.interpolate(..., align_corners=True)`.
123    pub fn interpolate1d(&mut self, x: NodeId, l_out: usize, mode: InterpMode) -> NodeId {
124        assert!(l_out > 0, "interpolate1d: l_out must be positive");
125        let xs = self.shape(x).clone();
126        let rank = xs.rank();
127        let l_in = xs.dim(rank - 1).unwrap_static();
128        if l_in == l_out {
129            return x;
130        }
131        let batch: usize = (0..rank - 1).map(|i| xs.dim(i).unwrap_static()).product();
132
133        // Host-build the [L_in, L_out] interpolation matrix W, so that
134        // out[.., j] = Σ_i x[.., i]·W[i, j].
135        let mut w = vec![0f32; l_in * l_out];
136        for j in 0..l_out {
137            let pos = if l_out == 1 {
138                0.0
139            } else {
140                j as f32 * (l_in as f32 - 1.0) / (l_out as f32 - 1.0)
141            };
142            match mode {
143                InterpMode::Nearest => {
144                    let i = (pos + 0.5) as usize;
145                    let i = i.min(l_in - 1);
146                    w[i * l_out + j] = 1.0;
147                }
148                InterpMode::Linear => {
149                    let i0 = pos.floor() as usize;
150                    let i0 = i0.min(l_in - 1);
151                    let i1 = (i0 + 1).min(l_in - 1);
152                    let frac = pos - i0 as f32;
153                    w[i0 * l_out + j] += 1.0 - frac;
154                    w[i1 * l_out + j] += frac;
155                }
156            }
157        }
158        let w_node = self.const_f32_tensor(w, &[l_in, l_out]);
159
160        // Flatten batch → [batch, L_in], matmul, restore shape.
161        let x2 = self.reshape_(x, vec![batch as i64, l_in as i64]);
162        let y2 = self.mm(x2, w_node); // [batch, l_out]
163        let mut out_dims: Vec<i64> = (0..rank - 1)
164            .map(|i| xs.dim(i).unwrap_static() as i64)
165            .collect();
166        out_dims.push(l_out as i64);
167        self.reshape_(y2, out_dims)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::{DType, Shape};
175
176    fn dims(g: &Graph, id: NodeId) -> Vec<usize> {
177        g.shape(id)
178            .dims()
179            .iter()
180            .map(|d| d.unwrap_static())
181            .collect()
182    }
183    fn input(g: &mut Graph, shape: &[usize]) -> NodeId {
184        g.input("x", Shape::new(shape, DType::F32))
185    }
186
187    #[test]
188    fn conv_transpose1d_upsamples_by_stride() {
189        let mut g = Graph::new("ct1d");
190        let x = input(&mut g, &[2, 3, 8]); // N,C_in,L
191        let w = g.param("w", Shape::new(&[3, 5, 4], DType::F32)); // C_in,C_out,K
192        let y = g.conv_transpose1d(x, w, 4, 2, 1, 1, 0, 1);
193        // L_out = (8-1)*2 - 2*1 + 1*(4-1) + 0 + 1 = 14 - 2 + 3 + 1 = 16
194        assert_eq!(dims(&g, y), vec![2, 5, 16]);
195    }
196
197    #[test]
198    fn interpolate1d_linear_shape() {
199        let mut g = Graph::new("interp");
200        let x = input(&mut g, &[2, 4, 10]);
201        let y = g.interpolate1d(x, 25, InterpMode::Linear);
202        assert_eq!(dims(&g, y), vec![2, 4, 25]);
203    }
204
205    #[test]
206    fn interpolate1d_nearest_identity() {
207        let mut g = Graph::new("interp_id");
208        let x = input(&mut g, &[3, 7]);
209        let y = g.interpolate1d(x, 7, InterpMode::Nearest);
210        assert_eq!(y, x); // no-op when l_in == l_out
211    }
212}