use multithreaded zstd compression for big chunks, #9961 - #9962
Merged
Conversation
libzstd can compress a single chunk with a thread pool, but borg never asked it to: zstd.compress() was called without options, so nb_workers stayed 0. Just setting nb_workers would not have helped either. zstd splits the input into jobs sized from the window log, and that default job size swallows any chunk borg produces whole, so the workers never engage - measured at 0.97x, i.e. slightly slower than single-threaded. The job size has to be set explicitly. libzstd refuses to use a job smaller than 512KiB, so we ask for exactly that: it gives the most jobs and thus the most parallelism. Small chunks are excluded, but not because they cannot be split - a 768KiB chunk is happily cut into 512KiB + 256KiB. The problem is that such a split is very uneven: every thread waits for the one full-size job, which takes about as long as compressing the whole chunk single-threaded, and the thread overhead comes on top. Measured on a 12-core machine over levels 1..10, that is a real loss: 0.80x..0.91x at 544KiB, 0.90x..1.07x at 608KiB, break-even around 640KiB, then 1.21x..1.38x at 768KiB. The cutoff is therefore 768KiB (= 1.5 jobs), which keeps some margin over break-even because the thread overhead is machine dependent. Measured on the same machine, zstd,3, 10GiB of compressible data in one file: borg create 55.0s -> 33.8s (1.63x). Compressor alone: 2.61x for a 2MiB chunk, 4.16x for an 8MiB one. This trades a little compression ratio for speed, because a job only sees its own data: +0.05% archive size for 1MiB chunks at zstd,3, +0.64% for 8MiB ones, and somewhat more at higher levels which rely on longer match history. It also uses more cpu time in total (+22% for the 10GiB create) to reduce wallclock time. BORG_ZSTD_MT_WORKERS=1 turns it off for those who prefer the smaller archive or have to share the cpu with other work. Note that the compressed bytes now depend on the worker count, so compressing the same chunk twice on different machines can give different (equally valid) output. Chunk ids are unaffected: they are computed on the plaintext before compression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #9962 +/- ##
=======================================
Coverage 85.70% 85.70%
=======================================
Files 95 95
Lines 16815 16815
Branches 2577 2577
=======================================
Hits 14411 14411
Misses 1668 1668
Partials 736 736 ☔ View full report in Codecov by Harness. |
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.
Fixes #9961.
libzstd can compress a single chunk with a thread pool, but borg never asked it to:
zstd.compress()was called withoutoptions, sonb_workersstayed 0.Setting
nb_workersalone would not have helped either - zstd sizes its jobs from the window log, and that default swallows any chunk borg produces whole, so the workers never engage (measured at 0.97x, i.e. marginally slower).job_sizehas to be set explicitly. libzstd refuses a job below 512KiB, so we ask for exactly that: most jobs, most parallelism.Why the 768KiB cutoff
Not because smaller chunks cannot be split - a 768KiB chunk is happily cut into 512KiB + 256KiB. The problem is that the split is uneven: every thread waits for the one full-size job, which costs about as much as compressing the whole chunk single-threaded, and thread overhead comes on top.
Measured on a 12-core machine (Apple M3 Pro), speedup vs single-threaded:
Break-even is ~640KiB. The cutoff is 768KiB (= 1.5 jobs) to keep margin, since the thread overhead is machine dependent.
This also answers the "job_size is probably level-dependent" question from #9961: it is not. Both the 512KiB job floor and the break-even are the same at levels 1, 3, 6 and 10 - they follow from libzstd's job size minimum, not from the window size.
Results
10GiB of compressible data in a single file,
zstd,3,-e aes256-ocb -i sha256, median of 2:1.63x on
borg create. The compressor alone gets 2.61x for a 2MiB chunk and 4.16x for an 8MiB one; the end-to-end figure is lower because compression is only part of create.Costs
zstd,3, +0.64% for 8MiB ones, more at higher levels which rely on longer match history). A job only sees its own data.BORG_ZSTD_MT_WORKERS=1turns it off; the default isos.cpu_count().Points for review
get_zstd_mt_workers().BORG_BLAKE3_MT_THRESHOLD, so an invalid value is reported by borg's normal error handling rather than as an import traceback.zstd,3. A low-core machine will see much less.Tests:
compress_test.py60 passed (incl. a new one for the env var),create_cmd_test.pyclean. black and ruff clean.🤖 Generated with Claude Code