Skip to content

Commit c16e0fe

Browse files
fastcdc: add FastCDC chunker with a keyed Gear hash
Add a new "fastcdc" content-defined chunker selectable via --chunker-params. It uses the FastCDC Gear rolling hash (fp = (fp << 1) + Gear[byte]), which is window-less and cheaper per byte than buzhash's cyclic-polynomial update, so it chunks noticeably faster (see "borg benchmark cpu" output), while producing the same chunk-size distribution and deduplication. The Gear table is keyed: it is derived from the repo id key via CSPRNG (own "fastcdc" domain), exactly like the buzhash64 table, so chunk cut points stay unpredictable without the key (anti-fingerprinting). It implements the same FastCDC techniques as buzhash64 (sub-minimum skipping, normalized chunking with a required nc_level, min/max clamping); the mask uses the high bits of the hash (Gear accumulates entropy there). chunker-params: "fastcdc,chunk_min,chunk_max,chunk_mask,nc_level" - there is no window field, because Gear is window-less. e.g. fastcdc,19,23,21,2 Also: borg benchmark cpu now measures the fastcdc chunker; tests in borg.testsuite.chunkers (golden vector, size distribution, keyed gear table, param parsing, slow fuzz); docs and changelog. Benchmarks (scripts/chunker_bench.py, buzhash64 vs fastcdc, both nc_level=2, incompressible data unless noted): 5 GiB, 2 MiB target (default params): buzhash64: CV 0.294, 1011 MB/s fastcdc: CV 0.295, 1313 MB/s (+30%) 64 MiB, 64 KiB target: buzhash64: CV 0.374, shift-resilience 0.9928, 963 MB/s fastcdc: CV 0.359, shift-resilience 0.9929, 1331 MB/s (+38%) Re-backup of a 2.5 GiB file after scattered single-byte edits (dedup ratio, 0.5 = v2 fully deduplicated, lower is better): 64 edits: buzhash64 0.5237, fastcdc 0.5236 320 edits: buzhash64 0.6133, fastcdc 0.6161 borg benchmark cpu, 1 GB: fastcdc 3.80s, buzhash 4.36s, buzhash64 8.13s, fixed 0.56s. Chunk-size distribution, deduplication and shift-resilience match buzhash64 within noise; fastcdc is consistently faster. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1a04046 commit c16e0fe

12 files changed

Lines changed: 522 additions & 8 deletions

File tree

docs/changes.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,12 @@ New features:
176176
``chunker-params`` for buzhash64 gains a required 6th field ``nc_level``
177177
(``buzhash64,chunk_min,chunk_max,chunk_mask,window_size,nc_level``).
178178
buzhash (32bit) is unchanged and stays bit-compatible with borg 1.x.
179+
- new ``fastcdc`` chunker: a FastCDC content-defined chunker using a window-less, keyed Gear
180+
rolling hash (the gear table is derived from the repo's id key, like buzhash64, so cut points
181+
stay unpredictable without the key). It supports the same normalized chunking as buzhash64 and
182+
produces the same chunk-size distribution and deduplication, but chunks roughly 1.3-1.5x faster.
183+
Select it via ``--chunker-params fastcdc,chunk_min,chunk_max,chunk_mask,nc_level`` (no window
184+
field; e.g. ``fastcdc,19,23,21,2``). ``borg benchmark cpu`` now reports its throughput too.
179185
- repo-create: split ``--encryption`` into orthogonal options. ``--encryption`` now
180186
selects only the cipher / AE algorithm (``none``, ``authenticated``, ``aes256-ocb``
181187
or ``chacha20-poly1305``), the new ``--id-hash`` selects the id hash function

docs/global.rst.inc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
.. _OpenSSL: https://www.openssl.org/
2020
.. _`Python 3`: https://www.python.org/
2121
.. _Buzhash: https://en.wikipedia.org/wiki/Buzhash
22+
.. _FastCDC: https://www.usenix.org/conference/atc16/technical-sessions/presentation/xia
2223
.. _msgpack: https://msgpack.org/
2324
.. _`msgpack-python`: https://pypi.org/project/msgpack-python/
2425
.. _llfuse: https://pypi.org/project/llfuse/

docs/internals/data-structures.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,8 @@ Borg has these chunkers:
403403
- "buzhash": variable, content-defined blocksize, uses a rolling hash
404404
computed by the Buzhash_ algorithm.
405405
- "buzhash64": similar to "buzhash", but improved 64bit implementation
406+
- "fastcdc": variable, content-defined blocksize, uses the window-less, keyed
407+
Gear rolling hash (FastCDC_); faster than buzhash, same deduplication.
406408

407409
For some more general usage hints see also ``--chunker-params``.
408410

@@ -483,6 +485,22 @@ The buzhash table is cryptographically derived from secret key material.
483485
These changes should improve resistance against attacks and also solve
484486
some of the issues of the original (32bit / XORed table) implementation.
485487

488+
"fastcdc" chunker
489+
+++++++++++++++++
490+
491+
FastCDC_ content-defined chunker using the Gear rolling hash. Unlike buzhash it
492+
is window-less (each byte's influence simply decays out of the hash), so its
493+
update is cheaper and it chunks noticeably faster, while producing the same
494+
deduplication and (with normalized chunking) the same chunk-size distribution.
495+
496+
Like "buzhash64", the Gear table is cryptographically derived from secret key
497+
material, so chunk cut points are unpredictable without the key.
498+
499+
``borg create --chunker-params fastcdc,CHUNK_MIN_EXP,CHUNK_MAX_EXP,HASH_MASK_BITS,NC_LEVEL``
500+
501+
There is no window size (Gear is window-less). NC_LEVEL is the normalized
502+
chunking level (0 disables it); 2 is a good default. E.g.: ``fastcdc,19,23,21,2``.
503+
486504
.. _cache:
487505

488506
The cache

scripts/chunker_bench.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,10 @@ def chunk_stats(algo, data, min_exp, max_exp, mask_bits, win, nc_level=0, normal
129129
params = [min_exp, max_exp, mask_bits, win]
130130
kw = dict(key=None, sparse=False)
131131
if algo == "buzhash64":
132-
params.append(nc_level) # nc_level is a positional buzhash64 param
132+
params.append(nc_level) # nc_level is a positional param
133+
kw["normal_size"] = normal_size
134+
elif algo == "fastcdc":
135+
params = [min_exp, max_exp, mask_bits, nc_level] # fastcdc is window-less
133136
kw["normal_size"] = normal_size
134137
chunker = get_chunker(algo, *params, **kw)
135138
sizes = []
@@ -285,11 +288,11 @@ def main():
285288
print(f"shift test: {args.shift_edits} edits repeats: {args.repeat}")
286289
print("-" * 118)
287290

288-
# build (algo, nc_level) variants; for buzhash64 also run the requested NC level
291+
# build (algo, nc_level) variants; for buzhash64/fastcdc also run the requested NC level
289292
variants = []
290293
for algo in args.algo:
291294
variants.append((algo, 0))
292-
if algo == "buzhash64" and args.nc_level > 0:
295+
if algo in ("buzhash64", "fastcdc") and args.nc_level > 0:
293296
variants.append((algo, args.nc_level))
294297

295298
for algo, nc in variants:

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
crypto_legacy_ll_source = "src/borg/legacy/crypto/low_level.pyx"
5757
buzhash_source = "src/borg/chunkers/buzhash.pyx"
5858
buzhash64_source = "src/borg/chunkers/buzhash64.pyx"
59+
fastcdc_source = "src/borg/chunkers/fastcdc.pyx"
5960
reader_source = "src/borg/chunkers/reader.pyx"
6061
hashindex_source = "src/borg/hashindex.pyx"
6162
item_source = "src/borg/item.pyx"
@@ -73,6 +74,7 @@
7374
crypto_legacy_ll_source,
7475
buzhash_source,
7576
buzhash64_source,
77+
fastcdc_source,
7678
reader_source,
7779
hashindex_source,
7880
item_source,
@@ -189,6 +191,7 @@ def lib_ext_kwargs(pc, prefix_env_var, lib_name, lib_pkg_name, pc_version, lib_s
189191
Extension("borg.item", [item_source], extra_compile_args=cflags),
190192
Extension("borg.chunkers.buzhash", [buzhash_source], extra_compile_args=cflags),
191193
Extension("borg.chunkers.buzhash64", [buzhash64_source], extra_compile_args=cflags),
194+
Extension("borg.chunkers.fastcdc", [fastcdc_source], extra_compile_args=cflags),
192195
Extension("borg.chunkers.reader", [reader_source], extra_compile_args=cflags),
193196
]
194197

src/borg/archiver/benchmark_cmd.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ def chunkit(ch):
204204
"chunkit(ch)",
205205
locals(),
206206
),
207+
# fastcdc (window-less keyed gear hash); gear table creation is slow, keep it in setup
208+
("fastcdc,19,23,21,2", "ch = get_chunker('fastcdc', 19, 23, 21, 2, sparse=False)", "chunkit(ch)", locals()),
207209
("fixed,1048576", "ch = get_chunker('fixed', 1048576, sparse=False)", "chunkit(ch)", locals()),
208210
]:
209211
dt = timeit(func, setup, number=number_default, globals=vars)

src/borg/archiver/completion_cmd.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -708,7 +708,13 @@ def do_completion(self, args):
708708
comp_spec_choices_str = " ".join(comp_spec_choices)
709709

710710
# Chunker params choices (static list)
711-
chunker_params_choices = ["default", "fixed,4194304", "buzhash,19,23,21,4095", "buzhash64,19,23,21,4095,2"]
711+
chunker_params_choices = [
712+
"default",
713+
"fixed,4194304",
714+
"buzhash,19,23,21,4095",
715+
"buzhash64,19,23,21,4095,2",
716+
"fastcdc,19,23,21,2",
717+
]
712718
chunker_params_choices_str = " ".join(chunker_params_choices)
713719

714720
# Relative time marker choices (static list)

src/borg/chunkers/__init__.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .buzhash import Chunker
22
from .buzhash64 import ChunkerBuzHash64
3+
from .fastcdc import ChunkerFastCDC
34
from .failing import ChunkerFailing
45
from .fixed import ChunkerFixed
56
from .reader import * # noqa
@@ -10,16 +11,23 @@ def get_chunker(algo, *params, **kw):
1011
sparse = kw.get("sparse", False)
1112
# key.chunk_seed only has 32 bits
1213
seed = key.chunk_seed if key is not None else 0
13-
# for buzhash64, we want a much longer key, so we derive it from the id key
14-
bh64_key = (
15-
key.derive_key(salt=b"", domain=b"buzhash64", size=32, from_id_key=True) if key is not None else b"\0" * 32
16-
)
1714
if algo == "buzhash":
1815
return Chunker(seed, *params, sparse=sparse)
1916
if algo == "buzhash64":
17+
# for buzhash64, we want a much longer key, so we derive it from the id key.
2018
# params is (chunk_min_exp, chunk_max_exp, hash_mask_bits, hash_window_size, nc_level);
2119
# nc_level is passed positionally. normal_size is an optional tuning knob (0 = auto).
20+
bh64_key = (
21+
key.derive_key(salt=b"", domain=b"buzhash64", size=32, from_id_key=True) if key is not None else b"\0" * 32
22+
)
2223
return ChunkerBuzHash64(bh64_key, *params, normal_size=kw.get("normal_size", 0), sparse=sparse)
24+
if algo == "fastcdc":
25+
# keyed gear table, derived from the id key (own domain). params is
26+
# (chunk_min_exp, chunk_max_exp, hash_mask_bits, nc_level) - no window (Gear is window-less).
27+
fc_key = (
28+
key.derive_key(salt=b"", domain=b"fastcdc", size=32, from_id_key=True) if key is not None else b"\0" * 32
29+
)
30+
return ChunkerFastCDC(fc_key, *params, normal_size=kw.get("normal_size", 0), sparse=sparse)
2331
if algo == "fixed":
2432
return ChunkerFixed(*params, sparse=sparse)
2533
if algo == "fail":

0 commit comments

Comments
 (0)