sonar

Scan files at memory bandwidth speed.
Log | Files | Refs

commit fbed86a27fbc7a64a0f445a929c552404749467c
parent b351ce96c62247bdc5ed83dca067eac5db84433f
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Fri, 18 Sep 2026 08:11:38 -0400

ntfs: read the volume unbuffered, with a flag to compare

The MFT is read once front to back and never re-read, so passing it through
the Windows cache manager buys nothing and costs an extra copy plus the
eviction of whatever the user had cached. Unbuffered reads land in our buffer
directly; the reader already works in whole clusters, so the alignment rules
are met once the last chunk rounds up. Measured on a live volume this made no
difference at all, both modes holding about 1.9 GB/s, which rules the cache
out as the bottleneck and points at read queue depth instead. Kept as the
default because it removes a variable before the read path goes parallel, and
--buffered keeps the comparison available.

Diffstat:
Mmain.odin | 21+++++++++++++--------
Mntfs/reader.odin | 24++++++++++++++++++++++--
Mntfs/volume_other.odin | 4++--
Mntfs/volume_windows.odin | 7+++++--
4 files changed, 42 insertions(+), 14 deletions(-)

diff --git a/main.odin b/main.odin @@ -43,17 +43,22 @@ run :: proc() -> int { defer free_all(context.temp_allocator) target := "C:" - if len(os.args) > 1 { - target = os.args[1] - } - if target == "-h" || target == "--help" || target == "/?" { - fmt.println("usage: sonar [drive-or-image] (default C:)") - return 0 + opts: ntfs.Read_Options + for arg in os.args[1:] { + switch arg { + case "-h", "--help", "/?": + fmt.println("usage: sonar [drive-or-image] [--buffered] (default C:)") + return 0 + case "--buffered": + opts.io_mode = .Buffered + case: + target = arg + } } - fmt.printfln("sonar: reading MFT of %s", target) + fmt.printfln("sonar: reading MFT of %s (%v io)", target, opts.io_mode) start := time.tick_now() - m, err := ntfs.read_mft(target) + m, err := ntfs.read_mft(target, opts) if err != nil { #partial switch err { case .Access_Denied: diff --git a/ntfs/reader.odin b/ntfs/reader.odin @@ -10,13 +10,30 @@ IO_ALIGN :: 4096 DEFAULT_CHUNK_SIZE :: 16 * mem.Megabyte +/* +How volume reads reach memory. + +Buffered reads pass through the Windows cache manager, which copies every byte into +the system cache and then again into our buffer, and evicts whatever the user had +cached to make room. Unbuffered reads land in our buffer directly. The MFT is read +once from front to back and never re-read, so the cache earns nothing here. + +Unbuffered reads require the buffer address, the file offset, and the length to be +sector aligned, which the reader already satisfies by working in whole clusters. +*/ +IO_Mode :: enum { + Unbuffered, // default + Buffered, +} + Read_Options :: struct { chunk_size: int, // bytes per volume read; 0 selects DEFAULT_CHUNK_SIZE + io_mode: IO_Mode, } // Open `path` (drive letter or image file) and read its whole MFT into a table. read_mft :: proc(path: string, opts := Read_Options{}, allocator := context.allocator) -> (m: Mft, err: Error) { - v, open_err := volume_open(path) + v, open_err := volume_open(path, opts.io_mode) if open_err != nil { return {}, open_err } @@ -138,8 +155,11 @@ read_mft_from_volume :: proc(v: ^Volume, opts := Read_Options{}, allocator := co record_number: u32 = 0 for offset: u64 = 0; offset < mft_bytes; offset += u64(chunk) { n := int(min(u64(chunk), mft_bytes - offset)) + // Unbuffered reads must cover whole sectors. The run list is cluster granular, + // so rounding the last chunk up to a cluster never reads past the allocation. + read_n := min(int((u64(n) + cluster - 1) / cluster * cluster), chunk) io_start := time.tick_now() - if read_err := read_logical(&reader, offset, buf[:n]); read_err != nil { + if read_err := read_logical(&reader, offset, buf[:read_n]); read_err != nil { return {}, read_err } m.stats.io_ns += i64(time.tick_since(io_start)) diff --git a/ntfs/volume_other.odin b/ntfs/volume_other.odin @@ -7,8 +7,8 @@ Volume :: struct { _: int, } -volume_open :: proc(path: string) -> (v: Volume, err: Error) { - _ = path +volume_open :: proc(path: string, mode := IO_Mode.Unbuffered) -> (v: Volume, err: Error) { + _, _ = path, mode return {}, .Unsupported_Platform } diff --git a/ntfs/volume_windows.odin b/ntfs/volume_windows.odin @@ -20,11 +20,14 @@ and why every read must be a whole number of sectors at a sector-aligned offset. FILE_SHARE_WRITE is required: the volume is mounted and in use, and opening it without sharing writes would fail. */ -volume_open :: proc(path: string) -> (v: Volume, err: Error) { +volume_open :: proc(path: string, mode := IO_Mode.Unbuffered) -> (v: Volume, err: Error) { name := path if is_drive_spec(path) { name = strings.concatenate({`\\.\`, path[:1], ":"}, context.temp_allocator) } + // FILE_FLAG_SEQUENTIAL_SCAN is a hint to the cache manager, so it means nothing + // once the cache is out of the path. + flags := win.FILE_FLAG_SEQUENTIAL_SCAN if mode == .Buffered else win.FILE_FLAG_NO_BUFFERING wname := win.utf8_to_wstring(name, context.temp_allocator) h := win.CreateFileW( wname, @@ -32,7 +35,7 @@ volume_open :: proc(path: string) -> (v: Volume, err: Error) { win.FILE_SHARE_READ | win.FILE_SHARE_WRITE | win.FILE_SHARE_DELETE, nil, win.OPEN_EXISTING, - win.FILE_FLAG_SEQUENTIAL_SCAN, + flags, nil, ) if h == win.INVALID_HANDLE_VALUE {