Skip to content

worker: preload streams into startup snapshot - #66311

Merged
nodejs-github-bot merged 3 commits into
nodejs:mainfrom
mcollina:worker-snapshot-streams
Nov 9, 2026
Merged

nodejs-github-bot merged 3 commits into
nodejs:mainfrom
mcollina:worker-snapshot-streams

Conversation

@mcollina

@mcollina mcollina commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

Worker stdio loads stream after restoring the main-thread context, initializing its constructors and prototype methods on every startup. Preload stream during snapshot construction so workers can restore that initialized state instead. Worker-specific stdio instances and message ports are still created at runtime.

Readable and Writable stay eagerly initialized for worker stdio; operators, pipeline, compose, promises, and the other stream constructors load on demand so their state stays out of the snapshot. Verify snapshot membership, stdio methods, and isolation from parent-thread stream mutations.

benchmark/misc/startup-core.js, this branch against its merge-base 4889fb0, 30 samples per binary per configuration (60 for the empty-script rerun):

Mode Script Change Confidence
worker empty.mjs +8.27% (rerun: +10.94%) ***
worker semicolon.js +6.59% **
worker require-builtins.js +3.23%
worker typescript.js +1.30%
worker import-builtins.mjs +0.62%
process empty.mjs -3.70% (rerun: -2.14%) * (rerun: none)
process semicolon.js -1.68%
process import-builtins.mjs -0.52%
process typescript.js -0.33%
process require-builtins.js +0.82%

The snapshot grows from 82 to 92 modules (about 270 KB in the binary), which shows up as a small, not statistically established cost on empty main-thread startup.


AI generated, reviewed by me.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/performance
  • @nodejs/startup

@nodejs-github-bot nodejs-github-bot added lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Sep 26, 2026
@mcollina

Copy link
Copy Markdown
Member Author

Added lazy stream initialization in 1a9691a. Readable and Writable remain eagerly initialized for worker stdio; operators, pipeline, compose, promises, and the other stream constructors load on demand.

Compared the combined changes at 1a9691a against the original baseline, 4889fb0, on an Intel i7-7700 (4 cores / 8 hardware threads), using release builds with identical configuration. Percentages are changes in mean throughput; positive means faster.

For benchmark/worker/startup.js, 100 samples per binary/configuration, followed by another 100 with binary order reversed:

Concurrent workers Throughput change Reversed-order confirmation
1 +12.40% ±1.82% +12.86%
4 +10.70% ±3.47% +8.21%
8 +7.73% ±1.59% +7.28%
16 +8.34% ±1.22% +9.34%

This measures time until all workers are online, keeping workers alive until then so teardown does not compete with startup. The ± values are the harness's unadjusted 95% accuracy estimates. All four gains survive Holm correction at 5%, in both orders.

Full-lifecycle results from benchmark/misc/startup-core.js:

Workload Single process Worker
Empty CommonJS −0.41% ±0.68% (not significant) +10.45% ±0.59%
Empty ESM −1.61% ±0.71% +10.47% ±0.40%
Require builtins +1.09% ±0.86% +4.22% ±0.76%
Import builtins +1.03% ±0.84% +1.90% ±0.71%
Immediate pipeline() plus .map() +10.88% ±0.53% +23.70% ±0.48%

Empty and immediate-stream-use cases used 60 samples per binary/mode, with 30 starts per sample. Builtin-loading cases used 40 samples with 10 starts per sample. All differences except empty CommonJS process startup survive Holm correction across the 14 primary comparisons, including the worker-online cases.

There is a small regression for empty ESM processes: about 0.45 ms per lifecycle. A separate 100-sample run with reversed binary order reproduced it at −1.49%. The measured builtin-loading and stream-use workloads improved, but these results do not establish a universal no-regression claim.

The immediate-stream-use fixture was:

'use strict';
const { Readable, Writable, pipeline } = require('node:stream');
pipeline(
  Readable.from([1, 2, 3]).map((value) => value * 2),
  new Writable({ objectMode: true, write(value, encoding, callback) { callback(); } }),
  (error) => { if (error) throw error; },
);

Worker-online comparison command, with binaries built at the two commits:

./node benchmark/compare.js --old /path/to/baseline --new /path/to/candidate \
  --runs 100 --filter startup --no-progress --analyze worker

