Skip to content

perf: use C++-order arithmetic except on AArch64 and x86 with fma - #37

Open
dignifiedquire wants to merge 3 commits into
mainfrom
fix/mul-add-without-fma
Open

dignifiedquire wants to merge 3 commits into
mainfrom
fix/mul-add-without-fma

Conversation

@dignifiedquire

@dignifiedquire dignifiedquire commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Summary

ac75be6 rewrote 41 multiply-add expressions in fft4g, the cascaded biquad filter, and the three-band filter bank as f32::mul_add chains. That gives single fmadd instructions on AArch64. On x86_64 without the fma target feature, which is the default for every downstream build, each mul_add instead becomes an fmaf library call:

  • Linux: an indirect call to compiler_builtins' runtime-dispatched fmaf.
  • Windows MSVC: the UCRT import.

These calls sit in per-sample loops (the high-pass filter and the AEC3 decimator via the biquad) and in per-frame loops (the filter bank and fft4g).

This PR keeps the mul_add chains only where the target has native FMA. Everywhere else it restores the expressions from before ac75be6, which follow the C++ reference's operation order:

// sonora-simd, the single definition used by sonora-fft, sonora-common-audio and sonora
pub const NATIVE_FMA: bool = cfg!(any(target_arch = "aarch64", target_arch = "arm64ec", target_feature = "fma"));

Effect by target

Target Arithmetic Output vs main
aarch64, arm64ec unchanged (mul_add → fmadd) bit-identical
x86/x86_64 with fma (for example -C target-cpu=native) unchanged (mul_add → vfmadd) identical
default x86_64, i686, armv7, wasm32, riscv64, … plain, C++ order; no fmaf calls tiny differences, closer to C++

Why it matters

  1. Performance on default x86_64: it removes the fmaf call from every multiply-add in these loops.
  2. The AEC3 slows 10–50× while the render reference is silent (denormals): upstream wraps APM calls in DenormalDisabler, the port does not #34 flush-to-zero guard: with a static CRT on Windows (+crt-static), the result of libucrt's fmaf is not flushed under FTZ/DAZ. On the AEC3 slows 10–50× while the render reference is silent (denormals): upstream wraps APM calls in DenormalDisabler, the port does not #34 branch, its regression test found 503 subnormal values in the AEC3 decimator output. With this change merged in, it passes. Refs AEC3 slows 10–50× while the render reference is silent (denormals): upstream wraps APM calls in DenormalDisabler, the port does not #34.
  3. C++ parity: against C++ built without FP contraction, the filter bank, the HPF, and 16 kHz echo cancellation become bit-exact (details below).

Verification

Codegen

  • Without this PR: x86_64 release code for Linux and Windows MSVC calls fmaf from all three crates.
  • With this PR: 0 fmaf references on either target.
  • With +fma: the same sites compile to native vfmadd.

aarch64 bit-identity

  • The release assembly of the three crates was instruction-identical to main at 001d67b. At the head it still is, except ThreeBandFilterBank::analysis and synthesis, which now always inline filter_core; their floating-point instructions are unchanged.
  • A pipeline run (HPF, AEC3, NS and AGC2; 16, 32 and 48 kHz; mono and stereo; 300 frames each) writes byte-identical output: 10,368,000 bytes, SHA-256 5de7d2f7….
  • arm64ec release code was instruction-identical to main at 001d67b (compiled only, not run).

Tests. The kernels are generic over const FMA: bool: the biquad loops, filter_core, and fft4g's cft1st, cftmdl, rftfsub, rftbsub and the rdft/irdft chain. Production code calls ::<NATIVE_FMA>. The tests run both ::<true> and ::<false> on every target and compare each form bit for bit against an independent reference: the mul_add chain, or the C++ expression. Each test also asserts that the two references differ, so it is sensitive. Further tests check that each public function equals its ::<NATIVE_FMA> instantiation, and that the fft4g transforms pass the form to every kernel. So macOS CI now tests the C++-order path, and x86 CI tests the fused path. The second commit made this change, after review found the first version's tests circular. Mutation testing, on aarch64 and on x86_64:

