Skip to content

Dedup APFS clones in native bulk scanner#19

Open
scottgerring wants to merge 1 commit into
thegreystone:mainfrom
scottgerring:sgg/apfs-clone-dedup
Open

Dedup APFS clones in native bulk scanner#19
scottgerring wants to merge 1 commit into
thegreystone:mainfrom
scottgerring:sgg/apfs-clone-dedup

Conversation

@scottgerring
Copy link
Copy Markdown

@scottgerring scottgerring commented May 29, 2026

This PR dedupes APFS clones in the native macOS bulk scanner improving the accuracy of the usage estimates. Some helpful screenshots - this fixes about a 7% over-read on my mac.

new
CleanShot 2026-05-29 at 12 59 38@2x

old
CleanShot 2026-05-29 at 13 02 13@2x

Per entry: two extra readLong calls, one ConcurrentHashMap.add, and 16 more bytes in the packed entry (~440 to 700 entries per 64 KB buffer, down from ~500 to 800). Layout: private_size at +56, clone_id at +64, variable region at +72.

In practice, this is still very fast on my mac, but takes a fair bit longer - from ~40s up to ~70s to do the entire drive. Whether or not the juice is worth the squeeze I leave as an exercise for the maintainer!

Request ATTR_CMNEXT_CLONEID and ATTR_CMNEXT_PRIVATESIZE alongside the existing fileid/allocsize attrs by passing FSOPT_ATTR_CMN_EXTENDED. clone_id groups files sharing APFS extents; private_size is the byte count that would be freed by deleting the file (i.e. blocks unique to it, not shared with clone siblings).

For each file:
- clone_id == 0: charge full allocsize (file is not part of any clone family; private == allocsize anyway)
- first occurrence of clone_id: charge full allocsize (covers the shared extents + this file's private blocks)
- subsequent occurrence: charge private_size only (just this file's CoW-modified blocks; shared was already counted)

Total for an N-member clone family becomes shared + sum(private_i), which equals actual on-disk usage. Correctly handles both pristine clones (private ~ 0) and heavily-diverged clones (private ~ allocsize). A naive 'first wins, rest zero' approach under-counted diverged clones — common in macOS Library/Containers where apps clone on install but data evolves over months.

Per-file cost: two extra Pointer.readLong() calls, one ConcurrentHashMap.add(), and 16 more bytes per packed entry (~440-700 entries per 64 KB buffer, down from ~500-800). Buffer parsing layout: private_size at +56, clone_id at +64, variable region starts at +72.

The JVM-fallback ParallelDirectoryScanner used in dev mode (mvn javafx:run) does not dedup clones — Java NIO doesn't expose either attribute — so dev builds still overcount.
@thegreystone
Copy link
Copy Markdown
Owner

Hi Scott!

Thanks a bunch for the PR! This is a very helpful one.

Now, should the ATTR_CMNEXT_CLONEID be 0x00000100 rather than 0x00000200? 0x200 looks like it would be ATTR_CMNEXT_EXT_FLAGS:
#define ATTR_CMNEXT_EXT_FLAGS 0x00000200

The reason your 7% improvement looks okay despite this is that EXT_FLAGS is usually 0 on user data, so the cloneId == 0 short-circuit hides the bug, and most files fall through to baseline behaviour, and a few collide accidentally. Once the value is right, every real APFS clone family should be properly detected.

A heads-up on perf once the value is corrected: because every APFS inode has a unique non-zero cloneId (it's the data-stream id, not a "set if cloned" flag), seenCloneIds.add(cloneId) will fire for every file rather than every clone. To keep the hash-set lean, the cleanest fix is to also request ATTR_CMNEXT_CLONE_REFCNT (bit 0x00001000, uint32) and short-circuit on refcnt <= 1 before touching the set, that way only files currently sharing extents get tracked.

Two more things if you want to keep iterating (lower prio, but very nice to have):

  • Gating the whole CMNEXT request behind a statfs(2) check for f_fstypename == "apfs" at scan start means HFS+/NFS/SMB users pay zero overhead. Small Darwin.statfs binding.
  • The first member charged full allocsize-rule is correct for pristine clones but mildly under-counts after divergence when blocks are shared heterogeneously within a family. Picking argmax(allocsize) as the baseline is closer; not worth blocking on though.

Anyways, thank you so much for helping fix the overcounting on APFS!

@thegreystone thegreystone self-requested a review May 30, 2026 00:18
@thegreystone
Copy link
Copy Markdown
Owner

Also, the documentation is actually incorrect, and should be updated to reflect these changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants