Skip to content

chunkers: add AVX-512 / VAES scan kernels - #10043

Open
ThomasWaldmann wants to merge 15 commits into
borgbackup:masterfrom
ThomasWaldmann:chunkers-avx512
Open

chunkers: add AVX-512 / VAES scan kernels#10043
ThomasWaldmann wants to merge 15 commits into
borgbackup:masterfrom
ThomasWaldmann:chunkers-avx512

Conversation

@ThomasWaldmann

@ThomasWaldmann ThomasWaldmann commented Aug 5, 2026

Copy link
Copy Markdown
Member

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.

chunker x86 before x86 default x86 fastest arm before arm default arm fastest
fastcdc 713 3851 3851 scalar 3361 2159 3545 neon
buzhash64 1060 2516 2517 scalar 2389 1558 2534 blockwise
toeplitz-aes 847 224 892 vaes 924 569 977 aes-arm64
rabin-aes 738 213 843 vaes 885 521 927 aes-arm64
goldilocks-aes 107 180 466 vaes 485 373 561 aes-arm64

Measured 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

  • A store-to-load forwarding stall (x86, the biggest win — fastcdc 713 → 3851). The vector kernels computed each block's prefix work in scalar registers and handed it to the vector unit through the stack; eight 8-byte stores followed immediately by one 32/64-byte vector load cannot forward, so every block stalled. Fixed by doing the next block's table lookups while the current one is tested, and building the aligned domain in the vector domain.
  • Branch mispredicts in goldilocks' field reductions (~27% miss rate, and it is 4.3x faster now). if() and ternaries both compile back to branches; __builtin_*_overflow keeps the carry in the flags. 15.1 → 4.4 cycles per multiply. A redundant canonicalisation was later dropped from the same multiply.
  • Cheaper per-lane tests: digests stored pre-spread so each AES input is a plain load, x <= ~M instead of (x & M) == 0 on aarch64, and the AES cut test kept in vector registers instead of moving 8 values to general registers.
  • New AVX-512 / VAES kernels, which is what the vaes column buys over aes-ni on 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 one leaq (%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 that leaq lands 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. .kernel reports the active kernel, and borg create --debug logs it (chunker: fastcdc, scan kernel: scalar).

var default other values
BORG_FASTCDC_KERNEL scalar avx512, avx2, neon, blockwise
BORG_BUZHASH64_KERNEL scalar avx512, avx2, neon, blockwise
BORG_AES_CHUNKER_KERNEL evp vaes, aes-ni, aes-arm64

avx512/avx2/vaes/aes-ni exist only on x86-64, neon/aes-arm64 only on aarch64; scalar, blockwise and evp are portable C and always available.

A value is a demand, not a preference: if that kernel cannot run here, creating the chunker raises ValueError rather than quietly falling back, so a benchmark can never silently measure something else.

BORG_FASTCDC_KERNEL='neon': not a kernel of this build. Valid values: avx512,avx2,blockwise,scalar
BORG_FASTCDC_KERNEL='avx512': this CPU does not support it. Valid values: avx512,avx2,blockwise,scalar
BORG_AES_CHUNKER_KERNEL='vaes': not compiled into this build (needs a newer compiler). Valid values: vaes,aes-ni,evp

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.

  • Cross-kernel identity tests iterate over every kernel the platform accepts (skipping those the build or CPU cannot run) and compare full chunk-boundary sequences — so they cover whichever tier a given machine has, and keep non-default kernels from bit-rotting.
  • Full chunker suite incl. fuzz tests passes: BORG_TESTS_SLOW=1, 180 passed / 5 skipped.
  • Chunk-boundary digests of all five keyed chunkers are identical across avx512/avx2/neon/blockwise/scalar and vaes/aes-ni/aes-arm64/evp over 64 MiB of random data, and unchanged from before this branch.
  • The right kernel really runs: perf record per 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.
  • Builds clean at -Wall -Wextra on aarch64 and cross-compiled x86-64; a pyinstaller binary was built and run.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.62500% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.60%. Comparing base (0d6fffb) to head (1284c47).
⚠️ Report is 24 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/borg/chunkers/kernel_env.py 88.23% 1 Missing and 1 partial ⚠️
src/borg/chunkers/__init__.py 93.33% 0 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

ThomasWaldmann and others added 2 commits August 5, 2026 18:16
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
ThomasWaldmann marked this pull request as draft August 5, 2026 22:33
ThomasWaldmann and others added 13 commits August 6, 2026 01:15
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
ThomasWaldmann marked this pull request as ready for review August 6, 2026 21:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant