Skip to content

Convolution improvements - #102

Merged
marton78 merged 12 commits into
masterfrom
zconvolve
Aug 28, 2026
Merged

marton78 merged 12 commits into
masterfrom
zconvolve

Conversation

@marton78

@marton78 marton78 commented Aug 24, 2026 •

Copy link
Copy Markdown
Owner

Convolution improvements — const setup, unscaled zconvolve, zero-phase convolution, _USE_MATH_DEFINES guard

Implements all four suggestions from #101 for both the float and double variants (which share one implementation header, so every change lands in both automatically). One suggestion is implemented with a design refinement specific to PFFFT's memory layout — see Zero-phase convolution below.

1. const setup pointers

All functions that only consume the setup (pffft_transform, pffft_transform_ordered, pffft_zreorder, pffft_zconvolve_*) now take const PFFFT_Setup * / const PFFFTD_Setup *. Verified read-only: the twiddle/ifac tables are written exclusively by the *_ti1_ps() init functions at setup creation; rfftf1_ps/cfftf1_ps already took (const float *wa, const int *ifac). Source-compatible with every existing caller; makes sharing one setup across threads formally correct.

2. Unscaled pffft_zconvolve()

New entry point computing dft_ab = dft_a * dft_b with no accumulation and no scaling argument. The scaling factor was a runtime value in the previous kernels, so the compiler could not fold away the dead multiply; removing it saves one vector multiply per SIMD group. As suggested in the issue, rescaling belongs on the static filter side.

Naming: the fork-specific pffft_zconvolve_no_accu() (which upstream never had) is renamed to pffft_zconvolve_scale() so the family reads by what each function does:

pffft_zconvolve_accumulate(setup, a, b, ab, scaling); /* ab += a*b*scaling */
pffft_zconvolve_scale     (setup, a, b, ab, scaling); /* ab  = a*b*scaling */
pffft_zconvolve           (setup, a, b, ab);          /* ab  = a*b         */

The old name remains as an exported deprecated synonym forwarding to the new symbol — existing binaries keep linking.

3. Zero-phase convolution — pffft_zconvert_zp() + pffft_zconvolve_zp()

Implements the r8brain trick for linear-phase FIRs centered at t=0 with left-hand taps wrapped around the block. One necessary refinement to the proposal: in PFFFT's unordered output the coefficients are not stored as interleaved re/im pairs — whole SIMD vectors alternate between real parts and imaginary parts, and F(0)/F(N/2) pack into the first lanes of vectors 0/1 (with the real-valued Nyquist coefficient sitting in an otherwise-imaginary lane). A flat sequential multiply therefore cannot work on unordered buffers directly. Instead this PR provides:

  • pffft_zconvert_zp(setup, in, out, scaling) — converts an unordered REAL forward spectrum once at filter-setup time: imaginary vector lanes are zeroed, real lanes scaled, the DC/Nyquist pair preserved component-wise.
  • pffft_zconvolve_zp(setup, x, hzp, ab) — multiplies any number of block spectra against the converted filter using two vector multiplies per re/im vector pair where a full complex multiply needs four (Y_re = X_re·H_re, Y_im = X_im·H_re).

This keeps the faster unordered transforms end-to-end, which is the point of the optimization. Measured vs zconvolve_scale(.., 1.0) on M2/NEON, best of 5: 1.53× / 1.60× / 1.59× / 1.47× at N = 128/512/2048/8192. Both functions return nonzero for non-REAL setups; aliasing (in == out) is supported and documented, including the DC/Nyquist lane caveat in the public headers.

C++ wrapper: convolve(a, b, ab), zconvertZP() and convolveZP() added to the public Fft<T> template (raw-pointer and AlignedVector forms); COMPLEX instantiations forward unchanged and are rejected by the underlying functions.

4. _USE_MATH_DEFINES

Now guarded with #ifndef so defining it as a compiler-level option no longer conflicts.

Also included

  • Pre-existing bug fixes surfaced by the new tests: pffft_zconvolve_scale_nosimd() accumulated instead of assigned at the two REAL boundary lanes (bins 0 and N/2), and test_fft_factors looped forever under SIMD_SZ == 1 because min_fft_size(COMPLEX) returns 1 there (N += N_min/2 stepped by zero).
  • CI: new build_no-simd_novect configuration (PFFFT_USE_SIMD=OFF -DPFFFT_USE_SCALAR_VECT=OFF) that compiles and ctests the SIMD_SZ==1 branch — previously never executed anywhere.

Testing

  • New ctest coverage: bit-exact agreement between zconvolve and zconvolve_scale(.., 1.0); zero-phase round-trip against independently computed time-domain circular convolution; sentinel-based invariant that conversion writes every destination lane; scaling linearity; in-place aliasing equivalence; REAL-only guards; wrapper-level equivalents of all of the above. Red→green verified for the lane-zeroing fix.
  • Full suite green in four configurations: default SIMD, PFFFT_USE_SIMD=OFF (4×scalar emulation), PFFFT_USE_SIMD=OFF + PFFFT_USE_SCALAR_VECT=OFF (SZ=1 nosimd), and float-less double-only builds.

Keeps editor/agent saves byte-exact; repo style (2-space indent, no
tabs, per AGENTS.md) is applied only via deliberate formatting runs.
The setup structure is read-only during transforms (twiddle/ifac tables
are only written by the *_ti1_ps init functions), so all functions that
merely consume it now take const PFFFT_Setup * / PFFFTD_Setup *.
Source-compatible with existing callers; allows passing shared setups
from threads without casting away const.
MSVC users may define _USE_MATH_DEFINES as a compiler-level option;
unconditionally redefining it in the source is at best redundant and
at worst a macro-redefinition warning. Only define it if not already
set.
Name API functions by what they do, not what they omit:
  zconvolve_accumulate : ab += a*b*scaling
  zconvolve_scale      : ab  = a*b*scaling   (was no_accu)
The old name stays as a deprecated synonym forwarding to the new one,
so existing callers keep linking and running unchanged.
dft_ab = dft_a * dft_b -- no accumulation, no scaling argument. The
compile-time-absent scaling factor saves one vector multiply per SIMD
group compared to pffft_zconvolve_scale(.., 1.0).

Also migrate the benchmark's circular-convolution check (previously
memset + zconvolve_accumulate(scaling=1.0), an exact equivalent) and
cover float and double in the ctest suite.
pffft_zconvert_zp() turns an unordered forward-transform result into
'zero-phase' form: imaginary vector lanes are zeroed, real lanes are
scaled; the DC/Nyquist lane pair is preserved component-wise. A
linear-phase FIR centered at t = 0 with left-hand taps wrapped to the
end of the block yields such a purely-real spectrum.

pffft_zconvolve_zp() then multiplies a block spectrum with that filter
spectrum using two vector multiplies per real/imag vector pair where a
full complex multiply needs four: Y_re = X_re*H_re, Y_im = X_im*H_re.

Micro-benchmark vs pffft_zconvolve_scale(.., 1.0) on M2/NEON, best of 5:
N=128: 1.53x, N=512: 1.60x, N=2048: 1.59x, N=8192: 1.47x faster.

Both return nonzero for non-REAL setups. Float and double, SIMD and
scalar paths. C++ wrapper: zconvertZP()/convolveZP().
Round-trip check: linear-phase FIR wrapped around the block, converted
with pffft_zconvert_zp() and applied with pffft_zconvolve_zp(), must
reproduce the time-domain circular convolution (float and double).
Cross-checks the zp kernel against the plain complex multiply outside
the DC/Nyquist lanes and verifies both functions reject PFFFT_COMPLEX
setups. Verified in SIMD and -DPFFFT_USE_SIMD=OFF builds.
The scalar-fallback kernel did ab[0] += .. / ab[N-1] += .. where the
no-accumulate contract (and the SIMD sibling) require plain stores,
leaving stale output values in bins 0 and N/2. Pre-existing defect
surfaced by review; note the nosimd branch is currently unreachable
through CMake -- scalar builds use the 4xScalar emulation with
SIMD_SZ == 4 -- so no test configuration exercises it directly.
pffft_min_fft_size(PFFFT_COMPLEX) returns SIMD_SZ^2, which is 1 in the
nosimd branch reachable via PFFFT_USE_SCALAR_VECT=OFF. The size sweep
then started at N = N_min/2 == 0 and stepped by 0 -- an endless stream
of rejected new_setup(0) calls that looked like a library hang. Step
by 1 when N_min < 2.
This is the only configuration that compiles the SIMD_SZ==1 nosimd
branch -- previously never executed by any test configuration.
Excludes the greenffts octave wrappers that cannot run outside their
environment; everything else must pass.
@marton78 marton78 changed the title const setup, unscaled zconvolve, zero-phase convolution, _USE_MATH_DEFINES guard Convolution improvements Aug 25, 2026
Address issue #101 feedback: with the unnormalized forward transform,
the zp pipeline carried a factor N, forcing callers to pass
scaling = 1/N to zconvert_zp to get correct convolution gain.

zconvert_zp() now applies scaling/N internally (both SIMD and nosimd
variants, including the Nyquist lane), so with scaling == 1 the
pipeline transform(x, FORWARD) -> zconvolve_zp() ->
transform(.., BACKWARD) yields the circular convolution at unit gain,
matching r8brain's convention. 'scaling' becomes an additional gain.
The normalization lives at filter-conversion time, so the per-block
zconvolve_zp kernel is unchanged.

Tests: round-trip now asserts Y ~= direct circular convolution without
rescaling; the bit-exact cross-check against zconvolve_scale converts
a second filter copy with scaling == N, which cancels the internal
1/N exactly. Wrapper test updated likewise.
@marton78
marton78 merged commit 8c6b35e into master Aug 28, 2026
@marton78
marton78 deleted the zconvolve branch August 28, 2026 11:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant