Skip to content

fs: read small files in one thread pool round trip - #65327

Open
codebytere wants to merge 1 commit into
nodejs:mainfrom
codebytere:perf/fs-readfile-one-roundtrip
Open

fs: read small files in one thread pool round trip#65327
codebytere wants to merge 1 commit into
nodejs:mainfrom
codebytere:perf/fs-readfile-one-roundtrip

Conversation

@codebytere

@codebytere codebytere commented Aug 16, 2026

Copy link
Copy Markdown
Member

fs.readFile() / fs.promises.readFile() of small files get 3–6× faster, and use one libuv thread-pool task instead of
four, by doing open + fstat + read + close in a single round trip.

fs/readfile.js            len=1024   concurrent=1                    ***   204.95 %  ±2.29%
fs/readfile.js            len=1024   concurrent=10                   ***   299.99 %  ±4.58%
fs/readfile-promises.js   len=1024   concurrent=1                    ***   238.56 %  ±2.68%
fs/readfile-promises.js   len=1024   concurrent=10                   ***   500.14 %  ±5.17%
fs/readfile-promises.js   len=524288 concurrent=10 encoding='utf-8'  ***   132.83 %  ±8.34%
fs/readfile-partitioned.js len=1024  concurrent=10 (vs. zlib work)   ***   240.87 %
fs/readfile*.js           len ≥ 4 MiB                                        ~0 %   n.s. (one exception below)
fs.readFile() of 4 KiB files at concurrency 64:   ~51 k → ~306 k files/s;  mixed with fs.stat + dns.lookup: ~66 k → ~312 k ops/s

(Linux x64, --set duration=2, 30 runs.)

Today a path-based readFile issues open, fstat, read and close as four separate uv_fs_* requests, each with
its own queue wait, completion callback and JS↔C++ crossing; the promise API does the same through a FileHandle. For
small files those round trips are the whole cost, and each one takes a pool slot away from concurrent dns/zlib/crypto/fs
work.

ReadFileJob (an AsyncWrap + ThreadPoolWork, provider FSREQCALLBACK) runs open + fstat + read-to-EOF + close as one
task and returns the content. If the file is larger than one chunk (kReadFileBufferLength, 512 KiB) it stops after
fstat and hands back the fd and size, and the existing chunked reader continues exactly as today (interleaved,
abortable between chunks). Both readFiles use it for path arguments without a user buffer; fds and FileHandles are
unchanged.

Preserved on purpose: identical results for every size/encoding; open errors report syscall: 'open' + path, read
errors 'read'; permission errors arrive through the callback/promise; an abort that lands while the round trip is in
flight still wins; the handed-back fd is tracked and closed like any other; size-0 files (procfs) are read to EOF.

One open point: 16–32 MiB reads via fs.promises.readFile(…, 'utf-8') at concurrency 10 measure −2…3 % (***),
reproducibly; the same sizes as Buffers, via the callback API, or at concurrency 1 are flat. They take the hand-back path
with identical syscalls, and direct timing shows ≤2 %, so I haven't pinned it down. If preferred, the promise API can keep
its current path and only the callback API changes.

Tests: test-fs-readfile-one-roundtrip.js (new; also passes on current main): sizes across the 512 KiB threshold,
encodings, flags, error shapes, abort before/during, fd/FileHandle inputs, no fd leak on hand-back, procfs/sysfs,
async_hooks lifecycle. Adjusted to keep testing what they test: test/async-hooks/test-fsreqcallback-readFile.js (accepts
one request instead of exactly four), test-graph.fsreq-readFile.js (reads a 512 KiB+1 file so the four-request
chain keeps its shape), test-async-exec-resource-match.js (resource + ≥1 fs request), test-trace-events-fs-async.js
(uses fs.fstat() for the fstat trace instead of readFile as a proxy), test-fs-promises-readfile.js (zero-size-liar
case goes through a FileHandle), test-fs-promises-file-handle-{op,aggregate,close}-errors.js (use a >512 KiB file
so the patched FileHandle path is taken). fs, async-hooks, permission, worker, process and child_process suites pass.


Disclosure: the code, test, measurements and this description were written by Claude Code, directed and reviewed by @codebytere.

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. fs Issues and PRs related to the fs subsystem / file system. needs-ci PRs that need a full CI run. labels Aug 16, 2026
fs.readFile(path) took four libuv thread pool round trips for a typical
small file -- open, fstat, read and close, each its own uv_fs request
with its own queue wait, completion callback and JS/C++ crossing -- and
fs.promises.readFile(path) did the same through a FileHandle. For the
small files applications read most, the round trips are the cost, and
each occupies a slot in the pool that concurrent dns.lookup(), fs and
crypto work is also queueing for.

Add ReadFileJob (an AsyncWrap + ThreadPoolWork) that performs
open + fstat + read-to-EOF + close as one pool task and reports the
whole content, or, when the file turns out to be larger than one chunk
(kReadFileBufferLength, 512 KiB), stops after fstat() and hands the fd
and size back so that the existing chunked reader continues unchanged
(large reads stay interleaved and abortable between chunks, and still
save the fstat round trip). fs.readFile() and fs.promises.readFile()
use it for path arguments without a user buffer; file descriptors,
FileHandles, options.buffer and an active VFS keep their paths.

Behavior is otherwise kept: same bytes for every size and encoding;
open failures report syscall 'open' with the path, read failures
'read'; permission errors are delivered through the callback/promise
as before; an abort that arrives while the read is in flight still
wins; the job is an FSREQCALLBACK resource for async_hooks; a handed
back fd is tracked exactly like one from a plain open().

Tests that asserted the internal open/fstat/read/close request chain,
used readFile() as a proxy for an fstat trace event, or injected
faults through FileHandle.prototype for path-based reads are adjusted
to keep testing what they test (a file just over one chunk where the
chain shape matters, fs.fstat() for the fstat trace, a larger file so
the FileHandle path is taken).

fs.readFile() of 4 KiB files at concurrency 64 goes from ~51k to ~306k
files per second, and a mixed stat/readFile/dns.lookup burst from ~66k
to ~312k operations per second.

Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
@codebytere
codebytere force-pushed the perf/fs-readfile-one-roundtrip branch from 0eaf058 to 21db0ab Compare August 16, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. fs Issues and PRs related to the fs subsystem / file system. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants