chunkers: add AVX-512 / VAES scan kernels - #10043
Open
ThomasWaldmann wants to merge 15 commits into
Open
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #10043 +/- ##
==========================================
- Coverage 86.63% 86.60% -0.03%
==========================================
Files 97 98 +1
Lines 16912 17009 +97
Branches 2550 2574 +24
==========================================
+ Hits 14651 14730 +79
- Misses 1570 1586 +16
- Partials 691 693 +2 ☔ View full report in Codecov by Harness. |
fastcdc, buzhash64: AVX-512 variants of the 8-lane candidate test
(one 512-bit vector, vptestnmq fusing the AND and the ==0 test into
a mask register), runtime-detected on x86-64 above the AVX2 kernels.
BORG_FASTCDC_NO_AVX512 / BORG_BUZHASH64_NO_AVX512 cap dispatch at
AVX2 for benchmarking the kernels against each other.
rabin-aes/goldilocks-aes/toeplitz-aes: VAES/AVX-512 variant of the
x86-64 hardware path, kind "vaes": groups of 32 positions encrypted
as 8 zmm vectors of 4 AES blocks each - 4x fewer AES instructions,
register-resident round keys (no per-round reloads), 8 independent
chains to hide the vaesenc latency, vpexpandq digest placement and
a masked vptestnmq cut test without extracts. BORG_PHTE_NO_VAES
caps the AES chunkers at the 128-bit AES-NI path. The VAES path
needs GCC >= 11 / clang >= 14 for __builtin_cpu_supports("vaes");
older compilers keep the AES-NI path.
All kernels return bit-identical cut points; the existing
kernel-identity tests cover the new paths where the CPU has them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cap dispatch at the blocked scalar kernel, completing the bench ladder on x86-64: default (avx512) -> NO_AVX512 (avx2) -> NO_AVX2 (blocked) -> FORCE_SCALAR (sequential). Read once per process, like the CPU detection itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThomasWaldmann
force-pushed
the
chunkers-avx512
branch
from
August 5, 2026 16:16
f79807d to
17ded35
Compare
ThomasWaldmann
marked this pull request as draft
August 5, 2026 22:33
The AVX2 and AVX-512 scan kernels of fastcdc and buzhash64 only vectorized
the 8-lane candidate test: the per-block prefix work stayed scalar and was
handed over through the stack. Eight 8-byte stores immediately followed by
one 32/64-byte vector load cannot use store-to-load forwarding, so every
block paid a forwarding stall - enough that the fastcdc AVX2 kernel was
slower than the blocked scalar fallback it is supposed to beat.
Both vector kernels now:
- do the table lookups for the next block while the current one is tested
(double buffer), which puts a full loop body between the stores and the
load, so the stores have retired by then and the stall disappears, and
- build the aligned domain in the vector domain: vpsllvq / vprolvq for the
per-lane shifts resp. rotations, then the prefix sum / prefix XOR over the
lanes - three valignq steps on AVX-512, two vpermq steps per 4-lane half
plus a carry on AVX2. Only the table lookups stay scalar.
The serial chain across blocks stays what it was (one scalar add + shift
resp. rotate); s[7] is read out of the vector with vpermq off that chain.
A gather-based kernel avoiding the round-trip entirely was tried and
dropped: vpgatherqq is too slow on Zen 4 (1102 vs 2778 MB/s). Pipelining
two blocks ahead instead of one also regressed.
All kernels still return bit-identical cut points; the kernel-identity and
fuzz tests cover this, and chunk boundaries were verified equal across the
avx512, avx2 and blocked tiers for all chunkers.
Measured on an AMD Ryzen 5 8500GE (Zen 4/4c, AVX-512 on a 256-bit datapath),
borg benchmark cpu, 1 GB, pinned:
fastcdc 0.818s -> 0.381s (2.15x)
buzhash64 0.906s -> 0.667s (1.36x)
Per kernel tier (MB/s, isolated chunker benchmark):
fastcdc avx512 1173 -> 2689 avx2 800 -> 2583
buzhash64 avx512 1052 -> 1509 avx2 1026 -> 1478
AVX-512 stays close to AVX2 here because Zen 4 double-pumps 512-bit ops;
the win is the removed stall and the vectorized prefix, not the width.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwV6jAxVttxcnJ5AVhfPxm
gl_add and gl_mul reduce with plain if() statements. The conditions are
data-dependent and essentially random, and GCC turns them into real
conditional jumps - 55 of them in the VAES scan kernel, missing about 27%
of the time. The mispredicts, not the arithmetic, were what made this
chunker slow: a single gl_mul cost 15.1 cycles, against 1.8 for the raw
64x64 multiply it is built on and 3.1 for the same code with the final
canonicalisation removed.
Writing the reductions as mask arithmetic or as a ternary does not help:
GCC recognises both and converts them straight back to branches (measured
identical miss counts). The __builtin_*_overflow forms do, because the
carry/borrow stays in the flags and the compiler settles on sbb/adc plus a
mask. gl_mul drops to 4.4 cycles and the kernel keeps 5 conditional jumps
instead of 55.
Everything stays canonical, which matters because the state is fed to AES
verbatim - a different representation of the same field element would
change cut decisions. Cut points are bit-identical: the chunker tests and
the fuzz tests pass, and the chunk-boundary digest is unchanged across the
vaes, aes-ni and evp paths.
Measured on an AMD Ryzen 5 8500GE, borg benchmark cpu, 1 GB, both states
built and measured back to back, pinned to one core, best of two passes:
goldilocks-aes 7.121s -> 2.164s (3.29x)
Isolated kernel, same box: 94.0 -> 336.9 MB/s.
This is the rolling hash only, so it applies to all three scan paths, not
just the VAES one. rabin-aes and toeplitz-aes are unaffected: their rolling
hashes are GF(2)[x] and have no data-dependent reduction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwV6jAxVttxcnJ5AVhfPxm
An AES input block wants the digest in its low qword and zero in its high
one. The VAES kernel stored the 32 digests of a group packed and spread
them at use with vpexpandq, eight of those per group.
Storing them pre-spread costs nothing extra: the odd slots hold the zeros,
they are written once when the scan starts and never touched again, and the
chain writes the same 32 values it wrote before - just at 2j instead of j.
Each vector's AES input then becomes a plain 512-bit load.
Measured on an AMD Ryzen 5 8500GE, isolated scan kernel, pinned, best of 5:
toeplitz-aes 652.9 -> 712.8 MB/s (+9.2%)
rabin-aes 650.0 -> 690.8 MB/s (+6.3%)
goldilocks-aes 337.3 -> 347.2 MB/s (+2.9%)
borg benchmark cpu, 1 GB, both states built and measured back to back,
pinned, best of three passes:
toeplitz-aes 1.250s -> 1.078s (1.16x)
rabin-aes 1.176s -> 1.107s (1.06x)
goldilocks-aes 2.160s -> 2.192s (unchanged; the passes overlap)
goldilocks-aes is dominated by its field arithmetic, so it barely notices
either way. Cut points are bit-identical: the chunker and fuzz tests pass
and the chunk-boundary digests are unchanged on all three chunkers across
the vaes, aes-ni and evp paths.
This is the only one of several attempts that paid off. For the record, on
this CPU the following were measured and rejected: widening the rolling
hash to four lanes at stride 4 (toeplitz 653 -> 402, rabin 649 -> 458 MB/s,
because stride-4 doubles the table footprint against a 32 KiB L1d while the
lookups per position stay the same); hoisting the chain-independent table
lookups into their own pass (653 -> 579 and 650 -> 495, the delta buffer's
traffic costs more than the scheduling gain, so the hardware was already
hoisting them); and rolling the digest buffer one group ahead (+2.5% and
+1%, not worth the complexity). A microbenchmark of the rolling step alone
puts the ceiling for extra lanes at 16% for rabin and nothing for toeplitz,
which is why the four-lane attempt could not have won.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwV6jAxVttxcnJ5AVhfPxm
Per lane the NEON kernel tested "(c + s_j) & M_j == 0". Because (x & M) == 0 means x's set bits are a subset of ~M's, which implies x <= ~M, the and plus compare-against-zero can be one unsigned compare against a limit computed once per scan. It never loses a cut for any mask, and for the contiguous high-bit masks fastcdc uses it is exactly equivalent - still the same superset test the block's sequential recheck resolves. Apple M3 Pro, 128 MiB of random data, chunkify end to end: 3088 -> 3431 MB/s (1.11x). Cut points unchanged (verified against the sequential kernel). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TwV6jAxVttxcnJ5AVhfPxm
The NEON kernel lost to the blocked scalar one on Apple M3 Pro: 2323 vs 2519 MB/s end to end, and in an isolated kernel benchmark 2360 vs 2590 MB/s with the same ~9% gap at every mask size from 17 to 23 bits. So aarch64 now falls through to the blocked scalar kernel. Nothing in this test wants a vector register. clang keeps the block's eight prefix XORs in general registers, and xor/and/compare per lane retires at more than one lane per cycle on the wide scalar ALUs; the vector form first has to move those eight values to the SIMD side and then reduce the 8-lane result back to a condition flag (umaxv) before the loop branch can resolve. fastcdc keeps its NEON kernel because its per-lane work is larger (add plus per-lane shifted masks) - enough to pay for the trip, and there it wins by 2x. Cut points unchanged - this only changes which bit-identical kernel runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TwV6jAxVttxcnJ5AVhfPxm
…ch64 The aarch64 scan moved all 8 ciphertexts to general registers (one fmov each) and tested them with 8 scalar and/compare pairs, even though the common path needs nothing but the single "did any lane hit" bit. uzp1 packs the low halves - the cut-decision uint64 - of two blocks into one vector, so the test is 4 and/compare-zero pairs, an or-reduce and one umaxv; the 8 values are only materialised on an actual hit. Apple M3 Pro, 128 MiB of random data, chunkify end to end: toeplitz-aes 866 -> 921 MB/s (1.06x) rabin-aes 854 -> 912 MB/s (1.07x) goldilocks-aes 461 -> 522 MB/s (1.13x) Cut points unchanged (verified against the EVP path). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TwV6jAxVttxcnJ5AVhfPxm
gl_mul ended with a conditional subtraction of p to return a canonical field element, but both callers feed the result straight into a gl_add that canonicalizes anyway. gl_mul_lazy skips it and returns a merely reduced representative (< 2^64 rather than < p). gl_add still returns canonical with a non-canonical first operand: for a < 2^64 and b < p a carry leaves a + b - 2^64 <= p - 2, so adding eps cannot carry a second time, and the single conditional subtraction of p then lands below p (r - p < 2^32). The digest handed to AES therefore stays canonical, which it must be - it is encrypted verbatim, so a different representative of the same element would move cut points. This kernel is bound by the multiply latency chain (~17 cycles per stride-2 roll, two lanes, which alone accounts for its throughput), so removing the trailing compare and select from that chain is worth more than the op count suggests. Apple M3 Pro, 128 MiB of random data, chunkify end to end: 517 -> 561 MB/s (1.08x). Cut points unchanged (identical chunk-boundary digests, and identical over 48 MiB at 17/19/21/23 mask bits against the canonical version). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TwV6jAxVttxcnJ5AVhfPxm
"blocked" is a past participle and reads as "obstructed" or "disabled" rather than "processed in blocks". The neighbouring API makes that misreading easy to land on: after setting BORG_FASTCDC_NO_AVX2=1, which does block AVX2, the chunker reports kernel == "blocked", which parses as "the kernel was blocked" - a wrong reading that happens to be almost right, so nothing corrects it. The exported name also dropped the word that carried the meaning: the code says "blocked scalar" in prose but exported only "blocked". "blockwise" says block by block and cannot mean obstructed. The name has not been released (it was added after 2.0.0b22), so nothing depends on it. Also drops 'neon' from buzhash64's documented kernel values and from the changelog: buzhash64 has no vector kernel on aarch64 any more, so that value can no longer be returned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TwV6jAxVttxcnJ5AVhfPxm
Replaces ten env vars with three: BORG_FASTCDC_NO_AVX512=1 -> BORG_FASTCDC_KERNEL=avx2 BORG_FASTCDC_NO_AVX2=1 -> BORG_FASTCDC_KERNEL=blockwise BORG_FASTCDC_FORCE_SCALAR=1 -> BORG_FASTCDC_KERNEL=scalar (same for BORG_BUZHASH64_*) BORG_PHTE_NO_VAES=1 -> BORG_AES_CHUNKER_KERNEL=aes-ni BORG_<X>_AES_FORCE_EVP=1 -> BORG_AES_CHUNKER_KERNEL=evp The NO_* names were negative flags that did not do what they said: they capped a dispatch ladder rather than disabling one kernel, so NO_AVX2=1 also disabled AVX-512. They could not express "use AVX-512" at all, they used a second grammar (FORCE_*) for the same job, and they left no way to select the blockwise kernel on aarch64, where the NO_AVX* vars are no-ops. The values are exactly the strings the .kernel property reports, so what you read back is what you set. "auto" (the default) takes the best kernel the CPU can run; any other value is a demand, and creating the chunker raises ValueError if it cannot be honoured. Silently falling back is the failure this is meant to prevent: it turns a benchmark, or a CI job that means to pin one kernel, into a measurement of a different one. The error separates the three reasons, which need different fixes - a typo, too old a compiler, or the wrong CPU - because on x86-64 "your CPU has VAES but your build does not" is a real and distinct case (it needs gcc >= 11 / clang >= 14). The three per-chunker *_AES_FORCE_EVP vars become one: all three AES chunkers share phte_scan.h, so which scan paths exist is a property of the build and the CPU, never of the individual chunker. Selection now happens per chunker instance, so the old caveat that the NO_* vars had to be set before the first chunker use is gone. Parsing and validation live in one place (kernel_env.py plus the C name resolvers) instead of being split between getenv() in C and os.environ in Cython. None of the old names were ever released (they postdate 2.0.0b22). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TwV6jAxVttxcnJ5AVhfPxm
Restores the NEON kernel removed in 193a26f, with BZ_K_AUTO still resolving to blockwise on aarch64. BORG_BUZHASH64_KERNEL=neon selects it. Removing it outright over-generalized the measurement. It loses to blockwise by ~9% on an Apple M3 Pro P-core, but the reason is core width: the 16 table lookups per block must happen in general registers (NEON has no gather), so the vector form only ADDS the move to the SIMD side plus a cross-lane reduce before the loop branch resolves - all it saves is the 8-lane test, three cheap ops per lane, which Apple's very wide scalar ALUs retire at more than one lane per cycle anyway. That measurement comes from the widest scalar ARM core in existence. Neoverse (Graviton, Ampere), Cortex-A7x and friends are 3-4 wide with comparatively healthy NEON, which is exactly where this should get competitive - and on this machine's much narrower E-cores the 9% gap already collapses into measurement noise. Deciding for all of aarch64 from one unrepresentative core was the wrong call; keeping the kernel costs a dispatch branch and makes flipping the default a one-line change once someone benchmarks such hardware. The cross-kernel identity tests now iterate over every kernel the platform accepts instead of just default-vs-reference, skipping the ones this build or CPU cannot run. That is what keeps a selectable but non-default kernel from bit-rotting, and it also covers whichever tier a given CI machine happens to have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TwV6jAxVttxcnJ5AVhfPxm
The binary build broke with
File "src/borg/chunkers/buzhash64.pyx", line 26, in init borg.chunkers.buzhash64
from .kernel_env import kernel_error, requested_kernel
ModuleNotFoundError: No module named 'borg.chunkers.kernel_env'
kernel_env is imported only from .pyx files, and the chunkers are Cython
extensions, so PyInstaller's static analysis of the Python sources cannot
see the import at all and never collects the module. The spec already
lists borg.chunkers.base and .phte_chunker as hiddenimports for the same
reason (they are cimported at C level); this adds kernel_env and widens
the comment, since the rule is "anything a compiled chunker pulls in has
to be listed here", not just the cimported base classes.
Verified both ways with a local pyinstaller build: with the entry the
binary runs "borg benchmark cpu" through all chunkers, and rebuilding
with the entry removed reproduces the traceback above exactly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwV6jAxVttxcnJ5AVhfPxm
Removes automatic kernel selection. FC_K_AUTO / BZ_K_AUTO / PHTE_K_AUTO are gone, "auto" is no longer a value, and the scan functions dispatch exactly the kernel id they are handed. Unset means the simplest implementation - the plain sequential loop, or the portable OpenSSL path for the AES chunkers - on every platform; the ids are renumbered so that one is 0. Guessing from arch macros was wrong on both machines it was checked on, in opposite directions (64 MiB, 21-bit mask, every byte scanned, MB/s): fastcdc scalar blockwise neon avx2 avx512 Apple M3 1996 2120 4292 - - Zen 4 gcc 4733 834 - 2584 2792 buzhash64 scalar blockwise neon avx2 avx512 Apple M3 1307 2503 2446 - - Zen 4 gcc 2402 767 - 1300 1334 On the M3 an "add with shifted operand" costs two cycles, so breaking the serial chain with NEON pays 2.1x. With gcc on Zen 4 the same update folds into a single leaq (%r9,%rdi,2), %rdi retiring in one cycle, leaving no dependency worth breaking - so every block-parallel kernel there loses to the sequential loop, AVX-512 included. perf confirms the dispatch rather than the timing: the scalar run has zero samples in any SIMD kernel and uses 1.13 G cycles against AVX-512's 1.53 G for identical output. All kernels are kept and all stay selectable by name, so anyone who has measured their own hardware can pin the winner. What is dropped is borg picking one on their behalf from information that does not predict it - the CPU alone does not, since the compiler decides whether that leaq appears at all. Also drops the blanks after the commas in the kernel name lists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TwV6jAxVttxcnJ5AVhfPxm
Which scan kernel runs is not implied by the platform any more - it is the simplest one unless BORG_*_KERNEL says otherwise - so there was no way to see what a given run actually used. get_chunker() now reports it: chunker: buzhash, scan kernel: n/a (single implementation) chunker: fastcdc, scan kernel: scalar chunker: toeplitz-aes, scan kernel: aes-arm64 Chunkers with only one implementation say so rather than leaving the field out, so that a missing kernel cannot be read as a reporting bug. get_chunker() is the single funnel every chunker goes through, which is why the logging sits there rather than in each of the five .pyx files. Guarded on logger.configured: chunkers get built before setup_logging() has run - the test suite does it, and so does anything using borg as a library - and create_logger() raises instead of logging at that point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TwV6jAxVttxcnJ5AVhfPxm
ThomasWaldmann
marked this pull request as ready for review
August 6, 2026 21:25
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.
Adds AVX-512 / VAES scan kernels for the chunkers on x86-64, plus a round of optimisation of the existing NEON / AVX2 / AES-NI ones. All kernels are bit-identical: same cut points, same chunk boundaries, same digests.
Throughput
MB/s, higher is better. before is the PR base (
effa698a7), default and fastest are this branch — borg now runs the simplest kernel unless an env var selects another one (see below), so both are shown.scalarneonscalarblockwisevaesaes-arm64vaesaes-arm64vaesaes-arm64Measured with the chunker section of
borg benchmark cpu— 100 chunkifications of 10 MB = 1 GB per data point, best of 3, after warm-up — on an AMD Ryzen 5 8500GE (Zen 4, avx512+vaes, gcc 14, Debian, pinned to one core) and an Apple M3 Pro (clang, macOS).Treat differences under roughly 10% on x86 and 20% on arm as noise: re-measuring chunkers this PR does not touch moves them by about that much between runs. The arm figures are the shakier of the two — macOS offers no CPU affinity API, so the process is free to migrate, and on a hybrid CPU that alone can shift a result. The x86 machine is pinned to one core. Everything above 1.3x is far outside that band and reproduced across repeats, cores and rebuilds.
How
if()and ternaries both compile back to branches;__builtin_*_overflowkeeps the carry in the flags. 15.1 → 4.4 cycles per multiply. A redundant canonicalisation was later dropped from the same multiply.x <= ~Minstead of(x & M) == 0on aarch64, and the AES cut test kept in vector registers instead of moving 8 values to general registers.vaescolumn buys overaes-nion x86.Which kernel is fastest is not predictable, so nothing is auto-selected
Measuring every kernel, including the plain sequential loop, gave opposite answers on the two machines. Scanning every byte of 64 MiB with fastcdc, the sequential loop runs at 4733 MB/s on Zen 4 against 2792 for AVX-512 and 834 for the portable blocked kernel — while on the M3 the same sequential loop manages 1996 against 4292 for NEON. buzhash64 splits the same way.
gcc folds fastcdc's
fp = (fp << 1) + gear[b]into oneleaq (%r9,%rdi,2), %rdi, retiring in a single cycle on Zen 4 — so no dependency is left worth breaking and every block-parallel kernel loses. On Apple cores that update costs two cycles, the chain is the bottleneck, and NEON wins 2.1x. Intel's LEA timings differ again, and a compiler that doesn't emit thatleaqlands elsewhere still.So borg runs the simplest implementation by default and every kernel stays selectable, for whoever has measured their own hardware. Numbers from other x86 boxes — Intel, Zen 5, clang — would be very welcome.
For beta testers
One env var per chunker family.
.kernelreports the active kernel, andborg create --debuglogs it (chunker: fastcdc, scan kernel: scalar).BORG_FASTCDC_KERNELscalaravx512,avx2,neon,blockwiseBORG_BUZHASH64_KERNELscalaravx512,avx2,neon,blockwiseBORG_AES_CHUNKER_KERNELevpvaes,aes-ni,aes-arm64avx512/avx2/vaes/aes-niexist only on x86-64,neon/aes-arm64only on aarch64;scalar,blockwiseandevpare portable C and always available.A value is a demand, not a preference: if that kernel cannot run here, creating the chunker raises
ValueErrorrather than quietly falling back, so a benchmark can never silently measure something else.The last one is real: the VAES path needs gcc >= 11 / clang >= 14, so "my CPU lists vaes but I don't get it" is its own failure.
Correctness
Every kernel must produce byte-for-byte identical chunking; that is the property the whole PR rests on.
BORG_TESTS_SLOW=1, 180 passed / 5 skipped.avx512/avx2/neon/blockwise/scalarandvaes/aes-ni/aes-arm64/evpover 64 MiB of random data, and unchanged from before this branch.perf recordper kernel shows each request landing in its own function, with zero samples in any SIMD kernel when a scalar one was requested; hardware cycle counters agree with wall-clock timings.-Wall -Wextraon aarch64 and cross-compiled x86-64; a pyinstaller binary was built and run.🤖 Generated with Claude Code