Skip to content

remove avoidable per-chunk memory copies on the hot data path - #10059

Merged
ThomasWaldmann merged 3 commits into
borgbackup:masterfrom
ThomasWaldmann:memory-perf
Aug 9, 2026
Merged

remove avoidable per-chunk memory copies on the hot data path#10059
ThomasWaldmann merged 3 commits into
borgbackup:masterfrom
ThomasWaldmann:memory-perf

Conversation

@ThomasWaldmann

@ThomasWaldmann ThomasWaldmann commented Aug 9, 2026

Copy link
Copy Markdown
Member

Analysis of where borg create / borg extract copy or allocate big amounts of memory (chunk-sized buffers) found several avoidable full-chunk copies per chunk. This PR removes the easy ones; three commits, each independently tested:

compress: do not copy chunk data to bytes, use the buffer protocol
The chunker hands chunk data to the compressors as a memoryview into its scan buffer, but LZ4._decide (default compression) coerced it to bytes first — one full plaintext copy + allocation per chunk. Now uses a typed memoryview. CompressorBase.compress only materializes bytes in legacy mode (the prefix concat needs it), which also removes a second full copy for incompressible chunks (LZ4→CNONE fallback). ObfuscateSize.compress joins instead of concatenating (and skips the copy for zero padding), as CNONE may now pass a memoryview through.

crypto: AEAD encrypt/decrypt directly into the result bytes object
_AEAD_BASE.encrypt/decrypt used a PyMem_Malloc'd scratch buffer and created the returned bytes by slicing it — one ciphertext-sized (encrypt) / plaintext-sized (decrypt) copy plus a malloc/free round-trip per chunk. Our AEAD ciphers are padding-free, so the output size is known exactly: allocate the result via PyBytes_FromStringAndSize(NULL, n) and let OpenSSL write into it directly. Caveat (caught by the tests): OpenSSL's OCB emits the buffered partial final block from EVP_EncryptFinal_ex, not from Update. As our AEAD modes are padding-free, Final can never write more than the space left in the exact-size result buffer, so it writes directly into it (review feedback); a length check afterwards verifies the total.

compress: lz4 decompresses directly into the result bytes object
LZ4.decompress guessed the output size, decompressed into a scratch buffer (growing/retrying) and copied the plaintext into the returned bytes. borg2 stores the exact plaintext size in the authenticated object metadata, so decompress straight into an exactly-sized result. The guess-and-retry loop remains as fallback for borg 1.x data read in legacy mode (borg transfer).

Measurements (20 GiB, half incompressible / half compressible non-deduplicating data, lz4, aes256-ocb, page-cache-warm, 4 alternating A/B runs each on Apple Silicon; every PR run was faster than every master run):

master PR delta
borg create 48.13 ± 0.86 s (426 MiB/s) 44.48 ± 0.12 s (460 MiB/s) −7.6%
borg extract --stdout 15.40 ± 0.13 s (1330 MiB/s) 14.40 ± 0.26 s (1422 MiB/s) −6.4%

The extract gain also benefits borg mount and check --verify-data.

cProfile confirms the savings come entirely out of the compression/crypto path (chunker, id_hash unchanged).

Noticed while working on this (pre-existing on master, not addressed here): --compression auto,... raises ZeroDivisionError in Auto.compress for empty input; not reachable via the chunker (it never yields empty chunks).

🤖 Generated with Claude Code

The chunker hands chunk data to the compressors as a memoryview into its
scan buffer, but LZ4._decide (the default compression) coerced it to
bytes first, copying the full plaintext chunk once per chunk just to get
a char* for the C call. Use a typed memoryview instead, so lz4 reads
straight from the chunker's buffer.

CompressorBase.compress (used by CNONE, i.e. also the fallback for
incompressible chunks) did the same coercion, which is only needed in
legacy mode for the ID/level prefix concatenation - keep it there and
pass the buffer through otherwise. This removed a second full copy for
incompressible chunks.

ObfuscateSize.compress now may receive a memoryview from the inner
compressor, so join instead of concatenating (and skip the copy
completely when the added padding size is 0).

Measured on a 5 GiB borg create (lz4, aes256-ocb, half incompressible /
half compressible input): ~3% faster overall, compression-path time per
stored chunk -23%.
@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.74%. Comparing base (8f2de9e) to head (10fbb99).
⚠️ Report is 38 commits behind head on master.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #10059      +/-   ##
==========================================
+ Coverage   86.64%   86.74%   +0.09%     
==========================================
  Files          98       98              
  Lines       17037    17085      +48     
  Branches     2581     2586       +5     
==========================================
+ Hits        14762    14820      +58     
+ Misses       1583     1571      -12     
- Partials      692      694       +2     

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

Comment thread src/borg/crypto/low_level.pyx Outdated
Comment thread src/borg/crypto/low_level.pyx Outdated
_AEAD_BASE.encrypt and .decrypt used a PyMem_Malloc'd scratch buffer
(with a one-block safety margin) and created the returned bytes object
by slicing it, copying the whole ciphertext / plaintext once more per
chunk before freeing the scratch buffer.

Our AEAD ciphers (AES-OCB, chacha20-poly1305) are padding-free, so the
output size is known exactly up front: allocate the result via
PyBytes_FromStringAndSize(NULL, n) and let OpenSSL write into it
directly - no scratch buffer, no copy, no malloc/free per chunk.