Mutant Caught
any of the 23 blocks forced to one form, or swapped 138/138
one statement in the other form (fft4g) 144/144
a ::<FMA> call site wired to a literal (where it can change output) 44/44
a public function wired to the wrong form 12/12
Check Result
cargo test --workspace (aarch64) 768 passed, 0 failed, 0 ignored
cargo test --workspace (x86_64 Linux, Docker) all pass
fmt, clippy -D warnings (host, x86_64, x86_64 with +fma), MSRV 1.91.1, docs pass
Windows x64, static and dynamic CRT, merged with the #34 branch (VM, x64 emulation) 209/209; silent_render_leaves_no_subnormal_state now passes under +crt-static (previously 503 subnormals)

C++ parity on x86_64 (sonora-bench, GCC 13, Docker). The per-test max diffs, main → this PR:

Test vs C++ built without -march=native vs C++ built with -march=native (CI)
filter_bank_analysis / synthesis 1.49e-8 → 0 / 1.49e-8 → 0 1.49e-8 → 7.89e-9
hpf 16k / 32k / 48k 2.6e-6 / 8.7e-6 / 3.1e-5 → 0 / 0 / 0 1.80e-6 → 1.75e-6 / 4.35e-6 → 8.40e-6 / 1.92e-5 → 2.46e-5
16k_mono ec_only 2.16e-6 → 0 2.36e-6 → 2.10e-6
16k_mono ns_only 5.43e-7 → 2.79e-9 6.84e-7 → 7.16e-7
48k pipelines unchanged (≈0.156 ec_only, pre-existing) unchanged

The C++ reference in CI is built with -march=native, so GCC fuses the C++ reference itself. Against that build, three maximum diffs increase, all within their tolerances. For hpf_48k, 2.46e-5 is about half of COMPONENT_TOL = 5e-5. These increases are the difference between fused and unfused C++, not a Rust regression: against the unfused C++ build, the same tests are bit-exact.

Commits

  • 001d67b perf: use C++-order arithmetic except on AArch64 and x86 with fma
  • 7a0ff9e refactor: share NATIVE_FMA and test both arithmetic forms on every target. This adds one shared constant (sonora_simd::NATIVE_FMA; sonora-fft gains a dependency on sonora-simd) and the const-generic kernels and tests described above. Pipeline output is byte-identical to 001d67b on every target, and to main on aarch64/arm64ec and x86 with fma. Code generation is identical to 001d67b except ThreeBandFilterBank::analysis and synthesis, where filter_core is now always inlined. 001d67b inlined it by a narrow margin on aarch64 and not at all on x86_64 under Cargo's default 16 codegen units. The floating-point instructions are the same, and the process_stream benchmarks show no change (measured on aarch64).
  • 1ab2c63 docs: correct the NATIVE_FMA rationale and the AArch64 parity note. On Apple arm64, Rust's fft4g twiddle tables differ from optimized C++ by 1 ulp in some entries, because Rust and C++ pair sinf/cosf calls into sincosf differently. So aarch64 is close to C++, not bit-identical.

Notes and follow-ups

  • CHANGELOG: there's no entry yet, because main has no "Unreleased" section and AEC3 slows 10–50× while the render reference is silent (denormals): upstream wraps APM calls in DenormalDisabler, the port does not #34's branch adds one. After AEC3 slows 10–50× while the render reference is silent (denormals): upstream wraps APM calls in DenormalDisabler, the port does not #34 lands, add: "Output changes by tiny amounts on every target except AArch64 and x86 with fma. fft4g, the cascaded biquad (HPF, AEC3 decimator), and the three-band filter bank use unfused arithmetic in C++ order, which is closer to C++ built without FP contraction. aarch64 and arm64ec output is unchanged."
  • Parity reference, possible follow-up: build the comparison C++ with -ffp-contract=off, or without -march=native, for a stable reference that isn't fused.
  • Other targets with FMA (riscv64gc, powerpc64, armv7 with VFPv4) now take the plain path. That matches C++ without contraction, but gives up fusion on those targets. CI doesn't cover them.
  • x87-only i586: the policy tests aren't meaningful under x87 excess precision. The commit message states this limit.
  • cpp_comparison.rs: its header comment is updated for the new x86 behavior.
  • Timings: real x86 before/after timings are in this comment: 1.13–2.22× faster. They measured 001d67b against main on the same runner. The later commits change code generation only in the filter bank's inlining, so the numbers very likely still apply; they were not re-measured.

🤖 Generated with Claude Code

https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx

ac75be6 rewrote 41 multiply-add expressions as f32::mul_add chains:
the butterflies and real-FFT post-processing in fft4g (cft1st, cftmdl,
rftfsub, rftbsub), the cascaded biquad filter, and the unrolled taps of
the three-band filter bank. mul_add is one instruction only where the
target has FMA. On x86_64 without the fma target feature, which is the
default for every downstream build, each mul_add becomes an fmaf
library call: on Linux an indirect call through the GOT to
compiler_builtins' runtime-dispatched fmaf, on Windows MSVC the UCRT
import. These calls sit in per-sample loops: the biquad feeds the
high-pass filter and the AEC3 decimator, and the filter bank and fft4g
run on every frame.

With a static CRT on Windows (+crt-static), the result of libucrt's
fmaf is not flushed under FTZ/DAZ. On the #34 flush-to-zero branch
(fix/issue-34-denormals), its regression test finds 503 subnormal
values in the AEC3 decimator output; with this change it passes.

Each site now selects its form with a compile-time constant,
USE_FMA = cfg!(any(target_arch = "aarch64", target_arch = "arm64ec",
target_feature = "fma")). Those targets keep the mul_add chains
unchanged; arm64ec runs AArch64 code and compiles mul_add to fmadd, as
aarch64 does. Every other target uses the expressions from before
ac75be6, which follow the operation order of the C++ reference (M145);
GCC and clang do not fuse them on baseline x86_64. Other targets with
native FMA (riscv64gc, powerpc64, armv7 with VFPv4, and others) also
take the plain path. It follows the C++ source order, as C++ compiled
without FP contraction does; GCC and clang would fuse these
expressions there.

Verification:
- aarch64 output is bit-identical to main. Release assembly of the
  three crates is instruction-identical after normalizing symbol
  hashes, and a pipeline run (HPF, AEC3, NS and AGC2 at 16, 32 and
  48 kHz, mono and stereo, 300 frames each) writes the same
  10,368,000 output bytes.
- arm64ec release code of the three crates is instruction-identical
  to main (built with nightly -Zbuild-std; not run).
- x86_64 release code for Linux and Windows MSVC references fmaf on
  main and does not on this branch. With +fma the same sites compile
  to vfmadd instructions.
- On x86_64, fft4g rdft and irdft output for n = 16 to 512 matches the
  upstream fft4g.cc built with -ffp-contract=off, bit for bit.

New tests in each crate pin the policy. Their inputs make the fused
and the C++-order results differ, and the implementation must match
the fused result on AArch64 and x86 with fma, and the C++-order result
on other targets with IEEE single-precision arithmetic. They do not
apply to x87-only targets such as i586, where f32 expressions can be
evaluated with excess precision. Flipping any single site fails them
on aarch64 and on x86_64.

The header comment of sonora-bench's cpp_comparison.rs no longer
attributes the x86 differences to Rust's mul_add. The C++ reference is
built with -march=native, so the C++ compiler may contract it on CPUs
with FMA.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
@codecov-commenter

codecov-commenter commented Sep 29, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.74%. Comparing base (413f64e) to head (1ab2c63).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #37      +/-   ##
==========================================
+ Coverage   93.64%   93.74%   +0.09%     
==========================================
  Files         146      146              
  Lines       31130    31627     +497     
  Branches    31130    31627     +497     
==========================================
+ Hits        29153    29650     +497     
  Misses       1753     1753              
  Partials      224      224              
