Skip to content

feat: add --shared-blocks to count cloned and reflinked blocks once - #645

Draft
laurigates wants to merge 4 commits into
bootandy:masterfrom
laurigates:feat/shared-blocks
Draft

laurigates wants to merge 4 commits into
bootandy:masterfrom
laurigates:feat/shared-blocks

Conversation

@laurigates

@laurigates laurigates commented Nov 2, 2026 •

Copy link
Copy Markdown

Draft for #590, as discussed there. Adds an off-by-default --shared-blocks flag that counts blocks shared between files (APFS clones, btrfs/XFS reflinks) once instead of once per file.

The example from the issue, five cp -c clones of a 200 MiB file:

$ dust -d1 .
200Mi   ├── clone5.bin│███████                                  │  17%
...
1.2Gi ┌─┴ .           │████████████████████████████████████████ │ 100%

$ dust -d1 --shared-blocks .
   0B   ├── clone5.bin│█                                        │   0%
...
200Mi   ├── big.bin   │████████████████████████████████████████ │ 100%
200Mi ┌─┴ .           │████████████████████████████████████████ │ 100%

It's four commits; the second and the fourth can each be dropped on their own:

  1. macOS, clone ids. One getattrlist per file reads the clone id and EF_SHARES_ALL_BLOCKS, without opening the file. Files with the same clone id are one family; clean_inodes charges the first one and gives the rest size 0, the same way it already handles hard links. A clone that has since been partly rewritten is still counted in full.
  2. exact-shared-blocks cargo feature, off by default. Files flagged EF_MAY_SHARE_BLOCKS get their extents mapped with F_LOG2PHYS_EXT, and each file is charged only for byte ranges no earlier file has claimed, so partly rewritten clones are handled too.
  3. Linux via FIEMAP. Linux has no clone id, so it always uses the extent approach from commit 2. One statfs per device skips filesystems that can't share extents, so files on ext4, tmpfs or NFS are never opened. A new CI job runs a reflink test on btrfs and XFS loop images.
  4. Private fd tables on Linux. With the flag, every file is opened and closed, and the threads of one process share an fd table whose spinlock both open() and close() take, so they queue on it. Each rayon worker now calls unshare(CLONE_FILES) at start, Linux and --shared-blocks only. That is only safe while every fd stays on the thread that opened it, so walk_dir resolves file types and drops the ReadDir on the thread that read the directory. That rule is the extra complexity this commit adds to the walker.