@mcollina
mcollina marked this pull request as ready for review September 26, 2026 15:18
@mcollina
mcollina requested a review from codebytere September 26, 2026 15:18
@codecov

codecov Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.63636% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.53%. Comparing base (4889fb0) to head (5e41708).
⚠️ Report is 700 commits behind head on main.

Files with missing lines Patch % Lines
src/node_util.cc 75.00% 1 Missing and 3 partials ⚠️
lib/internal/bootstrap/switches/is_main_thread.js 0.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #66311      +/-   ##
==========================================
+ Coverage   90.37%   90.53%   +0.15%     
==========================================
  Files         790      797       +7     
  Lines      274497   277619    +3122     
  Branches    52562    53334     +772     
==========================================
+ Hits       248077   251338    +3261     
+ Misses      16921    16696     -225     
- Partials     9499     9585      +86     
Files with missing lines Coverage Δ
lib/internal/streams/duplex.js 100.00% <100.00%> (ø)
lib/internal/streams/operators.js 95.77% <100.00%> (+0.37%) ⬆️
lib/internal/streams/passthrough.js 100.00% <100.00%> (ø)
lib/internal/streams/pipeline.js 95.24% <100.00%> (+0.12%) ⬆️
lib/internal/streams/transform.js 98.54% <100.00%> (+0.01%) ⬆️
lib/stream.js 100.00% <100.00%> (ø)
lib/stream/promises.js 100.00% <100.00%> (ø)
lib/internal/bootstrap/switches/is_main_thread.js 89.85% <0.00%> (-0.82%) ⬇️
src/node_util.cc 81.60% <75.00%> (-0.37%) ⬇️

... and 186 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.

@mcollina mcollina added the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Sep 29, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Sep 29, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@panva panva added the resume-ci Add this label to resume the latest eligible Jenkins CI run on a PR with an approving review. label Sep 30, 2026
@github-actions github-actions Bot removed the resume-ci Add this label to resume the latest eligible Jenkins CI run on a PR with an approving review. label Sep 30, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Comment thread benchmark/worker/startup.js Resolved
Comment thread test/fixtures/stream-lazy-frozen.js Resolved
Worker stdio loads stream after restoring the main-thread context,
initializing its constructors and prototype methods on every startup.
Preload stream during snapshot construction so workers can restore that
initialized state instead. Worker-specific stdio instances and message
ports are still created at runtime.

Verify snapshot membership, stdio methods, and isolation from
parent-thread stream mutations.

Assisted-by: pi
Signed-off-by: Matteo Collina <hello@matteocollina.com>
Keep Readable and Writable eagerly initialized for worker stdio, but
load operators, pipeline, compose, promises, and the other stream
constructors on demand. This keeps their implementation state out of
the startup snapshot when it is not needed.

Preserve export identity, prototype descriptors, and promisification.
Install subclass methods before linking prototype chains so lazy loading
also works when a base constructor or prototype has been frozen.

Update the bootstrap inventory.

Assisted-by: pi
Signed-off-by: Matteo Collina <hello@matteocollina.com>
Allow defineLazyProperties to map property names directly to module IDs
so stream can expose function exports without a separate lazy module.
Keep only module IDs in native callback data for snapshot serialization.

Set the mapping object's prototype to null after creation to retain
fast properties without inheriting from Object.prototype.

Assisted-by: pi
Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina
mcollina force-pushed the worker-snapshot-streams branch from a57a1e2 to 5e41708 Compare October 16, 2026 07:02
@mcollina mcollina added the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Nov 8, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Nov 8, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@mcollina mcollina added commit-queue PRs queued for automated landing through the Commit Queue. semver-minor PRs that contain new features and should be released in the next minor version. labels Nov 9, 2026
@nodejs-github-bot nodejs-github-bot added commit-queue-failed PRs whose Commit Queue landing failed and need manual intervention before retrying. and removed commit-queue PRs queued for automated landing through the Commit Queue. labels Nov 9, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Commit Queue failed

This pull request has multiple commits, but no landing policy was selected.

Add commit-queue-squash PRs the Commit Queue should land as one squashed commit. to land it as one commit, or commit-queue-rebase PRs the Commit Queue should land as multiple self-contained commits. to land the commits separately.

