Show HN: Dut – a fast Linux disk usage calculator (codeberg.org)
"dut" displays a tree of the biggest things under your current directory, and it also shows the size of hard-links under each directory as well. The hard-link tallying was inspired by ncdu[3], but I don't like how unintuitive the readout is. Anyone have ideas for a better format?
There's installation instructions in the README. dut is a single source file, so you only need to download it and copy-paste the compiler command, and then copy somewhere on your path like /usr/local/bin.
I went through a few different approaches writing it, and you can see most of them in the git history. At the core of the program is a datastructure that holds the directories that still need to be traversed, and binary heaps to hold statted files and directories. I had started off using C++ std::queues with mutexes, but the performance was awful, so I took it as a learning opportunity and wrote all the datastructures from scratch. That was the hardest part of the program to get right.
These are the other techniques I used to improve performance:
* Using fstatat(2) with the parent directory's fd instead of lstat(2) with an absolute path. (10-15% performance increase)
* Using statx(2) instead of fstatat. (perf showed fstatat running statx code in the kernel). (10% performance increase)
* Using getdents(2) to get directory contents instead of opendir/readdir/closedir. (also around 10%)
* Limiting inter-thread communication. I originally had fs-traversal results accumulated in a shared binary heap, but giving each thread a binary-heap and then merging them all at the end was faster.
I couldn't find any information online about fstatat and statx being significantly faster than plain old stat, so maybe this info will help someone in the future.
[1]: https://github.com/KSXGitHub/parallel-disk-usage
[2]: https://github.com/bootandy/dust
[3]: https://dev.yorhel.nl/doc/ncdu2, see "Shared Links"
152 comments
[ 4.0 ms ] story [ 216 ms ] threadI don't handle them specifically in dut, so it will total up whatever statx(2) reports for any reflink files.
dot files are just a convention on unix.
E.g. memory accesses might show up as slower die to CPU caches being flushed when switching between user and kernel space.
I would be extremely interested in a quick (standalone?) benchmark of e.g. 1M stats with vs without uring.
Also https://github.com/tdanecker/iouring-getdents reports big uring speedups for getdents, which makes it surprising to get no speedups for stat.
If uring turns out fast, you might ignore (a), just doing the getdents first and then all stats afterwards. Since getdents is a "batch" syscalls covering many files anyway, but stat isn't.
Maybe there could be an iterative breadth-first approach, where first you quickly identify and discard the small unimportant items, passing over anything that can't be counted quickly. Then with what's left you identify the smallest of those and discard, and then with what's left the smallest of those, and repeat and repeat. Each pass through, you get a higher resolution picture of which directories and files are using the most space, and you just wait until you have the level of detail you need, but you get to see the tally as it happens across the board. Does this exist?
What you described is a neat idea, but it's not possible with any degree of accuracy AFAIK. To give you a picture of the problem, calculating the disk usage of a directory requires calling statx(2) on every file in that directory, summing up the reported sizes, and then recursing into every subdirectory and starting over. The problem with doing a partial search is that all the data is at the leaves of the tree, so you'll miss some potentially very large files.
Picture if your program only traversed the first, say, three levels of subdirectories to get a rough estimate. If there was a 1TB file down another level, your program would miss it completely and get a very innaccurate estimate of the disk usage, so it wouldn't be useful at all for finding the biggest culprits. You have the same problem if you decide to stop counting after seeing N files, since file N+1 could be gigantic and you'd never know.
So after just a second or two, you might be able to know with certainty that a bunch of small directories are small, and then that a handful of others are at least however big has been counted so far. And that could be all you need, or else you could wait longer to see how the bigger directories play out.
You could sample files in a directory or across directories to get an average file size and use the total number of files from getdents to estimate a total size. This does require you to know if a directory entry is a file or directory, which the d_type field gives you depending on the OS, file system and other factors. An average file size could also be obtained from statvfs().
Another trick is based on the fact that the link count of a directory is 2 + the number of subdirectories. Once you have seen the corresponding number of subdirectories, you know that there are no more subdirectories you need to descend into. This could allow you to abort a getdents for a very large directory, using eg the directory size to estimate the total entries.
For anyone who doesn't know why this is, it's because when you create a directory it has 2 hard links to it which are
When you add a new subdirectory it adds one more link which is So each subdirectory adds one more to the original 2.https://github.com/CyberShadow/btdu
Not to mention it would likely be unable to handle the hardlink problem so it would consistently be wrong.
You can be a little lazy about updating parents this and have O(1) update and O(1) amortized read with O(n) worst case (same as now anyway).
Also, are you going to update a file’s size for every write (could easily be a thousand times if you’re copying over a 10MB file) or are you going to coalesce updates to file sizes? If the latter, how do you recover after a crash?
Virtual directories such as /dev and /proc would require special-casing.
Mounting and unmounting disks probably would require special-casing.
Also, doing that in databases isn’t a solved problem. count(*) can be slow in databases. See for example
- PostgreSQL: https://dba.stackexchange.com/questions/314371/count-queries..., https://wiki.postgresql.org/wiki/Count_estimate
- Oracle: https://forums.oracle.com/ords/apexds/post/select-count-very...
(Both databases use MVCC (https://en.wikipedia.org/wiki/Multiversion_concurrency_contr...) to ensure that concurrent queries all see the database in a consistent state. That makes it necessary to visit each row and check their time stamp when counting rows)
You can use getfattr to ask it for the recursive number of entries or bytes in a given directory.
Querying it is constant time, updates update it with a few seconds delay.
Extremely useful when you have billions of files on spinning disks, where running du/ncdu would take a month just for the stat()s.
ETA: Apparently the value in /proc/sys/vm/vfs_cache_pressure makes a huge difference. With the default of 100, my dentry and inode caches never grow large enough to contain the ~15M entries in my homedir. Dentry slabs get reclaimed to stay < 1% of system RAM, while the xfs_inode slab cache grows to the correct size. The threads in dut are pointless in this case because the access to the xfs inodes serializes.
If I set this kernel param to 15, then the caches grow to accommodate the tens of millions of inodes in my homedir. Ultimately the slab caches occupy 20GB of RAM! When the caches are working the threading in dut is moderately effective, job finishes in 5s with 200% CPU time.
https://share.firefox.dev/3XT9L7P
Also, TIL about that kernel parameter, thanks!
I wasn't aware that there was a rewrite of ncdu in Zig. That link is a nice read.
[0] https://github.com/Byron/dua-cli
TL;DR: dut is 3x faster with warm caches, slightly faster on SSD, slightly slower on HDD.
0 - https://linux.die.net/man/3/fts_read
1 - https://github.com/ttkb-oss/dedup/blob/6a906db5a940df71deb4f...
You cannot around getdents and stat family syscalls on Linux if you need file sizes.
There is not much to be done here. Directories entries are just names, no guarantees that the files were not modified or replaced.
The best you could do is something similar to the strategies of rsync, rely on metadata (modified date, etc) and cross fingers nobody did `cp -a`.
I guess I'm just annoyed that for Windows/NTFS really fast programs are available but not for Linux filesystems.
The name "duwim" stands for "du what I mean". It came naturally after I dabbled for quite a while to figure out how to make du do what I mean.
1: https://github.com/dundee/gdu
I have the suspicion that some file systems store stat info next to the getdents entries.
Thus cache locality would kick in if you stat a file after receiving it via getdents (and counterintuitively, smaller getdents buffers make it faster then). Also in such cases it would be important to not sort combined getdents outputs before starting (which would destroy the locality again).
I found such a situation with CephFS but don't know what the layout is for common local file systems.
Also some nebulous "being more secure". Never mind that this tool does not have elevated privileges. You gotta watch out for those remote root exploits even for a local only app, man.
But I suppose it's not a very likely bug to have in this kind of tool.
Memory safety is cool and all, but a program that effectively sums a bunch of numbers together isn't going to cause issues. Worst case the program segfaults
[1]: https://codeberg.org/201984/dut/src/branch/master/main.c#L16...
[2]: https://doc.rust-lang.org/nomicon/exotic-sizes.html#dynamica...
Would you like to expand on this? Is it because of type conversions that you'd have to do?
> I use[1] flexible array members to minimize allocations
I was under the impression that FAM was a non-standard extension, but alas it is part of C99.
From what I'm seeing you have intrusive list where each `entry` points to the previous and next element, where the path itself is a bag of bytes as part of the entry itself. I'm assuming that what you'd want from Rust is something akin to the following when it comes to the path?
For the FAM, your example looks like it requires a compile-time constant size. That's the same as hardcoding an array size in the struct, defeating the whole point. Short names will waste space, and long ones will get truncated.
You made me realize that the const generics stabilization work hasn't advanced enough to do what I was proposing (at least not in as straightforward way): https://play.rust-lang.org/?version=nightly&mode=debug&editi...
Those are const arguments, not const values, which means that you can operate on values as if they didn't have a size, while the compiler does keep track of the size throughout.
I'd have to take a more detailed look to see if this level of dynamism is enough to work with runtime provided strings, which they might not be, unless you started encoding things like "take every CStr, create a StackString<1> for each char, and then add them together".
Well I can just try :)
„Note that dut uses Linux-specific syscalls, so it won't run on MacOS.“