Skip to main content

oxicuda_webgpu/
fft.rs

1//! WGSL radix-2 Cooley-Tukey FFT (Cooley & Tukey 1965) — host plan + shader.
2//!
3//! Implements the P2 roadmap item `shader/fft_wgsl.rs`: a compute-only FFT
4//! dispatch suitable for browsers, expressed as a single iterative
5//! Cooley-Tukey butterfly kernel plus a host-side [`WgslFftPlan`] that carries
6//! the bit-reversal permutation and the twiddle-factor table.
7//!
8//! The plan (bit-reversal indices, twiddle factors) is **pure CPU arithmetic**
9//! and is fully unit-tested here.  The shader-source generation is likewise
10//! CPU-testable structurally.  Actually *running* the kernel on a GPU is gated
11//! on a real adapter and is not exercised here.
12//!
13//! # Layout
14//!
15//! Complex data is stored interleaved: element `i` occupies indices `2*i`
16//! (real) and `2*i + 1` (imaginary).  An in-place radix-2 DIT FFT proceeds in
17//! `log2(n)` stages; each stage applies butterflies with stride `m = 2^stage`.
18//!
19//! The host first bit-reverses the input into the natural-order scratch buffer
20//! (or vice-versa), then dispatches one [`fft_stage_wgsl`] pass per stage with
21//! a `FftStageParams` uniform carrying the current `half_size` and the
22//! per-stage twiddle base.  Twiddles `W_N^k = exp(-2πi k / N)` are precomputed
23//! by [`WgslFftPlan::twiddles`] so the shader needs no `sin`/`cos`.
24
25use std::f64::consts::PI;
26
27/// FFT transform direction.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum FftDirection {
30    /// Forward DFT: `W_N^k = exp(-2πi k / N)`.
31    Forward,
32    /// Inverse DFT: `W_N^k = exp(+2πi k / N)` (the host scales by `1/N`).
33    Inverse,
34}
35
36/// A host-side plan for a power-of-two radix-2 Cooley-Tukey FFT of length `n`.
37///
38/// Precomputes the bit-reversal permutation and the twiddle-factor table so
39/// the WGSL kernel can be a pure butterfly with no transcendental calls.
40#[derive(Debug, Clone)]
41pub struct WgslFftPlan {
42    n: u32,
43    log2n: u32,
44    direction: FftDirection,
45    bit_reversal: Vec<u32>,
46    /// Interleaved `(re, im)` twiddles `W_N^k` for `k in 0..n/2`.
47    twiddles: Vec<f32>,
48}
49
50impl WgslFftPlan {
51    /// Build a plan for transform length `n` (must be a power of two `>= 1`).
52    ///
53    /// # Errors
54    ///
55    /// Returns an error string if `n` is zero or not a power of two.
56    pub fn new(n: u32, direction: FftDirection) -> Result<Self, String> {
57        if n == 0 {
58            return Err("FFT length must be non-zero".to_string());
59        }
60        if !n.is_power_of_two() {
61            return Err(format!("FFT length {n} is not a power of two"));
62        }
63        let log2n = n.trailing_zeros();
64        let bit_reversal = (0..n).map(|i| reverse_bits(i, log2n)).collect();
65
66        // Twiddles W_N^k = exp(sign * 2πi k / N) for k in 0..n/2.
67        let sign = match direction {
68            FftDirection::Forward => -1.0_f64,
69            FftDirection::Inverse => 1.0_f64,
70        };
71        let half = (n / 2).max(1) as usize;
72        let mut twiddles = Vec::with_capacity(half * 2);
73        for k in 0..half {
74            let angle = sign * 2.0 * PI * (k as f64) / (n as f64);
75            twiddles.push(angle.cos() as f32);
76            twiddles.push(angle.sin() as f32);
77        }
78
79        Ok(Self {
80            n,
81            log2n,
82            direction,
83            bit_reversal,
84            twiddles,
85        })
86    }
87
88    /// Transform length.
89    #[must_use]
90    pub fn len(&self) -> u32 {
91        self.n
92    }
93
94    /// Whether the transform length is zero (always `false` for a built plan).
95    #[must_use]
96    pub fn is_empty(&self) -> bool {
97        self.n == 0
98    }
99
100    /// Number of FFT stages, `log2(n)`.
101    #[must_use]
102    pub fn num_stages(&self) -> u32 {
103        self.log2n
104    }
105
106    /// Transform direction.
107    #[must_use]
108    pub fn direction(&self) -> FftDirection {
109        self.direction
110    }
111
112    /// The bit-reversal permutation: `bit_reversal()[i]` is the source index
113    /// that lands at position `i` after the reorder.
114    #[must_use]
115    pub fn bit_reversal(&self) -> &[u32] {
116        &self.bit_reversal
117    }
118
119    /// The interleaved `(re, im)` twiddle table of length `n/2`.
120    #[must_use]
121    pub fn twiddles(&self) -> &[f32] {
122        &self.twiddles
123    }
124
125    /// The normalisation scale the host applies after an inverse transform
126    /// (`1/n` for [`FftDirection::Inverse`], `1.0` for forward).
127    #[must_use]
128    pub fn inverse_scale(&self) -> f32 {
129        match self.direction {
130            FftDirection::Inverse => 1.0 / self.n as f32,
131            FftDirection::Forward => 1.0,
132        }
133    }
134
135    /// The `half_size` (`m/2`) uniform value for stage `stage` (`0`-based).
136    ///
137    /// Stage `s` operates on butterfly groups of size `m = 2^(s+1)`; its half
138    /// size is `2^s`.  Returns `0` if `stage >= num_stages`.
139    #[must_use]
140    pub fn stage_half_size(&self, stage: u32) -> u32 {
141        if stage >= self.log2n {
142            0
143        } else {
144            1u32 << stage
145        }
146    }
147}
148
149/// Reverse the low `bits` bits of `value`.
150#[inline]
151#[must_use]
152pub fn reverse_bits(value: u32, bits: u32) -> u32 {
153    if bits == 0 {
154        return 0;
155    }
156    let mut v = value;
157    let mut r = 0u32;
158    for _ in 0..bits {
159        r = (r << 1) | (v & 1);
160        v >>= 1;
161    }
162    r
163}
164
165/// Generate WGSL for the bit-reversal reorder pass.
166///
167/// Reads `src` (interleaved complex) and writes each element to its
168/// bit-reversed position in `dst`, using a precomputed permutation buffer
169/// (the host uploads [`WgslFftPlan::bit_reversal`]).
170#[must_use]
171pub fn fft_bitreverse_wgsl() -> String {
172    r#"
173struct FftReorderParams {
174    n: u32,
175}
176
177@group(0) @binding(0) var<storage, read>       src:     array<f32>;
178@group(0) @binding(1) var<storage, read_write> dst:     array<f32>;
179@group(0) @binding(2) var<storage, read>       perm:    array<u32>;
180@group(0) @binding(3) var<uniform>             params:  FftReorderParams;
181
182@compute @workgroup_size(256)
183fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
184    let i = gid.x;
185    if (i >= params.n) { return; }
186    let j = perm[i];
187    // Move complex element j -> i.
188    dst[2u * i]      = src[2u * j];
189    dst[2u * i + 1u] = src[2u * j + 1u];
190}
191"#
192    .to_string()
193}
194
195/// Generate WGSL for a single radix-2 Cooley-Tukey butterfly stage (DIT).
196///
197/// Operates in-place on `data` (interleaved complex, already bit-reversed).
198/// Each invocation handles one butterfly pair within a group of size
199/// `2 * half_size`; the twiddle `W` is fetched from the precomputed `twiddles`
200/// buffer at the stride-appropriate index.
201///
202/// `FftStageParams.half_size` is `m/2` for the current stage; the per-stage
203/// twiddle stride is `n / (2 * half_size)`.
204#[must_use]
205pub fn fft_stage_wgsl() -> String {
206    r#"
207struct FftStageParams {
208    n:            u32,
209    half_size:    u32,  // m / 2 for this stage
210    twiddle_step: u32,  // n / (2 * half_size)
211    _pad:         u32,
212}
213
214@group(0) @binding(0) var<storage, read_write> data:     array<f32>;
215@group(0) @binding(1) var<storage, read>       twiddles: array<f32>;
216@group(0) @binding(2) var<uniform>             params:   FftStageParams;
217
218@compute @workgroup_size(256)
219fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
220    let pair = gid.x;
221    let total_pairs = params.n / 2u;
222    if (pair >= total_pairs) { return; }
223
224    let m = params.half_size * 2u;
225    // Index of this butterfly within its group, and the group base.
226    let k = pair % params.half_size;
227    let group = pair / params.half_size;
228    let base = group * m;
229    let i0 = base + k;          // top of the butterfly
230    let i1 = i0 + params.half_size; // bottom
231
232    // Twiddle W = (wr, wi) for this k.
233    let tw = k * params.twiddle_step;
234    let wr = twiddles[2u * tw];
235    let wi = twiddles[2u * tw + 1u];
236
237    let ar = data[2u * i0];
238    let ai = data[2u * i0 + 1u];
239    let br = data[2u * i1];
240    let bi = data[2u * i1 + 1u];
241
242    // t = W * b  (complex multiply).
243    let tr = wr * br - wi * bi;
244    let ti = wr * bi + wi * br;
245
246    // Butterfly: a' = a + t, b' = a - t.
247    data[2u * i0]      = ar + tr;
248    data[2u * i0 + 1u] = ai + ti;
249    data[2u * i1]      = ar - tr;
250    data[2u * i1 + 1u] = ai - ti;
251}
252"#
253    .to_string()
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn reverse_bits_known_values() {
262        // 3 bits: 0b001 (1) -> 0b100 (4).
263        assert_eq!(reverse_bits(0b001, 3), 0b100);
264        assert_eq!(reverse_bits(0b110, 3), 0b011);
265        assert_eq!(reverse_bits(0b000, 3), 0b000);
266        assert_eq!(reverse_bits(0b111, 3), 0b111);
267        // 0 bits -> 0.
268        assert_eq!(reverse_bits(5, 0), 0);
269    }
270
271    #[test]
272    fn plan_rejects_non_power_of_two() {
273        assert!(WgslFftPlan::new(0, FftDirection::Forward).is_err());
274        assert!(WgslFftPlan::new(3, FftDirection::Forward).is_err());
275        assert!(WgslFftPlan::new(6, FftDirection::Forward).is_err());
276        assert!(WgslFftPlan::new(7, FftDirection::Forward).is_err());
277    }
278
279    #[test]
280    fn plan_accepts_powers_of_two() {
281        for &n in &[1u32, 2, 4, 8, 16, 1024] {
282            let plan = WgslFftPlan::new(n, FftDirection::Forward).expect("power of two");
283            assert_eq!(plan.len(), n);
284            assert_eq!(plan.num_stages(), n.trailing_zeros());
285            assert!(!plan.is_empty());
286        }
287    }
288
289    #[test]
290    fn plan_bit_reversal_is_a_permutation() {
291        let plan = WgslFftPlan::new(8, FftDirection::Forward).expect("plan");
292        let perm = plan.bit_reversal();
293        assert_eq!(perm.len(), 8);
294        // Known 3-bit reversal of 0..8.
295        assert_eq!(perm, &[0, 4, 2, 6, 1, 5, 3, 7]);
296        // It is a genuine permutation (every index 0..8 appears once).
297        let mut seen = perm.to_vec();
298        seen.sort_unstable();
299        assert_eq!(seen, (0u32..8).collect::<Vec<_>>());
300    }
301
302    #[test]
303    fn plan_twiddles_length_and_dc() {
304        let plan = WgslFftPlan::new(8, FftDirection::Forward).expect("plan");
305        // n/2 complex = n interleaved floats.
306        assert_eq!(plan.twiddles().len(), 8);
307        // W_N^0 = 1 + 0i.
308        assert!((plan.twiddles()[0] - 1.0).abs() < 1e-6);
309        assert!(plan.twiddles()[1].abs() < 1e-6);
310    }
311
312    #[test]
313    fn plan_forward_twiddle_sign_is_negative() {
314        // Forward: W_8^1 = exp(-2πi/8) = (cos(-45°), sin(-45°)) ≈ (0.707, -0.707).
315        let plan = WgslFftPlan::new(8, FftDirection::Forward).expect("plan");
316        let re = plan.twiddles()[2];
317        let im = plan.twiddles()[3];
318        assert!((re - 0.707_106_77).abs() < 1e-5);
319        assert!((im + 0.707_106_77).abs() < 1e-5, "im was {im}");
320    }
321
322    #[test]
323    fn plan_inverse_twiddle_sign_is_positive() {
324        // Inverse: W_8^1 = exp(+2πi/8) ≈ (0.707, +0.707).
325        let plan = WgslFftPlan::new(8, FftDirection::Inverse).expect("plan");
326        let im = plan.twiddles()[3];
327        assert!(
328            im > 0.0,
329            "inverse imaginary twiddle should be positive, got {im}"
330        );
331        assert!((plan.inverse_scale() - (1.0 / 8.0)).abs() < 1e-7);
332    }
333
334    #[test]
335    fn plan_forward_inverse_scale() {
336        let fwd = WgslFftPlan::new(16, FftDirection::Forward).expect("plan");
337        assert!((fwd.inverse_scale() - 1.0).abs() < 1e-7);
338        let inv = WgslFftPlan::new(16, FftDirection::Inverse).expect("plan");
339        assert!((inv.inverse_scale() - 1.0 / 16.0).abs() < 1e-7);
340    }
341
342    #[test]
343    fn plan_stage_half_sizes() {
344        let plan = WgslFftPlan::new(8, FftDirection::Forward).expect("plan");
345        // 3 stages: half sizes 1, 2, 4.
346        assert_eq!(plan.stage_half_size(0), 1);
347        assert_eq!(plan.stage_half_size(1), 2);
348        assert_eq!(plan.stage_half_size(2), 4);
349        // Out-of-range stage -> 0.
350        assert_eq!(plan.stage_half_size(3), 0);
351    }
352
353    #[test]
354    fn plan_n1_has_zero_stages() {
355        let plan = WgslFftPlan::new(1, FftDirection::Forward).expect("plan");
356        assert_eq!(plan.num_stages(), 0);
357        // n/2 == 0, but we reserve at least 1 twiddle slot (DC).
358        assert_eq!(plan.twiddles().len(), 2);
359    }
360
361    // ── shader source ─────────────────────────────────────────────────────
362
363    #[test]
364    fn wgsl_bitreverse_moves_by_permutation() {
365        let src = fft_bitreverse_wgsl();
366        assert!(src.contains("@compute @workgroup_size(256)"));
367        assert!(src.contains("let j = perm[i];"));
368        assert!(src.contains("dst[2u * i]      = src[2u * j];"));
369        assert!(src.contains("dst[2u * i + 1u] = src[2u * j + 1u];"));
370        // Permutation buffer binding.
371        assert!(src.contains("perm:    array<u32>"));
372    }
373
374    #[test]
375    fn wgsl_fft_stage_complex_butterfly() {
376        let src = fft_stage_wgsl();
377        assert!(src.contains("@compute @workgroup_size(256)"));
378        // Complex multiply t = W * b.
379        assert!(src.contains("let tr = wr * br - wi * bi;"));
380        assert!(src.contains("let ti = wr * bi + wi * br;"));
381        // Butterfly add/sub.
382        assert!(src.contains("data[2u * i0]      = ar + tr;"));
383        assert!(src.contains("data[2u * i1]      = ar - tr;"));
384    }
385
386    #[test]
387    fn wgsl_fft_stage_uses_precomputed_twiddles() {
388        let src = fft_stage_wgsl();
389        // Twiddles come from a buffer, not from sin/cos in the shader.
390        assert!(src.contains("twiddles: array<f32>"));
391        assert!(src.contains("let wr = twiddles[2u * tw];"));
392        assert!(src.contains("let wi = twiddles[2u * tw + 1u];"));
393        assert!(!src.contains("sin("));
394        assert!(!src.contains("cos("));
395        // Stage params carry half_size and twiddle_step.
396        assert!(src.contains("half_size:    u32"));
397        assert!(src.contains("twiddle_step: u32"));
398    }
399
400    #[test]
401    fn wgsl_fft_stage_has_bounds_guard() {
402        let src = fft_stage_wgsl();
403        assert!(src.contains("let total_pairs = params.n / 2u;"));
404        assert!(src.contains("if (pair >= total_pairs) { return; }"));
405    }
406}