Skip to content

Precompute the Rader's permutation - #178

Open
HEnquist wants to merge 1 commit into
ejmahler:masterfrom
HEnquist:raders_precompute
Open

Precompute the Rader's permutation#178
HEnquist wants to merge 1 commit into
ejmahler:masterfrom
HEnquist:raders_precompute

Conversation

@HEnquist

@HEnquist HEnquist commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Rader's reorders the input by walking g^k mod len, one strength-reduced modular multiply per
element, in a serial chain recomputed on every call. This builds the sequence once at construction
instead.

  • each step is two 64x64->128 widening multiplies. wasm has no high-multiply opcode, so LLVM emits
    two call __multi3 plus a linear-memory round trip per element there, where aarch64 does it in
    seven instructions
  • one Box<[u32]> covers both directions: the inverse walk is that same table read backwards and
    rotated by one, since g^-k == g^(len - 1 - k)
  • costs 4 * (len - 1) bytes per instance, about 400 KB near len 100000, the same order as what
    avx_raders.rs and Bluestein's already store
  • M1, f64, eight smooth primes from 1009 to 100801: 1.24x to 1.61x. Under wasm the per-element
    permutation cost drops from 25.2-26.9 ns to 1.87-3.07 and Rader's comes out 1.9-3.5x faster

Rader's reorders the input by walking g^k mod len, one strength-reduced modular
multiply per element, in a serial chain recomputed on every call. Each step is
two 64x64->128 widening multiplies.

wasm has no high-multiply opcode, so LLVM emits two `call __multi3` plus a
linear-memory round trip per element there, where aarch64 does it in seven
instructions. That, and not the scattered access pattern, is where the backends
diverge: NEON has no gather either.

The sequence depends only on the primitive root and the length, so build it once
at construction into a Box<[u32]> and leave the hot loops as plain indexed
loads. avx_raders.rs already precomputes its output mapping for the same reason.
The inverse walk is that same table read backwards and rotated by one, since
g^-k == g^(len - 1 - k), so one table covers both directions.

Measured on an M1, f64, over eight smooth primes. Rader's runs its inner FFT
twice, so subtracting two inner FFTs isolates the permutation pass:

  len       raders ns  ->  raders ns   speedup   permutation ns/element
  1009          17027       10544        1.61x     8.09 -> 1.62
  2053          39685       26789        1.48x     8.10 -> 1.72
  4051         116000       90233        1.29x     8.33 -> 1.90
  8101         177284      126558        1.40x     8.32 -> 1.99
  15121        426733      337206        1.27x     8.17 -> 2.13
  32401        803375      615661        1.30x     8.12 -> 2.31
  65537       1239810      867489        1.43x     8.08 -> 2.36
  100801      2988425     2416661        1.24x     8.09 -> 2.27

Under wasm, where the chain was costing 25.2-26.9 ns per element, it drops to
1.87-3.07 and Rader's comes out 1.9-3.5x faster (node 24, f64).

The table costs 4 * (len - 1) bytes, about 400 KB for a prime near 100000, the
same order as the twiddles avx_raders.rs and Bluestein's already store at that
size.
@ejmahler

Copy link
Copy Markdown
Owner

one Box<[u32]> covers both directions: the inverse walk is that same table read backwards and
rotated by one, since g^-k == g^(len - 1 - k)

This is what was stopping me before, I was never able to figure out how to reuse the same buffer for both reorders. This is great work, thank you. Perhaps we should reconsider the raders vs bluestein's tuning with this in mind. Not necessarily as a part of this PR.

I'll review next week.

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