There are cases where btrfs snapshots lose their shared extents with the original file. This causes the data to be stored twice — once in the live file, once in each snapshot — wasting significant disk space.
This tool re-consolidates snapshot extents with the live file. Fast, metadata-only candidate detection — no hashing. Just stat + FIEMAP + single-dest FIDEDUPERANGE with brute-force prefetch.
- Defragmentation —
btrfs filesystem defragmentrewrites extents, snapshots keep the old ones - Post-compression —
btrfs filesystem defragment -czstdcreates new compressed extents, snapshots still reference the old uncompressed data - old bees dedup daemon — Deduplicated live files but did not consolidate snapshot copies. This is fixed since version v0.11.
For each file in the live subvolume:
- Binary search across snapshots to find the oldest copy (stat only)
- Size check — if size differs, skip
- FIEMAP check — compare physical extent offsets between live and snapshot
- Same offset → already shared → skip
- Different offset → candidate
- Collect group — all snapshot copies + logical-resolve for other references
- Smart source selection — compressed > less fragmented > fewer rewrites
- Single-dest FIDEDUPERANGE — one ioctl per dest with brute-force pread prefetch
- Single-dest dedup — one FIDEDUPERANGE per dest, not multi-dest batching. Avoids kernel page-by-page thrashing across many dests.
- pread prefetch — brute-force
pread()into a dummy buffer (128KB chunks) loads pages into the page cache before the ioctl. btrfs ignoresfadvise/readahead(low ioprio), so direct reads are necessary (same approach as bees). - Background source refresh — a goroutine re-reads the source file every 5s to prevent cache eviction during long dedup runs with many dests.
- Phys-addr blacklist — dests sorted by physical address. If the first dest with a given phys addr fails (content differs), all subsequent dests with the same phys addr are skipped.
- 4 size categories — workers categorized by file size (<10MB unlimited, 10-100MB/100MB-1GB/>1GB max 1 each). Prevents I/O contention on HDDs.
- Backpressure — scanner pauses when pending groups reach 10,000. Prevents unbounded memory growth.
The kernel verifies each deduplication byte by byte — metadata comparison is only used to find candidates.
- Linux with btrfs filesystem
- Go 1.22+ with cgo (uses kernel headers for ioctl definitions)
- Root access (FIEMAP and FIDEDUPERANGE require it)
go build -o btrfs-snapshot-dedup btrfs-snapshot-dedup.gosudo ./btrfs-snapshot-dedup [options] <mountpoint> <subvolume> [find-filter...]Everything after <mountpoint> <subvolume> is passed directly to find(1) as filter arguments. Default (no filter): find <path> -type f (all files).
Examples:
# Dedup all files
sudo ./btrfs-snapshot-dedup /mnt/btrfs mysubvol
# Only large files (>= 100MB)
sudo ./btrfs-snapshot-dedup /mnt/btrfs mysubvol -size +100M
# Only text/config files
sudo ./btrfs-snapshot-dedup /mnt/btrfs mysubvol '(' -iname '*.txt' -o -iname '*.json' -o -iname '*.xml' ')'
# Resume after interrupt
sudo ./btrfs-snapshot-dedup -start-at 'path/to/last/file' /mnt/btrfs mysubvol
# With debug logging (log actions >= 100ms)
sudo ./btrfs-snapshot-dedup -debug 100 /mnt/btrfs mysubvol
# Write candidate files for manual inspection
sudo ./btrfs-snapshot-dedup -write-candidates /mnt/btrfs mysubvol| Option | Default | Description |
|---|---|---|
-workers |
4 |
Number of parallel dedup workers |
-start-at |
(none) | Resume: skip files before this path (lexicographic) |
-debug |
0 |
Debug log: log actions taking >= N ms (writes to debug.log) |
-write-candidates |
false |
Write candidates.fdupes and candidates.done files |
[5m00s] found=767720 checked=10038/7920/0/0 pending=2084/58057/29.4G(371.7M,89.2M,9.7M,7.0M) deduped=33/918/83.8M skip=0 opt=524/242/1351/0 1% ETA:3h46m
| Field | Meaning |
|---|---|
found |
Files matching filter (walker progress) |
checked |
processed/shared/not_found/changed |
pending |
groups/copies/expectedSavings(active worker sizes) |
deduped |
groups/copies/saved |
skip |
Groups skipped by phys-addr blacklist |
opt |
Source selection: compressed/lessFragmented/lessRewrites/default |
debug.log— detailed timing log (only with-debug)candidates.fdupes— candidate groups in fdupes format (only with-write-candidates)candidates.done— successfully deduplicated groups (only with-write-candidates)
On interrupt (Ctrl+C), the last processed file path is shown for use with -start-at.
When bees deduplicates file A and file B to share the same extent, it does not consolidate their snapshot copies. With 1000 snapshots, this means 1000 separate extents that are byte-identical but not shared. bees will eventually find them through its sequential extent-tree scan, but on a 22 TB filesystem this takes months.
After running btrfs filesystem defragment -czstd /path, each file gets new compressed extents (e.g. 3 GiB → 1.1 GiB). But every snapshot still references the old 3 GiB uncompressed extents. Net result: 1.1 + 3.0 = 4.1 GiB — worse than before compression.
Running btrfs-snapshot-dedup after compression points all snapshots to the compressed extents, freeing the old uncompressed ones.
readahead() is limited by read_ahead_kb (typically 128KB). posix_fadvise(FADV_WILLNEED) has no size limit but btrfs may ignore it or process it with low I/O priority. bees discovered this and uses brute-force pread() into a dummy buffer — the only reliable way to force pages into the page cache on btrfs.
FIDEDUPERANGE works on read-only snapshots when dest files are opened with O_RDONLY. Opening with O_RDWR fails with EROFS.
Multiple snapshot copies often share the same physical extent (COW), so deduplicating N snapshots from extent B to extent A only frees one copy of B, not N copies. This tool counts unique physical offsets among dests to report accurate savings.
- btrfs-snapshot-compress — Sister project: zstd-compress existing data while preserving snapshots and reflinks (no temporary disk-grow, no
btrfs sendbreakage). - bees — btrfs deduplication daemon
- duperemove — Hash-based btrfs deduplication tool
GPL-2.0