Benchmarks¶
CpuFft: AVX2+FMA vectorization¶
Shaders/CpuFft.cs's butterfly stage (Transform) and magnitude
computation (ComputeMagnitude) are vectorized with AVX2+FMA (8 lanes at
a time), gated behind a runtime Avx2.IsSupported && Fma.IsSupported
check with a scalar fallback for CPUs without it (older x86, ARM). Twiddle
factors for the butterfly aren't contiguous in memory except for the very
last stage, so they're loaded with Avx2.GatherVector256 rather than
assuming a simple stride; stages where half < 8 (the early, small
stages, regardless of overall FFT size) always take the scalar path since
there aren't enough elements to fill a lane.
--benchmark-fft runs CpuFft.Process() (200 warmup + 2000 timed
iterations, fixed RNG seed, Scale=Linear to exclude
FrequencyBucketing from the measurement) across a spread of window
sizes and reports ms/call, calls/sec, and a checksum of the returned
spectrum. CpuFft.UsingAvx2 exposes which path actually ran, logged at
Debug on construction too (CpuFft: AVX2+FMA available, using the
vectorized butterfly path / ... using the scalar fallback).
Measured on an Intel Core i7-12700 (AVX2+FMA-capable), 3 runs averaged
per configuration, JIT build (dotnet build, not AOT — see the note
below for why):
| Size | AVX2+FMA (ms/call) | Scalar fallback (ms/call) | Speedup |
|---|---|---|---|
| 1024 | 0.0251 | 0.0308 | 1.23x |
| 2048 | 0.0364 | 0.0523 | 1.44x |
| 4096 | 0.0552 | 0.0813 | 1.47x |
| 8192 | 0.1176 | 0.1839 | 1.56x |
Scalar numbers came from the exact same binary with Avx2.IsSupported
forced constant-false at runtime (DOTNET_EnableAVX2=0 in the
environment — a real .NET diagnostic env var, not benchmark-specific
plumbing), so this isolates vectorization as the only variable; it isn't
comparing across different compiler output. Speedup grows with size
because the early FFT stages (half < 8) are scalar-only regardless of N,
so larger N means a larger fraction of total stages are actually
vectorizable — at N=1024 (10 stages) 7 stages qualify; at N=8192 (13
stages) 10 do.
Correctness: the checksums (sum of every returned magnitude, both
channels) matched to 6-7 significant figures between the AVX2 and scalar
runs at every size (e.g. size 8192: 37.241384 vectorized vs 37.241385
scalar) — the last-digit difference is expected FMA rounding (a fused
multiply-add rounds once instead of twice, so it's not bit-identical to
separate multiply+subtract), not a bug.
Native AOT gets none of this speedup as currently configured, and
that's a real gap, not a rounding footnote. Native AOT (ILC) compiles
Xxx.IsSupported for any ISA above its baseline (SSE2 on x64) as a
compile-time-constant false unless the target instruction set is
explicitly widened via <IlcInstructionSet> — confirmed by testing the
identical DLL both ways: JIT (dotnet GlavaSharp.dll) correctly detects
AVX2 at runtime, the AOT-published build/dist/GlavaSharp reports "AVX2+FMA
not available" on the same AVX2-capable CPU. This isn't a bug in
CpuFft.cs — it's Native AOT deliberately choosing portability over
performance by default, since an AOT binary (unlike JIT) is compiled once
and run on whatever hardware it's copied to, which might not be the build
machine. Setting <IlcInstructionSet>avx2,fma</IlcInstructionSet> doesn't
add a runtime check the way JIT has — it bakes AVX2 in as a hard
requirement, deletes the scalar fallback from the compiled output
entirely, and makes the runtime fail-fast at startup (or, per a known ILC
edge case, occasionally a raw illegal-instruction crash) on any CPU that
turns out not to have it. Native AOT has no JIT to fall back to, so there
is no single-binary way to get "AVX2 when present, scalar otherwise" the
way this file's own source code implies — that pattern only works for
JIT/framework-dependent builds.
Resolved: rather than picking one default for everyone, build/dist/
stays on the safe scalar baseline by default, and
-DGLAVASHARP_AVX2_CPU_FFT=ON (see Building)
opts a build into the AVX2+FMA requirement explicitly — cmake --build
build --target appimage names its output GlavaSharp-x86_64-avx2.AppImage
instead of the plain name whenever this is on, specifically so the
AVX2-requiring artifact can't be mistaken for, or silently overwrite, the
portable one. Verified live both ways: CpuFft.UsingAvx2/--benchmark-fft
reports no on a plain cmake --build build of this AVX2-capable CPU and
yes after reconfiguring with the option on and rebuilding — same
machine, only the ILC instruction-set flag differs.
--benchmark-fft: standalone CPU/GPU benchmark mode¶
--benchmark-fft runs entirely outside the normal app flow — no window,
no ShaderModule, no audio capture, just IFft.Process() timed in a loop
and a results table on stdout. --fft-device cpu|gpu (default cpu)
picks which IFft implementation gets benchmarked, reusing the same flag
the real app uses; --fft-attack/-decay/-gain/--sample-rate apply as
normal, but --fft-size itself is ignored — the benchmark always sweeps
its own fixed list (1024/2048/4096/8192) so one run reports the full
picture rather than needing four separate invocations.
--fft-device gpu needs a real GL context (compute shaders don't exist
without one) but deliberately never shows a window: it creates a GLFW
window with WindowHintBool.Visible false, uses it purely to get a
current GL 4.3 context, and never calls SwapBuffers or renders anything
— exactly what GpuFft.Process() itself touches on the GPU side anyway
(SSBO upload → dispatch → readback, no framebuffer involved).
Before running any GPU size, it queries GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS
(GpuFft dispatches a single workgroup of N/2 invocations) and skips
sizes that would exceed it, rather than attempting the dispatch and
risking a repeat of the exact failure mode already documented for GpuFft
bring-up: a compute shader that violates this limit is the kind of thing
that's hung glCompileShader/glLinkProgram with no error on some driver
paths instead of failing cleanly. Confirmed live on this machine (AMD RX
6700 XT, Mesa radeonsi): GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS = 1024,
so 1024/2048 ran and 4096/8192 were skipped with a clear reason instead of
risking a hang:
size ms/call calls/sec checksum
1024 0.1672 5980 17.431565
2048 0.1866 5359 21.986066
4096 skipped (needs 2048 compute invocations, this GPU allows 1024)
8192 skipped (needs 4096 compute invocations, this GPU allows 1024)
The checksums matched CpuFft's own (17.431567/21.986067 at the same
sizes) to 5-6 significant figures, cross-checking correctness between the
two backends the same way the AVX2-vs-scalar comparison above does. GPU
numbers here are slower than CPU's at these sizes — expected, since every
GpuFft.Process() call pays real upload/dispatch/readback round-trip
overhead that a single-workgroup, N≤2048-sized FFT is too small to
amortize; GpuFft's actual purpose is freeing up the CPU core FFT would
otherwise occupy, not raw throughput at this scale.