[FEATURE] Fused NEON WaveNet engine for AArch64 (Apple Silicon)#308
Draft
rikkus wants to merge 1 commit into
Draft
[FEATURE] Fused NEON WaveNet engine for AArch64 (Apple Silicon)#308rikkus wants to merge 1 commit into
rikkus wants to merge 1 commit into
Conversation
Add NAM/wavenet/fused.{h,cpp}: a fast path for standard-shape WaveNet
models (A1 standard/lite family, A2 standard, and similar) built from
register-tiled NEON kernels with compile-time channel counts.
Per layer, the dilated conv taps + bias + input mixin, the activation,
and the head accumulation + layer1x1 + residual are fused into three
passes over an L1-resident buffer, replacing ~8 buffer passes and 4
dynamic-size Eigen GEMM calls (which dominated the profile at ~65% and
heap-allocate on the audio thread). Conv history uses pow2 ring buffers
with a lazily-refreshed mirrored tail so tap reads are contiguous and
per-block cost is constant. Activations use NEON implementations for
fast-tanh / ReLU / LeakyReLU / Hardtanh / Softsign and fall back to the
exact generic Activation object for anything else, so semantics
(including enable_fast_tanh and LUTs) never diverge.
Measured on Apple M2 (48 kHz, buffer 64): wavenet_a1_standard 86ms ->
39ms per 2s of audio (23x -> 51x real-time, 2.2x), A2 standard ~2x,
and 2.7x at buffer 16. Cross-checked against Accelerate/AMX cblas_sgemm
(ties NEON at these matrix sizes; dependency not worth it) — see
docs/fused-engine.md for the analysis and rejected alternatives.
Correctness: tools/test/test_fused.cpp verifies fused == generic within
5e-5 across shapes, activations, and block sizes (incl. non-multiple-
of-4), detector negatives, and zero allocations in process(). Rendering
real models through both engines agrees to -127 dB RMS.
Dispatch order is slimmable -> fused -> a2_fast -> generic; A2 nano
(3 channels) stays on the a2_fast scalar path, and unsupported shapes
are declined to the generic path unchanged. Enabled by NAM_ENABLE_FUSED
(CMake option, default ON); no-op stub on non-ARM builds.
Also: benchmodel gains a --seconds flag; benchmark scripts and the
before/after Apple M2 reports are included under benchmark_reports/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fused NEON WaveNet engine for AArch64 (Apple Silicon)
Disclaimer
Human input here was restricted to requests for:
The rest of the work was done by Claude Code and Cursor, using Fable and Opus models.
Summary
Adds
NAM/wavenet/fused.{h,cpp}: a fused, register-tiled NEON fast path forstandard-shape WaveNet models on AArch64. Per layer, the dilated-conv taps +
bias + input mixin, the activation, and the head accumulation + layer1x1 +
residual run as three passes over an L1-resident buffer, replacing ~8 buffer
passes and 4 dynamic-size Eigen GEMM calls per layer (which dominated the
profile at ~65% and heap-allocate on the audio thread). Conv history lives in
power-of-two ring buffers with a lazily-refreshed mirrored tail, so tap reads
are contiguous and per-block cost is constant.
It is strictly additive: a shape detector routes only known-supported models
here, everything else runs the existing engines unchanged. Gated behind
NAM_ENABLE_FUSED(CMake option, default ON); a no-op stub on non-ARM builds.Speedup vs. what
mainruns todaymainalready has an ARM fast path for the A2 architecture (a2_fast, #251),so the honest baseline differs per model family. Measured on Apple M2, 48 kHz,
buffer 64, per 2 s of audio (full reports and scripts under
benchmark_reports/):mainmainmaina2_fasta2_fasta2_fastis already excellent thereNotes on the A2 standard row: fused-vs-generic is ~2×, but generic is not
what
mainactually runs for that model, so the number that matters isfused-vs-
a2_fast: ~1.7× per block (45.2 µs → 26.8 µs at p50). At 8 channelsa2_fastgains ~1.17× over generic; its large win (~7×) is the 3-channelnano, which this PR deliberately leaves on
a2_fastuntouched.As an end-to-end sanity check in a DAW (REAPER, M2 MacBook Air, 128-sample
buffer, a real trained A2-architecture capture, matched offline renders): the
fused build carried ~1.27× lower per-instance load than
a2_fast— thedifference between 100 simultaneous plugin instances playing cleanly and
crackling on that machine.
Supported shapes and dispatch
The strict detector (
is_fused_shape) accepts a model only when: mono in, nocondition DSP, no post-stack head; every layer array has
bottleneck == channels, channels a multiple of 4 (≤ 32), groups == 1, gatingnone, no FiLM,head1x1inactive. Kernel sizes (≤ 64), dilations,activations, head kernel size/bias, and the number of layer arrays are
arbitrary. In practice this covers the A1 standard/lite family and the A2
standard model.
Dispatch order in
wavenet::create_configis slimmable → fused → a2_fast →generic, so nothing that previously matched
a2_fastor slimmable loses itsfast path except A2 standard (8 ch), which moves to the faster engine.
Activations use NEON kernels only for known implementations (fast-tanh, ReLU,
LeakyReLU, Hardtanh, Softsign); anything else calls the exact same
Activationobject the generic path would, so semantics (includingenable_fast_tanh()and LUTs) never diverge.Benchmark methodology — how the A2 models were made
The A2 benchmark models are produced by the committed script
benchmark_reports/generate_a2_models.py: the exact A2 architecture — 23layers, the fixed kernel-size pattern (14×6, 15, 15, 7×6), the fixed dilation
pattern, LeakyReLU(0.01), head kernel 16, at 3 and 8 channels — filled with
uniform random weights in ±0.3 from a fixed seed, so the files are
byte-reproducible by anyone.
Why synthetic weights are valid for a performance comparison:
The generator uses the same layer constants
a2_fast.hhard-codes, and thegenerated files pass
a2_fast's own strictis_a2_shapedetector — ifthey didn't,
bench_a2_fastcould not route them to the fast path at all.fixed sequence of convolutions/GEMVs whose FLOP count and memory-access
pattern are functions of the architecture alone; there is no
data-dependent branching. Bounded ±0.3 weights also keep activations well
inside normal float range (no subnormal slowdowns), and both engines see
the same input signal regardless.
tools/test/test_a2_fast.cppbuilds its models the same way (
std::uniform_real_distribution(-0.3, 0.3),fixed seed).
validated on real trained models — see below.
Correctness
tools/test/test_fused.cpp(wired intorun_tests) compares fused vsgeneric within 5e-5 — the same tolerance as the existing
a2_fasttests —across: A1 standard 16/8 with exact tanh and fast tanh, 8/4, channels
12/20/32, ReLU / LeakyReLU / Hardtanh / Softsign / Sigmoid, A2-like mixed
kernel sizes with head kernel 16, layer1x1-inactive arrays, and block sizes
64/256/23 (the odd size exercises remainder tiles and ring-wrap paths).
Plus detector negative tests.
renderof real trained models through both engines: max sampledifference ~1e-6, RMS error −127 dB relative to the signal. The only
deviation source is FMA contraction/ordering inside the conv sums.
Real-time safety
Everything is preallocated in
SetMaxBufferSize;process()performs no heapallocation, verified by an allocation-tracking test.
Design notes
docs/fused-engine.mddocuments the profile that motivated the design, thekernel layout, and the alternatives that were measured and rejected —
including Accelerate/AMX (
cblas_sgemmonly ties the NEON kernel at thesetiny per-tap matrix sizes), fp16/bf16 (no compute win on Apple cores; we are
compute-bound), and multithreading. The conv kernel runs at ~105 GFLOP/s fp32,
~93% of a single M2 P-core's theoretical FMA peak.
Reproducing
To A/B against the previous behavior, configure a second build directory with
-DNAM_ENABLE_FUSED=OFF.benchmodelgains a--seconds Nflag for longerprofiling runs.