chunkers: add AVX-512 / VAES scan kernels - #10043
Draft
ThomasWaldmann wants to merge 5 commits into
Draft
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #10043 +/- ##
==========================================
- Coverage 86.63% 86.62% -0.01%
==========================================
Files 97 97
Lines 16912 16912
Branches 2550 2550
==========================================
- Hits 14651 14650 -1
- Misses 1570 1571 +1
Partials 691 691 ☔ 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
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 the AVX-512 tier to the chunker scan kernels on x86-64, above the existing AVX2 / AES-NI paths. All kernels stay bit-identical (same cut points, same digests); dispatch is runtime-detected as before.
fastcdc, buzhash64
AVX-512 variants of the 8-lane candidate test: one 512-bit vector instead of two ymm halves, with
vptestnmqfusing the AND and the==0test directly into a mask register. Only the test narrows — the scalar per-byte work (table loads, prefix tree) is shared with the other kernels and unaffected, so the expected gain is modest (the test is not the bottleneck, especially for buzhash64, which is included mainly to keep the kernel dispatch symmetric).rabin-aes, goldilocks-aes, toeplitz-aes
A VAES/AVX-512 variant of the shared x86-64 hardware path (kind
"vaes"), where the win should be substantial: these chunkers encrypt one AES block per input byte, and VAES does 4 blocks per instruction. The new path processes groups of 32 positions as 8 zmm vectors:vaesenclatency (a naive 2-zmm port would be latency-bound)vpexpandqplaces 4 digests into the block-low qwords in one instruction; a maskedvptestnmqtests the 4 ciphertext qwords without any extractsThe VAES path requires GCC >= 11 / clang >= 14 (
__builtin_cpu_supports("vaes")); older compilers keep the AES-NI path. Zen 3 (VEX-256 VAES without AVX-512) is not covered and falls back to AES-NI; a 256-bit VAES middle path could be a follow-up if that matters.Kernel selection for testers
Every kernel level can be selected via env vars, so all pairs can be benchmarked against each other on one machine. The active kernel is reported by the chunker's
.kernelproperty.fastcdc (buzhash64 works the same with
BORG_BUZHASH64_*):avx512→avx2→blockedBORG_FASTCDC_NO_AVX512=1avx2BORG_FASTCDC_NO_AVX2=1blockedBORG_FASTCDC_FORCE_SCALAR=1scalar(sequential reference loop)The
NO_*vars are read once per process (like the CPU detection), so set them before the first chunker use;FORCE_SCALARis per chunker instance.AES chunkers (all three):
vaes→aes-ni(aes-arm64on arm64) →evpBORG_PHTE_NO_VAES=1aes-niBORG_RABIN_AES_FORCE_EVP=1(analogousBORG_GOLDILOCKS_AES_FORCE_EVP,BORG_TOEPLITZ_AES_FORCE_EVP)evp(OpenSSL batch path)These are read at chunker creation time.
Benchmarks on real AVX-512 hardware
Measured on an AMD Ryzen 5 8500GE (Zen 4/4c:
avx512f/dq/bw/vl/vbmi/vbmi2/vnni,vaes,gfni,vpclmulqdq), Debian 13, GCC 14.2,performancegovernor, pinned to one core. Note Zen 4 implements AVX-512 on a 256-bit datapath (double-pumped), so 512-bit ops have roughly the throughput of two 256-bit ops — width alone buys little here.What the new tiers bought, as originally written
Isolated chunker benchmark, best of 5, MB/s:
Two results contradict the expectations above and are worth recording:
Both are addressed by the follow-up commits below.
Follow-up commits
chunkers: build the aligned domain in the vector kernels— the vector kernels computed the per-block prefix work in scalar registers and handed it to the vector unit 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 stall (confirmed byls_bad_status2.stli_other, and by the tier ordering: AVX2 has two such loads per block, AVX-512 one, the scalar path none). Both kernels now do the table lookups for the next block while the current one is tested, and build the aligned domain in the vector domain (vpsllvq/vprolvq+valignqprefix scan on AVX-512;vpermqper 4-lane half on AVX2).goldilocks-aes: keep the field reductions out of the branch predictor—gl_add/gl_mulreduced with plainif(), which GCC turned into 55 conditional jumps in the VAES kernel, missing ~27% of the time. A singlegl_mulcost 15.1 cycles against 1.8 for the raw 64x64 multiply it is built on. Mask arithmetic and ternaries do not help — GCC converts both straight back to branches — but__builtin_*_overflowkeeps the carry in the flags: 4.4 cycles, 5 conditional jumps.chunkers: store the AES chunkers' digests pre-spread— the digests are stored with a zero in every odd slot instead of packed, so each AES input is a plain 512-bit load rather than avpexpandq. (This replaces thevpexpandqbullet in the VAES description above.)Results
borg benchmark cpu, 1 GB, both states built and measured back to back, pinned, 2 passes each:Both passes are shown because this benchmark is noisy on this machine (untouched code varies by up to ~14% between passes). The first four rows have non-overlapping ranges. toeplitz-aes does not — a dedicated 3-pass A/B of that change measured 1.250 → 1.078 s and the isolated kernel +9.2%, so ~1.1x is the honest estimate.
Per kernel tier afterwards (isolated benchmark, MB/s):
fastcdc is now the fastest CDC chunker in borg, ahead of legacy buzhash. Note that AVX-512 and AVX2 now perform nearly the same (2689 vs 2583, 1509 vs 1478): on Zen 4 the gains came from removing the stall and vectorizing the prefix work, not from vector width. A machine with a true 512-bit datapath (Intel SPR, Zen 5) should show a wider gap — numbers from such a box would be welcome.
Optimizations tried and rejected
Measured on the same machine, none committed:
vpgatherqqis too slow on Zen 4.Verification
-Wall -Wextra); disassembly shows the intended codeaes-ni, and cut points + digests are bit-identical to the nativeaes-arm64runBORG_TESTS_SLOW=1, 182 tests) passes, and the chunk-boundary digests of all six chunkers are identical across theavx512/avx2/blockedandvaes/aes-ni/evptiers🤖 Generated with Claude Code