Git ls-files is Faster Than Fd and Find
cj.rs
cj.rs
The "simple" code is in https://github.com/c-blake/cligen/blob/master/cligen/dents.n... (just `dents find` does not require the special kernel batch system call module to be fast. That kernel module is more about statx batching but IO uring can also do that these days. For those unfamiliar with Nim, it's a high productivity, high performance systems language.)
I believe that GNU find is slow because it is specifically written to allow arbitrary filesystem depth as opposed to "open file descriptor limit-limited depth". (I personally consider this over-/mis-engineered from days of bygone systems with default ulimits of, say, only 64 open fd's. There should at least be a "fast mode" since let's be real - file hierarchies deeper than 256..1024 levels, while possible, are rare and one should optimize for the common case or at least allow manual instruction that this case prevails. AFAIK there is no such "fast mode". Maybe on some BSD?)
Meanwhile, I think the Rust fd is slow because of (probably counterproductive) multi-threading (at least it does 11,000 calls to futex).
I haven't benchmarked find specifically, but I believe the most common Rust library for the purpose, walkdir[1], also allows arbitrary file system recursion depth, and is extremely fast. It was fairly close to some "naive" limited depth code I wrote in C for the same purpose. A lot of go-to C approaches seem to needlessly call stat on every file, so they're even slower.
I'd be curious to see benchmarks of whether this actually makes a difference.
It may actually be because most ftw()s call stat (and so are quite slow, at least without some kernel magic like IO uring or sys_batch) that the non-stat calling mode is poorly optimized. In that context, it may seem like a more minor optimization.
There’s probably a switch to run single-threaded so that should be testable.
I think OP is trying to benchmark which tool is fastest/most efficient for his workflow. If one of the tools has bugs (or intentional, but unnecessary behavior) that slow it down unnecessarily, that's great if they're fixed, but doesn't help if they're not.
I do think it's going to be pretty hard for a directory walker to do better than a pre-made index listing of the files, though, even with a warm filesystem cache.
My answer is "at least partly because `fd` & `find` are both slow - for different reasons". You are never going to do better than reading a saved answer, but I only get a 1.8x hit for not having an index { which needs maintenance as has been pointed out by almost everyone :-) }. `walk`, linked elsewhere, is less of a strawman comparison but could probably be optimized a bit more.
linux.git$ strace -fc fd -j1 --no-ignore --hidden --exclude .git --type file --type symlink>/dev/null
strace: Process 24100 attached
strace: Process 24101 attached
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ------------------
61.34 0.382389 21243 18 2 futex
26.50 0.165192 2 74307 write
4.72 0.029454 3 9754 getdents64
2.22 0.013846 2 4884 openat
2.03 0.012677 2 4886 close
2.02 0.012566 2 4884 newfstatat
0.96 0.005960 5 1002 clock_gettime
...
------ ----------- ----------- --------- --------- ------------------
100.00 0.623428 6 99900 6 total
which suggests that the fd slow down (on Linux, anyway) may be `fd` doing unbuffered writes to stdout (probably to avoid interleave/mixing of multi-threaded output) and not be about the way the file tree recursion is written. The fix is probably for `fd -j1` to do buffered output (and ideally never clone/fork). (The MThread may be unproductive in a different way than my initial futex guess.)My guess (without looking at the code - honestly Rust kind of hurts my eyeballs) is that this unbuffered mode is not even really guaranteed/sound anyway. If you pipe the output, e.g. `fd -jN|cat>out` with `N>1` somewhere and any deep hierarchy pathname output is bigger than PIPE_BUF you hit trouble. Once those pipe writes lose atomicity, threads can be put to sleep and re-awakened in various orders and wind up interleaving their pipe writes with or without userspace-level buffering.
(filename max)*(open fd default limit)=255*1024 =~ 4*PIPE_BUF. So the "full path print out" may still be capable of being > PIPE_BUF and so still interleave producing non-existent pathnames (which would be so long they would need special care like chdir + chdir... + stat to even verify non-existence). You would very likely have to synthesize a file tree to trigger this failure mode, of course.
I say all the above in terms of a robust tool. In terms of benchmarking walks, it is much easier to just do like `dents dstats` and use the walk to produce a depth histogram instead of mega/gigabytes of path output. If you have some burning need to be multi-threaded it is even easy to histogram in parallel and then merge at the end and the histograms should almost always fit in private L2 CPU caches. Could always be a total count instead of depth histo. AFAIK, GNU find has no `-print`-like `-count` operator to compare, but I'd bet the C internals make such not so hard to add. (Yes, you could use `-exec..+`, but this might be so slow as to make the benchmark questionable.)
Parenthetically, I wonder if any stdlib in any prog.lang does the "chdir loop + stat|other" type of work for users with such pathologically long pathnames for all the file system calls. Seems unlikely, but not impossible, and easy to know when it is needed once a "string knows its length". Food for thought.
But, as mentioned below, this is faster because git-ls is using a pregenerated index.
I was mostly just adding some performance color that the points of comparison of the article are, in some sense, not very fast.
In my experience, fast vs. slow "reputations" are sadly very unreliable.
Here is some system call color.
$ find | wc -l
79348
$ strace -c find >/dev/null
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
29.57 0.039172 1 24453 fcntl
24.51 0.032477 1 19615 close
19.87 0.026329 1 14724 newfstatat
16.78 0.022229 2 9786 getdents64
7.48 0.009909 2 4948 6 openat
0.86 0.001137 1 755 write
...
------ ----------- ----------- --------- --------- ----------------
100.00 0.132487 1 74336 9 total
$ dents find|wc -l
79348
$ strace -c dents f >/dev/null
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
42.43 0.018491 3 5084 getdents64
28.69 0.012503 2 4896 openat
25.40 0.011071 2 4896 close
3.47 0.001514 2 755 write
...
------ ----------- ----------- --------- --------- ----------------
100.00 0.043579 2 15697 2 total
So, you know, almost 5x the system call count. (EDIT: and yes, yes, not all syscalls are created equally. In this hot cache case there are so many that syscall overhead alone could be a large fraction of costs, though.)But you don't need to trust me. It's probably about 50 lines of C to code up a file tree walk recursion and test it yourself.
This is exactly what I was doing, fwiw. I can't match up a lot of the extra calls to close(2) but if you're doing what find does you need to be calling newfstatat(2) to get file information. I'll look again -- there may be a way to get these faster. It was something I was going to propose putting into io_uring.
IIRC, this d_type in dirent usage is somewhat new (maybe mid- to late- 1990s) compared to Unix itself. In olden times, one had to stat. It is possible the filesystem you are testing on gives DT_UNKNOWN all the time mandating stats. [2]
[1] https://lobste.rs/s/kpzdew/git_ls_files_is_faster_than_fd_fi...
[2] https://stackoverflow.com/questions/47078417/readdir-returni...
OTOH, I think people generally do not realize just how niche >1024 depths are. Even huge hierarchies (like dozens of TB) may be only 12..120 deep. I have trouble getting `dents dstats` to ever report >128 levels deep.
Further -- depending upon competition for open fd's -- you can still always fail if you need any new fd's. I.e., the very first opendir could fail. So, it's not like being very file descriptor conservative guarantees success against fd exhaustion. This is also almost surely trickier in a multi-threaded setting where threads share the set of fd's and other threads could open files.
Admin authority can also bump RLIMIT_NOFILE (on machines with access to very deep hierarchies or just all the time). One might argue that fd conservative approaches are programmers complicating their life and slowing code to mask (partially!) small (default) limits. One might counter-argue "There is also no quota/limit system provided for 'how deep'!", though.
Anyway, I doubt there is a perfect solution here and "out of open files at too deep x/y/z/..." is not so inscrutable an error message. (EDIT: There are admittedly some different design considerations here in a CLI tool which has its own OS process & rlimits vs. a library that might be used in a multi-threaded program.)
Afaik rustaceans call this “fearless concurrency”.
NTFS maintains a journal of all files modification (https://en.wikipedia.org/wiki/USN_Journal). This is used by Everything (https://www.voidtools.com/support/everything/) to quickly and efficiently index _all_ files and folders. Thanks to that, searching for a file is instantaneous because it's "just an index read".
The feature is common: listing files. We know that indexes help solve the issue. But we still use `find` because there's no efficient files indexing strategy. Yes, there's updatedb/mlocate, but updating the db requires a full traversal of the filesytem because there's no efficient listing of changes in linux, only filesystems-specific stuff.
So we will still have articles like this until the situation changes, and it will still be relevant to the discussion because it's not "solved"
I think these tradeoffs are why there hasn't been as much interest in replicating it, combined with the fact that mlocate/fd/find/fsearch are already a lot faster in most circumstances than the default Windows search and good enough for the most common use cases (although there are certainly usecases where they're not).
Most people only have a single user. Sharing a computer with someone is not that common of a thing to do anymore.
In user-visible-oriented folders though, oh heck yes it should all be indexed.
So, in practice, it works.
Especially not because I'm particularly interested in visualizing free disk space on the regular, but because I want to backup changed files as soon as possible and the only reliable way to do it is to have some kind of journal.
I wonder why common linux filesystems like ext2/ext4 don't support this. After having used locate and all its friends (rlocate, plocate, lolcate-rs) - tools like Everything & WizTree on Windows feel like a breath of fresh air!
But if we're talking about the future, we're probably talking about btrfs and zfs, both of which have the internal machinery to give you a feed of "recently changed files" up to the beginning of the filesystem.
While writing this answer I stumbled upon https://github.com/rflament/loggedfs which is probably a very nice solution to this problem.
What the USN Journal does is implement a pull-based system: the sender stores everything, and the receiver queries the sender when it wants/can, on its own rhythm, starting from an offset it manages. In a generic pull-based system the sender can optimistically send a notification for the receiver to be informed as soon as possible.
I have my own personal views, but a push-based system with no acknowledgments only makes sense if missing events is ok, typically because you know you'll receive another event about the same "thing" in a short time; this system is not viable for file changes. A push-based system with acks requires the sender to register each receiver, which is a bit heavy. A pull-based system is just the simplest to implement and solves all problems.
I agree, what I found noteworthy though is that git ls-files uses this index you already have for.
(author here, “proof”: https://cj.rs/contact/ & https://keybase.io/leowzukw)
Is locate/mlocate some obscure command? It works pretty well for this sort of thing (and has the advantage that you wouldn't need to put git repos everywhere, or something silly like that).
I often forget what I've named a pdf that I've downloaded, but usually I'll put something related to the topic of a paper in the file name, so a command like:
locate -i "*svd*.pdf"
will have a decent chance of finding any papers about svd's that I have, and is more or less instantaneous.Although -- I think I did come across the locate command because I was searching for alternatives to 'find.' I can not for the life of me remember the correct order for the path to start searching, and the filename.
I feel like it should be the job of the filesystem to maintain an index and incrementally update it whenever files are created or removed, but afaict no modern filesystem offers it.
The background indexing does get bogged down when there’s a massive number of file changes, so I exclude things like “node_modules” folders. It also indexes the actual content of text files and not just filenames.
Sadly the CLI interface for `mdfind` is incredibly obtuse and verbose, and very under documented [0]. When querying anything besides just a filename I still have to lookup the correct incantation. For example, here's how to do a case insensitive search for any folder containing "apple" modified in the last year:
mdfind 'kMDItemDisplayName = "*apple*"c && kMDItemFSContentChangeDate <= $time.today(-365) && kMDItemKind == "Folder"'
The mysterious `c` after `"apple"c` is how you specify a case insensitive search...of course.Compare that to fd:
fd --changed-within=1y -t d -i apple
[0] Nothing in the macOS man pages actually explains the `mdfind` query language, nor is there even a link or suggestion of where to learn more. This developer documentation sort of covers it, but it's not quite the same: https://developer.apple.com/documentation/corespotlight/csse...I've thought about just making a simpler shell script that turns a valid `fd` query into the obscure `mdfind` incantation. And you can use `-onlyin ...` to limit the search to a particular dir just like fd/find. Might get around to it!
We already pretend files are on the physical disk and sync it in the background. Adding smarter indexing to that shouldn't be a huge deal.
[1] https://www.linuxuprising.com/2021/09/plocate-is-much-faster...
This seems utterly bizarre statement. What does author imagine SCM dies? Stuff, but listing the files that are the source it is managing is up there.
Like saying FPS is a game its not pushing triangles through GPU.
The larger purpose is facilitated by and requires the lessor purpose.
For Linux:
echo 3 > /proc/sys/vm/drop_caches
In this case, the author is using hyperfine with --warmup 10, so the numbers are all using a warm buffer cache. A cold cache probably would have been more realistic for comparison, since the benchmark is traversing lots of directories.Because a mounted file system isn't a simple indexed list of paths. File systems are shared, mutable state.
The mechanism you're asking about is called the dentry cache[1] and a decent narrative of its operation is found here[2]. It has the fabulously complex job of atomically brokering the state of an arbitrary number of local and remote file systems between an arbitrary number of processes using arbitrary access patterns without consuming all available RAM. Expecting the dentry cache to yield 'instantaneous' results is unreasonable. Comparing its performance to that of an VCS index is naïve.
[1] https://www.kernel.org/doc/Documentation/filesystems/vfs.txt [2] https://www.halolinux.us/kernel-reference/the-dentry-cache.h... (no endorsement of these people, but the dentry cache description is both concise and sufficient.)
Linux already transparently caches filesystem metadata. You already get a good speedup if you attempt the same directory walk twice, and not much have changed in the filesystem.
My workstation has 64GB of RAM and only ~7M directory entries so I have 'vm.vfs_cache_pressure = 1' in /etc/sysctl.conf and cache everything with a full directory traversal via find. The first time it takes 52s; subsequent times take 5s. It has never given me memory problems.
As in 52s the first time since boot, 5+ for subsequent runs (incorporating time for changed files/directories)
So if your shell is doing some tab completion that goes into an uninterruptible sleep, there's no escape sequence you can send it to cancel that operation.
Where I've seen this be the most vexing is in AWS if an EBS volume dies (surprisingly more frequent of an occurrence than you'd think!). It effectively prevents the machine from cleanly shutting down, and you wind up having to wait for the instance to be forcefully terminated.
e.g., in Go, blocking syscalls get put on their own thread in a thread pool, so that processing can continue while waiting.
I'm sure you can do something similar in a raw event loop, but I don't think the machinery is generally baked in to the io interfaces to do so.
This is often unwanted noise. I haven't been able to find if there's some combination of flags that would get the desired behaviour, but it's been a while since I've messed around with this.
Thanks for bringing it up, I didn't know about the command. I also might have a use case for it.
It's a useful command that I consider to be relevant to end users, not just an internal "plumbing" utility