Skip to main content

rlx_ir/ops/
fft_ops.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//! gpu-fft-shaped graph helpers: real-input FFT, spectrum split/merge, PSD, STFT.
6//!
7//! These methods compose primitive ops (`Op::Fft`, `narrow`, `concat`, …) into
8//! NumPy/JAX-style signal-processing building blocks. Backends that cannot lower
9//! the full subgraph (for example Metal MPSGraph on `Op::Fft`) still execute
10//! the underlying `Op::Fft` nodes via thunks or host fallback.
11
12use crate::infer::GraphExt as _;
13use crate::{DType, Graph, NodeId, Op, Shape, fft::FftNorm};
14
15impl Graph {
16    /// Zero-pad the last axis to the next power of two (no-op when already pow2).
17    pub fn pad_last_axis_to_pow2(&mut self, x: NodeId) -> NodeId {
18        let shape = self.shape(x).clone();
19        let rank = shape.rank();
20        let last = rank - 1;
21        let n = shape.dim(last).unwrap_static();
22        let n_pad = crate::fft::next_pow2(n);
23        if n_pad == n {
24            return x;
25        }
26        let pad_len = n_pad - n;
27        let mut pad_dims: Vec<usize> = shape.dims().iter().map(|d| d.unwrap_static()).collect();
28        pad_dims[last] = pad_len;
29        let pad_shape = Shape::new(&pad_dims, shape.dtype());
30        let zeros = self.zeros_tensor(&pad_shape);
31        self.concat_(vec![x, zeros], last)
32    }
33
34    /// Split a 2N real-block spectrum into separate real / imag tensors.
35    pub fn split_spectrum(&mut self, spectrum: NodeId) -> (NodeId, NodeId) {
36        let shape = self.shape(spectrum).clone();
37        let meta = crate::fft::fft_meta(&shape);
38        let last = shape.rank() - 1;
39        let n = meta.n_complex;
40        let re = self.narrow_(spectrum, last, 0, n);
41        let im = self.narrow_(spectrum, last, n, n);
42        (re, im)
43    }
44
45    /// Real-input FFT (gpu-fft `fft`): auto zero-pads to pow2, returns `(re, im)`.
46    pub fn fft_real(&mut self, x: NodeId, norm: FftNorm) -> (NodeId, NodeId) {
47        assert_eq!(
48            self.shape(x).dtype(),
49            DType::F32,
50            "fft_real: requires F32 real input"
51        );
52        let padded = self.pad_last_axis_to_pow2(x);
53        let shape = self.shape(padded).clone();
54        let rank = shape.rank();
55        let last = rank - 1;
56        let n = shape.dim(last).unwrap_static();
57        let mut im_dims: Vec<usize> = shape.dims().iter().map(|d| d.unwrap_static()).collect();
58        im_dims[last] = n;
59        let im_shape = Shape::new(&im_dims, DType::F32);
60        let zero_im = self.zeros_tensor(&im_shape);
61        let block = self.concat_(vec![padded, zero_im], last);
62        let spectrum = self.fft_norm(block, false, norm);
63        self.split_spectrum(spectrum)
64    }
65
66    /// Batched real-input FFT — same as `fft_real` when the last axis is signal
67    /// length; leading axes are independent batch dimensions.
68    pub fn fft_batch_real(&mut self, x: NodeId, norm: FftNorm) -> (NodeId, NodeId) {
69        self.fft_real(x, norm)
70    }
71
72    /// Real-input FFT with half-spectrum output (`n_pad/2 + 1` complex bins).
73    ///
74    /// The input is zero-padded to the next power of two along the last axis
75    /// before the transform, matching NumPy `rfft` padding semantics.
76    pub fn rfft(&mut self, x: NodeId, norm: FftNorm) -> (NodeId, NodeId) {
77        let (re, im) = self.fft_real(x, norm);
78        let rank = self.shape(re).rank();
79        let last = rank - 1;
80        let n = self.shape(re).dim(last).unwrap_static();
81        let half = n / 2 + 1;
82        (
83            self.narrow_(re, last, 0, half),
84            self.narrow_(im, last, 0, half),
85        )
86    }
87
88    /// Exact real-input FFT for an **arbitrary** length `n` (no power-of-two
89    /// padding): returns the half-spectrum `(re, im)` with `n/2 + 1` complex bins
90    /// along the last axis.
91    ///
92    /// Unlike [`Self::rfft`] — which zero-pads the last axis to `next_pow2(n)` and
93    /// therefore samples the *padded* transform's frequencies — this computes the
94    /// genuine `n`-point real DFT via a **constant DFT-matrix matmul**, exact for
95    /// every `n` (odd, prime, non-pow2). It is a pure decomposition over existing
96    /// graph ops (`Op::Constant`, `Op::MatMul`, elementwise), so it lowers on every
97    /// backend and stays differentiable — the same strategy as
98    /// [`Self::interpolate1d`]. Two baked `[n, n/2+1]` matrices give
99    ///
100    /// ```text
101    ///   re[k] =  Σ_t x[t]·cos(2π k t / n)          COS [n, n/2+1]
102    ///   im[k] = −Σ_t x[t]·sin(2π k t / n)          NSIN[n, n/2+1]  (= −sin)
103    /// ```
104    ///
105    /// matching the sign convention of `torch.fft.rfft` and this crate's radix-2
106    /// [`Self::rfft`] (forward transform `X[k] = Σ x[t] e^{−2πi k t / n}`). The
107    /// last axis of `x` must be the real signal of length `n`; leading axes are
108    /// independent batch dimensions. `norm` scales the forward transform exactly
109    /// as [`Self::rfft`] (`FftNorm::output_scale(n, false)`: `Backward`/`Forward`
110    /// → 1, `Ortho` → 1/√n). PyTorch `norm='forward'`'s `1/n` forward scale is not
111    /// an `FftNorm` variant — apply it explicitly on the result if needed (see
112    /// `rlx-cbramod`'s spectral front-end).
113    ///
114    /// Cost is `O(n²)` per row; for large `n` a Bluestein / mixed-radix kernel
115    /// (the `rlx-fft` butterfly/Stockham path could host one) would be
116    /// asymptotically cheaper. For the small EEG-tokenizer windows this targets
117    /// (`n = 200`, `400`) the exact matmul is both simpler and cheap enough.
118    pub fn rfft_exact(&mut self, x: NodeId, n: usize, norm: FftNorm) -> (NodeId, NodeId) {
119        assert!(n >= 1, "rfft_exact: n must be positive");
120        assert_eq!(
121            self.shape(x).dtype(),
122            DType::F32,
123            "rfft_exact: requires F32 real input"
124        );
125        let xs = self.shape(x).clone();
126        let rank = xs.rank();
127        let last = rank - 1;
128        let l = xs.dim(last).unwrap_static();
129        assert_eq!(l, n, "rfft_exact: last axis {l} != n {n}");
130        let n_freq = n / 2 + 1;
131        let batch: usize = (0..last).map(|i| xs.dim(i).unwrap_static()).product();
132
133        // Host-build the [n, n/2+1] DFT matrices in f64, baked as f32 constants.
134        let two_pi = 2.0 * std::f64::consts::PI;
135        let mut cos_m = vec![0f32; n * n_freq];
136        let mut nsin_m = vec![0f32; n * n_freq];
137        for t in 0..n {
138            for k in 0..n_freq {
139                let ang = two_pi * (k as f64) * (t as f64) / (n as f64);
140                cos_m[t * n_freq + k] = ang.cos() as f32;
141                nsin_m[t * n_freq + k] = -(ang.sin() as f32);
142            }
143        }
144        let cos = self.const_f32_tensor(cos_m, &[n, n_freq]);
145        let nsin = self.const_f32_tensor(nsin_m, &[n, n_freq]);
146
147        // Flatten leading axes → [batch, n], two matmuls, then restore shape.
148        let x2 = self.reshape_(x, vec![batch as i64, n as i64]);
149        let mut re = self.mm(x2, cos); // [batch, n_freq]
150        let mut im = self.mm(x2, nsin); // [batch, n_freq]
151
152        let scale = norm.output_scale(n, false);
153        if scale != 1.0 {
154            let s = self.constant(scale, DType::F32);
155            re = self.mul(re, s);
156            im = self.mul(im, s);
157        }
158
159        let mut out_dims: Vec<i64> = (0..last)
160            .map(|i| xs.dim(i).unwrap_static() as i64)
161            .collect();
162        out_dims.push(n_freq as i64);
163        let re = self.reshape_(re, out_dims.clone());
164        let im = self.reshape_(im, out_dims);
165        (re, im)
166    }
167
168    /// Magnitude of the exact arbitrary-`n` half-spectrum: `sqrt(re² + im²)`,
169    /// shape `[.., n/2 + 1]`. The imaginary sign is irrelevant to the magnitude,
170    /// so this is the spectral front-end the EEG tokenizers (CBraMod, BrainBERT)
171    /// want. `norm` scales as in [`Self::rfft_exact`].
172    pub fn rfft_exact_mag(&mut self, x: NodeId, n: usize, norm: FftNorm) -> NodeId {
173        let (re, im) = self.rfft_exact(x, n, norm);
174        let re2 = self.mul(re, re);
175        let im2 = self.mul(im, im);
176        let mag2 = self.add(re2, im2);
177        self.sqrt(mag2)
178    }
179
180    /// Inverse real FFT from half-spectrum `(re, im)` with Hermitian symmetry.
181    ///
182    /// Mirrors the conjugate half of the spectrum (excluding DC and Nyquist) before
183    /// calling [`Self::ifft_spectrum`], then truncates to length `n`.
184    pub fn irfft(&mut self, re_half: NodeId, im_half: NodeId, n: usize, norm: FftNorm) -> NodeId {
185        assert_eq!(
186            *self.shape(re_half),
187            *self.shape(im_half),
188            "irfft: re/im shape mismatch"
189        );
190        let n_pad = crate::fft::next_pow2(n);
191        let half = n_pad / 2 + 1;
192        let rank = self.shape(re_half).rank();
193        let last = rank - 1;
194        assert_eq!(
195            self.shape(re_half).dim(last).unwrap_static(),
196            half,
197            "irfft: expected half-spectrum length {half}, got {}",
198            self.shape(re_half).dim(last).unwrap_static()
199        );
200        let (re_full, im_full) = if half > 2 {
201            let mirror_len = half - 2;
202            let mirror_re = self.narrow_(re_half, last, 1, mirror_len);
203            let mirror_im = self.narrow_(im_half, last, 1, mirror_len);
204            let mirror_re_rev = self.reverse_last_axis(mirror_re);
205            let mirror_im_rev = self.reverse_last_axis(mirror_im);
206            let neg = self.scalar_f32(-1.0);
207            let mirror_im_neg = self.mul(mirror_im_rev, neg);
208            (
209                self.concat_(vec![re_half, mirror_re_rev], last),
210                self.concat_(vec![im_half, mirror_im_neg], last),
211            )
212        } else {
213            (re_half, im_half)
214        };
215        let recovered = self.ifft_spectrum(re_full, im_full, norm);
216        self.narrow_(recovered, last, 0, n)
217    }
218
219    /// Short-time Fourier transform: `[..., T]` → `[frames, ..., 2·half]` (re/im block per frame).
220    ///
221    /// Each frame is `rfft`'d with length `frame_len` and hop `hop` along the last axis.
222    pub fn stft(&mut self, x: NodeId, frame_len: usize, hop: usize, norm: FftNorm) -> NodeId {
223        assert!(
224            frame_len > 0 && hop > 0,
225            "stft: frame_len and hop must be positive"
226        );
227        let shape = self.shape(x).clone();
228        let rank = shape.rank();
229        let last = rank - 1;
230        let t = shape.dim(last).unwrap_static();
231        assert!(
232            t >= frame_len,
233            "stft: signal length {t} < frame_len {frame_len}"
234        );
235        let n_frames = 1 + (t - frame_len) / hop;
236        // Stack the TIME-domain frames into `[n_frames, ..., frame_len]` first,
237        // then take a SINGLE batched rfft (outer = n_frames·…) instead of one
238        // rfft per frame. This collapses `n_frames` FFT dispatches into one and
239        // lets the batched FFT kernels parallelize across frames (on GPU: one
240        // dispatch with outer=n_frames; on CPU: rayon over the batch). The
241        // per-frame `narrow_ + reshape + concat` is unchanged — only reordered
242        // to precede the transform — so the output shape `[frames, ..., 2·half]`
243        // is identical to the old per-frame path.
244        let mut rows = Vec::with_capacity(n_frames);
245        for f in 0..n_frames {
246            let start = f * hop;
247            let frame = self.narrow_(x, last, start, frame_len);
248            let mut dims: Vec<i64> = self
249                .shape(frame)
250                .dims()
251                .iter()
252                .map(|d| d.unwrap_static() as i64)
253                .collect();
254            dims.insert(0, 1);
255            rows.push(self.reshape_(frame, dims));
256        }
257        let framed = if rows.len() == 1 {
258            rows.pop().unwrap()
259        } else {
260            self.concat_(rows, 0)
261        };
262        let (re, im) = self.rfft(framed, norm);
263        let flast = self.shape(re).rank() - 1;
264        self.concat_(vec![re, im], flast)
265    }
266
267    /// 1D convolution via the convolution theorem (`rfft` → complex multiply → `irfft`).
268    ///
269    /// Both inputs are zero-padded to at least `n_fft` (or the next power of two covering
270    /// `len(a) + len(b) - 1` when `n_fft` is small).
271    pub fn fft_conv1d(&mut self, a: NodeId, b: NodeId, n_fft: usize, norm: FftNorm) -> NodeId {
272        let n_fft = n_fft.max(crate::fft::next_pow2(
273            self.shape(a).dim(self.shape(a).rank() - 1).unwrap_static()
274                + self.shape(b).dim(self.shape(b).rank() - 1).unwrap_static()
275                - 1,
276        ));
277        let pad_a = self.pad_axis_to_len(a, n_fft);
278        let pad_b = self.pad_axis_to_len(b, n_fft);
279        let (a_re, a_im) = self.rfft(pad_a, norm);
280        let (b_re, b_im) = self.rfft(pad_b, norm);
281        let ar_br = self.mul(a_re, b_re);
282        let ai_bi = self.mul(a_im, b_im);
283        let prod_re = self.sub(ar_br, ai_bi);
284        let ar_bi = self.mul(a_re, b_im);
285        let ai_br = self.mul(a_im, b_re);
286        let prod_im = self.add(ar_bi, ai_br);
287        let out_len = self.shape(a).dim(self.shape(a).rank() - 1).unwrap_static()
288            + self.shape(b).dim(self.shape(b).rank() - 1).unwrap_static()
289            - 1;
290        self.irfft(prod_re, prod_im, out_len.max(1), norm)
291    }
292
293    /// Constant tensor of FFT sample frequencies (length `n`, f64).
294    pub fn fftfreq_tensor(&mut self, n: usize) -> NodeId {
295        let xs = crate::fft::fftfreq(n);
296        let mut bytes = Vec::with_capacity(n * 8);
297        for x in &xs {
298            bytes.extend_from_slice(&x.to_le_bytes());
299        }
300        self.add_node(
301            Op::Constant { data: bytes },
302            vec![],
303            Shape::new(&[n], DType::F64),
304        )
305    }
306
307    /// Constant tensor of rFFT sample frequencies (length `n/2 + 1`, f64).
308    pub fn rfftfreq_tensor(&mut self, n: usize) -> NodeId {
309        let xs = crate::fft::rfftfreq(n);
310        let half = xs.len();
311        let mut bytes = Vec::with_capacity(half * 8);
312        for x in &xs {
313            bytes.extend_from_slice(&x.to_le_bytes());
314        }
315        self.add_node(
316            Op::Constant { data: bytes },
317            vec![],
318            Shape::new(&[half], DType::F64),
319        )
320    }
321
322    /// Power spectral density from real input: `rfft` → `psd`.
323    pub fn psd_real(&mut self, x: NodeId, norm: FftNorm) -> NodeId {
324        let (re, im) = self.rfft(x, norm);
325        self.psd(re, im)
326    }
327
328    /// Inverse FFT from separate real / imag spectra (gpu-fft `ifft` real part).
329    pub fn ifft_spectrum(&mut self, re: NodeId, im: NodeId, norm: FftNorm) -> NodeId {
330        let re_shape = self.shape(re).clone();
331        assert_eq!(
332            re_shape,
333            *self.shape(im),
334            "ifft_spectrum: re/im shape mismatch"
335        );
336        let rank = re_shape.rank();
337        let last = rank - 1;
338        let n = re_shape.dim(last).unwrap_static();
339        let block = self.concat_(vec![re, im], last);
340        let full = self.fft_norm(block, true, norm);
341        self.narrow_(full, last, 0, n)
342    }
343
344    /// Power spectral density: `(re² + im²) / N` (gpu-fft `psd::psd`).
345    pub fn psd(&mut self, re: NodeId, im: NodeId) -> NodeId {
346        let n = self
347            .shape(re)
348            .dim(self.shape(re).rank() - 1)
349            .unwrap_static();
350        let re2 = self.mul(re, re);
351        let im2 = self.mul(im, im);
352        let power = self.add(re2, im2);
353        let inv_n = self.scalar_f32(1.0 / n as f32);
354        self.mul(power, inv_n)
355    }
356
357    fn reverse_last_axis(&mut self, x: NodeId) -> NodeId {
358        // `Op::Reverse` is batch-general (flips only the listed axis, per row)
359        // and lowers on every backend. A previous `Op::Gather`-with-index
360        // implementation reversed the flattened last axis and so corrupted every
361        // batch row but the first — the irfft mirror bug for any rank ≥ 2 real
362        // spectrum (multi-channel `fft_conv1d`, framed convolution, …).
363        let last = self.shape(x).rank() - 1;
364        self.reverse(x, vec![last])
365    }
366
367    fn pad_axis_to_len(&mut self, x: NodeId, len: usize) -> NodeId {
368        let shape = self.shape(x).clone();
369        let last = shape.rank() - 1;
370        let n = shape.dim(last).unwrap_static();
371        if n >= len {
372            return self.narrow_(x, last, 0, len);
373        }
374        let pad_len = len - n;
375        let mut pad_dims: Vec<usize> = shape.dims().iter().map(|d| d.unwrap_static()).collect();
376        pad_dims[last] = pad_len;
377        let zeros = self.zeros_tensor(&Shape::new(&pad_dims, shape.dtype()));
378        self.concat_(vec![x, zeros], last)
379    }
380
381    fn zeros_tensor(&mut self, shape: &Shape) -> NodeId {
382        let n = shape.num_elements().unwrap();
383        let bytes = vec![0u8; n * shape.dtype().size_bytes()];
384        self.add_node(Op::Constant { data: bytes }, vec![], shape.clone())
385    }
386
387    fn scalar_f32(&mut self, v: f32) -> NodeId {
388        self.add_node(
389            Op::Constant {
390                data: v.to_le_bytes().to_vec(),
391            },
392            vec![],
393            Shape::scalar(DType::F32),
394        )
395    }
396}
397
398#[cfg(test)]
399mod rfft_exact_tests {
400    use super::*;
401    use crate::Shape;
402
403    fn dims(g: &Graph, id: NodeId) -> Vec<usize> {
404        g.shape(id)
405            .dims()
406            .iter()
407            .map(|d| d.unwrap_static())
408            .collect()
409    }
410
411    #[test]
412    fn rfft_exact_shape_non_pow2() {
413        // n = 200 (not a power of two) → n/2+1 = 101 bins; batch axes preserved.
414        let mut g = Graph::new("rfft_exact");
415        let x = g.input("x", Shape::new(&[22, 5, 200], DType::F32));
416        let (re, im) = g.rfft_exact(x, 200, FftNorm::Backward);
417        assert_eq!(dims(&g, re), vec![22, 5, 101]);
418        assert_eq!(dims(&g, im), vec![22, 5, 101]);
419    }
420
421    #[test]
422    fn rfft_exact_mag_shape_400() {
423        let mut g = Graph::new("rfft_exact_mag");
424        let x = g.input("frames", Shape::new(&[7, 400], DType::F32));
425        let mag = g.rfft_exact_mag(x, 400, FftNorm::Backward);
426        assert_eq!(dims(&g, mag), vec![7, 201]);
427    }
428}