Caveat found by the tests: EVP_EncryptFinal_ex is not output-free for
OCB - OpenSSL buffers the partial final block in EncryptUpdate and
emits it at Final (the total is still exactly the input length). As our
AEAD modes are padding-free, Final can never write more than the space
left in the exact-size result buffer, so it writes directly into it; a
length check afterwards verifies the total.

This helps both directions: borg create saves one ciphertext-sized copy
per chunk, and the read paths (extract, mount, check --verify-data)
save a plaintext-sized copy per chunk. Measured on 5 GiB (lz4,
aes256-ocb): create ~1.5% faster, extract ~6% faster.
LZ4.decompress guessed the output size, decompressed into a thread-local
scratch buffer (growing and retrying on overflow) and then copied the
whole plaintext into the returned bytes object - one full plaintext-sized
copy per chunk on every read (extract, mount, check --verify-data).

borg2 stores the exact plaintext size in the (authenticated) object
metadata, so when meta["size"] is known, allocate the result exactly
and let LZ4_decompress_safe write straight into it: no scratch buffer,
no guessing, no copy. A size mismatch raises DecompressionError (this
also covers what check_fix_size asserted).

The guess-and-retry loop remains as the fallback for borg 1.x data read
in legacy mode (borg transfer), where the size is not known up front.

Measured on a 5 GiB extract (lz4, aes256-ocb): ~4% faster.
ThomasWaldmann added a commit to ThomasWaldmann/borg that referenced this pull request Aug 9, 2026
ChunkerFixed used FileReader.read(), which assembles each chunk in an
intermediate bytearray (copying the data out of the reader's block
buffers) and then converts it to bytes (copying everything again).

Read each chunk via FileReader.readinto() into a fresh per-chunk buffer
instead: each byte is copied exactly once, from the reader's block
buffer into the chunk, and the chunk is yielded as a memoryview over
that buffer. All-zero detection now happens at chunk granularity, like
in the content-defined chunkers.

Behavior notes:
- chunk data stays valid after the iterator advances (the buffer is
  per chunk, not reused), matching the previous semantics.
- ranges stemming from holes in sparse files are now reported as
  CH_ALLOC instead of CH_HOLE - downstream treats both identically.

Measured on a 20 GiB create (fixed,4194304, lz4, unencrypted repo,
on top of the copy-removal changes of borgbackup#10059 which let the yielded
memoryview flow through compression without being copied to bytes):
~10% faster. Standalone (without borgbackup#10059) only ~2%, as the compressor's
bytes() coercion then re-adds one copy.
ThomasWaldmann added a commit to ThomasWaldmann/borg that referenced this pull request Aug 9, 2026
ChunkerFixed used FileReader.read(), which assembles each chunk in an
intermediate bytearray (copying the data out of the reader's block
buffers) and then converts it to bytes (copying everything again).

Read each chunk via FileReader.readinto() into a fresh per-chunk buffer
instead: each byte is copied exactly once, from the reader's block
buffer into the chunk, and the chunk is yielded as a memoryview over
that buffer. All-zero detection now happens at chunk granularity, like
in the content-defined chunkers.

Also update the reader type stub: FileReader.readinto was missing
there, and Chunk data may be a memoryview (as the CDC chunkers already
yield).

Behavior notes:
- chunk data stays valid after the iterator advances (the buffer is
  per chunk, not reused), matching the previous semantics.
- ranges stemming from holes in sparse files are now reported as
  CH_ALLOC instead of CH_HOLE - downstream treats both identically.

Measured on a 20 GiB create (fixed,4194304, lz4, unencrypted repo,
on top of the copy-removal changes of borgbackup#10059 which let the yielded
memoryview flow through compression without being copied to bytes):
~10% faster. Standalone (without borgbackup#10059) only ~2%, as the compressor's
bytes() coercion then re-adds one copy.
@ThomasWaldmann
ThomasWaldmann merged commit b63e7b9 into borgbackup:master Aug 9, 2026
19 checks passed
@ThomasWaldmann
ThomasWaldmann deleted the memory-perf branch August 9, 2026 12:50
ThomasWaldmann added a commit to ThomasWaldmann/borg that referenced this pull request Aug 9, 2026
ChunkerFixed used FileReader.read(), which assembles each chunk in an
intermediate bytearray (copying the data out of the reader's block
buffers) and then converts it to bytes (copying everything again).

Read each chunk via FileReader.readinto() into a fresh per-chunk buffer
instead: each byte is copied exactly once, from the reader's block
buffer into the chunk, and the chunk is yielded as a memoryview over
that buffer. All-zero detection now happens at chunk granularity, like
in the content-defined chunkers.

Also update the reader type stub: FileReader.readinto was missing
there, and Chunk data may be a memoryview (as the CDC chunkers already
yield).

Behavior notes:
- chunk data stays valid after the iterator advances (the buffer is
  per chunk, not reused), matching the previous semantics.
- ranges stemming from holes in sparse files are now reported as
  CH_ALLOC instead of CH_HOLE - downstream treats both identically.

Measured on a 20 GiB create (fixed,4194304, lz4, unencrypted repo,
on top of the copy-removal changes of borgbackup#10059 which let the yielded
memoryview flow through compression without being copied to bytes):
~10% faster. Standalone (without borgbackup#10059) only ~2%, as the compressor's
bytes() coercion then re-adds one copy.
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