Apply zstd compression to existing btrfs data transparently — without breaking
snapshots, reflinks, or btrfs send incremental chains, and without the temporary
disk-usage explosion that plain btrfs filesystem defragment -czstd causes on
volumes with shared extents.
Sister tool to btrfs-snapshot-dedup. Same parameter style, same find-filter logic, same status conventions.
Plain defrag-with-compression rewrites each extent into a new compressed extent.
But snapshots, cp --reflink copies, and prior duperemove output keep
referencing the old uncompressed extent — so:
- The old extent stays alive (still referenced)
- The new compressed extent is added to disk
- Disk usage grows instead of shrinking — by exactly the compressed size
- Net result on a heavily-shared volume:
+(N-1)/N × compressed_size
For a volume with 39 % sharing (typical after a duperemove run), this can fill a
near-full disk before any compression benefit becomes visible. On a btrfs send
target, it also breaks the incremental chain.
This tool fixes that by retargeting every reflink sibling onto the new
compressed extent atomically per-extent, before moving on. The old extent
becomes orphan, the kernel frees it, sharing is preserved with the compressed
data, and btrfs send keeps working.
| Aspect | btrfs filesystem defragment -czstd |
btrfs-snapshot-compress |
|---|---|---|
| Snapshots / reflinks | Breaks sharing → disk grows | Preserves sharing → disk shrinks |
btrfs send chain |
Fine | Fine (FIDEDUPERANGE preserves it) |
| Already-compressed extents | Skips | Skips |
| Files that don't compress well | Wastes I/O rewriting them anyway | Probe-compress bails out cheaply |
| Per-extension learning | n/a | Smart-Speed locks in skip/fastpath after sample |
| I/O on uncompressible content | High | Low (probe is 128 KiB read, no write) |
| Status reporting | None | 10 s status with file/extent/reflink counters |
The probe-compress + smart-speed combination means the tool only touches files that actually benefit — so unchanged files mean less I/O, less metadata churn, less follow-up work for backups, snapshots, replication, or any other system that watches your volume.
For each file from find(1):
- Probe-compress — read first 128 KiB, zstd-encode in memory. If ratio < threshold (default 1.20×) → bail out, no writes.
- For each extent in the file (FIEMAP):
- Skip if already encoded / inline / unwritten / preallocated
BTRFS_IOC_DEFRAG_RANGEwithCOMPRESS|ZSTDon the live extentBTRFS_IOC_LOGICAL_INO_V2on the old physical address → list every reflink sibling (snapshots,cp --reflink, prior duperemove output)- Sibling path resolution via
BTRFS_IOC_INO_PATHS+ cached subvol-fd - For each sibling: pread-prefetch +
FIDEDUPERANGEretargets it onto the new compressed extent - Old extent is now orphan → btrfs cleaner frees it on next commit
The "danger window" is bounded by a single extent (typically ≤ 128 MB), not a whole file. Atomic per-extent — compressions never accumulate without their reflink propagation.
Per-extension learning, case-insensitive. Two regimes:
- Warmup — first
-smart-min-samples(default 20) files of an extension are individually probed (128 KiB read + zstd test). Each file is then defrag-compressed if probe says it's worth it. - Locked-in — once the extension has ≥ min-samples, the per-extension
mean encoded-rate decides:
- < 30 % compressible → skip the whole extension
- ≥ 30 % compressible → compress directly (no probe; btrfs's chunk-level decision keeps only the chunks that actually compress)
The metric is fed by real defrag outcomes (FIEMAP_EXTENT_ENCODED ratio
post-defrag), not the probe alone — so an extension with a compressible
header but incompressible body (some .exe, .bin) eventually drops below
the threshold even if its first 128 KiB looks promising.
Drift detection: in skip-mode, every Nth decision still probes a file
(-smart-resample, default 1/50). If the data drifts toward compressibility,
the metric notices. Set -smart-resample=0 to disable.
Files without an extension are always probed (no learning signal possible).
A static blacklist of always-skipped extensions (mp4 jpg jpeg png webp mov mp3 m4a flac zip gz bz2 xz zst 7z rar iso ...) runs before smart-speed and
can be disabled via -skip-incompressible-ext=false.
If you want every file probed and (if probe passes) defragged regardless of the extension's history:
sudo ./btrfs-snapshot-compress -smart-skip-thresh=0 /mnt/btrfs mysubvol-smart-skip-thresh=0 means "skip extension only if < 0 % compressible" — a
threshold that's never reached, so no extension is ever skipped wholesale.
Every file goes through probe-then-compress.
Costs more I/O than the default (no probe-shortcut for known-compressible extensions, no whole-extension skipping for known-bad ones), but ensures every file's individual probe result decides its fate.
- Linux with btrfs filesystem
- Go 1.22+ with cgo (uses kernel headers for ioctl definitions)
- Kernel ≥ 5.6 recommended (stable
BTRFS_IOC_LOGICAL_INO_V2semantics) - Root access (BTRFS ioctls)
btrfscli in PATH (subprocess call forsubvolid-resolve)
go build -o btrfs-snapshot-compresssudo ./btrfs-snapshot-compress [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.
<subvolume> can be . to scan the entire mount.
Reflink siblings can live in any subvolume on the filesystem. The tool resolves
sibling paths via btrfs inspect-internal subvolid-resolve <id> <mount> —
which returns paths relative to the FS top-level (subvolid=5).
This means the mount you pass must either be the top-level itself, or it must expose all needed subvols as subdirectories. Practical options:
- Mount the top-level somewhere accessible:
sudo mount -o subvolid=5 /dev/your-luks-or-block-device /mnt/btrfs-topthen runsudo ./btrfs-snapshot-compress / mnt/btrfs-top . - Run on
/if your root is mounted withsubvolid=5(some installs). - Single-subvol-only mounts (e.g. just
/homewithsubvolid=258) are not enough on their own — sibling resolution will fail for refs in other subvols.
If sibling resolution fails frequently, the path-resolve error counter in
the status output will grow. Compression of the live file still works — only
the reflink propagation fails for unreachable siblings.
Compress + reflink propagation generates substantial I/O. On a busy system SSD the foreground latency can suffer noticeably. Mitigations:
sudo nice -n 19 ionice -c 3 ./btrfs-snapshot-compress -workers 1 /mnt/btrfs mysubvolnice -n 19— lowest CPU priorityionice -c 3— idle I/O class (only when nothing else needs disk)-workers 1— process one file at a time
-workers 1 alone gives most of the win on heavily-loaded systems. Total
runtime grows roughly linearly, but the desktop stays responsive.
# Compress everything in mysubvol
sudo ./btrfs-snapshot-compress /mnt/btrfs mysubvol
# Conservative first run on a near-full disk: tiny files only, single worker
sudo ./btrfs-snapshot-compress -workers 1 \
-size +4k -size -32k /mnt/btrfs mysubvol
# Only common compressible extensions
sudo ./btrfs-snapshot-compress /mnt/btrfs mysubvol \
'(' -iname '*.txt' -o -iname '*.json' -o -iname '*.log' \
-o -iname '*.html' -o -iname '*.csv' -o -iname '*.md' ')'
# Resume after interrupt
sudo ./btrfs-snapshot-compress -start-at 'path/to/last/file' /mnt/btrfs mysubvol| Flag | Default | Description |
|---|---|---|
-workers |
4 |
Parallel workers (each processes one file at a time) |
-start-at |
"" |
Resume: skip files until this relative path (lexicographic) |
-debug |
0 |
Log actions taking ≥ N ms to debug.log (0 = off) |
-min-size |
4096 |
Skip files smaller than this many bytes |
-probe-ratio |
1.20 |
Minimum compression ratio to actually compress (else skip file) |
-skip-incompressible-ext |
true |
Skip known-incompressible extensions without probing |
-smart-min-samples |
20 |
Smart-speed: warmup probes before locking in |
-smart-skip-thresh |
0.30 |
Smart-speed: extension skipped when mean encoded-rate < X. Set 0 to disable extension-skipping. |
-smart-resample |
50 |
Smart-speed: in skip-mode, probe 1-in-N files for drift detection. Set ≤0 to disable. |
[1m23s] found=12345 buf=234 checked=8000/3210/45/12 skip=4594/28832/0/8694/137 \
probed=12653(fast=3723) poor=7977 cmp=1180 refs=945/17760/0/1 saved=297M ETA:5m23s
| Field | Meaning |
|---|---|
found |
Files matched by walker so far |
buf |
Files queued waiting |
checked |
processed/incompressible/notFound/changed |
skip |
ext-blacklist / below-min-size / nocow / already-compressed / smart-skip |
probed |
files where probe-compress was run (fast=N bypassed by smart-speed) |
poor |
files skipped because probe ratio was below threshold |
cmp |
extents successfully recompressed |
refs |
filesWithSiblings / dedupSucc / dedupFail / lookupErr (reflink propagation) |
saved |
approximate on-disk bytes freed (under-counts; df is the truth) |
After completion, prints aggregate counters and a top-N table of extensions with their observed compressible-rate. Useful both as a sanity-check and as a guide for tuning future runs.
debug.log— detailed timing log (only with-debug)btrfs-snapshot-compress.log— per-file action log- The first 5 errors of each reflink-resolution phase (
path-resolve/open/ioctl) are printed to stderr with errno detail; further occurrences suppressed.
On interrupt (Ctrl+C), the tool stops cleanly after the current file. Resume
via -start-at <last-file-path>.
Per-extent atomic compress + reflink-propagation means transient overhead is small (one extent at a time per worker). Still, on near-full volumes:
- Watch
df -h /your-mountandbtrfs filesystem usage /your-mount - Start small (size filter, single worker) and scale up after the first run
shows healthy
dfbehaviour - The reported
savedcounter under-counts (see Reporting Limits below).dfis your real source of truth.
The saved counter currently reports approximate boundary deltas, not real
on-disk bytes freed. The actual disk savings come from two sources we don't
track precisely yet:
- Compression itself — the new compressed extent's
disk_num_bytesvs the old uncompresseddisk_num_bytes. FIEMAP returns logical (uncompressed) sizes for compressed extents, so this is invisible to a FIEMAP-only walk.BTRFS_IOC_TREE_SEARCH_V2on the extent tree can give the real value; not yet implemented in this tool. - Reflink-orphan freeing — when all siblings of an old extent are redirected, the kernel cleaner frees the entire old extent on next commit.
For real measurements use btrfs filesystem usage, compsize, or compare
df -h before/after.
The tool only writes via two well-tested kernel ioctls:
BTRFS_IOC_DEFRAG_RANGE— same ioctlbtrfs filesystem defragmentuses. Atomic per extent. File logical content is byte-identical before and after — only the on-disk encoding changes.FIDEDUPERANGE— kernel performs a byte-by-byte verification of src vs dst before retargeting any extent. Cannot corrupt data.
Because compress and dedup are different operations, they compose cleanly:
- Run
btrfs-snapshot-compressfirst → compressed live extents, reflink-sharing preserved - Optional: run
btrfs-snapshot-dedupsecond → finds byte-identical files that aren't yet reflink-shared (e.g. across subvols) and unifies them
The reverse order also works.
readahead() is capped by read_ahead_kb (typically 128 KiB). posix_fadvise (FADV_WILLNEED) has no cap but btrfs runs it at low I/O priority and may
discard it. Brute-force pread() into a dummy buffer is the only reliable way
to warm the page cache before FIDEDUPERANGE. Without it, throughput drops
roughly 10×. This is the same lesson bees
documented years ago.
BTRFS_IOC_INO_LOOKUP only finds inode_ref entries. Files in heavily
populated directories (e.g. .git/lfs/objects/<2>/<2>/) use inode_extref
(extended refs) — INO_LOOKUP returns ENOENT for them. BTRFS_IOC_INO_PATHS
handles both ref types but uses the file descriptor's tree, not an explicit
treeid. We open and cache one fd per subvol root.
BTRFS_IOC_CLONE_RANGE would be much faster (pointer manipulation, no byte
compare) but:
- breaks
btrfs sendincremental chains - introduces race conditions (no kernel verify)
- requires write-mode access (no on read-only snapshots)
FIDEDUPERANGE's byte-verify is slower per call but completely safe and
preserves btrfs send compatibility. With pread-prefetch the throughput is
~500 MB/min, which is fine for the use case.
Per-subvol fd cache (opened lazily, kept for program lifetime). Subvol path
resolved via btrfs inspect-internal subvolid-resolve subprocess (cached).
INO_PATHS returns paths relative to subvol root; we prepend the subvol's
path-from-mount to get the absolute path.
- bees — Continuous block-hash-based btrfs dedup daemon. We share the pread-prefetch trick, but the scan model is different (file-walk vs extent-tree-scan).
- btrfs-snapshot-dedup — Sister project: deduplicate broken snapshot extents.
- duperemove — Hash-based dedup tool. After running compress, you can run duperemove to find further dedup candidates not yet reflink-shared.
GPL-2.0