Results, as promised.
Hardware and data: PVE 9.1.18, kernel 7.0.2-5-pve, zfs-2.4.2-pve1. The pool is a two-way mirror of 7200 rpm drives (Exos ST12000NM0127 + SkyHawk ST8000VX004), 7.27T size / 6.29T allocated, 86% capacity, 3% fragmentation,
no special vdev. Compression is on (lz4/on, zstd-3 on one dataset, off on one), recordsize 128K except one dataset at 1M. The content is exactly the "m
ixed data" I described: archives, media, backups, ISOs.
3,860,159 files, 8.00 TiB apparent size / 6.27 TiB actually allocated.
Number one, block level. zdb -S tank — a DDT simulation, nothing has to be enabled:
dedup = 1.51, compress = 1.07, copies = 1.00
Total 36.8M blocks 4.29T allocated <- 56.0M blocks 6.48T referenced
Number two, file level. Walk every file, group by size, blake2b only for files that have a size twin:
1,016,158 groups of byte-identical whole files, about 1976 GiB of actually allocated space reclaimable, ratio ≈1.44.
On the argument in this thread. @IsThisThingOn was right about the number — 1.51, below two, exactly as predicted. But
@daanw is right about how to read it. He measured 1.3 on his archive box, I got 1.51, and on my pool that is
roughly 2 TiB of real space against 1003 GiB currently free. It would free up more than the pool has free right now. Arguing about "worth it" in terms of the ratio alone doesn't work, because 1.5 on 10 GiB and 1.5 on 8 TB are different conversations.
What I don't think anyone has posted before: the gap between file-level and block-level was only ~5% (1.44 vs 1.51, in comparable units). Practically that means, for data shaped like this, simply collapsing whole identical files with block cloning gets about 95% of what inline dedup would — with no DDT, no memory budget, and no one-way pool setting. The difference comes from blocks repeated
inside different files, not from whole files.
You can see exactly where it comes from in the histogram. The bulk of the saving (1.28 TiB) is refcnt 2 — ordinary pairs of copies, which whole-file matching catches too. The high-refcount tail is what whole-file matching can never reach:
refcnt 64 636 MiB allocated -> 63.0 GiB referenced
refcnt 128 549 MiB -> 89.8 GiB
refcnt 64K 1 MiB -> 76.8 GiB <- one block, 76,800 references
That last line is a single 1 MiB block in the dataset with recordsize 1M, referenced nearly 77,000 times.
A methodology trap I fell into — worth warning others about. My first pass computed "reclaimable" from st_size and gave 2143 GiB, ratio 1.35. That's wrong: reflink frees allocated blocks, not apparent bytes. On this pool the difference is large — 8.00 TiB apparent vs 6.27 TiB allocated, i.e. 21.6% (holes plus compression). What surprised me was the direction of the correction: the duplicate files turned out to be three times denser than average (7.78% vs 21.6% — all the sparseness sits in files that have no twin), so the file-level number didn't drop, it
rose from 1.35 to ≈1.44. If you repeat this, account with st_blocks, otherwise you get an answer that's wrong in both directions at once.
Caveats, so nobody takes this for more than it is. The file-level figure in allocated bytes is estimated from the density of size-candidates rather than measured directly — I failed to save the group list, which I'm kicking myself for. The scan ran against a live filesystem, not a frozen snapshot. And zdb -S covers the whole pool including snapshots, while the file walk only covered live files.
For anyone who wants to repeat this — timings and rakes. The metadata census of 3.86M files took 13 minutes. Hashing 3.94 TiB of candidates took 17 hours 15 minutes on the HDD mirror; throughput fell from 265 to 15 MiB/s as it moved from large files to small ones, and the tail bottomed out at 142 IOPS, i.e. pure 7200 rpm seek mechanics. zdb -S took only 40 minutes because the metadata was still warm in ARC after the scan — from cold it will be longer.