Skip to content

Add a nightly-only FCMA backend - #177

Merged
ejmahler merged 8 commits into
ejmahler:masterfrom
HEnquist:fcma_backend
Sep 21, 2026
Merged

ejmahler merged 8 commits into
ejmahler:masterfrom
HEnquist:fcma_backend

Conversation

@HEnquist

@HEnquist HEnquist commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Picks up #144 the way you suggested: a copy of the neon folder renamed to fcma, behind a nightly-only feature flag, rather than waiting for the intrinsics to stabilize. Built fresh off current master, since the old branch predates a lot of the neon code.

  • new fcma cargo feature, off by default, needs nightly for stdarch_neon_fcma
  • src/fcma is src/neon with the complex arithmetic swapped for FCMA instructions
  • FftPlannerFcma, detected with is_aarch64_feature_detected!("fcma") and picked ahead of Neon
  • the butterfly generator takes an fcma architecture, and the has_fused_rotate flag finally does the // todo: when we get FCMA in there
  • tests/fcma_matches_neon.rs compares both planners for every length up to 256 plus larger ones
  • benches/bench_compare_neon_fcma.rs for the numbers below

On an M1, as a fraction of the Neon time: 0.69 to 0.80 for f32 and 0.69 to 0.74 for f64 at powers of two from 256 up, and down to 0.52 for small f64 sizes. Prime and power-of-three lengths gain less, 0.89 to 0.97, so there is more to do there.

The rotations matter more than the complex multiply. Swapping just mul_complex was worth about 0.80, folding the 90 degree rotations into the surrounding add or subtract got the rest.

The tests run on the ubuntu-24.04-arm and macos-latest runners, both of which execute the instructions natively. Two jobs because fcma is a default target feature on aarch64-apple-darwin but not on aarch64-unknown-linux-gnu.

HEnquist and others added 7 commits September 8, 2026 22:21
The FCMA instructions from ARMv8.3-A do complex multiply-accumulate directly,
but the intrinsics are still unstable in Rust. Rather than hold the Neon code
hostage to that, put the FCMA work in its own backend behind a nightly-only
"fcma" feature, off by default.

This commit is the mechanical part: src/neon copied to src/fcma with everything
renamed, the target features widened to "neon,fcma", and runtime detection
switched to "fcma". The code is still plain Neon, so the new backend performs
exactly like the old one for now.

The prime butterflies are regenerated rather than renamed, so the generator
gained an fcma architecture. Its target feature list needs more than the single
detected feature, so that is now a separate field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both scalar types now do a complex multiply as two fcmla against a zero
accumulator, instead of the shuffle-negate-multiply-reverse-fma sequence.

Measured on an M1 with the new bench_compare_neon_fcma bench, at sizes 4 to
131072. Everything from 16 up gets faster, and below that the two are equal:

  f32: 0.94 to 0.76 of the Neon time, best around 4096
  f64: 0.92 to 0.84 of the Neon time, fairly flat above 256

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A rotation by 90 degrees is a complex multiplication by i, so an FCMA
instruction does the rotation and the accumulation into the next add or
subtract at once. Rotation90 now holds the factor to multiply by instead of a
sign mask, and column_butterfly4 uses the fused version for its second pair of
size-2 FFTs.

On top of the complex multiplication change, still on an M1:

  f32: 0.73 to 0.82 of the Neon time from size 256 and up
  f64: 0.77 to 0.82 of the Neon time from size 256 and up

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hand written butterflies rotate and then add or subtract in several places,
which the FCMA instructions do in one go. Butterfly 4 and 5 use the fused
version directly, and butterfly 8 now calls the 45 and 135 degree rotation
helpers instead of open coding them, which makes its root2 constants redundant.

The new test compares every planned length up to 256, plus a handful of larger
ones, against the Neon backend in both directions. The fused instructions round
differently, so it compares within a tolerance. The observed difference is
around 4e-17 for f64 and 1e-8 for f32, well under the limits used.

Measured on an M1:

  f32: 0.69 to 0.83 of the Neon time from size 16 and up
  f64: 0.52 at the smallest sizes, settling around 0.70 to 0.74

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generator had a todo for this: the rotation on the subtraction half of each
size-2 butterfly only feeds a multiply by a real twiddle, and FCMA does the
rotation and that multiply-accumulate in one instruction. So the standalone
rotations are gone, and the imaginary half of each sum is a chain of fcmla.

The generator takes a has_fused_rotate flag for this, which only the fcma
architecture sets, so the sse, neon and wasm_simd files are unchanged. The
vector trait lost mul, nmadd and apply_rotate90, which nothing in the backend
calls any more.

Prime lengths on an M1, as a fraction of the Neon time, before and after:

  len     f32            f64
    7   0.998 -> 0.911   0.765 -> 0.667
   11   0.985 -> 0.929   0.816 -> 0.734
   13   0.979 -> 0.950   0.831 -> 0.780
   23   0.856 -> 0.799   0.938 -> 0.766
   29   0.977 -> 0.926   0.900 -> 0.658
   31   0.988 -> 0.955   0.894 -> 0.817
  961   0.989 -> 0.886   0.951 -> 0.905

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Butterfly 3 rotates the difference of its two inputs and then multiplies it by
a real twiddle twice, once with each sign. Both are single FCMA instructions on
the unrotated difference, one rotating each way, so the standalone rotation and
the negated copy of the twiddle both go away.

Power of three lengths on an M1, as a fraction of the Neon time:

  len      f32            f64
    9    0.911 -> 0.884   0.665 -> 0.621
   24    0.867 -> 0.837   0.685 -> 0.609
   81    0.925 -> 0.936   0.923 -> 0.888
  243    0.955 -> 0.901   0.950 -> 0.918
  729    0.961 -> 0.948   0.967 -> 0.936

The single value f32 path is untouched, it packs the sum and the rotated
difference into one vector and does not fit the same rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The job added with the backend was a compile check, based on a wrong assumption
of mine that no available runner implements ARMv8.3-A. Both the Linux arm64
runners and the macOS ones do, so drop the cross and QEMU detour and just run
the tests.

There is one job per target because fcma is in the default target features on
aarch64-apple-darwin but not on aarch64-unknown-linux-gnu, and the two detect
it in different ways.

The tests assert that the feature was detected, so they fail rather than
quietly passing if a runner ever stops supporting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ejmahler

Copy link
Copy Markdown
Owner

That was fast! Thanks so much for taking this on. Like I said in the other thread I'll take a look after this weekend.

@HEnquist

Copy link
Copy Markdown
Contributor Author

There wasn't really that much to do, just copying the files and applying the existing FCMA changes to them. Perfect work for Claude, the whole thing took like 15 minutes :)

@ejmahler ejmahler left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Looks great. I did a diff between the neon and fcma directories to see what was different, and noticed lots of good optimizations etc. One thing I was thinking about is that our rotate45, rotate135 etc methods that butterfly8 uses probably aren't much faster than a regular complex multiplication with a twiddle anymore. Both are just 2 instructions, excluding load/broadcast/etc. The rotate methods do involve less memory traffic because they don't have to load a twiddle factor vector though, which is worth something.

There was only one thing I noticed, once we make a decision on that we're ready to go.

@@ -34,7 +34,7 @@ pub const fn prime_butterfly_lens() -> &'static [usize] {
}

/// Safety: The current machine must support the {{arch.cpu_feature_name}} target feature

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Should this also be updated to reference the full list of instruction sets, like the lines down below were?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, updated those and the two in new() as well.

@ejmahler

Copy link
Copy Markdown
Owner

rust-lang/rust#117222

I posted a comment on the stabilization issue for fcma and it's showing signs of progress. We may not have to wait long.

@HEnquist

Copy link
Copy Markdown
Contributor Author

One thing I was thinking about is that our rotate45, rotate135 etc methods that butterfly8 uses probably aren't much faster than a regular complex multiplication with a twiddle anymore. Both are just 2 instructions, excluding load/broadcast/etc. The rotate methods do involve less memory traffic because they don't have to load a twiddle factor vector though, which is worth something.

Interesting idea, so I tried it. I swapped the rotate_45/135/225 helpers in the fcma backend for plain twiddle multiplies and benchmarked on an M1. As you guessed it doesn't help. Most lengths came out the same, and a few got slightly slower. Nothing got faster, so better to not do this.

@ejmahler
ejmahler merged commit 0256700 into ejmahler:master Sep 21, 2026
21 checks passed
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