Skip to content

worker: start worker threads from the built-in snapshot - #65336

Closed
codebytere wants to merge 1 commit into
nodejs:mainfrom
codebytere:worker/start-from-snapshot
Closed

codebytere wants to merge 1 commit into
nodejs:mainfrom
codebytere:worker/start-from-snapshot

Conversation

@codebytere

@codebytere codebytere commented Aug 16, 2026 •

Copy link
Copy Markdown
Member

new Worker() currently runs the whole internal bootstrap in the new isolate (realm, node, web exposure, the thread and process-state switches), compiling ~80 builtins with the code cache before internal/main/worker_thread starts. Only the main thread deserializes its principal context from the built-in snapshot, and that bootstrap is about half of a worker's cold start.

The bootstrapped principal context in the snapshot is nearly thread-neutral already: is_not_main_thread.js and does_not_own_process_state.js are written as overrides of the main-thread switches, and the only thread-specific data baked into the context were six properties of the worker binding. This lets a worker deserialize the same kNodeMainContextIndex context and EnvSerializeInfo the main thread uses, and applies the two worker-side switches on top.

misc/startup-core.js mode='worker' script='test/fixtures/semicolon.js'                ***    98.45 %  ±0.82%   (21.1 -> 10.6 ms)
misc/startup-core.js mode='worker' script='benchmark/fixtures/empty.mjs'              ***    99.64 %  ±0.60%
misc/startup-core.js mode='worker' script='benchmark/fixtures/require-builtins.js'    ***    47.58 %  ±1.19%   (33.8 -> 22.9 ms)
misc/startup-core.js mode='worker' script='benchmark/fixtures/import-builtins.mjs'    ***    30.03 %  ±0.54%
misc/startup-core.js mode='worker' script='test/fixtures/snapshot/typescript.js'      ***     8.59 %  ±0.46%
misc/startup-core.js mode='process' (all five scripts)                                        ±0.5 %  n.s.

(x64 Linux, 30 runs. An idle worker's RSS also drops from ~12.2 to ~8.1 MiB, heapTotal 8.6 to 5.4 MiB.)

  • worker binding: threadId, threadName, isMainThread, isInternalThread, ownsProcessState and resourceLimits become lazy properties of the per-isolate template, computed from the Environment on first read, so no bootstrapped context carries them. Bootstrap itself only takes getEnvMessagePort from the binding.
  • CreateEnvironment(): an empty context already means "deserialize from the snapshot". When the caller is a worker (its IsolateData has a Worker), deserialize the main context as before, then run internal/bootstrap/switches/is_not_main_thread and, unless the worker owns process state, does_not_own_process_state after InitializeMainContext().
  • Worker::Run(): take that path only when the snapshot in use is the embedded one (an embedder's own snapshot or a --snapshot-blob one has run application code in its main context, so it is never reused), browser globals aren't disabled (kNoBrowserGlobals changes the bootstrap shape) and --no-worker-snapshot wasn't passed. Otherwise the worker bootstraps from scratch as today, which is also the path for embedders that create environments without a snapshot.
  • is_not_main_thread.js: also delete process._debugPause, _startProfilerIdleNotifier and _stopProfilerIdleNotifier, which is_main_thread.js installs.
  • prepareExecution() runs the snapshot's deserialize callbacks (Buffer pool re-creation, the default resolver, cached cwd) on worker threads too, now that a worker's context can come from a snapshot; the --worker test suite caught a mis-aligned Buffer pool without it.
  • --[no-]worker-snapshot per-isolate option, documented, as the escape hatch.

Modules read no options at bootstrap time (getCLIOptionsValues() throws before bootstrapping is done and refreshOptions() runs in pre-execution), so a worker's execArgv can't disagree with anything captured in the context; per-thread runtime state (argv, execArgv, title, env proxy contents, time origin, inspector, message port, stdio) is established after context creation by Worker::Run() and prepareWorkerThreadExecution() as before.

Testing:

  • Object.getOwnPropertyNames() plus descriptor kinds of process and globalThis are identical between a from-snapshot and a --no-worker-snapshot worker, as are process listeners, features and versions.
  • Same results as main for env: SHARE_ENV and copied env, resourceLimits, piped stdio, nested workers, eval/CJS/ESM/data: entries, exit codes, early terminate(), uncaught errors, structured-clone workerData, argv/execArgv/name, --frozen-intrinsics, --disable-proto=throw, the permission model, and a --build-snapshot user snapshot (workers do not see its globals).
  • Full default suite on Release; tools/test.py --worker parallel (every parallel test inside a worker) passes except test-net-boundsocket, which fails the same way on main; worker, messageport, broadcastchannel, inspector-worker, async-hooks, process, bootstrap, snapshot and es-module suites on a Debug build (437/437). test-bootstrap-modules now expects the snapshot's module set in a snapshot-born worker.

An earlier version added a dedicated worker context to the snapshot instead. Any second bootstrapped context in the node_mksnapshot isolate currently fails inside V8's serializer (SerializeBackingStore() on a typed array reached from both contexts, or CHECK(!SerializePendingObject(*code)) in VisitJSDispatchTableEntry()), so reusing the existing context is both the smaller change and the one that works today; i'll file the serializer limitation separately.


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

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/config
  • @nodejs/startup

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codebytere codebytere 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 Aug 16, 2026
@codebytere
codebytere marked this pull request as ready for review August 16, 2026 20:50
@codebytere
codebytere force-pushed the worker/start-from-snapshot branch 2 times, most recently from f7e1092 to b55b8e2 Compare August 16, 2026 21:05
@codecov

codecov Bot commented Aug 16, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.47619% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.12%. Comparing base (705646f) to head (539ecc7).
⚠️ Report is 86 commits behind head on main.

Files with missing lines Patch % Lines
src/api/environment.cc 58.33% 2 Missing and 3 partials ⚠️
src/node_worker.cc 95.16% 0 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65336      +/-   ##
==========================================
+ Coverage   90.07%   90.12%   +0.05%     
==========================================
  Files         754      754              
  Lines      256395   256433      +38     
  Branches    48499    48505       +6     
==========================================
+ Hits       230937   231123     +186     
+ Misses      16569    16428     -141     
+ Partials     8889     8882       -7     
Files with missing lines Coverage Δ
.../internal/bootstrap/switches/is_not_main_thread.js 100.00% <100.00%> (ø)
lib/internal/process/pre_execution.js 97.96% <100.00%> (+0.23%) ⬆️
src/node_options.cc 79.66% <100.00%> (+0.03%) ⬆️
src/node_options.h 95.39% <100.00%> (+0.02%) ⬆️
src/node_worker.h 91.66% <ø> (ø)
src/node_worker.cc 82.14% <95.16%> (+0.24%) ⬆️
src/api/environment.cc 77.88% <58.33%> (-0.53%) ⬇️

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

Comment thread src/node_worker.cc Outdated
Comment thread src/node_worker.cc Outdated
@codebytere
codebytere force-pushed the worker/start-from-snapshot branch from b55b8e2 to a0d71cf Compare August 17, 2026 05:16
@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 Aug 17, 2026
@nodejs nodejs deleted a comment from nodejs-github-bot Aug 23, 2026
@nodejs nodejs deleted a comment from nodejs-github-bot Aug 23, 2026
@nodejs nodejs deleted a comment from nodejs-github-bot Aug 23, 2026
@nodejs nodejs deleted a comment from nodejs-github-bot Aug 23, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs nodejs deleted a comment from nodejs-github-bot Aug 25, 2026
@nodejs nodejs deleted a comment from nodejs-github-bot Aug 25, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@joyeecheung joyeecheung 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.

Simply overriding the main thread snapshot sounds like a brilliant idea, thanks :) LGTM with the CI happy.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebytere codebytere added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 30, 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 Aug 30, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebytere
codebytere force-pushed the worker/start-from-snapshot branch from a0d71cf to 90320cf Compare August 31, 2026 16:31
@codebytere codebytere 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 Aug 31, 2026
@codebytere
codebytere force-pushed the worker/start-from-snapshot branch from 90320cf to fc80ff6 Compare September 1, 2026 00:48
Every `new Worker()` runs the whole internal bootstrap (realm, node, web
exposure, thread and process-state switches) in its fresh isolate,
compiling ~80 builtins with the code cache; only the main thread
deserializes its principal context from the built-in snapshot. That
bootstrap is about half of a worker's cold start.

The bootstrapped principal context in the snapshot is nearly
thread-neutral: the worker-side switch scripts (is_not_main_thread,
does_not_own_process_state) are written as overrides of the main-thread
ones, and the per-thread values of the `worker` binding were the only
thread-specific data baked into the context. Let a worker deserialize
that same context and EnvSerializeInfo and apply the two worker-side
switches on top:

- worker binding: threadId, threadName, isMainThread, isInternalThread,
  ownsProcessState and resourceLimits become lazy properties of the
  per-isolate template, computed from the Environment on first read.
- CreateEnvironment(): when a worker (its IsolateData has a Worker)
  passes an empty context, deserialize kNodeMainContextIndex and run
  internal/bootstrap/switches/is_not_main_thread and, unless the worker
  owns process state, does_not_own_process_state after
  InitializeMainContext().
- Worker::Run(): take that path when the embedded built-in snapshot is
  in use (not an embedder's or a --snapshot-blob one, which has run
  application code), browser globals are not disabled and
  --no-worker-snapshot was not given; otherwise bootstrap as before.
- is_not_main_thread.js: also delete _debugPause and the profiler idle
  notifier helpers that is_main_thread.js installs.
- pre_execution: run the snapshot's deserialize callbacks (Buffer pool,
  default resolver, cached cwd) on worker threads too, now that a worker
  can come from a snapshot.
- --[no-]worker-snapshot per-isolate option, documented.

Sequential new Worker() -> 'online' -> terminate goes from ~20.9 ms to
~10.3 ms per worker on x64 Linux; --no-worker-snapshot restores the old
number.

Signed-off-by: Shelley Vohr <[email protected]>
@codebytere
codebytere force-pushed the worker/start-from-snapshot branch from fc80ff6 to 539ecc7 Compare September 1, 2026 00:50
@nodejs nodejs deleted a comment from nodejs-github-bot Sep 1, 2026
@nodejs nodejs deleted a comment from nodejs-github-bot Sep 1, 2026
@nodejs nodejs deleted a comment from nodejs-github-bot Sep 1, 2026
@nodejs nodejs deleted a comment from nodejs-github-bot Sep 1, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebytere codebytere removed the commit-queue-failed PRs whose Commit Queue landing failed and need manual intervention before retrying. label Sep 1, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@panva panva 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 2, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

codebytere added a commit that referenced this pull request Sep 4, 2026
Every `new Worker()` runs the whole internal bootstrap (realm, node, web
exposure, thread and process-state switches) in its fresh isolate,
compiling ~80 builtins with the code cache; only the main thread
deserializes its principal context from the built-in snapshot. That
bootstrap is about half of a worker's cold start.

The bootstrapped principal context in the snapshot is nearly
thread-neutral: the worker-side switch scripts (is_not_main_thread,
does_not_own_process_state) are written as overrides of the main-thread
ones, and the per-thread values of the `worker` binding were the only
thread-specific data baked into the context. Let a worker deserialize
that same context and EnvSerializeInfo and apply the two worker-side
switches on top:

- worker binding: threadId, threadName, isMainThread, isInternalThread,
  ownsProcessState and resourceLimits become lazy properties of the
  per-isolate template, computed from the Environment on first read.
- CreateEnvironment(): when a worker (its IsolateData has a Worker)
  passes an empty context, deserialize kNodeMainContextIndex and run
  internal/bootstrap/switches/is_not_main_thread and, unless the worker
  owns process state, does_not_own_process_state after
  InitializeMainContext().
- Worker::Run(): take that path when the embedded built-in snapshot is
  in use (not an embedder's or a --snapshot-blob one, which has run
  application code), browser globals are not disabled and
  --no-worker-snapshot was not given; otherwise bootstrap as before.
- is_not_main_thread.js: also delete _debugPause and the profiler idle
  notifier helpers that is_main_thread.js installs.
- pre_execution: run the snapshot's deserialize callbacks (Buffer pool,
  default resolver, cached cwd) on worker threads too, now that a worker
  can come from a snapshot.
- --[no-]worker-snapshot per-isolate option, documented.

Sequential new Worker() -> 'online' -> terminate goes from ~20.9 ms to
~10.3 ms per worker on x64 Linux; --no-worker-snapshot restores the old
number.

Signed-off-by: Shelley Vohr <[email protected]>
PR-URL: #65336
Reviewed-By: James M Snell <[email protected]>
Reviewed-By: Joyee Cheung <[email protected]>
@codebytere

Copy link
Copy Markdown
Member Author

Landed in ddc0a0a

@codebytere codebytere closed this Sep 4, 2026
aduh95 pushed a commit that referenced this pull request Sep 7, 2026
Every `new Worker()` runs the whole internal bootstrap (realm, node, web
exposure, thread and process-state switches) in its fresh isolate,
compiling ~80 builtins with the code cache; only the main thread
deserializes its principal context from the built-in snapshot. That
bootstrap is about half of a worker's cold start.

The bootstrapped principal context in the snapshot is nearly
thread-neutral: the worker-side switch scripts (is_not_main_thread,
does_not_own_process_state) are written as overrides of the main-thread
ones, and the per-thread values of the `worker` binding were the only
thread-specific data baked into the context. Let a worker deserialize
that same context and EnvSerializeInfo and apply the two worker-side
switches on top:

- worker binding: threadId, threadName, isMainThread, isInternalThread,
  ownsProcessState and resourceLimits become lazy properties of the
  per-isolate template, computed from the Environment on first read.
- CreateEnvironment(): when a worker (its IsolateData has a Worker)
  passes an empty context, deserialize kNodeMainContextIndex and run
  internal/bootstrap/switches/is_not_main_thread and, unless the worker
  owns process state, does_not_own_process_state after
  InitializeMainContext().
- Worker::Run(): take that path when the embedded built-in snapshot is
  in use (not an embedder's or a --snapshot-blob one, which has run
  application code), browser globals are not disabled and
  --no-worker-snapshot was not given; otherwise bootstrap as before.
- is_not_main_thread.js: also delete _debugPause and the profiler idle
  notifier helpers that is_main_thread.js installs.
- pre_execution: run the snapshot's deserialize callbacks (Buffer pool,
  default resolver, cached cwd) on worker threads too, now that a worker
  can come from a snapshot.
- --[no-]worker-snapshot per-isolate option, documented.

Sequential new Worker() -> 'online' -> terminate goes from ~20.9 ms to
~10.3 ms per worker on x64 Linux; --no-worker-snapshot restores the old
number.

Signed-off-by: Shelley Vohr <[email protected]>
PR-URL: #65336
Reviewed-By: James M Snell <[email protected]>
Reviewed-By: Joyee Cheung <[email protected]>
aduh95 pushed a commit that referenced this pull request Sep 7, 2026
Every `new Worker()` runs the whole internal bootstrap (realm, node, web
exposure, thread and process-state switches) in its fresh isolate,
compiling ~80 builtins with the code cache; only the main thread
deserializes its principal context from the built-in snapshot. That
bootstrap is about half of a worker's cold start.

The bootstrapped principal context in the snapshot is nearly
thread-neutral: the worker-side switch scripts (is_not_main_thread,
does_not_own_process_state) are written as overrides of the main-thread
ones, and the per-thread values of the `worker` binding were the only
thread-specific data baked into the context. Let a worker deserialize
that same context and EnvSerializeInfo and apply the two worker-side
switches on top:

- worker binding: threadId, threadName, isMainThread, isInternalThread,
  ownsProcessState and resourceLimits become lazy properties of the
  per-isolate template, computed from the Environment on first read.
- CreateEnvironment(): when a worker (its IsolateData has a Worker)
  passes an empty context, deserialize kNodeMainContextIndex and run
  internal/bootstrap/switches/is_not_main_thread and, unless the worker
  owns process state, does_not_own_process_state after
  InitializeMainContext().
- Worker::Run(): take that path when the embedded built-in snapshot is
  in use (not an embedder's or a --snapshot-blob one, which has run
  application code), browser globals are not disabled and
  --no-worker-snapshot was not given; otherwise bootstrap as before.
- is_not_main_thread.js: also delete _debugPause and the profiler idle
  notifier helpers that is_main_thread.js installs.
- pre_execution: run the snapshot's deserialize callbacks (Buffer pool,
  default resolver, cached cwd) on worker threads too, now that a worker
  can come from a snapshot.
- --[no-]worker-snapshot per-isolate option, documented.

Sequential new Worker() -> 'online' -> terminate goes from ~20.9 ms to
~10.3 ms per worker on x64 Linux; --no-worker-snapshot restores the old
number.

Signed-off-by: Shelley Vohr <[email protected]>
PR-URL: #65336
Reviewed-By: James M Snell <[email protected]>
Reviewed-By: Joyee Cheung <[email protected]>
aduh95 pushed a commit that referenced this pull request Sep 15, 2026
Every `new Worker()` runs the whole internal bootstrap (realm, node, web
exposure, thread and process-state switches) in its fresh isolate,
compiling ~80 builtins with the code cache; only the main thread
deserializes its principal context from the built-in snapshot. That
bootstrap is about half of a worker's cold start.

The bootstrapped principal context in the snapshot is nearly
thread-neutral: the worker-side switch scripts (is_not_main_thread,
does_not_own_process_state) are written as overrides of the main-thread
ones, and the per-thread values of the `worker` binding were the only
thread-specific data baked into the context. Let a worker deserialize
that same context and EnvSerializeInfo and apply the two worker-side
switches on top:

- worker binding: threadId, threadName, isMainThread, isInternalThread,
  ownsProcessState and resourceLimits become lazy properties of the
  per-isolate template, computed from the Environment on first read.
- CreateEnvironment(): when a worker (its IsolateData has a Worker)
  passes an empty context, deserialize kNodeMainContextIndex and run
  internal/bootstrap/switches/is_not_main_thread and, unless the worker
  owns process state, does_not_own_process_state after
  InitializeMainContext().
- Worker::Run(): take that path when the embedded built-in snapshot is
  in use (not an embedder's or a --snapshot-blob one, which has run
  application code), browser globals are not disabled and
  --no-worker-snapshot was not given; otherwise bootstrap as before.
- is_not_main_thread.js: also delete _debugPause and the profiler idle
  notifier helpers that is_main_thread.js installs.
- pre_execution: run the snapshot's deserialize callbacks (Buffer pool,
  default resolver, cached cwd) on worker threads too, now that a worker
  can come from a snapshot.
- --[no-]worker-snapshot per-isolate option, documented.

Sequential new Worker() -> 'online' -> terminate goes from ~20.9 ms to
~10.3 ms per worker on x64 Linux; --no-worker-snapshot restores the old
number.

Signed-off-by: Shelley Vohr <[email protected]>
PR-URL: #65336
Reviewed-By: James M Snell <[email protected]>
Reviewed-By: Joyee Cheung <[email protected]>
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++. lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants