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