Back to Blog
July 12, 20266 min read

144.8 Seconds to 11.8

A Rust disk analyzer felt slow, so a multi-agent review swept the whole stack. The walk that looked parallel wasn't, every file cost three kernel round-trips, and the NTFS fast path was throttled by a wrapper crate reading 4 KB per syscall. Same volume, same totals: 144.8 seconds down to 11.8.

"It Feels Pretty Slow"

EldinDisk is a Tauri desktop app: a Rust scanner walks a volume, and a React canvas renders the result as a sunburst. It worked. It also made you watch a spinner for minutes on a big folder, which for a disk analyzer is the whole product.

Rather than guess, I fanned out a review team: one agent on the Rust hot path, one on the frontend render loop, one on the IPC boundary, and one researching how WizTree and Everything get near-instant scans. The findings ranked themselves.

144.8s
Before
Direct MFT scan, 4 KB per syscall
11.8s
After
Same MFT scan, buffered + parallel decode
2M
Nodes
Per-file syscalls fix, separate test tree
0
Syscalls per file
Directory-level enumeration
Two fixes, two benchmarks: the MFT reader and the per-file syscalls

The Parallel Scanner That Wasn't

The walk used a rayon pool and looked concurrent. It wasn't. Metadata for one directory's children was fetched in parallel; then the recursion into subdirectories ran in a plain sequential loop, because every visit needed &mut access to one shared arena. Eight workers, one of them busy.

The fix was structural: each subtree builds into its own local arena where ids are just indices, and parents merge children with a single offset-fixup pass. Sibling subtrees genuinely run concurrently; the arena invariant (nodes[i].id == i) survives by construction. Cancellation short-circuits through the same collect::<Result> that gathers the subtrees.

Three Syscalls Per File, One of Them Twice

The per-file cost was worse than the missing parallelism:

CallPurposeNote
symlink_metadatasize, kind, timestampsopens a handle internally
GetCompressedFileSizeWon-disk sizeopens by path internally
File::open + GetFileInformationByHandlehard-link detectionrepeats what call #1 already did

The clean fix looked obvious: read the identity fields off metadata Rust already had. Two traps, both caught only by compiling and testing:

  • The accessors (file_index, number_of_links, volume_serial_number) are still unstable in 2026. A design doc claimed they stabilized in Rust 1.87. They didn't.
  • DirEntry::metadata() on Windows is documented to return None for exactly those fields. Combining the two "obvious" optimizations silently breaks hard-link dedup, with no error.

The real answer skips per-file calls entirely: one GetFileInformationByHandleEx(FileIdExtdDirectoryInfo) conversation per directory returns sizes, timestamps, attributes, and 128-bit file ids for every child. Zero syscalls per file. Result on a 2,000,000-node tree: 24.9 s cold cache, 7.1 s warm.

One test failed after the change, and it was right to. Enumeration sizes come from the directory index, which NTFS updates lazily for files still open for writing. The fixture wrote a file and scanned before closing the handle. Every fast scanner makes this trade; now it's documented instead of accidental.

The Fast Path That Read 4 KB at a Time

For NTFS volumes with admin rights, the plan was WizTree's trick: skip the directory tree and read the Master File Table directly. A one-day spike existed precisely to de-risk the crate choice, and it earned its keep twice.

First failure: the MFT crate refused the volume with data runs shorter than declared size. On a 3.7 TB volume, the $MFT file itself is fragmented enough that its extents spill into $ATTRIBUTE_LIST extension records, which the crate's loader never chases. Its record parser was fine, so the fix was surgical: extract the raw $MFT stream with a lower-level crate that follows attribute lists, apply the update-sequence fixups by hand (~40 lines of documented NTFS format), and keep the existing decoder. The extraction layer and the pure tree-builder were deliberately separate modules: one was swapped, the other (and its entire test suite) never changed.

Second failure, the quiet one: it worked, in 144.8 seconds. Reading 11.8 GB of MFT at 84 MB/s on an NVMe drive is not an I/O limit; it's a plumbing limit. The ecosystem's raw-volume reader capped every read at one 4 KB alignment unit, each with its own seek: about three million syscall round-trips. The BufReader wrapped around it didn't help, because large destination reads bypass its buffer entirely.

A ~60-line replacement reader (sector-aligned 8 MB refills, positions tracked logically, seeks served from the hot buffer), plus fanning record decoding across rayon per chunk:

RunTimeChange
1failedloader can't handle fragmented $MFT
2144.8 sworks; 4 KB-per-syscall reads
314.9 sbuffered volume reader
411.8 sparallel per-chunk decode

Same 11.5 million records, byte-identical totals on every run. 12.3x, without touching the format logic.

Perceived Speed Shipped Alongside

Raw throughput wasn't the only complaint hiding in "it feels slow." The same build shipped: a live sunburst that appears within half a second and refines every 500 ms while the scan runs (a depth-2 skeleton fed by relaxed atomic counters, no locks on the walk's hot path); rescans that keep the old results on screen until new data lands; navigation served from a client-side view cache instead of re-serializing megabyte payloads; and a canvas that stops re-parsing thousands of Path2D arcs (and reallocating its own backing store) on every mouse move.

What Transfers

  • Reviews scale better than intuition. Four agents with different lenses ranked the bottlenecks in parallel; the "obvious" suspect (the canvas) was real but third on the list.
  • Compile-check the confident claim. The unstable-API detail and the DirEntry documentation trap both survived design review and died in cargo test.
  • Layer where the risk is. The spike's extraction/builder split meant a failed crate cost one module, not the feature.
  • Look at the wrapper's read size. The most expensive line in the whole system was an I/O loop nobody wrote and nobody profiled: 4 KB per syscall, three million times.
Key takeaways
  • A rayon pool over a shared &mut arena is sequential in disguise. Profile the structure, not the API.
  • Per-file syscalls are the enemy on Windows. One directory-level FileIdExtdDirectoryInfo query replaces three calls per file.
  • Wrapper crates can throttle a fast path invisibly: the MFT reader's volume crate capped every read at a 4 KB alignment unit, three million syscalls deep.
  • Multi-agent review beats intuition. Four agents ranked the bottlenecks in parallel, and the obvious suspect (the canvas) ranked third, not first.

FAQ

Why was the Rust disk scanner slow?
Three compounding causes: the parallel walk serialized on a shared arena, every file cost three kernel round-trips, and the NTFS fast path read 4 KB per syscall. The per-file syscalls fix alone cut a 2-million-node tree scan to 24.9 seconds cold, 7.1 warm. The MFT reader fix, a buffered reader with parallel decode, took the direct MFT scan from 144.8 seconds to 11.8.