Measured, target-specific notes for the DSP hot path. Every claim here is a
number from the deterministic instruction-count ratchet (bench/README.md,
scripts/icount.py) under QEMU, not an estimate. The deployment precision is
float32 on both embedded targets (the M55 has no double-precision FPU;
double runs soft-float and is the desktop golden model only).
The real FFT is the single hottest kernel in the chain. Profiling the M55
(-mcpu=cortex-m55, GCC 13, Helium/MVE) showed GCC does autovectorize the
vendored Ooura float FFT (third_party/ooura/fftsg_float.c) — but not nearly
as well as Arm's hand-tuned CMSIS-DSP kernels. Measured, per forward transform,
instructions under QEMU:
| N (use) | Ooura (GCC autovec) | CMSIS arm_rfft_fast_f32 |
advantage |
|---|---|---|---|
| 2048 (suppressor analysis) | 83,950 | 27,632 | 3.04x (−67%) |
| 512 (canceller) | 17,729 | 6,261 | 2.83x (−65%) |
Carried through the whole certified chain, that FFT win lands as a uniform
~42% reduction in total M55 instructions (icount workloads, CMSIS vs the
prior Ooura baselines; the CMSIS column is what bench/baselines.json now
records for m55):
| scenario | Ooura (prior) | CMSIS | delta |
|---|---|---|---|
| chain_48k | 729,807,065 | 424,704,686 | −41.8% |
| chain_16k | 629,556,308 | 367,921,588 | −41.6% |
| suppressor_48k | 399,774,768 | 231,421,941 | −42.1% |
| suppressor_16k | 403,403,614 | 235,023,308 | −41.7% |
| fdkf_48k | 243,300,702 | 139,974,811 | −42.5% |
| fdkf_16k | 140,681,366 | 80,854,232 | −42.5% |
| shadow_48k | 89,371,476 | 51,279,103 | −42.6% |
CMSIS uses different butterflies and twiddle factors than Ooura, so its output is not bit-identical — it agrees only to single-precision rounding (measured max relative error 1.3e-7 at N=512, 2.0e-7 at N=2048, i.e. float32 epsilon). Two things make that acceptable as the M55 default:
- The double golden model is unaffected.
basic_real_fft<double>never routes through CMSIS, so the double leg of the certification (and every bit-identity claim indocs/itu-compliance.md) stands unchanged. - The float32 embedded path stays within its asserted gates. The ITU
battery is now certified at float32 as well as double (typed
<float, double>suites,docs/itu-compliance.md); that full battery is host-only, and on the M55 the float32 gate batterytests/test_float32.cpp— the headline ITU rows at deployment precision — passes on the CMSIS backend. The parity oracle was always a tolerance oracle, never bit-exact, precisely because float32 is a rounding-level approximation of the double reference.
So the backend is default ON for the bare-metal M55 embedded profile — the
deployment target — and OFF everywhere else. The Ooura float32 path remains one
flag away (-DMUTAP_FFT_CMSIS=OFF) and is kept alive by a dedicated CI leg.
include/mutap/fft.h routes basic_real_fft<float> through CMSIS when
MUTAP_FFT_CMSIS is defined; the CMake option defaults ON for the bare-metal
M55 profile (CMAKE_SYSTEM_NAME=Generic + arm) and OFF everywhere else, so
desktop, the Max/C-ABI host builds (including Apple Silicon arm64), and Hexagon
are untouched. double always keeps Ooura regardless.
The wrapper re-presents CMSIS in Ooura's exact numeric contract so nothing
downstream changes and every intermediate spectrum matches the Ooura build to
float epsilon:
- Sign convention. CMSIS uses the engineering convention exp(−i2π/N);
Ooura (and our documented packed layout) uses exp(+i2π/N). The wrapper
conjugates the imaginary bins on every transform. Verified by
real_fft_test/0.SignConventionIsPlusIrunning on the CMSIS backend. - Inverse scaling. CMSIS's inverse RFFT is 1/N-normalized; Ooura's is
unnormalized (the caller applies 2/N). The wrapper scales the CMSIS inverse
by N/2, so the existing
inverse()2/N normalization still closes the round trip. Both fold into passes the_inplacemethods already do (~1% overhead).
It is on automatically with the M55 toolchain; force the Ooura path with
-DMUTAP_FFT_CMSIS=OFF:
# CMSIS backend (default on M55)
cmake -B build-m55 -DCMAKE_TOOLCHAIN_FILE=cmake/arm-cortex-m55-mps3.cmake \
-DCMAKE_BUILD_TYPE=Release
# Ooura fallback
cmake -B build-m55-ooura -DCMAKE_TOOLCHAIN_FILE=cmake/arm-cortex-m55-mps3.cmake \
-DCMAKE_BUILD_TYPE=Release -DMUTAP_FFT_CMSIS=OFFForcing the option ON on a non-Arm processor is a hard error (Helium/NEON only).
tests/test_fft_backend.cpp— asserts the CMSIS forward output matches a direct Oourardft_freference bin-for-bin (<5e-6 relative) and that the round trip reproduces the input, at both certified sizes (512, 2048). Runs on the M55-CMSIS leg (intests/bare_metal_main.cpp's selection).- The whole
test_fft.cppcontract suite (packing, +i sign convention, Parseval, float-tracks-double) exercisesbasic_real_fft<float>, so it re-validates the CMSIS backend automatically when the option is on. - The full emulated float32 ITU battery runs on the M55 with the CMSIS
backend (now the default): 58/58 tests pass — the AEC still meets every
asserted float32 gate on CMSIS FFTs. A dedicated CI leg re-runs the same
battery with
-DMUTAP_FFT_CMSIS=OFFto keep the Ooura fallback honest.
The swap is Arm-only. On Hexagon (HVX V68) the Ooura FFT compiles fully scalar (0 HVX), so an FFT backend would be the biggest single lever there too — but there is no free HVX FFT: Qualcomm's is a proprietary SDK component, and HVX-float autovectorization does not fire on the strided packed-complex loop shape. Hexagon stays on scalar Ooura until an HVX FFT is available.
third_party/cmsis-dsp/ is a minimal subset (8 sources + header closure),
pinned by commit in third_party/cmsis-dsp/VENDOR.md. To bump it: re-run the
gcc -M closure over the eight sources for -mcpu=cortex-m55, copy exactly the
files it opens, update VENDOR.md, then re-run tests/test_fft_backend.cpp and
the full float32 battery on the M55 leg. Do not hand-edit vendored sources.
The same seam carries a second backend: on macOS the float32 real FFT routes
through Apple's vDSP (Accelerate) instead of Ooura (MUTAP_FFT_ACCELERATE,
default ON for Apple). This is the FFT the mutap.aec~ Max external runs, and
it is the only fast FFT Apple Silicon gets — the CMSIS backend is scoped to
the bare-metal M55, so arm64 macOS was on Ooura before this.
Per forward transform, ns, including the split-complex conversion vDSP needs (its data layout is split real/imag, ours is interleaved — the conversion is the part that could have eaten the win; it didn't):
| N (use) | Ooura (clang autovec) | vDSP | advantage |
|---|---|---|---|
| 2048 (suppressor analysis) | 4885 ns | 1552 ns | 3.15x |
| 512 (canceller) | 1103 ns | 347 ns | 3.18x |
Unlike the M55 — where the win was load-bearing against a tight budget — a Mac has enormous headroom, so this is a "best FFT per platform" consistency win, not a necessity. It was cheap to add because the backend seam already existed.
vDSP works in split-complex form, the engineering convention exp(−i2π/N), and a
2×-scaled forward. The wrapper (detail::accelerate_real_fft_f32 in fft.h)
re-presents it in Ooura's exact contract with constants measured on Apple
Silicon (not derived), verified to <4e-7 relative error and an exact round
trip at N=512 and N=2048:
- Forward —
vDSP_ctoz(deinterleave) →vDSP_fft_zrip→ conjugate the imaginary bins and scale by 0.5 (undo vDSP's 2×). - Inverse — rebuild vDSP's split spectrum (undo the 0.5, conjugate back),
vDSP_fft_zripinverse,vDSP_ztoc(reinterleave), scale by 0.25 to land on Ooura's unnormalized inverse (the caller's 2/N then normalizes).
double stays Ooura (golden model), so the double leg of the certification is
unaffected. Not bit-identical (float epsilon), same as CMSIS — but note the
stronger guarantee this backend gets: the full float32 ITU battery (the typed
<float, double> suites, docs/itu-compliance.md) runs on the macos-latest
CI host with vDSP active, so the /0 legs certify the ITU thresholds at
deployment precision through vDSP itself, not merely parity against Ooura.
Default ON for Apple (APPLE in CMake), OFF elsewhere, mutually exclusive with
the CMSIS backend; CMake links -framework Accelerate and defines the macro on
the mutap interface target. Force Ooura with -DMUTAP_FFT_ACCELERATE=OFF.
Validation is automatic: the macos-latest CI job builds with the backend on by
default, so tests/test_fft_backend.cpp (bin-for-bin vs Ooura rdft_f), the
whole test_fft.cpp contract suite, and the full float32 battery all run on
vDSP there. The vDSP setup's read-only twiddle tables are held by a shared_ptr
so basic_real_fft keeps value semantics; transforms are noexcept and
allocation-free.
The suppressor's pass-1 per-bin estimator (residual_suppressor::process_block)
has two bit-identical shapes, selected by MUTAP_SUPPRESSOR_BRANCHLESS
(include/mutap/postfilter.h):
- Branch-free (default when
__ARM_FEATURE_MVE) — the two data-dependent branches (the no-echo coherence zero and the coherence-gated leakage re-learn) are lowered to selects whose kept values equal the branchy form's. GCC's MVE autovectorizer bails on the branchy form's control flow but lowers this one. - Branchy (default elsewhere) — the original scalar form the compliance battery certified. On scalar targets the selects would do unconditional work the vectorizer can't hoist, so branchy is faster there.
Measured (icount, vs the branchy form on each target):
| target | suppressor | chain |
|---|---|---|
| M55 (Helium, branch-free) | −2.9% | −1.7 to −2.0% |
| Hexagon (HVX, branchy) | ±0.0% (unchanged) | ±0.0% |
So each target runs its faster form; neither regresses. Because it is
per-target, no single build compiles both shapes — the branchless-parity CI
job compiles tests/branchless_parity_check.cpp once per macro value and diffs
an output fingerprint over a 600-block double-talk corpus, guaranteeing the two
forms stay sample-exact. The m55 baselines record the branch-free form.