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}