Repository navigation
perf(walk): list folders with getattrlistbulk on macOS - #650
Open
trevorlasn wants to merge 1 commit into
Open
trevorlasn wants to merge 1 commit into
trevorlasn wants to merge 1 commit into
Conversation
On macOS, list each folder with getattrlistbulk(2) and take a regular file's stat fields from the batch, as Apple's fts(3) does, instead of one lstat per file. Blocks are ATTR_FILE_ALLOCSIZE / 512, rounded up, as xnu's vn_stat computes st_blocks. Folders, symlinks and other entries are still stat'ed, so mount points, firmlinks and -L behave as before. A folder goes to read_dir and stat, as before, when the call fails (EACCES on a folder that can be listed but not searched, ENOTSUP), when a record is incomplete, or when its link count shows more than 50,000 entries. On APFS and HFS+ that count is the entry count plus 2, and there the parallel stat finishes a huge folder sooner. -f without -L or a metadata filter keeps read_dir, since it stats no file. A folder's entries come back in one of two Vecs, so a read_dir listing takes the same memory per entry as before. tuple_from_metadata now calls tuple_from_stat, which holds the same arithmetic, so both paths share it. libc was already in Cargo.lock. Output, -j included, matched master on APFS, HFS+ and ExFAT trees for 58 flag sets, apart from the order of error lists, which are HashSets. Fixes bootandy#649 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #649
On macOS, dust now lists each folder with
getattrlistbulk(2)and takes a regular file's stat fields from the batch. It no longer callslstatonce per file. Output does not change. On a 61,007-entry APFS tree the walk takes 57% less wall time and 58% less CPU time. On a freshly mounted ExFAT image it takes 37% to 75% less wall time.What changed
src/macos_listing.rs(new, macOS only): lists one folder withgetattrlistbulk. For each regular file it returns the size, allocated size, I/O block size, inode, device, and modified, accessed and changed times. Blocks areATTR_FILE_ALLOCSIZE / 512, rounded up. That is how xnu'svn_statcomputesst_blocks, and how Apple'sfts(3)does it.src/dir_walker.rs:list_dirreturns a folder's entries as aListing: theread_dirVec, as before, or the bulk Vec.ignore_fileandprocess_entrytake anEntrythat holds one item of either. A file whose stat fields came with the listing skipsget_metadata. Linux and Windows build only theread_dirvariants, so their types and sizes stay as they were.src/platform.rs:tuple_from_metadatafills aStatFieldsand calls the newtuple_from_stat. That function holds the existing arithmetic, NTFS cap included, so both paths share it.Cargo.toml:libcfor macOS only.Cargo.lockalready had libc 0.2.191 through 6 other crates, so nothing new is downloaded. 0.2.133 is the first version withgetattrlistbulk.Folders, symlinks and other entries are still stat'ed. A folder has to be: at a mount point or a firmlink, the batch describes the object itself, and
lstatdescribes what it leads to./usr/share/snmpis one; its batch inode differs from itslstatinode.When it falls back
A folder is listed with
read_dirand one stat per entry, as on master, when:openor anygetattrlistbulkcall fails. A folder that can be listed but not searched (mode 444) fails withEACCES. A file system without the call fails withENOTSUP. dust then reports the same errors as before.-fis on without-L,-x,-M,-Aor-y. There master stats no file (it usesd_ino), andread_dircosts less than the bulk call.ExFAT reports a link count of 1, so its folders are listed in bulk at any size. On the 3 GB ExFAT image, whose largest folder holds 62,000 entries, the walk took 62% of master's wall time and 21% of its CPU time at the default thread count. A file whose record has an error or lacks a field is stat'ed on its own.
Why a small module and not dua-core
read_dir(path, Options)for macOS and Windows. Using it would add 4 crates toCargo.lock:dua-core,crossbeam,crossbeam-channelandcrossbeam-queue. This PR adds none.Entry,MetadataandErrorto its own stat tuple and error lists, and keep its own fallbacks so that errors print as before.libc, which dust already locks. It has 3unsafeblocks:open,getattrlistbulkandclose. Records are parsed from byte slices with bounds checks; a short or malformed record sends the folder toread_dir.Results
Machine: Apple M4 Max (14 cores), 36 GB, macOS 26.3.1 (25D771280a), internal SSD. Both binaries were built with
cargo build --release --lockedand rustc 1.99.1; master is 079c552.Each tree ran 9 rounds. A round runs each binary once at the default thread count and once with
-T 1, and alternates which binary and which thread setting go first. The command was/usr/bin/time -l dust -P -c -n 5 [-T 1] <tree>. The table gives medians. CPU is user plus system time. Peak istime -l's peak memory footprint. The machine was shared with other work, such as builds: the 1-minute load average, which also counts dust's own threads, was 9.6 to 25.0 at the start of the runs. Both binaries ran back to back in each round, so both saw the same load.The trees:
web/: a Next.js app with itsnode_modules: 61,007 entries (55,982 files, 4,007 folders, 1,018 symlinks)./usr/share: 20,030 entries (15,588 files, 886 folders, 3,556 symlinks), on the sealed system volume.hdiutil: 43,890 entries, in copies of the test tree (see below) and ofweb/src, and one folder of 29,354 entries. Copying to ExFAT adds an AppleDouble (._) file for each file with extended attributes. "Fresh mount" means the image was detached and attached again before each run.._files), and 20 folders hold about 1,000 each.web/, APFSweb/, APFS-T 1/usr/share, APFS/usr/share, APFS-T 1-T 1-T 1-T 1-T 1-T 1-T 1web/, the walk takes 57% less wall time and 58% less CPU time at the default thread count, and the peak footprint is 20% lower. Part of that is paths: the branch allocates each listed file's path at its exact length, whereDirEntry::path()can leave up to half of the buffer unused, and dust keeps every path in its tree.-T 1.read_dircode there. Those differences are noise. With 1,000,000 files and-T 1, wall time ranged from 35 s to 82 s within each binary: the folder has more files than the vnode cache holds (kern.maxvnodesis 263,168)./usr/shareat the default thread count, the peak was 0.6 MB higher.The scripts and the raw numbers for every run are available if you want them.
Output equality
A script ran master (079c552) and this branch on each tree with
-P -c -w 200and each of 58 flag sets. It compared stdout, stderr and the exit code. The sets:(none)-s-f-f -s-d 0-d 2-L-x-X <tree>/a-X <tree>/src-X <tree>/node_modules-e \.rs$-v \.txt$-e \.json$ -v test-t-t -s-z 10K-i-M -30-M +30-A -1-A +1-y -1-y +3000-m a-m c-m m-F-D-p-n 50-r -b-T 1--print-errors-f -L-f -x-f -M -30-f -e \.rs$-f -i-s -L-s -x-L -x-j-j -s-j -f-j -d 2-j -o b-j -p -o b -s-o b -n 100000, and-o b -n 100000with-s,-f,-L,-x,-i -p,-F -p,-D -p,-m m -p, and-T 1 --print-errors.web/cp -c) ofweb//usr/shareweb/srcand onenode_modulespackageweb/clone, x86_64 buildThe 1,000,000-file folder ran
(none),-s,-f,-x,-M -30and-j -o b.The test tree has: 3 hard links to one file across 2 folders, symlinks to a file, to a folder and to nothing, a FIFO, a file with APFS transparent compression, an APFS clone, a file with a resource fork, a 100 MB sparse file, Unicode names, hidden files, an empty folder, a mode 444 folder, a mode 000 folder, and a folder with 3,500 entries.
--print-errorsand the error summary print the paths from aHashSet, so their order changes from run to run on master too. The script compares those lists as sorted items. 14 comparisons differed in that order only, all of them with-L.With an earlier build of this branch, 3 of about 150 master runs on the mount-image tree lacked 1 or 7 AppleDouble (
._) files on the 3 GB ExFAT image, with no error. The branch's totals were right in every run. master drops a file whoselstatfails and does not report it (build_nodereturnsNone). That is one possible cause; I did not confirm it. 30 more rounds of each binary showed no drop, and a probe that ranlstaton every listed entry saw no failure in 30 walks. The files had not changed since they were created.A separate probe compared each field from the batch with
read_dirandlstat, file by file. There were no differences on APFS (web/55,982 files,/usr/share15,588, the test tree 3,520) or on HFS+ (15,474). On ExFAT, every difference was the inode of an empty file. An empty file there has no cluster, so FSKit gives it a new inode number on every lookup:lstatand the batch each get a fresh one. They are unique within a walk, so hard-link counting is unaffected; master's-falready takesd_inofromread_dirin the same way.Tests
cargo test --lockedon macOS 26.3.1 with rustc 1.99.1, on master and on this branch:src/main.rs)tests/tests.rstests/test_exact_output.rstests/test_flags.rstests/tests_symlinks.rsNo existing test needed a change. The integration tests run the built binary, so on macOS they now go through the bulk listing. The 2 new unit tests are in
src/macos_listing.rs:test_bulk_listing_matches_read_dir_and_lstatlists a temp folder of 3,007 entries, more than one buffer holds. Each name must be one thatread_dirreturned, and each type and inode must matchlstat. Each regular file's size, blocks, block size, inode, device and three times must matchlstattoo. A link count over the limit must leave the folder toread_dir.test_bulk_listing_leaves_unsearchable_folder_to_read_dirchecks that a mode 444 folder is left toread_dir. It checks nothing when run as root, who can search the folder.Other checks, all passing:
cargo fmt --checkcargo clippy --locked -- -D warnings, and the same with--target x86_64-unknown-linux-gnu,--target x86_64-pc-windows-msvcand--target x86_64-apple-darwincargo check --locked --target x86_64-unknown-linux-gnutypos1.28.4, the version CI pinscargo build --release --locked --target x86_64-apple-darwin; that binary ran the equality sets above under Rosetta 2For review
unsafeblocks. The whole diff is 646 lines added and 43 removed.tuple_from_metadatanow callstuple_from_staton every Unix platform. The arithmetic is the same; only its place changed.--shared-blocks) also changesdir_walker.rsandplatform.rsand addslibc, so whichever lands second needs a rebase. After this PR, feat: add --shared-blocks to count cloned and reflinked blocks once #645'sgetattrlistcall per file forATTR_CMNEXT_CLONEIDandATTR_CMNEXT_EXT_FLAGScould take both from the same batch.web/has 1,018 symlinks. Taking their fields from the batch too would save more calls, but would mean handling-Lin the batch path.lstat, as described above. Nothing dust prints shows it.Not verified
ENOTSUPand fall back; I did not run one.getattrlistbulkhas been in macOS since 10.10.getattrlistbulkfailure in the middle of a folder. The code hands the whole folder toread_dir; I never saw it happen.read_dirpath as before.🤖 Generated with Claude Code