Skip to content

Several improvements: const for *Setup, zconvolve w/o scaling, zero-phase convolution #101

Closed

Description

@avaneev

I've already proposed these to Julien of PFFFT. Repeating here as these apply to DOUBLE version as well.

  1. As I wanted to optimize memory use in a multi-threaded application, I asked myself - can the pffft_new_setup() called once for a specific N, and then used for concurrent calls of pffft_transform(), only with differing "work" buffers? However, the setup argument of pffft_transform() is non-const. It would be great if a const PFFFT_Setup * is specified in the function's declaration - if it actually uses setup in read-only manner.

  2. It would be great if you could add a pffft_zconvolve() function that puts the result into dft_ab (may be equal to dft_b) without accumulation and scaling. A scaling factor is unnecessary, because it's more efficient to rescale the dft_a because it's a static filter used numerous times, usually.

  3. Another suggestion is to provide a function (e.g. pffft_zconvert_zp) that permits convolving a fft with "zero-phase" fft (impulse responses). Linear-phase filters all have zero-phase response if the time-domain FIR filter's center is at t=0 and left-hand taps are wrapped around the FFT block (placed right-to-left at the end of the input transform block). The FFT transform of such a filter yields zero complex components. I do that already in r8brain-free-src and this is very efficient. I transform filter's FFT into "zero-phase" FFT by copying real components to complex components (except first and last bin of course as they have no complex components). However, unordered pffft requires knowing the ordering of real and complex components. So, a function is necessary for the unordered PFFFT result, to obtain an "zero-phase" FFT first (a scaling factor can be specified in this function). Then a simple sequential multiplication would work - for both unordered and ordered fft; compilers usually vectorise that well. It seems that engineers probably do not know this simple optimization trick. Unordered PFFFT is substantially faster so I wanted to utilize it, but currently can't - if I use a usual complex-domain convolution, the efficiencies of zero-phase convolution are lost.

  4. PFFFTD defines _USE_MATH_DEFINES unconditionally while sometimes _USE_MATH_DEFINES is defined as compilator-level option. It would be useful to add #ifndef _USE_MATH_DEFINES before defining it.

Activity

  1. marton78 commented on Aug 24, 2026

    @marton78
    Owner

    Hi @avaneev, all good suggestions, thanks! I would be grateful if you could review my attempt at implementing them: #102

  2. avaneev commented on Aug 25, 2026

    @avaneev
    ContributorAuthor

    Thank you. The issue with zconvolve is that it still applies inverse N gain. I can't get correct convolution gain with scale=1. If I change the call to pffftd_zconvert_zp( s -> setup, p, p, 1.0/N ), it works. Beside that, pffftd_zconvolve loses precision - with ordered FFT and hand-written complex multiplication in r8brain I can get -200 dB null-test while with pffftd_zconvolve I get only -167 dB.

    The reason you need a special zconvolve_zp is because you are applying an inverse gain which should not be there. If you remove it, zconvolve_zp won't be necessary, it's a redundant function. For backwards compatibility, it's better to apply this gain inside the pffftd_zconvolve_no_accum() function.

  3. avaneev commented on Aug 25, 2026

    @avaneev
    ContributorAuthor

    Also, the performance of pffftd_zconvolve_zp() is not too great - note the code SSE2 in r8brain CDSPRealFFT multiplyBlocksZP() function - it runs much faster. With this function and unordered FFT I get 5% overall performance improvement, while with pffftd_zconvolve_zp() I get only 2% (on Ryzen 9950X with GCC 13). But currently I can't use pffftd_zconvert_zp() in production, because it lowers precision - looks like double-to-float conversion sneaks in somewhere.

  4. marton78 commented on Aug 26, 2026

    @marton78
    Owner

    Hi @avaneev,

    Thank you for your insights.

    1. Inverse-N gain

    Yes, makes sense. Will do.

    BTW, your claim that zconvert_zp becomes redundant is only true for ordered spectra. When unordered, whole vectors alternate real/imag, so Y_im = X_im · H_re needs cross-vector indexing

    2. Double→float precision loss

    I don't think this comes from us. I asked the Ox Alpha model (which is free for a few days on Openrouter), it gave me:

    • Source level: pffft_double.c does #define float double; every double backend union is double f[4] (pf_avx_double.h:60, sse2/neon/scalar variants too). No float-typed temporary exists in the shared impl.
    • Binary level: disassembled all five pffftd_z*convolve*/zconvert* functions on this build — zero fcvt* instructions.
    • Empirical: frequency-domain pffftd_zconvolve output matches hand-written complex multiply on ordered spectra to −321 dB (pure double rounding); full zp round-trip vs time-domain circular convolution: −308 dB.

    His −167 dB cannot originate in these kernels. Likely harness-side — during this verification I stepped twice into the same trap he may have: feeding an unordered spectrum to transform_ordered(.., BACKWARD) or using the wrong zreorder direction silently produces plausible-but-wrong output (I measured garbage at "−3.7 dB" that way). His −200 dB baseline also indicates other error sources dominate his null test either way.

    3. zconvolve_zp performance

    Again, tested by Ox Alpha:

    Kernel-only on M2/NEON, ns/call (.perf/bench101.c):

    N type zconvolve zconvolve_scale(1) zconvolve_zp zp/scale
    128 float 10.2 11.5 7.8 1.47×
    2048 float 150.6 183.7 104.4 1.76×
    8192 float 540.8 672.1 486.9 1.38×
    2048 double 271.2 329.8 248.6 1.33×

    No pathology — zp delivers its ~2-multiplies-vs-6-per-pair advantage, degrading toward memory-bound at large N. Since the multiply stage is small next to the FFTs, a ~1.5× kernel win correctly shrinks to a few percent end-to-end — his 2% here vs 5% with r8brain SSE2 is plausible without any bug. Side observation: plain zconvolve already beats zconvolve_scale by 10–20% because the runtime scaling multiply can't be constant-folded — the unscaled entry point captures part of the optimization on its own.

  5. marton78 commented on Aug 26, 2026

    @marton78
    Owner

    Re: unit-gain zero-phase convolution

    I think it's best to fold the 1/N into zconvert_zp only, because this would make the zp pipeline unit-gain:

    transform(x, FORWARD) → zconvolve_zp(x, zconvert_zp(h, 1)) → transform(.., BACKWARD)

    => circular convolution, like r8brain's convention.

    Why conversion-side and not in the kernel:

    • Conversion runs once per filter, so normalizing there is free. If we folded 1/N into zconvolve_zp, it would cost one extra VMUL per re/im vector pair in every call. The kernel advantage over zconvolve_scale is already small (see above), and this would lose roughly a third of it.
    • Folding it into zconvolve_scale would break pffastconv, which feeds pre-scaled filters to zconvolve_scale.
    • Non-zp functions remain unchanged: zconvolve_accumulate keeps upstream's spectral-scaling semantics; zconvolve_scale/zconvolve stay spectral-domain operations.

    New zconvert_zp semantics: output = zero-phase spectrum * scaling/N, i.e. the scaling becomes an additional gain on top of unit-gain normalization.

  6. marton78 commented on Aug 26, 2026

    @marton78
    Owner

    Please have a look at the new commit, @avaneev, and my comments above.

    As for "the performance of pffftd_zconvolve_zp() is not too great", I have a performance branch in preparation, which I hope will improve things slightly.

  7. avaneev commented on Aug 28, 2026

    @avaneev
    ContributorAuthor

    Thank you.

  8. avaneev commented on Aug 28, 2026

    @avaneev
    ContributorAuthor

    You may be right that zconvolve_zp may be needed. I did assume ordered FFT where sequential multiplication works correctly.

  9. avaneev commented on Aug 28, 2026

    @avaneev
    ContributorAuthor

    I stand corrected about my critique. In r8brain-free I do spectrum mirroring which requires ordered FFT, so my null-test evaluations were incorrect and your latest commit is in fact completely correct.

  10. marton78 commented on Aug 28, 2026

    @marton78
    Owner

    amazing! then i'll merge

  11. marton78 commented on Sep 2, 2026

    @marton78
    Owner

    Hi @avaneev you mentioned earlier that "the performance of pffftd_zconvolve_zp() is not too great". Could you give it another try with the new perf branch? I hope it should become faster now.

  12. avaneev commented on Sep 3, 2026

    @avaneev
    ContributorAuthor

    Hi @marton78 After a more thorough testing the performance was actually good in commit 102. I've utilized the "zp" functions already in r8brain-free-src. Thanks again for your effort.

    The pffftd_zconvolve_zp() in the perf branch is actually 4% slower in my SSE2 tests compared to commit 102.

  13. avaneev commented on Sep 3, 2026

    @avaneev
    ContributorAuthor

    I would also suggest to remove SCALAR1 branch completely along with #if ( SIMD_SZ == 4 ) branching - this would make SIMD_SZ=4 uniform throughout. SCALAR1 is adds no value from performance standpoint. And I guess scaling to e.g. SIMD_SZ=8 is technically impossible.

  14. avaneev commented on Sep 3, 2026

    @avaneev
    ContributorAuthor

    I'd like to also point out to restrict related contract violations (which were inherited from the original PFFFT). E.g. in zconvolve_accumulate the vab may point to both va or vb yet all pointers were defined with the restrict specifier. The issues do not practically happen, but it's a formal violation. I have not checked other instances. restrict is tricky - it's a contract that a pointer does not alias any other pointer in the code context.

  15. marton78 commented on Sep 3, 2026

    @marton78
    Owner

    @avaneev

    The pffftd_zconvolve_zp() in the perf branch is actually 4% slower in my SSE2 tests compared to commit 102.

    Perfect question for AI. I am only mildly ashamed of being a meat proxy in this case and will paste you the relevant parts, edited for brevity:

    The function is byte-identical between master and perf, it has no hunk touching FUNC_ZCONVOLVE_ZP/FUNC_ZCONVERT_ZP, and pf_sse2_double.h only gained a VMSUB macro that these kernels don't use. I cross-compiled pffft_double.c for x86-64/SSE2 at both commits (clang -O3) and diffed the disassembly: _pffftd_zconvolve_zp is the same 45 instructions. Same for zconvert_zp, zconvolve, zconvolve_scale, zconvolve_accumulate. No build-flag changes for x86 either (target_optimizations.cmake diff is FMA/ARMv7 messages + WASM only). So an isolated-kernel 4 % delta is noise/thermal/code-placement, a few percent swing between two builds is normal.

    Did you measure the whole pipeline (transform → zp → transform) or just kernel only? What did change on SSE2 double:

    • passf2/3/5 (SVMADD restructure, fused multiply-add instead of mul+add ops)
    • passf8 (N=32 only)
    • radf4/radb4 and inlined radf3/radb3 got ido==3 fast paths (only active for N = 3 * 2^k)

    So on SSE2, which is a non-FMA target, the FFT itself is mostly a code-layout-level change.

    If you want, you could try to pin it down with perf stat -r 20 or -falign-functions=64.

    I would also suggest to remove SCALAR1 branch completely along with #if ( SIMD_SZ == 4 ) branching [...] SCALAR1 is adds no value from performance standpoint.

    SCALAR1 exists not for performance, but for sizes below/not multiple of 32. The cost of maintaining it is small, I'd keep it.

    And I guess scaling to e.g. SIMD_SZ=8 is technically impossible.

    Correct, unfortunately. I looked into supporting AVX for floats, but that would be a HUGE rewrite for questionable benefit.

    I'd like to also point out to restrict related contract violations (which were inherited from the original PFFFT).

    Holy shit, thats a GREAT catch, thanks! Fixed on master.

    Fortunately, the generated code with/without RESTRICT is instruction-identical for all six kernels on x86-64 and arm64, float and double (with clang -O3), so there is no performance cost to this fix, and that's also why it never happened in practice so far.

  16. marton78 commented on Sep 3, 2026

    @marton78
    Owner

    Also added on master in the branch just merged: expanded floating point constants for the twiddle factors (sin & cos of pi*p/q) to double precision. Before, they had only floating point precision.

  17. avaneev commented on Sep 3, 2026

    @avaneev
    ContributorAuthor

    That's okay about LLM reply. We'll all soon be senior developers riding AI spines. The performance difference I've got may be due to code layout differences, nothing serious. I've simply measured the __rdtsc() tick count of the pffftd_zconvolve_zp() function call.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions