Skip to content

src: avoid env lookups and a global handle in InternalCallbackScope - #66316

Open
nigrosimone wants to merge 3 commits into
nodejs:mainfrom
nigrosimone:callback-scope-cost
Open

nigrosimone wants to merge 3 commits into
nodejs:mainfrom
nigrosimone:callback-scope-cost

Conversation

@nigrosimone

@nigrosimone nigrosimone commented Sep 26, 2026 •

Copy link
Copy Markdown

InternalCallbackScope looks up the Environment from the isolate two times per call, inside async_context_frame::exchange, and it keeps the prior async context frame in a v8::Global also when there is no frame, that is the common case. Every call from native code into JS pays this: MakeCallback, CallbackScope, AsyncWrap, Node-API.

Now the scope passes the Environment it already has, the option is read with an inline accessor instead of copying the shared_ptr, and the global handle is created only when the prior frame is not undefined.

Benchmark: benchmark/napi/make_callback (added here) with benchmark/compare.js, Node 26.3.0 built from source with and without this change, Linux x64, 30 runs per binary. From 202-208 ns to 155-159 ns per call:

                                confidence improvement accuracy (*)   (**)  (***)
napi/make_callback n=1000000           ***     27.55 %      ±5.39% ±7.21%  ±9.45%
napi/make_callback n=10000000          ***     33.68 %      ±2.70% ±3.60%  ±4.70%

Side note: async_context_frame::Scope, used by AsyncResource::MakeCallback, still looks up the Environment twice and always creates a global handle. I would leave it for a separate PR.

Refs: nodejs/performance#24

Disclosure: I used Fable 5.1 (Max) as coding assistant. I built Node with and without the change, ran the benchmark and the tests myself.

@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 Sep 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Welcome to Node.js, and thank you for your first contribution!

Before review, please take a moment to read:

Please make sure every commit is signed off. For a first pull request, GitHub Actions require collaborator approval and Jenkins CI must be started by a collaborator or triager, so an initial wait is normal.

@nigrosimone
nigrosimone force-pushed the callback-scope-cost branch 2 times, most recently from b4d12f6 to e83b5b2 Compare September 26, 2026 12:27
InternalCallbackScope looks up the Environment from the isolate two
times per call, inside async_context_frame::exchange, and it keeps the
prior async context frame in a v8::Global also when there is no frame,
that is the common case. Every call from native code into JS pays this:
MakeCallback, CallbackScope, AsyncWrap, Node-API.

Now the scope passes the Environment it already has, the option is read
with an inline accessor instead of copying the shared_ptr, and the
global handle is created only when the prior frame is not undefined.

benchmark/napi/make_callback, Node 26.3.0 built with and without this
change, Linux x64, 30 runs: from 202-208 ns to 155-159 ns per call.

Refs: nodejs/performance#24
Signed-off-by: Nigro Simone <nigro.simone@gmail.com>
Comment thread benchmark/napi/make_callback/binding.gyp
@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 26, 2026
@mcollina
mcollina requested a review from Qard September 26, 2026 14:37
The addon calls into JS with node::MakeCallback from a libuv timer, so
every call opens a top-level callback scope, like an I/O callback does.

Signed-off-by: Nigro Simone <nigro.simone@gmail.com>
A CallbackScope must restore the async context frame that was active
before it, when there was none and when there was one.

Signed-off-by: Nigro Simone <nigro.simone@gmail.com>
@codecov

codecov Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.36%. Comparing base (66f26d3) to head (1221198).
⚠️ Report is 13 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #66316      +/-   ##
==========================================
- Coverage   90.38%   90.36%   -0.02%     
==========================================
  Files         790      790              
  Lines      274497   274522      +25     
  Branches    52557    52567      +10     
==========================================
- Hits       248100   248076      -24     
- Misses      16879    16927      +48     
- Partials     9518     9519       +1     
Files with missing lines Coverage Δ
src/api/callback.cc 82.93% <100.00%> (-0.32%) ⬇️
src/async_context_frame.cc 100.00% <100.00%> (ø)
src/env-inl.h 93.99% <100.00%> (+0.02%) ⬆️
src/env.h 97.33% <ø> (ø)

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

@uNetworkingAB

uNetworkingAB commented Sep 26, 2026 •

Copy link
Copy Markdown

If the benchmark would (also) compare against a vanilla v8::Function::Call it would show the Node.js mandated (relative) overhead compared to core V8 that is added to all V8 addons that touch Node.js for event delivery. In my own tests, Node.js tend to add 100-200% overhead.

nigrosimone added a commit to nigrosimone/node that referenced this pull request Sep 26, 2026
AsyncResource saves the async context frame when it is created, and
MakeCallback() enters it with async_context_frame::Scope. Then
node::MakeCallback() opens the callback scope with an undefined frame,
so the callback never runs in the saved one. Since AsyncContextFrame is
the default, AsyncLocalStorage loses its store in these callbacks.

Pass the saved frame to InternalMakeCallback(), as the Node-API
AsyncContext already does. This also removes the Scope from every call,
with its two Environment lookups and its global handle.

Refs: nodejs#66316
Refs: nodejs#43038
Refs: nodejs/performance#24
Signed-off-by: Nigro Simone <nigro.simone@gmail.com>
@panva panva added author ready PRs with CI started, the required approvals, and no outstanding review comments. and removed request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. labels Sep 26, 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

author ready PRs with CI started, the required approvals, and no outstanding review comments. 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.

7 participants