Find out which layer is making a directory slow.
A directory with tens of thousands of files can take minutes to open in a file
manager, and the usual diagnostic response is to time ls and conclude that
ls is slow. That does not identify anything. dirperf measures the same
directory at every layer between the disk and the desktop, one syscall apart at
a time, so the gap between two adjacent numbers attributes the cost to
something specific: the filesystem, the kernel's directory read, per-file
metadata lookup, the open path, the data read, userland sorting, or the file
manager's own configuration.
It was written for FreeBSD and GhostBSD on ZFS.
dirperf only reads. It never writes to the directory under test.
Clone the repository, make the binary executable, and install it into your system's executable search path:
git clone https://github.com/xcrsz/dirperf
cd dirperf
chmod +x dirperf
sudo mv dirperf /usr/local/bin/Verify the installation:
dirperf -hdirperf [options] <directory>
dirperf compare <a.json> <b.json>
| option | meaning |
|---|---|
-r, --runs N |
warm iterations per probe after the cold one (default 5) |
-f, --format FMT |
text, md, json or html (default text) |
-o, --out FILE |
write the report to a file instead of stdout |
-j, --threads N |
threads for the parallel metadata probe (default: CPU count) |
--max-files N |
cap how many entries the per-file probes touch |
--read-bytes N |
bytes read from the head of each file (default 4096) |
--cold |
one cold pass only, no warm iterations |
--no-open |
skip the open and read probes |
--no-parallel |
skip the parallel metadata probe |
--no-external |
skip ls, find and du |
--no-desktop |
skip reading desktop configuration |
--no-latency |
skip per-call latency instrumentation |
--gui CMD |
time a GUI application against the directory, repeatable |
--gui-timeout N |
seconds to wait for a GUI application to settle (default 300) |
-q, --quiet |
no progress output |
The complaint that motivates this tool is that opening a directory you have not visited takes minutes. That is the cold case. A benchmark that warms three times and then reports a mean over ten runs is measuring a state the file manager never sees, and it will report that everything is fine.
So the first iteration of every probe is recorded on its own, and the warm distribution is kept apart from it. The ratio between them is itself a reported finding: a high ratio means caching works and the problem is the first visit; a ratio near 1 means the cache is not helping at all, which is a different problem with a different fix.
The census of the directory (file counts, extension histogram, size distribution) is built entirely from data the probes already had to collect. A separate census pass would warm the caches that the cold measurement exists to observe.
dirperf cannot flush the ARC or the page cache, so its cold figures are cold
only relative to whatever was resident when it started. For a genuinely cold
measurement, export and re-import the pool, or reboot, and then run with
--cold. The report says this rather than leaving you to assume otherwise.
Probes run in ascending order of cache damage. Enumeration only touches the directory's own blocks, so it runs first; reads run last.
| probe | layer | isolates |
|---|---|---|
enumerate_cold |
kernel | the first pass, names retained, also the census source |
enumerate_raw |
kernel | getdents(2) into our own buffer, nothing else |
enumerate_libc |
libc | opendir/readdir, adding libc's buffering |
enumerate_std |
std | std.Io.Dir.iterate(), adding Zig's layer |
enumerate_retain |
userland | the same read plus copying every name into memory |
sort_names |
userland | sorting, with no filesystem access at all |
stat_follow |
kernel | per-file metadata lookup |
stat_nofollow |
kernel | the same without symlink resolution |
open_close |
kernel | the open path: access control, extended attributes |
read_head |
kernel | the first data block, which is what a thumbnailer reads |
stat_parallel |
kernel | the same metadata pass across N threads |
The last one is the most useful and the least obvious. If spreading the work across threads speeds it up roughly in proportion, the cost is waiting, and an application that issued its lookups concurrently would be much faster. If it does not speed up at all, something in the path is serialised and concurrency will not rescue anything. Those two situations look identical from the outside and have completely different fixes.
ls, ls -f, ls -l, find and du are also timed, but as controls rather
than as measurements. The one genuinely diagnostic pair is ls against
ls -f: the difference between them is sorting and column layout, entirely in
userland, so a large gap there means the answer is not in the filesystem.
For the metadata and open probes, dirperf records a timestamp either side of
every individual syscall and reports the distribution: median, p90, p99, p99.9,
worst, and the p99-to-median ratio.
This matters because a mean cannot tell the difference between a uniformly slow path and a fast path with a long tail, and those have different causes. A long tail usually means most requests are served from cache and a few go to the device.
The instrumentation costs two clock reads per call. Rather than fold that into the headline number, the instrumented pass runs once more after the warm set and its own wall time is discarded. The cost of a single monotonic clock read is measured at startup and printed in the report, and it is not subtracted from the per-call figures, because subtracting an estimate invents precision the estimate does not have. Read the figure, and treat any per-call median near the clock cost as being at the floor of what this method can measure.
Dataset properties are configuration: they explain why a workload is slow and they are what you can change. ARC counters are sampled either side of every probe, which turns "the filesystem is slow" into "N metadata requests during that pass missed the ARC". A low metadata hit rate on the warm passes means data read moments earlier is being fetched again, which is a very specific finding with very specific remedies.
The properties read are chosen because each has a defensible causal story for large-directory latency, not because they were available:
atimeandrelatime. Withatime=onandrelatime=off, every file read writes back an access time, so browsing 25,000 images generates 25,000 metadata writes.xattr. Withxattr=direvery extended attribute lookup becomes a hidden directory lookup, and desktop file managers ask for extended attributes constantly.primarycacheandsecondarycache, which decide whether metadata is cacheable at all.dnodesize,recordsize,redundant_metadata,special_small_blocks,logbias,sync,compression,checksum.
Outside ZFS, dirperf reads vfs.numvnodes against kern.maxvnodes. A vnode
cache close to its limit makes the kernel recycle vnodes to make room, so
entries that were just looked up have to be reconstructed. It is a common and
easily missed cause of exactly this symptom, and kern.maxvnodes is sized from
memory at boot and is frequently too small for a directory of this shape.
ZFS renames its sysctls between releases, so every lookup tries a list of candidate names. A rename shows up as a longer candidate list rather than as a silently absent number.
For a directory that is mostly images, the setting that decides whether a file
manager takes two seconds or ten minutes is often not a filesystem property at
all. dirperf reads the MATE and GNOME keys that change how much work is done
per file (show-image-thumbnails, thumbnail-limit,
show-directory-item-counts, preview-sound, default-folder-viewer, the
thumbnail cache limits) and prints them next to the timings, each with a line
saying why it is on the list.
If thumbnailing is on and most of the directory is images, the report projects
the measured read_head cost across the whole directory and says what the
floor on opening it is, before any image is even decoded.
GUI applications can be timed with --gui, but the result is labelled
indicative and nothing more. There is no honest way to measure "time until the
window is usable" from outside an application. What is actually measured is the
time until the process stops accumulating CPU, and the report says that is what
was measured rather than dressing it up.
dirperf -f json -o freebsd.json /zroot/photos
dirperf -f json -o linux.json /mnt/photos
dirperf compare freebsd.json linux.json
Probes are matched by name and diffed. A probe present in one report and missing from the other is listed rather than silently dropped. The warm ratio compares two medians and is the one to quote; the cold ratio comes from a single pass on each side and is a direction, not a measurement.
Nothing in analysis.zig changes a measurement. If a rule and a number
disagree, the number is right and the rule is wrong. Each rule states what it
observed, in the units it observed it, and what follows; a rule that cannot
decide says nothing rather than hedging, because a report full of "this may
indicate" is a report nobody acts on.
Copyright (c) 2026 Vester Imanuel Thacker
All rights reserved.
This software is licensed, not sold.
Permission is granted to download, install, and use the unmodified binary form of this software for any lawful purpose, subject to the following conditions:
- You may not modify, reverse engineer, decompile, disassemble, or create derivative works of this software except where such restrictions are prohibited by applicable law.
- You may not redistribute, sublicense, rent, lease, or sell this software without prior written permission from the copyright holder.
- This license does not grant any rights to the source code. No source code is provided or implied.
- All copyright, trademark, and other proprietary notices must remain intact.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF THE SOFTWARE.
Any rights not expressly granted by this license are reserved by the copyright holder.