Flag Coverage Δ
rust 93.74% <100.00%> (+0.09%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

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

@dignifiedquire

Copy link
Copy Markdown
Owner Author

Real x86_64 timings

  • Source: the temporary job ci/mul-add-timing, run 36614917852.
  • Method: each job benchmarks main's versions of the three changed files, then this PR's versions, on the same runner. Each variant builds in its own target dir. Default target (no -C target-cpu), bench profile, 10 s of measurement per case. Figures are Criterion mean times per call.
Runner (CPU) process_stream/16k_mono 48k_mono 48k_stereo noise_suppressor
ubuntu 1 (AMD EPYC 9V74) 15.64 → 7.04 µs, 2.22× 29.85 → 23.32 µs, 1.28× 56.88 → 43.78 µs, 1.30× 4.06 → 1.97 µs, 2.06×
ubuntu 2 (AMD EPYC 9V74) 17.59 → 8.98 µs, 1.96× 38.66 → 30.12 µs, 1.28× 73.65 → 56.36 µs, 1.31× 5.29 → 2.57 µs, 2.05×
windows 1 (AMD EPYC 7763) 13.86 → 8.96 µs, 1.55× 34.75 → 30.32 µs, 1.15× 65.76 → 57.52 µs, 1.14× 3.77 → 2.42 µs, 1.55×
windows 2 (AMD EPYC 9V74) 13.98 → 9.56 µs, 1.46× 35.61 → 31.50 µs, 1.13× 67.37 → 59.39 µs, 1.13× 4.03 → 2.53 µs, 1.59×
  • The largest gains are in the noise suppressor and the 16 kHz pipeline. There, fft4g and the biquad make up a larger share of the work, and each mul_add had been an fmaf call.
  • Windows gains less than Linux. The UCRT fmaf import is a direct call to a small FMA3 routine, while Linux went through compiler_builtins' runtime-dispatch stub.
  • aarch64 is unchanged, because the code is instruction-identical to main.
  • All four runners were AMD this time. The gain comes from removing library calls and does not depend on subnormal handling, so we expect similar results on Intel.

The temporary branch will be deleted.

🤖 Generated with Claude Code

dignifiedquire and others added 2 commits October 1, 2026 12:12
…rget

Review of 001d67b raised two problems.

1. The predicate that selects the fused or the plain form was written
   six times: a USE_FMA constant in each of fft4g.rs,
   cascaded_biquad_filter.rs and three_band_filter_bank.rs, and an
   EXPECT_FUSED copy in each of their test modules.

2. The tests did not test FMA against non-FMA. USE_FMA was a
   compile-time constant, so each build compiled and tested one form
   only, and EXPECT_FUSED restated the same predicate, so the check was
   circular. On aarch64 the plain C++-order form was never tested; on
   baseline x86_64 the fused form was never tested.

sonora-simd now defines the predicate once, as the documented public
constant NATIVE_FMA. The three USE_FMA constants and the three
EXPECT_FUSED copies are removed. sonora-fft now depends on sonora-simd
for it; sonora-simd depends only on cpufeatures, so this adds no cycle.
The lockfiles of the excluded sonora-bench and fuzz workspaces record
the new edge.

The kernels are generic over `const FMA: bool` and select their form
with `if FMA { fused } else { plain }`: cft1st, cftmdl, rftfsub and
rftbsub in fft4g, reached through cftfsub, cftbsub and the private
rdft_with and irdft_with; the three biquad loops, in process_with,
process_in_place_with and apply_biquad; and filter_core, reached through
analysis_with and synthesis_with. The public functions keep their
signatures and call the NATIVE_FMA instantiation.

The tests run both instantiations on every target. Each compares
::<true> with the mul_add chain and ::<false> with the C++-order
expression, bit for bit, and asserts that the two references differ for
its inputs:

- butterflies_match_fused_and_plain_references (cft1st, cftmdl)
- rft_sub_matches_fused_and_plain_references (rftfsub, rftbsub)
- recursion_matches_fused_and_plain_references (the three biquad loops)
- filter_core_matches_fused_and_plain_references (Parts 1 and 3)

The butterfly test runs cft1st at n = 4096 and cftmdl at n = 8192 on
pseudo-random input. It asserts that every twiddled output (real and
imaginary, at j + l, j + 2l and j + 3l, in both groups) differs between
the references in at least one butterfly, so each statement is checked
on its own. With the golden-ratio input of the other tests, inputs l
apart differ by almost the same amount, and some outputs did not differ
at all.

transforms_pass_the_form_to_every_kernel checks the fft4g call chain:
rdft_with::<F> and irdft_with::<F> at n = 512 must equal the kernels
composed with the form F written out (with a copy of the last radix-4
pass, which has no mul_add). It also asserts that each set of kernels
one ::<FMA> in the chain controls (cft1st; both cftmdl passes; all
three; rftfsub or rftbsub) changes both transforms when it alone takes
the other form.

A second test per file checks that the public functions equal the
NATIVE_FMA instantiation, on inputs where the two forms differ:
rdft_and_irdft_use_native_fma, public_methods_use_native_fma and
analysis_and_synthesis_use_native_fma.

Mutation testing on aarch64 and on x86_64 (under Rosetta), with each
mutant built and tested on both:
- Forcing any one of the 23 if/else blocks to one form fails a test, as
  does swapping its branches (138 of 138).
- In the 18 fft4g blocks, putting one statement or tuple element in the
  other form fails a test (144 of 144).
- Replacing one ::<FMA> in rdft_with, irdft_with, cftfsub or cftbsub
  with a literal fails a test at the eight sites that can change the
  output (32 of 32). The two n == 4 calls run no kernel, so their
  mutants are equivalent.
- Wiring a public function to the wrong form fails a test: ::<false> on
  aarch64, ::<true> on x86_64.
As before, the bit-exact tests assume IEEE single-precision evaluation,
so x87-only targets such as i586 are not covered.

Three attributes keep the production code as it was, or close to it.
The generic bodies
behind process, analysis, synthesis, rdft and irdft are
#[inline(always)]; without it, process, analysis and synthesis compiled
with small differences in block layout, scheduling and register
allocation. rustc makes small functions that call nothing but
intrinsics available for inlining in other crates. process_in_place was
one; as a wrapper that calls process_in_place_with, it is not, so it is
#[inline]. Its body is not forced inline, because that changed the code
of the high-pass filter in sonora.
filter_core is #[inline(always)]: its LLVM inline cost sits at the
threshold, so whether analysis and synthesis inlined it depended on the
codegen-unit partition. With Cargo's default 16 units, 001d67b inlined
it on aarch64 (cost 520 against a threshold of 525) and not on x86_64;
behind the generic chain it was no longer inlined on aarch64 either,
which roughly doubled the code of analysis and synthesis there.

Verification against 001d67b:
- With the workspace release profile (thin LTO, one codegen unit),
  release assembly of sonora-fft, sonora-common-audio, sonora-aec3 and
  sonora-ns is instruction-identical on aarch64-apple-darwin,
  x86_64-unknown-linux-gnu with and without +fma, and
  x86_64-pc-windows-msvc. In sonora, every function is identical except
  ThreeBandFilterBank::analysis and synthesis, which differ in
  instruction scheduling and register allocation only, with the same
  floating-point instructions (aarch64-apple-darwin: 219 -> 229 and
  222 -> 233 instructions; x86_64-unknown-linux-gnu: the same two
  functions differ). The comparison normalizes symbol hashes and label
  numbers, and maps the generic instance names (cft1st::<true> and
  others) to the old names.
- With Cargo's default release profile (16 codegen units, no LTO),
  sonora-fft, sonora-common-audio, sonora-aec3 and sonora-ns are
  identical. In sonora, filter_core is now always inlined into analysis
  and synthesis. Instructions in analysis / synthesis:
  aarch64-apple-darwin 1240 / 1072 in 001d67b, 1256 / 1090 here (same
  floating-point instructions); x86_64-unknown-linux-gnu 2537 / 2459
  plus a separate 212-instruction filter_core in 001d67b, 930 / 1060
  here.
- process_stream benchmarks (aarch64, workspace profile) show no change
  (p > 0.05 for 16k mono, 48k mono and 48k stereo).
- A pipeline run (HPF, AEC3, NS and AGC2 at 16, 32 and 48 kHz, mono and
  stereo, 300 frames each) writes the same 10,368,000 output bytes on
  aarch64 and on x86_64 under Rosetta.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
The NATIVE_FMA documentation gave one reason for the plain path on
every other target: that mul_add is an fmaf library call there. That
reason holds only for targets without native FMA. riscv64gc, powerpc64
and armv7 with VFPv4 have native FMA and take the plain path by policy,
so that they evaluate the expressions as C++ built without
floating-point contraction does. The two cases now have separate
sentences.

The header of sonora-bench's cpp_comparison.rs said that on ARM (NEON)
Rust and C++ produce bit-identical results. They do not. In fft4g.cc,
makewt and makect pass a float argument to cos and sin, so C++ calls
the float functions too. On aarch64-apple-darwin the -O3 C++ reference
computes each cos/sin pair of one argument with one __sincosf_stret
call (its fft4g object imports only ___sincosf_stret and _cosf). Rust
calls cosf and sinf separately in debug builds, and merges only some
pairs in release builds. In a standalone reproduction of the tables for
n = 16 to 4096 (4,088 values), Rust differs from C++ -O3 in 92 entries
(debug) and 42 (release), and Rust debug differs from C++ -O0, which
calls cosf and sinf separately, in none. Each difference is 1 ulp. The
header now names AArch64, where NATIVE_FMA selects mul_add, and gives
this cause; 32-bit ARM with NEON takes the plain path in Rust.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H29DamSLugosGXJSz1e5Yx
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