Skip to main content

oxigdal_gpu/
convolution_fft.rs

1//! FFT-based 1D linear convolution for OxiGDAL GPU.
2//!
3//! Implements linear convolution via the FFT theorem:
4//!
5//!   `Y = IFFT(FFT(signal) * FFT(kernel))`
6//!
7//! where `*` is pointwise complex multiplication.  This is equivalent to
8//! direct convolution for finite-length sequences, and is efficient when
9//! both sequences are padded to the same power-of-two length.
10//!
11//! # Limitation
12//!
13//! The underlying [`Fft1d`] kernel supports sizes up to 2048.  For inputs
14//! where `(signal.len() + kernel.len() - 1).next_power_of_two() > 2048` this
15//! module returns [`GpuError::InvalidKernelParams`].  Long signals require an
16//! Overlap-Save or Overlap-Add segmentation strategy (not implemented here).
17//!
18//! # Sign convention
19//!
20//! Forward FFT: twiddle angle = `-2π·k/N` (no normalisation).
21//! Inverse FFT: twiddle angle = `+2π·k/N`, result divided by `N`.
22//!
23//! # Thread safety
24//!
25//! [`FftConvolution`] holds an `Arc<GpuContext>` and is `Send + Sync`.
26
27use crate::context::GpuContext;
28use crate::error::{GpuError, GpuResult};
29use crate::fft::Fft1d;
30use std::sync::Arc;
31use tracing::debug;
32
33// ─────────────────────────────────────────────────────────────────────────────
34// Public constant
35// ─────────────────────────────────────────────────────────────────────────────
36
37/// Maximum single-pass FFT size supported by [`FftConvolution`].
38///
39/// This is determined by the maximum size accepted by [`Fft1d`] (2048).
40/// Convolution of two sequences whose combined output length exceeds this
41/// value (after rounding up to the next power of two) will return an error.
42pub const MAX_FFT_CONVOLUTION_SIZE: usize = 2048;
43
44// ─────────────────────────────────────────────────────────────────────────────
45// Pure-Rust helpers (no GPU required)
46// ─────────────────────────────────────────────────────────────────────────────
47
48/// Pointwise complex multiplication: `(ar + i·ai) × (br + i·bi)`.
49///
50/// Returns `(real, imag)` where:
51/// - `real = ar·br − ai·bi`
52/// - `imag = ar·bi + ai·br`
53///
54/// # Examples
55///
56/// ```
57/// use oxigdal_gpu::convolution_fft::complex_multiply;
58///
59/// // (1+0i) * (1+0i) = 1
60/// let (r, i) = complex_multiply(1.0, 0.0, 1.0, 0.0);
61/// assert!((r - 1.0).abs() < 1e-6);
62/// assert!(i.abs() < 1e-6);
63///
64/// // (0+1i) * (0+1i) = -1
65/// let (r, i) = complex_multiply(0.0, 1.0, 0.0, 1.0);
66/// assert!((r + 1.0).abs() < 1e-6);
67/// assert!(i.abs() < 1e-6);
68/// ```
69#[inline]
70pub fn complex_multiply(ar: f32, ai: f32, br: f32, bi: f32) -> (f32, f32) {
71    (ar * br - ai * bi, ar * bi + ai * br)
72}
73
74/// Pure-Rust reference linear convolution (direct O(N·M) algorithm).
75///
76/// Computes `output[i] = Σ_k signal[i−k] · kernel[k]` for a finite-support
77/// linear (non-circular) convolution.
78///
79/// Output length is `signal.len() + kernel.len() - 1` when both are
80/// non-empty, and zero when either is empty.
81///
82/// This function exists as a ground-truth reference for tests; it is not
83/// optimised and should not be used on large inputs.
84///
85/// # Examples
86///
87/// ```
88/// use oxigdal_gpu::convolution_fft::convolve_reference;
89///
90/// // Polynomial multiplication: (1+x) * (1+x) = 1 + 2x + x²
91/// let result = convolve_reference(&[1.0, 1.0], &[1.0, 1.0]);
92/// assert!((result[0] - 1.0).abs() < 1e-6);
93/// assert!((result[1] - 2.0).abs() < 1e-6);
94/// assert!((result[2] - 1.0).abs() < 1e-6);
95/// ```
96pub fn convolve_reference(signal: &[f32], kernel: &[f32]) -> Vec<f32> {
97    if signal.is_empty() || kernel.is_empty() {
98        return vec![];
99    }
100    let output_len = signal.len() + kernel.len() - 1;
101    let mut output = vec![0.0_f32; output_len];
102    for (i, &s) in signal.iter().enumerate() {
103        for (j, &k) in kernel.iter().enumerate() {
104            output[i + j] += s * k;
105        }
106    }
107    output
108}
109
110// ─────────────────────────────────────────────────────────────────────────────
111// FftConvolution struct
112// ─────────────────────────────────────────────────────────────────────────────
113
114/// GPU-accelerated FFT-based 1D linear convolution.
115///
116/// Internally uses two [`Fft1d`] dispatches (forward FFT of signal and kernel,
117/// then inverse FFT of the pointwise product).  Complex multiplication is done
118/// on the CPU because the buffer is small relative to the GPU dispatch overhead.
119///
120/// # Construction
121///
122/// ```rust,no_run
123/// use std::sync::Arc;
124/// use oxigdal_gpu::convolution_fft::FftConvolution;
125/// use oxigdal_gpu::GpuContext;
126///
127/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
128/// let ctx = Arc::new(GpuContext::new().await?);
129/// let conv = FftConvolution::new(Arc::clone(&ctx));
130/// # Ok(())
131/// # }
132/// ```
133pub struct FftConvolution {
134    ctx: Arc<GpuContext>,
135}
136
137impl FftConvolution {
138    /// Create a new [`FftConvolution`] bound to the given GPU context.
139    pub fn new(ctx: Arc<GpuContext>) -> Self {
140        Self { ctx }
141    }
142
143    // ── Core single-call convolution ─────────────────────────────────────────
144
145    /// Compute the linear 1D convolution of `signal` with `kernel`.
146    ///
147    /// Output length: `signal.len() + kernel.len() - 1`.
148    ///
149    /// Both sequences are zero-padded to `fft_size = (output_len).next_power_of_two()`.
150    /// Forward FFTs of each padded sequence are computed on the GPU, complex
151    /// multiplication is performed on the CPU, and then the inverse FFT is
152    /// computed on the GPU.  Only the first `output_len` samples of the IFFT
153    /// result are returned (the remainder is circular aliasing that vanishes
154    /// because of the zero-padding).
155    ///
156    /// # Errors
157    ///
158    /// - Returns [`GpuError::InvalidKernelParams`] when `fft_size > MAX_FFT_CONVOLUTION_SIZE`.
159    /// - Returns a [`GpuError`] variant on any GPU pipeline or buffer error.
160    pub fn convolve(&self, signal: &[f32], kernel: &[f32]) -> GpuResult<Vec<f32>> {
161        if signal.is_empty() || kernel.is_empty() {
162            return Ok(vec![]);
163        }
164
165        let output_len = signal.len() + kernel.len() - 1;
166
167        // Choose FFT size: next power of two >= output_len
168        let fft_size = output_len.next_power_of_two();
169
170        if fft_size > MAX_FFT_CONVOLUTION_SIZE {
171            return Err(GpuError::InvalidKernelParams {
172                reason: format!(
173                    "FFT convolution size {} exceeds maximum {}; \
174                     use overlap-save for long signals",
175                    fft_size, MAX_FFT_CONVOLUTION_SIZE
176                ),
177            });
178        }
179
180        let fft_size_u32 = fft_size as u32;
181
182        // Guard: Fft1d requires size >= 4 and power of two.
183        // When output_len < 4 the next_power_of_two may be 1 or 2.
184        // Clamp fft_size_u32 upward to 4 in that case.
185        let fft_size_u32 = fft_size_u32.max(4);
186        let fft_size = fft_size_u32 as usize;
187
188        debug!(
189            "FftConvolution::convolve signal_len={} kernel_len={} output_len={} fft_size={}",
190            signal.len(),
191            kernel.len(),
192            output_len,
193            fft_size
194        );
195
196        // Zero-pad both inputs to fft_size
197        let mut signal_padded = signal.to_vec();
198        signal_padded.resize(fft_size, 0.0_f32);
199        let signal_imag = vec![0.0_f32; fft_size];
200
201        let mut kernel_padded = kernel.to_vec();
202        kernel_padded.resize(fft_size, 0.0_f32);
203        let kernel_imag = vec![0.0_f32; fft_size];
204
205        // Forward FFT of signal
206        let fft_fwd = Fft1d::new(&self.ctx, fft_size_u32, false)?;
207        let (s_re, s_im) = fft_fwd.execute(&self.ctx, &signal_padded, &signal_imag)?;
208
209        // Forward FFT of kernel (reuse the same Fft1d — same size, same direction)
210        let (k_re, k_im) = fft_fwd.execute(&self.ctx, &kernel_padded, &kernel_imag)?;
211
212        // Pointwise complex multiply (CPU — buffer is at most 2048 f32 each)
213        let mut y_re = vec![0.0_f32; fft_size];
214        let mut y_im = vec![0.0_f32; fft_size];
215        for i in 0..fft_size {
216            let (r, im) = complex_multiply(s_re[i], s_im[i], k_re[i], k_im[i]);
217            y_re[i] = r;
218            y_im[i] = im;
219        }
220
221        // Inverse FFT
222        let fft_inv = Fft1d::new(&self.ctx, fft_size_u32, true)?;
223        let (result_re, _result_im) = fft_inv.execute(&self.ctx, &y_re, &y_im)?;
224
225        // Trim to output_len (the zero-pad region contains circular artefacts)
226        Ok(result_re[..output_len].to_vec())
227    }
228
229    // ── Correlation ──────────────────────────────────────────────────────────
230
231    /// Compute the linear 1D cross-correlation of `signal` with `kernel`.
232    ///
233    /// Cross-correlation is equivalent to convolution with a time-reversed
234    /// kernel.  This method reverses `kernel` and delegates to [`Self::convolve`].
235    ///
236    /// Output length: `signal.len() + kernel.len() - 1` (same as convolution).
237    ///
238    /// # Errors
239    ///
240    /// Propagates any error from [`Self::convolve`].
241    pub fn correlate(&self, signal: &[f32], kernel: &[f32]) -> GpuResult<Vec<f32>> {
242        if signal.is_empty() || kernel.is_empty() {
243            return Ok(vec![]);
244        }
245        // Time-reverse the kernel to convert correlation → convolution
246        let reversed_kernel: Vec<f32> = kernel.iter().copied().rev().collect();
247        self.convolve(signal, &reversed_kernel)
248    }
249
250    // ── Batch convolution ────────────────────────────────────────────────────
251
252    /// Apply the same `kernel` to each signal in `signals`.
253    ///
254    /// This is a sequential loop over [`Self::convolve`].  A future
255    /// optimisation could share the forward-FFT of the kernel across all
256    /// signals; for now each signal gets an independent GPU dispatch.
257    ///
258    /// Returns a `Vec<Vec<f32>>` with one result per input signal, in the
259    /// same order.
260    ///
261    /// # Errors
262    ///
263    /// Returns the first error encountered (processing stops on error).
264    pub fn convolve_batch(&self, signals: &[Vec<f32>], kernel: &[f32]) -> GpuResult<Vec<Vec<f32>>> {
265        let mut results = Vec::with_capacity(signals.len());
266        for signal in signals {
267            let output = self.convolve(signal, kernel)?;
268            results.push(output);
269        }
270        Ok(results)
271    }
272}
273
274// ─────────────────────────────────────────────────────────────────────────────
275// Unit tests (pure-Rust, no GPU required)
276// ─────────────────────────────────────────────────────────────────────────────
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_complex_multiply_real_unit() {
284        let (r, i) = complex_multiply(1.0, 0.0, 1.0, 0.0);
285        assert!((r - 1.0).abs() < 1e-6, "real(1*1) should be 1, got {r}");
286        assert!(i.abs() < 1e-6, "imag(1*1) should be 0, got {i}");
287    }
288
289    #[test]
290    fn test_complex_multiply_imaginary_squared() {
291        // (0+1i) * (0+1i) = -1
292        let (r, i) = complex_multiply(0.0, 1.0, 0.0, 1.0);
293        assert!((r + 1.0).abs() < 1e-6, "real(i²) should be -1, got {r}");
294        assert!(i.abs() < 1e-6, "imag(i²) should be 0, got {i}");
295    }
296
297    #[test]
298    fn test_complex_multiply_real_times_imaginary() {
299        // (1+0i) * (0+1i) = i
300        let (r, i) = complex_multiply(1.0, 0.0, 0.0, 1.0);
301        assert!(r.abs() < 1e-6, "real(1*i) should be 0, got {r}");
302        assert!((i - 1.0).abs() < 1e-6, "imag(1*i) should be 1, got {i}");
303    }
304
305    #[test]
306    fn test_convolve_reference_identity_kernel() {
307        let result = convolve_reference(&[1.0, 2.0, 3.0], &[1.0]);
308        assert_eq!(result.len(), 3);
309        assert!((result[0] - 1.0).abs() < 1e-6);
310        assert!((result[1] - 2.0).abs() < 1e-6);
311        assert!((result[2] - 3.0).abs() < 1e-6);
312    }
313
314    #[test]
315    fn test_convolve_reference_delta_kernel() {
316        // signal=[1,2,3] kernel=[0,1,0] → output_len=5: [0,1,2,3,0]
317        let result = convolve_reference(&[1.0, 2.0, 3.0], &[0.0, 1.0, 0.0]);
318        assert_eq!(result.len(), 5);
319        assert!((result[0] - 0.0).abs() < 1e-6, "output[0]={}", result[0]);
320        assert!((result[1] - 1.0).abs() < 1e-6, "output[1]={}", result[1]);
321        assert!((result[2] - 2.0).abs() < 1e-6, "output[2]={}", result[2]);
322        assert!((result[3] - 3.0).abs() < 1e-6, "output[3]={}", result[3]);
323        assert!((result[4] - 0.0).abs() < 1e-6, "output[4]={}", result[4]);
324    }
325
326    #[test]
327    fn test_convolve_reference_box_blur() {
328        // signal=[1,1,1,1,1] kernel=[0.5,0.5] → [0.5,1,1,1,1,0.5]
329        let result = convolve_reference(&[1.0, 1.0, 1.0, 1.0, 1.0], &[0.5, 0.5]);
330        assert_eq!(result.len(), 6);
331        assert!((result[0] - 0.5).abs() < 1e-6, "output[0]={}", result[0]);
332        assert!((result[1] - 1.0).abs() < 1e-6, "output[1]={}", result[1]);
333        assert!((result[2] - 1.0).abs() < 1e-6, "output[2]={}", result[2]);
334        assert!((result[3] - 1.0).abs() < 1e-6, "output[3]={}", result[3]);
335        assert!((result[4] - 1.0).abs() < 1e-6, "output[4]={}", result[4]);
336        assert!((result[5] - 0.5).abs() < 1e-6, "output[5]={}", result[5]);
337    }
338
339    #[test]
340    fn test_convolve_reference_empty_inputs() {
341        assert!(convolve_reference(&[], &[1.0, 2.0]).is_empty());
342        assert!(convolve_reference(&[1.0, 2.0], &[]).is_empty());
343        assert!(convolve_reference(&[], &[]).is_empty());
344    }
345
346    #[test]
347    fn test_convolve_reference_polynomial_multiplication() {
348        // (1 + x) * (1 + x) = 1 + 2x + x²
349        let result = convolve_reference(&[1.0, 1.0], &[1.0, 1.0]);
350        assert_eq!(result.len(), 3);
351        assert!((result[0] - 1.0).abs() < 1e-6, "coeff x^0 = {}", result[0]);
352        assert!((result[1] - 2.0).abs() < 1e-6, "coeff x^1 = {}", result[1]);
353        assert!((result[2] - 1.0).abs() < 1e-6, "coeff x^2 = {}", result[2]);
354    }
355
356    #[test]
357    fn test_max_fft_convolution_size_is_power_of_two() {
358        assert!(
359            MAX_FFT_CONVOLUTION_SIZE.is_power_of_two(),
360            "MAX_FFT_CONVOLUTION_SIZE ({}) must be a power of two",
361            MAX_FFT_CONVOLUTION_SIZE
362        );
363    }
364}