The pull request was removed from the Commit Queue and labeled commit-queue-failed PRs whose Commit Queue landing failed and need manual intervention before retrying. . After resolving the failure, remove that label and add commit-queue PRs queued for automated landing through the Commit Queue. to retry.

Full Commit Queue output
�[36m⠋�[39m Loading data for nodejs/node/pull/66311
�[36m⠋�[39m Loading data for nodejs/node/pull/66311
�[36m⠋�[39m Getting collaborator contacts from README of nodejs/node
�[36m⠋�[39m Getting PR from nodejs/node/pull/66311
�[36m⠋�[39m Getting reviews from nodejs/node/pull/66311
�[36m⠋�[39m Getting comments from nodejs/node/pull/66311
�[36m⠋�[39m Getting commits from nodejs/node/pull/66311
✔  Done loading data for nodejs/node/pull/66311
----------------------------------- PR info ------------------------------------
Title      worker: preload streams into startup snapshot (#66311)
Author     Matteo Collina <matteo.collina@gmail.com> (@mcollina)
Branch     mcollina:worker-snapshot-streams -> nodejs:main
Labels     semver-minor, lib / src, needs-ci, commit-queue
Commits    3
 - worker: preload streams into startup snapshot
 - stream: defer unused stream initialization
 - stream: inline lazy module mappings
Committers 1
 - Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/66311
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Robert Nagy <ronagy@icloud.com>
------------------------------ Generated metadata ------------------------------
PR-URL: https://github.com/nodejs/node/pull/66311
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Robert Nagy <ronagy@icloud.com>
--------------------------------------------------------------------------------
   ℹ  This PR was created on Sat, 26 Sep 2026 10:38:19 GMT
   ✔  Approvals: 2
   ✔  - James M Snell (@jasnell) (TSC): https://github.com/nodejs/node/pull/66311#pullrequestreview-5353954201
   ✔  - Robert Nagy (@ronag) (TSC): https://github.com/nodejs/node/pull/66311#pullrequestreview-5817414011
   ✔  Last GitHub CI successful
   ℹ  Last Full PR CI on 2026-11-08T20:34:27Z: https://ci.nodejs.org/job/node-test-pull-request/78912/
�[36m⠙�[39m Querying data for job/node-test-pull-request/78912/
�[36m⠙�[39m Querying data for job/node-test-pull-request/78912/
�[36m⠙�[39m Querying API for job/node-test-pull-request/78912/
✔  Build data downloaded
   ✔  Last Jenkins CI successful
--------------------------------------------------------------------------------
   ✔  No git cherry-pick in progress
   ✔  No git am in progress
   ✔  No git rebase in progress
--------------------------------------------------------------------------------
�[36m⠹�[39m Bringing origin/main up to date...
�[36m⠹�[39m Bringing origin/main up to date...
From https://github.com/nodejs/node
 * branch                  main       -> FETCH_HEAD
   5d59de845c..7ae68bd0f4  main       -> origin/main
✔  origin/main is now up-to-date
main is out of sync with origin/main. Mismatched commits:
 - 1890941edc stream: trim webstreams sink wrappers and BYOB transfers
 - 7ae68bd0f4 stream: trim webstreams sink wrappers and BYOB transfers
--------------------------------------------------------------------------------
HEAD is now at 7ae68bd0f4 stream: trim webstreams sink wrappers and BYOB transfers
   ✔  Reset to origin/main
�[36m⠸�[39m Downloading patch for 66311
�[36m⠸�[39m Downloading patch for 66311
From https://github.com/nodejs/node
 * branch                  refs/pull/66311/merge -> FETCH_HEAD
✔  Fetched commits as 7ae68bd0f41e..5e41708c0d38
--------------------------------------------------------------------------------
[main 8744ea9a23] worker: preload streams into startup snapshot
 Author: Matteo Collina <hello@matteocollina.com>
 Date: Sat Sep 26 12:35:49 2026 +0200
 3 files changed, 63 insertions(+)
 create mode 100644 test/parallel/test-worker-snapshot-streams.js
Auto-merging lib/stream/promises.js
[main 860ec1167c] stream: defer unused stream initialization
 Author: Matteo Collina <hello@matteocollina.com>
 Date: Sat Sep 26 14:47:24 2026 +0200
 9 files changed, 103 insertions(+), 100 deletions(-)
 create mode 100644 lib/internal/streams/lazy.js
Auto-merging src/node_util.cc
[main 60898e440d] stream: inline lazy module mappings
 Author: Matteo Collina <hello@matteocollina.com>
 Date: Wed Sep 30 09:46:00 2026 +0200
 3 files changed, 38 insertions(+), 35 deletions(-)
 delete mode 100644 lib/internal/streams/lazy.js
   ✔  Patches applied
There are 3 commits in the PR. Attempting autorebase.
(node:889) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
(Use `node --trace-deprecation ...` to show where the warning was created)
Rebasing (2/6)
Executing: git node land --amend --yes
--------------------------------- New Message ----------------------------------
worker: preload streams into startup snapshot

Worker stdio loads stream after restoring the main-thread context,
initializing its constructors and prototype methods on every startup.
Preload stream during snapshot construction so workers can restore that
initialized state instead. Worker-specific stdio instances and message
ports are still created at runtime.

Verify snapshot membership, stdio methods, and isolation from
parent-thread stream mutations.

Assisted-by: pi
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/66311
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Robert Nagy <ronagy@icloud.com>
--------------------------------------------------------------------------------
[detached HEAD 54b6a7a3a2] worker: preload streams into startup snapshot
 Author: Matteo Collina <hello@matteocollina.com>
 Date: Sat Sep 26 12:35:49 2026 +0200
 3 files changed, 63 insertions(+)
 create mode 100644 test/parallel/test-worker-snapshot-streams.js
Rebasing (3/6)
Rebasing (4/6)
Executing: git node land --amend --yes
--------------------------------- New Message ----------------------------------
stream: defer unused stream initialization

Keep Readable and Writable eagerly initialized for worker stdio, but
load operators, pipeline, compose, promises, and the other stream
constructors on demand. This keeps their implementation state out of
the startup snapshot when it is not needed.

Preserve export identity, prototype descriptors, and promisification.
Install subclass methods before linking prototype chains so lazy loading
also works when a base constructor or prototype has been frozen.

Update the bootstrap inventory.

Assisted-by: pi
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/66311
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Robert Nagy <ronagy@icloud.com>
--------------------------------------------------------------------------------
[detached HEAD 6280b3f420] stream: defer unused stream initialization
 Author: Matteo Collina <hello@matteocollina.com>
 Date: Sat Sep 26 14:47:24 2026 +0200
 9 files changed, 103 insertions(+), 100 deletions(-)
 create mode 100644 lib/internal/streams/lazy.js
Rebasing (5/6)
Rebasing (6/6)
Executing: git node land --amend --yes
--------------------------------- New Message ----------------------------------
stream: inline lazy module mappings

Allow defineLazyProperties to map property names directly to module IDs
so stream can expose function exports without a separate lazy module.
Keep only module IDs in native callback data for snapshot serialization.

Set the mapping object's prototype to null after creation to retain
fast properties without inheriting from Object.prototype.

Assisted-by: pi
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/66311
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Robert Nagy <ronagy@icloud.com>
--------------------------------------------------------------------------------
[detached HEAD 0ace498f0f] stream: inline lazy module mappings
 Author: Matteo Collina <hello@matteocollina.com>
 Date: Wed Sep 30 09:46:00 2026 +0200
 3 files changed, 38 insertions(+), 35 deletions(-)
 delete mode 100644 lib/internal/streams/lazy.js
Successfully rebased and updated refs/heads/main.
--------------------------------------------------------------------------------
   ℹ  Add `commit-queue-squash` label to land the PR as one commit, or `commit-queue-rebase` to land as separate commits.

View workflow run

@mcollina mcollina added the commit-queue PRs queued for automated landing through the Commit Queue. label Nov 9, 2026
@mcollina mcollina added the commit-queue-squash PRs the Commit Queue should land as one squashed commit. label Nov 9, 2026
@nodejs-github-bot
nodejs-github-bot merged commit f344e7f into nodejs:main Nov 9, 2026
80 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in f344e7f

@nodejs-github-bot nodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commit-queue-failed PRs whose Commit Queue landing failed and need manual intervention before retrying. commit-queue-squash PRs the Commit Queue should land as one squashed commit. lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. semver-minor PRs that contain new features and should be released in the next minor version.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants