sys: hint POSIX_FADV_SEQUENTIAL on the copy-file read/write fallback

Merged
RO
4mo ago
+158-1

When clonefile/copy_file_range/sendfile all bail and we fall back to a manual read()/write() loop, tell the kernel up front that the source will be read sequentially so it doubles its readahead window.

What changed

  • NodeFS::copy_file_using_read_write_loop (node_fs.rs): one posix_fadvise(src_fd, 0, 0, POSIX_FADV_SEQUENTIAL) at the top, gated to Linux/Android/FreeBSD. Failures are ignored.
  • bun_sys::copy_file_range (sys/copy_file.rs): a new HAS_HINTED_SEQUENTIAL bit on LinuxCopyFileState, cleared on entry to copy_file_with_state and set the first time we fall through to copy_file_read_write_loop. This gives exactly one hint per source fd even though the outer loop re-enters the 32 KiB read/write helper once per chunk, and even when bun install reuses the state across a whole directory tree.
  • bench/copyfile/fallback-rw-loop.mjs: reproducible cold-cache benchmark (uses POSIX_FADV_DONTNEED to evict the source between iterations).

Benchmarks

All on overlayfs/NVMe, kernel 6.17, 128 KiB default readahead.

Isolated read/write loop (standalone C, 512 MiB file, cold cache, 10 iters alternating order; this is the shape of both fallback loops with their respective buffer sizes):

bufferno hintwith hintspeedup
32 KiB2387 ms1715 ms1.39x
64 KiB2352 ms1748 ms1.35x
8 MiB1813 ms1809 ms~1.00x

End-to-end Bun.write(file, file) with BUN_CONFIG_DISABLE_COPY_FILE_RANGE=1 (debug build, cold cache, interleaved 6x10 iters):

file sizepathbefore (min)after (min)speedup
1 MiB64 KiB buffer6.5-7.3 ms5.4-5.9 ms~1.2x
512 MiB8 MiB slab1810 ms1810 ms1.00x
64 MiB warm cache46.2 ms med46.6 ms medno regression

Why the 8 MiB slab case is flat

copy_file_using_read_write_loop allocates an 8 MiB heap slab for files > 1 MiB. An 8 MiB read() already dwarfs the 128/256 KiB readahead window, and the kernel's adaptive readahead ramps to its ceiling after the first couple of reads with or without the hint. So for large files through this path the hint is a no-op (one extra syscall, ~2 us). The measurable win is on the fixed 32 KiB loop in sys/copy_file.rs (always) and the 64 KiB stack-buffer branch of node_fs.rs (files <= 1 MiB).

Windows

Not touched. On Windows fs.copyFile/fs.cp and bun_sys::copy_file all go through CopyFileW, so the read/write fallback in node_fs.rs/sys/copy_file.rs is never reached. The only Windows-side read/write loop is CopyFileWindows::ReadWriteLoop in blob/copy_file.rs (libuv-async, 64 KiB, used when Bun.write can't get a source path); adding FILE_FLAG_SEQUENTIAL_SCAN there would mean reopening a handle we were handed, which is a larger change than this PR attempts.

Correctness

Existing coverage exercises the fallback path directly:

  • test/js/bun/io/bun-write.test.js "should work when copyFileRange is not available" (large + small)
  • test/js/node/fs/fs.test.ts "copyFileSync > should work when copyFileRange is not available"
  • test/js/node/fs/cp.test.ts (all passing with this change)

no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/io/bun-write.test.js