Conversation
|
I run the measurements in M1 and they hold, similar >2x improvement. I also backported to NeuralAmpModelerPlugin and looked at CPU usage with 10 plugins using activity monitor and there is a good improvement as well! |
a2_fast keeps a frame's channels adjacent and vectorises across channels.
These kernels keep each channel in its own plane and vectorise across
frames instead, so one NEON lane runs a2_fast's per-frame scalar chain
verbatim. Nothing is reassociated, so the output does not move by a bit.
On an Apple M2, against a 10.9 s render at 64-frame blocks:
A2 standard (8 ch) 417 ms -> 172 ms 2.43x
A2 nano (3 ch) 57 ms -> 28 ms 2.01x
and at 32-frame blocks, which is what a plugin actually runs, 2.65x and
2.03x -- a2_fast degrades at small blocks and these do not.
The two channel counts reproduce two different orders of arithmetic,
because a2_fast itself branches. C=3 reproduces its hand-written scalar
3x3 GEMV. C=8 reproduces what its Eigen expressions compute, including
the per-tap partial that is summed into the running total only at the end
of the tap, and the mixin's separate multiply and add -- folding the taps
into one chain is the obvious thing to write and it is a different
association. That order was established by comparing candidate orderings
bit-for-bit against Eigen's own output, not assumed.
Selection happens in A2FastConfig::create, and only on AArch64 with the A2
fast path already enabled; -DNAM_DISABLE_A2_PLANAR opts back out. On every
other target the new file compiles to nothing and behaviour is unchanged.
Verification ships with it:
tools/test/test_a2_planar.cpp asserts memcmp equality against the
reference over 14 block sizes per channel count, including 1, 3 and 7,
which exercise the partial-tile and single-frame tails.
tools/bench_a2_planar.cpp renders a whole signal through both engines,
compares bit for bit, and only then reports speed. Built at -O3 rather
than -Ofast on purpose: -ffast-math lets the compiler contract a
multiply and an add across statements, which is the freedom the parity
result is checking has not been taken.
a2_fast.h gains create_a2_fast_reference_model so a test can get at the
portable implementation directly rather than through the dispatcher, which
now may hand back a specialised one.
…f it Two corrections, both about where this code is allowed to exist. bench_a2_planar.cpp was guarded on NAM_ENABLE_A2_FAST but called create_a2_planar_model unconditionally, so it failed to compile on any target without the planar kernels -- which is every non-AArch64 target, including the x86 Linux runners CI uses. Caught by cross-building for x86_64. It now builds everywhere and, where there is no planar kernel, prints that there is nothing to measure and exits 0. The target is still built on every platform on purpose: a tool that quietly disappears from some configurations is a tool nobody notices has stopped compiling. The activation gate was any AArch64 target. It is now Apple Silicon (__APPLE__ && __aarch64__). The kernels are very likely correct and faster on any AArch64 part, but they have only been built and measured on Apple Silicon, and two of the things they depend on are toolchain properties rather than architectural ones: the tile widths are M2 measurements, and bit-identity relies on the compiler contracting a*b+c into an FMA inside a2_fast's own 3-channel branch, which clang and gcc do by default and MSVC at /fp:precise does not. Claiming a target nobody has run is not worth the reach. Verified on x86_64 (cross-built on this machine): every target builds, a2_planar.cpp.o contains no symbols at all, and the full test suite passes. Off Apple Silicon the only thing that changes anywhere is that two lines of A2FastConfig::create now live in a named function.
The gate was __APPLE__ && __aarch64__ because Apple Silicon was the only place these kernels had been built and measured. It is now __aarch64__. Bit-identity was the thing worth checking off Apple, since it leans on the compiler contracting a*b+c into an FMA inside a2_fast's own 3-channel branch -- a toolchain behaviour rather than an architectural one. It holds: both submodels bit-identical to a2_fast, max|diff| exactly zero over a full render, on a Cortex-A76 (Raspberry Pi 500, Ubuntu 24.04) under GCC 13, and on Neoverse N2 under GCC 14 and Clang 18. The speed holds too, with a different shape. M2: 2.47x on A2 standard, 2.00x on A2 nano. Cortex-A76: 2.13x and 2.94x. Still __aarch64__ rather than a spelling that also catches MSVC's _M_ARM64. MSVC at /fp:precise does not contract into an FMA, so the reference branch it would be compared against computes something else and bit-identity would not hold. clang-cl on ARM64 defines __aarch64__ and is unaffected. The tile widths remain M2 measurements. They affect speed only, never output, and the Cortex-A76's different profile suggests re-tuning per part would be worth someone's time.
The kernels were AArch64-only because that is where they had been built and
measured. They now build for ARMv7-A with NEON and VFPv4, gated on
__arm__ && __ARM_NEON && __ARM_FEATURE_FMA -- the FMA half being
load-bearing, since without it Eigen computes a2_fast's own C=8 path with
non-fused vmlaq_f32 and the reference stops being the reference.
Measured on a Rockchip RK3288 (quad Cortex-A17), GCC 13.3, clock-pinned:
both submodels bit-identical to a2_fast, max|diff| exactly zero, with
A2 standard at 78.5% -> 57.8% of one core and A2 nano 12.32% -> 8.87%.
Three things this needed beyond a recompile:
* ARMv7 has no by-element FMA at all, so vfmaq_laneq_f32 and friends do
not exist. LaneWeights hides the difference: lane-addressed on
AArch64, broadcast-from-memory (vld1q_dup_f32) on ARMv7, which is the
right shape anyway on a machine with 16 Q registers rather than 32.
* The tile widths do not transfer. The C=3 ladder peaks at 32 frames on
an M2 and at 8 on a Cortex-A17, where 32 runs slower than a2_fast.
Tile 8 is the last rung whose accumulators fit in 16 registers, and
the measured spill counts turn over exactly there.
* The C=8 mixin is a product and then a separate add -- two roundings,
because that is what Eigen does -- and on ARMv7 -ffp-contract=fast
folds the pair back into one vfma. round_now blocks that locally, at
the point a2_fast rounds twice, rather than by turning contraction off
globally, which the C=3 branch depends on.
AArch64 is unchanged in output and in speed: bit-identical as before,
identical fmla and fmul counts in the object, and 2.126x -> 2.146x
(A2 standard) and 2.927x -> 2.921x (A2 nano) measured back to back on a
Cortex-A76, which is noise.
The header records the three caveats the ARMv7 claim carries: bit-identity
rests on FPSCR.FZ being set, and both the C=8 parity and the C=3 speedup
are GCC claims.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate widened to 32-bit ARM in the previous commit, and the kernels were bit-identical there from the first build. They were also slow: 68.8% of one core on a Cortex-A17 against 57.4% for the lab kernel they were ported from, which is behind even a plain tile-8 kernel carrying none of the ring switches. The cause is the fold over generic lambdas that unrolls the C=8 conv's input and output channel loops. AArch64 needs that: each index has to be a compile-time value for vfmaq_laneq_f32. ARMv7 has no by-lane FMA at all, so it gains nothing from the fold and pays for it heavily -- with 16 Q registers the accumulators cannot stay resident at any useful tile width, and GCC schedules the spill traffic it cannot avoid far better for a plain loop nest than for a fold it must first decide to inline. Measured over the 523,808-frame render, fold form against loop form: 12.12 G instructions and 8.33 G memory accesses become 9.79 G and 6.34 G. Not stalls -- the fold form had the higher IPC of the two; it simply did a third more memory traffic. So ARMv7 gets the loop nest and AArch64 keeps the fold. The AArch64 object is byte-identical before and after this commit. On a Rockchip RK3288 at a pinned 1416 MHz, 32-frame blocks, both submodels bit-identical to a2_fast over the full render: A2 standard 78.79% -> 55.63% of one core (1.416x) A2 nano 12.29% -> 8.36% of one core (1.470x) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJ6wuGZ9ndKjCDh7Zkjui3
The header already justified every per-architecture decision, but only in prose, spread over two sections and a caveat list. Anyone arriving at this file to find out what it actually does on their target had to read all of it to learn that there is one engine here rather than four, and to assemble the speed figures from three different paragraphs. Two small tables up front: what varies per target and channel count -- tile width, weight delivery, and the C=8 conv loop shape -- and what each combination is worth against a2_fast. The speed table also records something the prose did not. The M2 rows are carried from the Apple Silicon campaign these kernels came out of and have not been re-measured since; every other row is a direct measurement of the code as it stands. And the Cortex-A76 rows are at a 32-frame block, which is why they read 2.40x/2.87x against the 2.13x/2.94x quoted further down -- same code, different block size. Both figures were already in the file with nothing to say they were not in conflict. Comment-only: the AArch64 object is byte-identical, and both conformance suites still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJ6wuGZ9ndKjCDh7Zkjui3
rikkus
force-pushed
the
apple-silicon-a2-planar
branch
from
September 11, 2026 12:28
ca349f6 to
d3814ad
Compare
rikkus
marked this pull request as ready for review
September 11, 2026 14:36
Author
Thanks for trying it out, @honkkis! I've updated the README to be a lot clearer, added ARMv7 kernels with their own possibly-worthwhile optimisations, put some public benchmarks up and shared the repo I've been running the optimisation work from. |
rikkus
added a commit
to rikkus/OptimisationWorkOnNeuralAmpModelerPlugin
that referenced
this pull request
Sep 11, 2026
Point NeuralAmpModelerCore at rikkus/OptimisationWorkOnNeuralAmpModelerCore (the armv7-a2-planar branch, head of sdatkinson/NeuralAmpModelerCore#313) and wire the planar kernels into plugin builds. This replaces an earlier attempt at enabling a different, now-retired engine ("fused"); that work is superseded by the planar kernels and dropped here. - macOS: swap wavenet/fused.{cpp,h} for wavenet/a2_planar.{cpp,h} in the Xcode project across all 8 native targets. NAM_ENABLE_A2_FAST was already defined here; no new build flag is needed, since a2_planar.h gates itself internally on NAM_ENABLE_A2_FAST plus the target being AArch64 or ARMv7 with NEON+FMA — the header declares nothing and the translation unit compiles to no symbols everywhere else. - iOS: same swap, plus a fix -- the previous attempt gave a2_fast.cpp and fused.cpp the same UUID for both their PBXFileReference and PBXBuildFile entries, a duplicate-key bug that meant only one of the two was ever actually wired into the build. Re-added both a2_fast.{cpp,h} (this PR's iOS enablement, kept) and a2_planar.{cpp,h} with distinct UUIDs across all 3 native targets. Also defines NAM_ENABLE_A2_FAST on iOS, which wasn't set before. - Windows: a2_fast.cpp/h was already registered in the three .vcxproj files; added a2_planar.cpp/h alongside it the same way. NAM_ENABLE_FUSED (which had been added to NeuralAmpModeler-win.props without ever registering fused.cpp in any .vcxproj -- a build that would not have linked) is removed; nothing else changes since a2_fast was already there. - All three platforms: also registered linear.{cpp,h}, nam_file.{cpp,h}, sequential.{cpp,h} and the newly-referenced headers (compiler.h, container.h, model_config.h, slimmable.h, wavenet/detail.h, wavenet/params.h, wavenet/slimmable.h), none of which this project's Core submodule pin had reached before. Without these the build fails to link on nam::validate_nam_file -- unrelated to fused/planar, just Core having grown new files since this project was last synced, surfaced now because the submodule bump needed to pick up the planar kernels also picks up everything else Core has added since. - .gitmodules points at the fork rather than upstream NeuralAmpModelerCore for now, since sdatkinson/NeuralAmpModelerCore#313 is not yet merged. Repoint to upstream once it lands. Verified on macOS (Apple Silicon): VST3 and AU targets both build clean (universal arm64+x86_64) from the official NeuralAmpModeler/scripts recipe's own toolchain steps, the planar kernel symbols (A2PlanarNano/A2PlanarFull) are present in the built binary, and `auval -v aufx 1YEo SDAa` passes in full, including real audio-render tests at multiple block sizes and sample rates. iOS and Windows are verified by project-file correctness only (no device/ Windows machine available here) -- same caveat as the PR this replaces. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tream Between this branch's original base and current main, upstream added a cached-prewarm optimisation to A2FastModel: Reset() restores a cached steady-state instead of re-running the legacy silence-processing prewarm, verified by a new test asserting zero allocations on that path (A2FastModel<N>::cached prewarm). The planar models derive from DSP directly, not from A2FastModel, so they simply inherited the old, allocating base-class prewarm() -- and that new test caught it after the rebase: 12 allocations where it expected zero. Same mechanism, mirrored for the planar ring layout: each ring's steady-state prewarm makes every column of every channel plane equal to the last one written, so caching that one column per plane is enough to rebuild the whole ring later without reprocessing silence. Added PrewarmFromCache / CacheStateAsPrewarmed to A2PlanarNano and A2PlanarFull, and an override of prewarm() that uses the cache once one exists, matching A2FastModel's own prewarm()/PrewarmFromCache()/CacheStateAsPrewarmed() shape. Full test suite passes after this, cross-built for AArch64, ARMv7 and x86_64. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
rikkus
added a commit
to rikkus/OptimisationWorkOnNeuralAmpModelerPlugin
that referenced
this pull request
Sep 11, 2026
The rebase behind sdatkinson/NeuralAmpModelerCore#313 had picked up upstream's cached-prewarm optimisation to A2FastModel without adapting the planar models to it, so the previous pin (d3814ad) fails Core's own zero-allocation prewarm test. Follows that fix (d3814ad -> 552c5ab). Verified: VST3 target rebuilds clean (universal arm64+x86_64) with this pin. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…hold test_a2_planar asserts memcmp equality against A2FastModel, but run_tests compiles every one of its sources at -O0 (upstream does this so the allocation tracking behaves). A2FastModel's 3-channel branch is a plain `a * b + c` chain, and its bit-identity premise is that the compiler contracts that into an FMA. GCC only contracts in its optimisers, so at -O0 the reference computes something the planar kernels -- intrinsics, so unconditionally fused -- cannot match: the test failed on the first sample of the first block size on every GCC target. Clang contracts during codegen, which is why the same test passed on Apple Silicon and hid this. Every shipping build is optimised, so -O0 was the one configuration in which the reference is not the code the claim is about. The kernels now build as an object library at -O3 (/O2 on MSVC) that only run_tests links, leaving every other test at the -O0 the allocation tracking wants. The allocation assertions that reach these kernels all require zero allocations, which optimisation cannot introduce. Verified: the full suite passes on an Apple M2 (clang), a Cortex-A76 and a Cortex-A17 (GCC 13). Before this, the latter two failed at channels=3, block=1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.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.
Planar NEON kernels for:
Bit-identical to a2_fast as tested - 64 and 32 frame blocks
To give me a base to work on this, I made a project called NAMBench
That project is set up to build on various platforms and run some conformance tests (latest here).
Benchmark results
(Also published on Bencher as part of the build of NAMBench)
Macbook Air M2, MacOS 27 Beta 8
64-frame blocks
a2_fast32-frame blocks
a2_fastCortex-A76 (Raspberry Pi 500, Ubuntu 24.04, GCC 13, 64-frame blocks)
a2_fastCortex-A17 (RK3288 / ASUS Tinker Board, GCC 13, clock capped to 1416 MHz, 64-frame blocks)
a2_fast(clock capped because it overheats on long benchmark runs at its usual ~1800 Mhz)
Notes and caveats
-O3build. Under-ffast-maththe compiler is free to contract across statements in either engine and the guarantee no longer applies - toa2_fasteither.