On your complexity concern: the walk change is a few lines in process_entry (plus, with commit 4, keeping each directory's ReadDir on the thread that read it), and the rest is platform code in platform.rs plus the extra accounting in clean_inodes. Without the flag none of it runs and Node is unchanged; time and peak memory with the flag off match master on macOS and Linux.

With the flag on (hyperfine -N -w 2 -r 10, release builds; Linux rows on a GCP n2-standard-8 VM with kernel 7.0, before commit 4):

master --shared-blocks
macOS APFS, ~/.cargo/registry 258 ms 316 ms
Linux ext4, kernel source tree 60 ms 61 ms
Linux XFS, never reflinked 54 ms 102 ms
Linux XFS, tree + its reflink 105 ms 275–506 ms
Linux btrfs, never reflinked 56 ms 178 ms
Linux btrfs, tree + its reflink 114 ms 379 ms

Commit 4 on 100,000 never-reflinked files with --shared-blocks, builds run in both orders:

before after
GCP n2-standard-8 (KVM), 8 threads 99.0–99.1 ms 95.6–95.8 ms
bare metal, Ryzen 9 3900X, 24 threads 76.9–78.5 ms 69.1–69.8 ms
OrbStack VM on an M4 Pro, 13 threads 74.9–75.8 ms 29.8–35.2 ms

Output is identical to before at every thread count tested, and the default walk and reflinked trees are unchanged. Outside that VM the gain is 3–11%, so commit 4 is the first one I'd drop if the walker rule is unwelcome.

The reflinked XFS case doesn't scale with threads. The time goes into XFS itself: FIEMAP's shared-extent check takes a lock per allocation group, so the XFS range above is 32 vs 4 allocation groups.

cargo test passes in both builds on macOS, and on Linux with commit 4. The reflink test from the new CI job passed on btrfs and XFS in a privileged ubuntu:24.04 container, but the job itself hasn't run in Actions yet.

Happy to drop commit 2 or 4, or change anything else, if that makes it easier to take.

Closes #590

🤖 Generated with Claude Code

https://claude.ai/code/session_01JkCD9BwvWZwfV7FXbbuD2E

laurigates and others added 4 commits November 2, 2026 09:51
Files that share physical blocks, such as APFS clones made with `cp -c`
or `clonefile(2)`, were counted at full size each. uv and bun build
per-project environments from clones of their global caches, so the
totals overstate real disk use.

--shared-blocks reads each file's clone id and extended flags with one
getattrlist call (no open, works on unreadable files). Files flagged
EF_SHARES_ALL_BLOCKS that have the same clone id belong to one clone
family. clean_inodes, which already deduplicates hard links in a fixed
order, now charges the family's blocks to its first member and gives
later members a size of 0.

The walk records clone ids in a map keyed by (inode, device), allocated
only under the flag and filled only for clones, rather than in a field
on every Node, so without the flag memory use is unchanged.

A clone that has since been partly rewritten gets a new clone id and no
EF_SHARES_ALL_BLOCKS, so it is still counted in full, as before. The
flag needs macOS 14+ and is ignored, with a warning, on other
platforms. It does not apply to apparent size, file counts or file
times.

Refs bootandy#590

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkCD9AcGnt1yFN53gxksru
Claude-Session: https://claude.ai/code/session_01JkCD9BwvWZwfV7FXbbuD2E
With this off-by-default cargo feature, --shared-blocks also handles
clones that have been partly rewritten, which the clone id misses.

Files the kernel flags EF_MAY_SHARE_BLOCKS are opened and mapped with
fcntl(F_LOG2PHYS_EXT). Their physical extents feed a per-device map of
byte ranges already counted, and each file is charged only for bytes no
earlier file has claimed. Matching byte ranges rather than whole extents
is what catches a partly rewritten clone: once either copy is written
to, the two map the same blocks as differently split extents.

Holes (device offset -1) are skipped. A file that can't be opened or
fully mapped (compressed files return ENOTSUP) is counted in full.

On ~/.cargo/registry (hyperfine, 10 runs) the feature build took
333.0 ms with --shared-blocks, against 316.4 ms for the default build
and 258.4 ms for upstream master without the flag.

The feature only adds code under cfg(feature = "exact-shared-blocks"),
so dropping it means deleting that code, or reverting this commit.

Refs bootandy#590

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkCD9AcGnt1yFN53gxksru
btrfs and XFS reflinks (`cp --reflink`) share physical extents the same
way APFS clones do, and were likewise counted once per file.

Linux has no clone id, and no way to learn that a file may share blocks
without opening it, so Linux uses the extent mode that the macOS
exact-shared-blocks feature already has: FS_IOC_FIEMAP per file, and
the byte-range map in clean_inodes. build.rs sets cfg(shared_extents)
for Linux or the feature, which selects that mode in one place.

- One statfs per device decides whether a filesystem can share extents
  at all (btrfs, XFS, OCFS2, bcachefs). Files elsewhere, such as ext4,
  tmpfs or NFS, are never opened.
- Only extents flagged FIEMAP_EXTENT_SHARED are recorded, since others
  can't overlap another file. Inline, delayed-allocation and unknown
  extents have no real address, and btrfs compressed extents don't
  map byte for byte, so these are counted in full.
- ZFS block clones can't be detected: ZFS has no FIEMAP.

A new CI job loop-mounts btrfs and XFS images and runs an ignored
integration test that reflinks a 1 MiB file twice and rewrites one
block of the second copy. In a privileged ubuntu:24.04 container
(kernel 7.2.12) it passed on both filesystems, and failed with
`left: 0, right: 2093056` once the extent read was stubbed out.

Refs bootandy#590

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkCD9AcGnt1yFN53gxksru
…inux (#2)

* perf: give rayon workers private fd tables under --shared-blocks on Linux

--shared-blocks opens and closes every file to FIEMAP it. All threads of
a process share one fd table, and open() and close() both take its
spinlock (files->file_lock), so on a never-reflinked XFS tree the walk
gets slower past about four threads. Each rayon worker now calls
unshare(CLONE_FILES) at start, on Linux and only with --shared-blocks.

That is safe only while every fd stays on the thread that opened it.
get_shared_blocks opens and closes its file in one call. A DirEntry
holds its directory stream, whose last drop calls closedir(), and
DirEntry::file_type() falls back to a statx relative to the directory
fd for DT_UNKNOWN, so walk_dir now resolves file types on the reading
thread and hands workers borrowed entries; the entries drop there.

Measured on an OrbStack VM (kernel 7.2.12, 13 vCPUs), 100,000
never-reflinked files, --shared-blocks -T 13, hyperfine, both orders:
76.2 -> 29.6 ms, system time 708 -> 134 ms. Output is byte-identical to
the previous build at -T 1/4/13 on never-reflinked, reflinked and mixed
trees. The default walk and reflinked trees are unchanged within noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkCD9CsC2rrmnMz6pAF8Vw

* perf: drop DirEntry values on the workers again

The previous commit kept each directory's DirEntry values on the
reading thread so the directory stream's closedir() ran there. That
also made the entries drop serially on that thread, and on bare metal
(Ryzen 9 3900X, 24 threads, 300,000 files) the default walk got about
3% slower, reproducible with the builds in either order.

ReadDir holds the same Arc as its entries, so keeping the ReadDir alive
(collect via by_ref) until the parallel collect returns is enough: the
workers drop their entries as before, the ReadDir is the last holder,
and closedir() still runs on the thread that opened the directory. File
types are still resolved on the reading thread first, for the
DT_UNKNOWN statx fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkCD9CsC2rrmnMz6pAF8Vw

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
laurigates added a commit to laurigates/claude-plugins that referenced this pull request Nov 7, 2026
…PR base (#3453)

* fix(git-plugin): ignore the pre-update head's checks in merge-chain.sh

merge-chain.sh read statusCheckRollup straight after `gh pr update-branch`
and stopped on the first failed check. GitHub reads can lag the update, so
that first read may still show the old head and its rollup. On
laurigates/mcu-tinkering-lab#945 the chain stopped CI_RED on a gitleaks run
that failed on the pre-update head for a DNS error, while the same check
passed on the post-update head.

The script now records the head before each update. Unless gh printed
"already up-to-date" (its BehindBy == 0 path, where the head stays put),
wait_for_ci ignores every read whose head is still the pre-update one: no
failure is counted, nothing settles, and the empty-rollup wait restarts. A
head that never moves within --timeout fails closed as ERROR
head_not_updated.

test-merge-chain.sh gains a stub head lag and cases 317-319; 317 and 318
fail on the old script, and 319 keeps a no-op update's failed check CI_RED.
The stub now gives a repeat update a new head, as a real update would.

Fixes #3421

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkCD9ADTrR7yJ2rNNTq82W

* feat(git-plugin): catch a fork PR based on an open upstream PR's head

Merging a fork PR whose base branch is also the head of an open PR against
the upstream repository pushes the merged commits into that upstream PR.
Squash-merging laurigates/dust#2 did exactly that to bootandy/dust#645, and
nothing in git-merge-hazards or merge-chain.sh looked past the fork.

git-merge-hazards §2 gains a skill-only pointer to
references/fork-upstream-base.md, which carries the hand check (the repo's
fork parent, then the parent's open PRs with head <owner>:<base>) and what to
do when updating the upstream PR is intended. To stay inside the
10,000-character band, the Agentic Optimizations table drops its
merge-over-red row, which restated §4's prose. sync-notes.md records both.

merge-chain.sh reads the repository once per run (default branch, fork
parent). For a PR in a fork whose base is not the default branch it lists the
parent's open PRs for that head: a hit is SKIPPED base_is_upstream_pr_head,
naming the upstream PR, and a failed read is ERROR. Outside a fork the check
does not run, so a same-repo stacked base is unaffected.

test-merge-chain.sh cases 120-125 cover the skip, a non-default base with no
upstream PR, a default-branch base, a non-fork stack and both failed reads;
11 assertions fail on the previous script.

Fixes #3445

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkCD9ADTrR7yJ2rNNTq82W

* fix(regression-ledger): move this PR's ledger rows into entry files

The bot's union merge of origin/main kept the pre-#3469 ledger table and
appended it to the new index page, re-adding the rows #3469 moved into
docs/regression-ledger/. Restore the index from main and record this PR's own
rows as docs/regression-ledger/<number>-<slug>.md entries, the format #3469
introduced. check-regression-ledger.sh reports STATUS=OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkCD9ADTrR7yJ2rNNTq82W

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
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.

Shared physical extents (APFS clones, btrfs/XFS reflinks) are counted once per file

1 participant