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:
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 {