Skip to content

use multi-threaded blake3 for big chunks, #9958 - #9959

Merged
ThomasWaldmann merged 1 commit into
borgbackup:masterfrom
ThomasWaldmann:blake3-mt
Jul 27, 2026
Merged

use multi-threaded blake3 for big chunks, #9958#9959
ThomasWaldmann merged 1 commit into
borgbackup:masterfrom
ThomasWaldmann:blake3-mt

Conversation

@ThomasWaldmann

@ThomasWaldmann ThomasWaldmann commented Jul 27, 2026

Copy link
Copy Markdown
Member

Fixes #9958.

borg only ever used single-threaded blake3, even though the library can hash a single input in parallel. This lets ID_BLAKE3_256.id_hash() use blake3's internal thread pool once the input is big enough.

Why a threshold, and why 256 KiB

Multi-threading is not a win at every size. blake3 parallelises over its binary hash tree, so the speedup peaks at powers of two and decays in between, and for small inputs the thread dispatch costs far more than it saves. Sweeping 71 input sizes on a 12-core machine (Apple M3 Pro, 6P+6E, blake3 1.0.8):

input AUTO vs single-threaded
< 8 KiB 1.00x (blake3 never dispatches)
8 KiB 0.16x — 6x slower
64 KiB 0.72x
240 KiB 0.74x
256 KiB 2.15x
512 KiB 3.32x
2 MiB (typical chunk) 5.60x
8 MiB (max chunk) 7.00x

The sub-256 KiB region is erratic as well as bad: 128 KiB wins at 1.34x but 144 KiB loses at 0.87x, 192 KiB wins at 1.03x but 208 KiB loses at 0.83x. Chunk sizes are content-defined, so we cannot rely on landing on the sizes that happen to parallelise well.

256 KiB is the smallest size that never lost across the measured range (worst case above it: 1.07x at 368 KiB), so that is the default and anything smaller keeps hashing single-threaded.

Measured end-to-end through id_hash() on this branch:

chunk size before after
64 KiB 2095 MiB/s 2110 MiB/s 1.01x (below threshold, unchanged)
256 KiB 2139 MiB/s 4211 MiB/s 1.97x
512 KiB 2085 MiB/s 6656 MiB/s 3.19x
2 MiB 2103 MiB/s 11747 MiB/s 5.59x
8 MiB 2114 MiB/s 15382 MiB/s 7.28x

Full measurement data and charts are in the issue comment.

Tuning

The optimal threshold depends on the core count, so BORG_BLAKE3_MT_THRESHOLD overrides the default. The unit is KiB, which is also what the measurement script prints, so its output can be pasted verbatim:

export BORG_BLAKE3_MT_THRESHOLD=512

0 means "always multi-threaded", a very large value effectively disables multi-threading. Documented in docs/usage/general/environment.rst.inc, which points at the measurement script below.

Scope and compatibility

  • Only affects repos created with --id-hash blake3. sha256 repos (the default) are untouched.
  • Chunk ids are unchanged. max_threads only affects how the hash is computed, not the result — that invariant is the blake3 project's to test, so there is no test for it here.
  • No repo format change, no migration.

scripts/blake3-optimize-mt-threshold.py

The optimal threshold depends on the core count, so it differs per machine — a 4-core NAS CPU will not look like the numbers above. This adds a small end-user script to measure it:

scripts/blake3-optimize-mt-threshold.py                 # ~25s, prints the recommended threshold
scripts/blake3-optimize-mt-threshold.py --html --open   # ...and a chart in the browser
scripts/blake3-optimize-mt-threshold.py --thorough      # 16 KiB steps, several minutes

It only needs the blake3 package, which borg already depends on. Two details matter for getting a trustworthy answer:

  • The size ladders deliberately avoid round numbers. A ladder made of powers of two samples only the sawtooth's peaks and reports a threshold that is far too low — an early version of the script did exactly that and confidently returned 128 KiB. The ladders now include odd offsets and sizes just below a power of two, which are the local worst cases.
  • A candidate threshold is actively attacked before being reported. The script probes the sizes that are usually worst above the candidate to try to disprove it, and re-measures the point that forces the threshold up with 3x the runs, so one scheduling hiccup cannot skew the result. Both corrections iterate until the answer stops moving.

It can also write a stand-alone SVG (--svg) and re-render a chart from an earlier run's JSON without re-measuring (--from-json).

Notes for review

  • max_threads=1 is already the blake3 default, so the else 1 branch is technically redundant. It is spelled out on purpose: it keeps a single call site, and the blake3 docs note that API-compatible reimplementations may ignore max_threads entirely, so being explicit keeps the small-chunk path single-threaded regardless of what a future version defaults to.
  • The env var is read at hash time, not at import time. That is deliberate: parsing it at import meant an invalid value produced a traceback before borg's error handling existed. Now it reports Error: BORG_BLAKE3_MT_THRESHOLD must be an integer (KiB), but is: '64k' and exits 2. (For comparison, BORG_PACK_MAX_SIZE=abc currently produces an unhandled crash dump - could be worth fixing the same way.)
  • The threshold is cached after the first lookup, because get_blake3_mt_threshold() runs for every chunk and os.environ.get() is not free: ~425ns per call (the env-unset path is the slower one, it raises and catches KeyError internally), which is ~30% of id_hash() for a 512B chunk. Cached, the call costs ~45ns.
  • A worthwhile follow-up would be extending the existing blake3-mt entry in borg benchmark cpu into a size sweep, so this is checkable per platform without a separate script.

Tests: full src/borg/testsuite/crypto/ suite passes (86, incl. a new one covering the env var parsing), plus create_cmd_test.py and check_cmd_test.py. black and ruff clean.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.70%. Comparing base (4a25abc) to head (198c751).
⚠️ Report is 1 commits behind head on master.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9959      +/-   ##
==========================================
+ Coverage   85.68%   85.70%   +0.01%     
==========================================
  Files          95       95              
  Lines       16799    16815      +16     
  Branches     2574     2577       +3     
==========================================
+ Hits        14394    14411      +17     
+ Misses       1669     1668       -1     
  Partials      736      736              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

blake3 can hash a single input in parallel, but borg always used the
single-threaded path. Let id_hash use blake3's internal thread pool once the
input is big enough.

Multi-threading is not a win at every size: blake3 parallelises over its binary
hash tree, so the speedup peaks at powers of two and decays in between, and for
small inputs the thread dispatch costs much more than it saves. Measured on a
12-core machine (Apple M3 Pro): 0.16x at 8 KiB, still below 1x at 240 KiB, then
2.0x at 256 KiB, 3.2x at 512 KiB, 5.6x at 2 MiB and 7.3x at 8 MiB.

256 KiB was the smallest size that never lost there, so that is the default and
smaller chunks keep hashing single-threaded. Chunk sizes are content-defined, so
we can not rely on hitting the sizes that happen to parallelise well and have to
be conservative.

The optimal value depends on the core count and therefore differs per machine,
so BORG_BLAKE3_MT_THRESHOLD (in KiB) can override the default. It is evaluated
on first use and then cached: get_blake3_mt_threshold() runs for every chunk and
os.environ.get() costs ~425ns (~30% of id_hash for a 512B chunk), while the
cached call costs ~45ns. Evaluating it lazily rather than at import time also
means an invalid value is reported by borg's normal error handling instead of a
traceback while importing.

scripts/blake3-optimize-mt-threshold.py measures the best value for a machine:
it sweeps input sizes, verifies a candidate by probing the sizes that are
usually worst, re-measures the point that determines the threshold to keep
scheduling noise out, and optionally writes an HTML or SVG chart.

This only affects repos using --id-hash blake3. The resulting chunk ids are
unchanged - that single- and multi-threaded blake3 agree is the blake3
project's invariant, so there is no test for it here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ThomasWaldmann

Copy link
Copy Markdown
Member Author

End-to-end benchmark of borg create / borg extract, rather than id_hash() in isolation.

Workload: one 10 GiB file of incompressible random data, -C none, -e aes256-ocb, fastcdc and buzhash64 at default parameters, create and extract only. Warm page cache. Median of 2 runs. Apple M3 Pro, 12 cores (6P+6E), 18 GiB RAM.

All three rows are the same binary from this branch — the single-threaded variant is produced with BORG_BLAKE3_MT_THRESHOLD set to a huge value, so the only thing that differs is what id_hash() does internally.

fastcdc,19,23,21,2

id hash create MB/s create cpu extract MB/s extract cpu
hmac-sha256 30.5s 336 27.7s 16.3s 628 13.6s
blake3, single-threaded (= master) 31.9s 321 29.3s 17.0s 601 15.0s
blake3, multi-threaded (this PR) 28.4s 361 45.3s 13.5s 758 30.8s

buzhash64,19,23,21,4095,2

id hash create MB/s create cpu extract MB/s extract cpu
hmac-sha256 30.8s 332 29.9s 16.0s 640 13.8s
blake3, single-threaded (= master) 32.8s 312 31.2s 16.9s 605 14.8s
blake3, multi-threaded (this PR) 30.3s 338 47.2s 15.1s 679 30.5s

Reading it

  • Single-threaded blake3 loses to hmac-sha256 here (4-6% slower on create), which is expected on a CPU with SHA extensions and is presumably why sha256 is the default. This PR flips that: blake3-mt beats hmac-sha256 by 7% on create / 17% on extract with fastcdc, and by 2% / 6% with buzhash64.
  • The end-to-end gain is much smaller than the ~5.6x that id_hash() alone gets, because id hashing is only a slice of the work: a native profile of this workload put read() at ~36% of wall time and chunking at another 15-29%. Against single-threaded blake3, create drops 3.5s out of 31.9s.
  • Extract gains more than create (17% vs 7%): it is a shorter pipeline with no chunking, so assert_id() re-hashing every chunk is a bigger share of it.
  • The chunker changes how much is left to win. fastcdc gains 7% on create, buzhash64 only 2% - consistent with buzhash64 using ~33% more chunking CPU, i.e. more non-hash work to hide the hashing behind.

The cost: CPU time

blake3-mt buys wallclock by spending ~55% more CPU seconds on create (45.3s vs 29.3s) and ~2x on extract (30.8s vs 15.0s). It is trading otherwise-idle cores for latency, not doing less work - rayon's tree splitting has real overhead.

On a 12-core desktop with 11 cores idle that is close to free. On a busy server, a low-core NAS, or anything where borg competes for CPU with other work, it is not obviously a good trade. Two things follow:

  • it is an argument for keeping the default threshold conservative, and
  • it may be worth mentioning in the docs that multi-threading can be turned off entirely with e.g. BORG_BLAKE3_MT_THRESHOLD=1000000000, for people who would rather not have borg use every core.

Happy to add that note to docs/usage/general/environment.rst.inc if you think it is worth having.

Scope reminder

None of this affects default repositories, which use sha256 chunk ids. It only applies to --id-hash blake3, where the summary is: blake3 goes from a small wallclock loss to a modest wallclock win, at a real cost in total CPU.

Reproduce with scripts/profile_idhash.py (not part of this PR - happy to add it if useful).

🤖 Generated with Claude Code

@ThomasWaldmann
ThomasWaldmann merged commit 2b9d052 into borgbackup:master Jul 27, 2026
19 checks passed
@ThomasWaldmann
ThomasWaldmann deleted the blake3-mt branch July 27, 2026 19:54
@jdchristensen

Copy link
Copy Markdown
Contributor

The cost: CPU time

blake3-mt buys wallclock by spending ~55% more CPU seconds on create (45.3s vs 29.3s) and ~2x on extract (30.8s vs 15.0s). It is trading otherwise-idle cores for latency, not doing less work - rayon's tree splitting has real overhead.

On a 12-core desktop with 11 cores idle that is close to free. On a busy server, a low-core NAS, or anything where borg competes for CPU with other work, it is not obviously a good trade. Two things follow:

  • it is an argument for keeping the default threshold conservative, and

As I followed this discussion, I was going to make the point that I now see that Claude has made, quoted above. Looking at the table shows that create's wall clock goes from 32.8s to 30.3s while the cpu time goes from 31.2s to 47.2s, and this does not seem worth it to me. That will interfere with other processes, cause more heat and more power usage, and may make fans run more, all for the time taken by what is usually a background job. On some systems, this might be worth it, but I don't think it should be the default. So I would agree with what Claude said, namely that the threshold should be conservative by default, with multithreading only kicking in when the time savings is more significant than 32.8s -> 30.3s. (I know that this can be tweaked by the user by setting an environment variable, so I'm just arguing that the default should be different.) I don't fully understand how the numbers above would change with different thresholds, so I don't have a specific suggestion for a value. Maybe it's worth some additional testing for overall create time (like above) with the threshold varying?

I think similar arguments apply to the extract part of the table as well. While the wall clock savings is larger, the cpu time increase is even worse, so again it is questionable. OTOH, extract is often done interactively, so it might be worth it then.

@ThomasWaldmann

ThomasWaldmann commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

@jdchristensen Thanks for your feedback.

If one values CPU cycles as high as saving overall runtime, you're totally right.

My goal was to optimise for overall runtime though, considering there are lots of cores nowadays, backups are often run in less busy times (e.g. at night or weekend), so most of these cores are sitting around idle.

You say "who cares for runtime, it is a cron job", but some people have so much data to back up that timing becomes an issue. Also, if one interactively runs backups, one usually also prefers higher speed over saving cpu cycles. Last, but not least, people like to compare tools with benchmarks. :-)

It is also NOT the case that borg would hog all the cores all the time, it is just using MT for blake3 (and zstd, see the other PR), everything else is still very sequential and using less than 1 full core).

For extract, the overall runtime is even much more important: if you lost a disk, you will run a full extract and until that is done, you maybe can't use that system. So you definitely will prefer using more cores over waiting longer. Same thing, even if it is just a few, but big files, e.g. a VM or big database dump.

So I'ld say, we'll just test it as is and wait for feedback from testers.

About "being worth it": the roughly 10% overall savings is quite a lot, considering that hashing is just one of many steps. I did some own measurements last night and had a speed up of borg create of x1.75 (it was just 1 big file) when comparing blake3+zstd MT to non-MT.

If one wants to rather safe energy etc., guess hw accelerated (hmac-)sha256 (and lz4) is the way to go.

@jdchristensen

Copy link
Copy Markdown
Contributor

@ThomasWaldmann You make some good points, but I wonder if a higher threshold would get most of the wall clock savings with lower cpu time? The wall clock savings for sizes just above the current threshold was much lower, so that's the range in which you might be spending lots of cpu on little gain. Doing a comparison of a full create with the current threshold and double the current threshold would be pretty informative, I think.

@ThomasWaldmann

Copy link
Copy Markdown
Member Author

A higher threshold would be better for efficiency, but worse for speed, because less chunks then would qualify. It could be also set e.g. at 512kiB, but then a lot of files between 256 and 512kiB would not get a (small) speedup by MT.

Guess we'll need some experiment with real world datasets to see how much difference that makes.

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.

borg2: use blake3-mt

2 participants