Skip to content

perf: generic odd-radix FFT pass for lengths with prime factors above 7 - #45

Merged
Paullo9 merged 1 commit into
mainfrom
issue-4-generic-radix
Oct 31, 2026
Merged

Paullo9 merged 1 commit into
mainfrom
issue-4-generic-radix

Conversation

@Paullo9

@Paullo9 Paullo9 commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

Requested by Paul · project thread

Fixes #4

Summary

  • MixedRadixPlan gains a generic odd-radix pass for any prime factor other than 2, 3, 4, 5 and 7. It uses the pocketfft passg butterfly, works on one output group at a time, and runs four j at a time on fused multiply-adds. Before this, lengths off vDSP with factors such as 11, 13 or 23 always went through Bluestein.

  • FFT2D chooses between the plan and Bluestein with the shape of pocketfft's make_cfft_plan:

    • lengths below 50, or whose largest prime factor is at most √n, always use the plan;
    • otherwise Bluestein is used when 2.5 · m · log2(m) < cost_guess(n).

    pocketfft's own Bluestein weight is 6 · m · log2(m). vDSP's power-of-two DFT is cheaper than pocketfft's, so the weight here is measured with FFTBenchmarkTests instead.

  • Issue perf: mixed-radix FFT for lengths off vDSP that factor into 2, 3, 5 and 7 #42 already brought in the 2, 3, 5, 7 plan. With this PR, every length now takes the cheaper of the two paths.

Timings

Local forward fft2, median of 21, one thread:

n before after
506 = 2 · 11 · 23 3.77 ms (Bluestein) 3.06 ms (mixed radix)
1001 = 7 · 11 · 13 about 19 ms (Bluestein, as at 1009) 11.5 ms (mixed radix)
97, 106, 127, 509 Bluestein Bluestein, unchanged (the forced generic pass is 1.3x to 10x slower there)

NumPy 2.5.4 takes 2.34 ms at 506², so a gap of about 1.3x remains there. Lengths in 2, 3, 5 and 7 are unchanged from #42.

Tests

  • lengthsPickTheirFFTPath: red before the change, because 506, 11, 13 and 1001 took Bluestein. It now lists the boundary lengths on both sides.
  • mixedRadixCostRuleIsPocketffts: the factorisation, cost_guess and the rule at worked values.
  • The direct-DFT comparison now covers every element at generic lengths: (97, 11), (13, 1001), (496, 127) and (47, 198).
  • A new test compares the inverse with a direct inverse DFT at 121, 169 and 198. These include two generic passes in a row.
  • All tests use numpy assert_allclose rtol 1e-7. In the reviewer's scratch run, the generic lengths agreed with a direct DFT to between 1e-16 and 8e-15 relative, in both directions.
  • Fast gate green: swift test -c release --parallel --skip SimulatoKitParityTests. The parity target was not run in this session.
  • Reviewed before the commit: no must-fix. The inverse test was added from the review.

Left for later

docs/open-items.md and the Grid row in docs/parity.md still describe the old FFT routing. Both files are touched by open docs PRs, so they get a line once those PRs land. README, docs/status.md and the DocC article are updated here.

🤖 Generated with Claude Code

MixedRadixPlan gains a generic pass for any odd prime radix (the pocketfft passg butterfly, written per output group with four j at a time on fused multiply-adds), so lengths off vDSP with factors such as 11, 13 or 23 no longer all go through Bluestein. FFT2D picks the plan or Bluestein with the pocketfft make_cfft_plan rule (below 50 or largest prime factor at most sqrt(n): the plan), otherwise Bluestein when 2.5 m log2(m) < cost_guess(n); pocketfft weighs Bluestein at 6 m log2(m), but vDSP power-of-two DFTs are cheaper than its own, measured with FFTBenchmarkTests.

Local forward fft2, median of 21, one thread: 506 (2 x 11 x 23) 3.77 to 3.06 ms; 1001 (7 x 11 x 13) 11.5 ms against about 19 ms for Bluestein at 1009; primes 97 and up and 2 x 53 stay on Bluestein, where it is two to four times faster than the generic pass. Against a direct DFT the generic lengths agree to 1e-16 to 8e-15 relative in both directions (reviewer scratch run); the tests hold numpy assert_allclose rtol 1e-7.

Reviewed before the commit: no must-fix; the inverse is now also checked against a direct inverse DFT at 121, 169 and 198.

Done: generic radix pass, calibrated routing rule, path, cost-rule and inverse tests, benchmark lengths at the boundary. Next: 506 still trails NumPy (2.34 ms) by about 1.3x; docs/open-items.md and the parity row still describe the old FFT routing and need a line once the open docs PRs land.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Paullo9 Paullo9 self-assigned this Oct 31, 2026
@Paullo9
Paullo9 merged commit 1f626b6 into main Oct 31, 2026
8 checks passed
@Paullo9
Paullo9 deleted the issue-4-generic-radix branch October 31, 2026 22:01
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.

Any-length FFT (Bluestein) 2x to 7x slower than NumPy

1 participant