Skip to content

Straight-line codelets for the 8/16/32/64-point base cases of the radix-4 kernel - #135

Open
pankgeorg wants to merge 2 commits into
JuliaMath:mainfrom
JuliaComputing:feat/pow2-codelets
Open

Straight-line codelets for the 8/16/32/64-point base cases of the radix-4 kernel#135
pankgeorg wants to merge 2 commits into
JuliaMath:mainfrom
JuliaComputing:feat/pow2-codelets

Conversation

@pankgeorg

Copy link
Copy Markdown

Item C of the plan in #130, stacked on #134 (twiddle tables; the diff includes that commit until it lands).

Before, fft_pow2_radix4! recursed down to 2- and 4-point base cases, so roughly half of the work on cache-resident powers of two was call and index overhead; FFTW's advantage at these sizes is its library of generated straight-line codelets.

Now, for Complex{Float32} and Complex{Float64} the recursion stops at 64 points (or the whole transform if smaller) in a fully unrolled radix-2 DIT codelet produced by an @generated function keyed on the size and direction — every intermediate in its own SSA variable, twiddles folded to constants, trivial multiplications removed, explicit fma so rounding does not depend on which array type the codelet is specialised for. This is what FFTW's genfft emits offline; here Julia's compiler does it. Other element types (BigFloat, Float16, symbolic) keep the generic recursion, which stays the reference implementation.

Compile latency. Each (size, element type, direction) codelet costs 0.1–0.6 s of LLVM time on first use, so the package gets a PrecompileTools workload that compiles the 8/16/32/64-point codelets and the common plan/execute paths at package precompile time (new dependency, PrecompileTools = "1"). Net effect on a fresh session: using FFTA; fft(randn(ComplexF64, 4096)) goes from ~1.9 s to well under a millisecond. Standalone, the 8/16/32/64-point codelets run 3.3×/1.6×/2.2×/1.3× faster than the recursion they replace (14.6/47/125/334 ns vs FFTW's 24.8/40.6/80.2/308 ns on this machine).

Before/after (aarch64 Neoverse-N1, Julia 1.12.6, benchmark/suite.jl, planned execution, single thread; "before" is #134, FFTA/FFTW vs FFTW 3.3.11 ESTIMATE):

class kind type cases FFTA speedup geomean (min–max) FFTA/FFTW before → after max bytes/exec before → after plan time geomean before → after
1d/awkward fft Float32 29 1.17× (0.92–1.53) 3.20× → 2.74× 0 → 0 1.2 ms → 1.1 ms
1d/awkward fft Float64 29 1.14× (1.02–1.48) 2.55× → 2.25× 0 → 0 1.5 ms → 1.5 ms
1d/pow2 fft Float32 20 1.39× (1.05–2.22) 2.32× → 1.68× 0 → 0 35.0 µs → 34.3 µs
1d/pow2 fft Float64 40 1.37× (1.00–2.42) 1.96× → 1.43× 0 → 0 48.1 µs → 44.9 µs
1d/prime fft Float32 19 1.21× (0.99–1.59) 3.32× → 2.71× 0 → 0 109.5 µs → 96.0 µs
1d/prime fft Float64 19 1.17× (0.96–1.57) 2.37× → 2.04× 0 → 0 135.2 µs → 121.4 µs
1d/smooth fft Float32 24 1.07× (0.97–1.26) 4.97× → 4.60× 0 → 0 70.3 µs → 68.6 µs
1d/smooth fft Float64 24 1.07× (0.99–1.23) 3.23× → 3.06× 0 → 0 88.9 µs → 88.7 µs
2d fft Float32 9 1.38× (1.07–1.77) 4.16× → 3.05× 32 KiB → 32 KiB 2.2 µs → 2.1 µs
2d fft Float64 14 1.32× (1.09–1.75) 2.86× → 2.19× 64 KiB → 64 KiB 6.9 µs → 6.4 µs
3d fft Float32 5 1.44× (1.20–1.73) 8.17× → 5.47× 2 KiB → 2 KiB 1.3 µs → 1.2 µs
3d fft Float64 5 1.37× (1.04–1.65) 5.39× → 3.91× 4 KiB → 4 KiB 1.6 µs → 1.5 µs
batched_dim1 fft Float32 6 1.33× (1.22–1.55) 3.25× → 2.47× 0 → 0 10.0 µs → 9.9 µs
batched_dim1 fft Float64 6 1.29× (1.21–1.49) 2.04× → 1.60× 0 → 0 12.6 µs → 12.7 µs
batched_dim2 fft Float32 6 1.18× (1.06–1.41) 2.14× → 1.81× 0 → 0 9.9 µs → 9.7 µs
batched_dim2 fft Float64 6 1.12× (0.88–1.36) 1.77× → 1.54× 0 → 0 12.2 µs → 12.5 µs

Largest slowdowns / speedups (planned execution, FFTA before → after; FFTW for reference):

  • 0.88× — fft Float64 64×1024 dims=(2,): 1.51 ms → 1.71 ms (FFTW 1.12 ms)
  • 0.91× — fft Float64 64×4096 dims=(2,): 8.16 ms → 9.01 ms (FFTW 5.50 ms)
  • 0.92× — fft Float32 49233 dims=(1,): 9.15 ms → 9.92 ms (FFTW 3.11 ms)
  • 0.96× — fft Float64 7 dims=(1,): 0.1 µs → 0.1 µs (FFTW 0.0 µs)
  • 2.12× — fft Float32 32 dims=(1,): 0.3 µs → 0.1 µs (FFTW 0.1 µs)
  • 2.21× — fft Float64 32 dims=(1,): 0.3 µs → 0.1 µs (FFTW 0.1 µs)
  • 2.22× — fft Float32 8 dims=(1,): 0.1 µs → 0.0 µs (FFTW 0.0 µs)
  • 2.29× — fft Float64 32 dims=(1,): 0.3 µs → 0.1 µs (FFTW 0.1 µs)
  • 2.37× — fft Float64 8 dims=(1,): 0.1 µs → 0.0 µs (FFTW 0.0 µs)
  • 2.42× — fft Float64 8 dims=(1,): 0.1 µs → 0.0 µs (FFTW 0.0 µs)

261 matched cases; geometric-mean speedup 1.22×; 3 cases slower by >5%.

Against main the combination #134 + this PR is a 2.35× geometric-mean speedup over the 495 comparable cases of the suite. Three strided-batched cases are within 10% either way (noise-level on this shared host).

Tests: the existing suite (exact-equality checks between strided and contiguous execution included) passes; a 3D-rfft-unsupported error count in the benchmark is the known limitation, not new.

Twiddle factors were regenerated on every execution: every kernel seeded
Singleton's recurrence with a sincospi call (per output row of the O(n^2)
DFT leaf, per j1 in the composite step, per level of the radix-4/3
kernels), and fft_bluestein! allocated three pad-length buffers and
recomputed the chirp and its FFT on every call.

CallGraph now carries, per node, a twiddle table in the layout its kernel
reads sequentially (DFT: w^k; composite: the (j1, k2) block; radix-4/3:
per-level interleaved triplets/pairs addressed by a flat offset), a
BluesteinScratch (chirp, its pre-scaled transform, work arrays, pow2
tables for the padded length) per Bluestein node, and the direction the
tables were built for. All tables derive from one unit_roots table per
node that evaluates sincospi on the first octant only when 8 | N.

Planned execution is allocation-free for every size. Tables are
correctly rounded, so the Float32 error no longer grows with n (~1.5 ulp
at 2^22 instead of ~1000); the accuracy test grid is extended to 2^22.
The old kernel signatures remain as wrappers that build tables on the
fly.
…ix-4 kernel

The power-of-two kernel recursed down to 2- and 4-point base cases, so
about half of its work was call and index overhead. For Complex{Float32}
and Complex{Float64} the recursion now stops at 64 points (or the size
of the transform if smaller) in a fully unrolled radix-2 DIT codelet
emitted by an @generated function keyed on the size and direction, with
folded twiddle constants and one SSA variable per intermediate — the
same structure FFTW's genfft produces offline. Other element types keep
the generic recursion.

Each (size, element type, direction) codelet costs 0.1-0.6 s of LLVM
time on first use, so a PrecompileTools workload compiles them, and the
common plan/execute paths, at package precompile time; time to first
fft(x) in a fresh session drops from ~1.9 s to well under a millisecond.
@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.90%. Comparing base (7aeb327) to head (5b02c9d).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #135      +/-   ##
==========================================
+ Coverage   98.80%   98.90%   +0.09%     
==========================================
  Files           5        6       +1     
  Lines         585      729     +144     
==========================================
+ Hits          578      721     +143     
- Misses          7        8       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/algos.jl
Comment on lines -421 to +381
out_inds = range(start_out; step=stride_out, length=N)
copyto!(out, CartesianIndices((out_inds,)), Xk, CartesianIndices((N,)))
# X_k = conj(b_k) · conv_k
@inbounds for i in 1:N
out[start_out + (i-1)*stride_out] = conj(chirp[i]) * conj(a[i])
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect copyto! is often more optimized for large N

Comment thread src/codelets.jl
Comment on lines +38 to +41
# explicit fma (not muladd) so that rounding does not depend on
# whether LLVM contracts for a particular array type
push!(stmts, :($t = Complex{$T}(fma($wr, real($o), -$wi * imag($o)),
fma($wr, imag($o), $wi * real($o)))))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

examples for when LLVM chooses not to contract for some array types? might have seen something similar before but cannot remember.

in general I think muladd is still the way to go, it accommodates machines with no native fma (emulated fma is slow). IMHO granular fast-math flags would be exactly what's needed here, but the issue for that on the Julia repo is still open...

Comment thread src/codelets.jl
# implementation.

const CODELET_MAX = 64
const CodeletEltype = Union{ComplexF32,ComplexF64}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe better to use traits?

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.

2 participants