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
@codebytere
codebytere force-pushed the perf/fs-readfile-one-roundtrip branch from 0eaf058 to 21db0ab Compare August 16, 2026 16:54
@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.55975% with 65 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.10%. Comparing base (30bff4a) to head (b626e93).
⚠️ Report is 23 commits behind head on main.

Files with missing lines Patch % Lines
src/node_file.cc 69.34% 35 Missing and 26 partials ⚠️
lib/internal/fs/promises.js 92.85% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65327      +/-   ##
==========================================
- Coverage   90.13%   90.10%   -0.04%     
==========================================
  Files         752      752              
  Lines      251568   251915     +347     
  Branches    47270    47353      +83     
==========================================
+ Hits       226759   226976     +217     
- Misses      16168    16266      +98     
- Partials     8641     8673      +32     
Files with missing lines Coverage Δ
lib/fs.js 98.39% <100.00%> (+0.02%) ⬆️
lib/internal/fs/promises.js 92.52% <92.85%> (-0.48%) ⬇️
src/node_file.cc 73.92% <69.34%> (-0.27%) ⬇️

... and 37 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codebytere
codebytere requested review from anonrig and jasnell August 16, 2026 18:36
@codebytere codebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-ci PRs that need a full CI run. labels Aug 16, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 16, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Comment thread src/node_file.cc Outdated

@jasnell jasnell left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test failure on AIX needs to be looked at. Also, are these reads actually abortable in any way?

Comment thread test/parallel/test-fs-readfile-one-roundtrip.js Outdated
Comment thread src/node_file.cc
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 21db0ab to b626e93 Compare August 17, 2026 05:17
@codebytere

Copy link
Copy Markdown
Member Author

@jasnell re aborts: the single round trip itself isn't interruptible once it's on the pool, same as an individual read req now; an already-aborted signal never schedules it, an abort that lands while it's in flight wins when it completes, and anything over one chunk hands the fd back to the existing chunked reader, so those stay abortable between chunks exactly as before.

@codebytere codebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 17, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 17, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants