Intra-file chunked hashing (#88) - #90
Conversation
Groundwork for intra-file chunked hashing: a per-byte whole-file running checksum can't be split across chunks (XXH128 isn't additive). Redefine the file digest as a running checksum over each block's (loff, digest), folded in ascending-loff order as the scan proceeds: - Layout-independent (a block is a fixed blocksize file-offset window, not a physical extent), so two byte-identical files digest equal regardless of fragmentation - required for whole-file dedup. - Chunk-independent: one-pass scanning and folding per-chunk block records in loff order yield the same digest (the property #88 needs; the chunked merge will fold the same (loff, digest) sequence). - Hole-aware: skipped blocks leave gaps in the loff sequence, so different sparse layouts over identical data still differ. This subsumes the #87 hole-descriptor fold, which is removed. Block hashes are now computed for every block (they feed the digest and replace the deleted whole-file byte pass - cost-neutral in extent mode, one pass fewer in block mode), but folded streaming (O(1) memory): block records are only retained/stored in block-dedup mode. The file digest is now uniform across all modes, including --dedupe-options=only_whole_files. Bumps DB_FILE_MINOR (5.0 -> 5.1): files.digest values change, forcing a one-time rebuild. Block and extent digests are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Split a large file into chunks scanned in parallel on the existing scan pool, so a scan dominated by one big file uses more than one IO thread. A chunk is just scan_range() over [start, end); the whole-file path is factored onto the same helper. Chunk boundaries sit on extent ends that are also blocksize- aligned (common on btrfs, where 128 MiB/128 KiB extents align), so every block and every extent stays wholly inside one chunk and mapped-byte budgeting composes with the #87 hole-skip. Chunks share one read-only fiemap. A refcounted scan_job collects per-chunk hashes; the last chunk to finish (atomic dec-and-test) folds the file digest from all chunks' blocks in loff order and stores everything under one write transaction. Because the digest is the same (loff, digest) fold whether streamed whole or merged per-chunk (see prior commit), chunking never changes a file's digest, block or extent hashes - verified: whole vs chunked scans produce identical files.digest, blocks and extents, cross-dedupe (a chunked file against a whole copy), and a stable digest across runs. Progress: a chunk claims a display slot like any work item, but only the last counts the file (pscan_thread.count_as_file), so a K-chunk file still counts once and the scanned/total files converge. Chunking is gated behind DUPEREMOVE_CHUNK_BYTES for now (0/unset = off, the default); the --chunksize option and device-aware auto-default come next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Promote intra-file chunking from the DUPEREMOVE_CHUNK_BYTES scaffolding to a real, auto-tuned option: - --chunksize=SIZE sets the target mapped bytes per chunk; --chunksize=0 turns chunking off. Parsed with parse_size (K/M/G suffixes). - Default is auto: auto_tune_scan_params() (renamed from auto_tune_io_threads, now resolving both knobs from one storage_detect) enables chunking with a 1 GiB target on non-rotational or multi-device storage - where concurrent reads raise throughput (per the #88 spike) - and leaves it off on a single spinning disk, where they only add seeks. -v prints whether it's on. Only files at least twice the target are split, and boundaries fall on blocksize-aligned extent ends, so chunking never changes any digest - it is a pure throughput knob (like --io-threads) and needs no hashfile persistence. Documents --chunksize in the man page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unit tests (src/tests.c): - test_plan_chunks: boundaries land on blocksize-aligned extent ends, the mapped-byte budget and max_chunks cap are honoured, and files that are too small or have no aligned split point decline to chunk. - test_storage_benefits_from_concurrency: SSD/pool/unknown -> chunk, single spinning disk -> don't. Integration tests (tests/integration/test_chunking.py) drive a fragmented multi-extent file (confirmed to split into several chunks) and assert a chunked scan is byte-for-byte identical to an unchunked one - same file digest, extents and block hashes - plus a no-op chunked rescan changes nothing and identical files still dedupe when chunk-scanned. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ec08462 to
08dd324
Compare
|
Parking this rather than merging — after building it out I don't think the cost/benefit lands. Writing up the reasoning here for the record. What it costs
Why it's mostly the wrong tool for #88 The one case LPT doesn't cover is a single file larger than the sum of everything else (a lone 50 GiB file among small ones) — only chunking parallelizes that. It's rare, and if it ever becomes a real need we can add chunking behind an opt-in flag without the digest redefinition that forces the global rebuild. So: holding this in favor of the LPT branch. Keeping it open as a draft for reference in case the single-giant-file case ever justifies revisiting. |
|
Closing in favor of #91 (largest-file-first scheduling), which merged to master as the cheaper solution to #88 — no digest redefinition, no hashfile rebuild, no new concurrency path. Leaving the branch intact for reference if the single-giant-file case (the one thing LPT can't parallelize) ever justifies revisiting intra-file chunking. |
Closes #88. Splits a large file into chunks hashed in parallel across the IO threads, so a scan dominated by one very large file uses more than one thread. Still draft — the notable item for your call is the default (see below).
Design
A per-byte whole-file checksum can't be split across chunks (XXH128 isn't additive), so the file digest is redefined first, then the chunking is built on top.
(loff, digest)array (commit 1). Layout-independent (a block is a fixed 128 KiB file-offset window, not a physical extent → byte-identical files match regardless of fragmentation) and chunk-independent (one pass or N chunks fold the same sequence). Streamed O(1) for whole-file scans; block records are retained only for block-dedup or a chunk merge. Subsumes the More efficient handling of large very sparse files #87 hole handling.DB_FILE_MINOR5.0→5.1 (one-time rebuild); block/extent digests unchanged.[start, end); boundaries sit on blocksize-aligned extent ends so blocks and extents stay whole and mapped-byte budgeting composes with More efficient handling of large very sparse files #87. A refcountedscan_jobcollects per-chunk hashes; the last chunk to finish (atomic dec-and-test) folds the digest and stores everything in one transaction. The K=1 path is unchanged.--chunksize+ device-aware default (commit 3). Auto-enables with a 1 GiB target on SSD/NVMe/multi-device pools (where concurrent reads help) and stays off on a single spinning disk;--chunksize=0forces off. Pure perf knob — not persisted in the hashfile.storage_benefits_from_concurrency; integration tests asserting a chunked scan is byte-for-byte identical to an unchunked one (file digest, extents, block hashes), plus no-op rescan and dedupe, gated@requires_btrfswith a fragmentation assertion so they can't pass vacuously.Correctness
Chunking never changes any digest, block or extent — verified end-to-end: whole vs chunked scans produce identical
files.digest, extents and block hashes; cross-dedupe (chunked file vs whole copy) works; digest is stable across runs; valgrind clean on the merge/refcount path.Spike (validates the premise)
Cold buffered read+XXH128, one large file, single NVMe, fresh slices (O_DIRECT cross-checked): 1 thread 1.85 GiB/s → 4 threads 3.88 GiB/s (~2.1×), saturating ~4 streams. A single synchronous reader gets ~half the device; more on multi-device pools.
For review — the one decision
This bundles a default-behavior change: chunking is now auto-ON for SSD/NVMe/pools (files ≥2 GiB). It's the intended #88 behavior and heavily verified, but it moves a brand-new concurrency path onto the default. Easy to make opt-in instead (resolve the auto default to 0). Happy to split the default-flip into its own commit or hold it if you'd prefer soak time as opt-in first.
Verification
scripts/verify.shgreen: build (warnings=errors), 9 unit tests (5078 assertions) + 93 integration tests, valgrind scan/dedupe/replay smoke clean.🤖 Generated with Claude Code