Closed
nodejs/node
#56275Description
Hi folks,
I've been investigating some areas of regression on Node.js 22 and apparently, the Node.js startup has degraded. I've run a pretty simple benchmark
➜ nodejs-internal-benchmark git:(main) ✗ hyperfine --warmup 20 -- './node18 semicolon.js'
Benchmark 1: ./node18 semicolon.js
Time (mean ± σ): 19.5 ms ± 0.3 ms [User: 16.1 ms, System: 2.2 ms]
Range (min … max): 18.6 ms … 21.0 ms 142 runs
➜ nodejs-internal-benchmark git:(main) ✗ hyperfine --warmup 20 -- './node20 semicolon.js'
Benchmark 1: ./node20 semicolon.js
Time (mean ± σ): 19.6 ms ± 0.3 ms [User: 16.3 ms, System: 2.0 ms]
Range (min … max): 18.9 ms … 20.6 ms 142 runs
➜ nodejs-internal-benchmark git:(main) ✗ hyperfine --warmup 20 -- './node22 semicolon.js'
Benchmark 1: ./node22 semicolon.js
Time (mean ± σ): 27.5 ms ± 0.4 ms [User: 23.9 ms, System: 2.1 ms]
Range (min … max): 26.7 ms … 28.5 ms 102 runs
➜ nodejs-internal-benchmark git:(main) ✗ hyperfine --warmup 20 -- './node23 semicolon.js'
Benchmark 1: ./node23 semicolon.js
Time (mean ± σ): 30.5 ms ± 0.4 ms [User: 26.8 ms, System: 2.4 ms]
Range (min … max): 29.8 ms … 31.6 ms 92 runs
Where the semicolon is just a js file with ;
Although this regression isn't confirmed by our benchmarks:
misc/startup-core.js n=30 mode='process' script='test/fixtures/semicolon' *** 5.82 % ±1.01% ±1.34% ±1.74%
Please note, the hyperfine benchmarks were run on a local macOS machine -- I couldn't run it in a dedicated one as I'm travelling and I don't have access via SSH to my machines and the nodejs benchmark (misc/startup-core.js) was run in a dedicated aws machine
Does someone has an idea? cc @joyeecheung
Metadata
Metadata
Assignees
Labels
No labels
Activity
joyeecheung commentedon Oct 26, 2024
I cannot reproduce on a Linux server though can reproduce on an arm64 macbook. There has been a regression in the range of
0522ac086c...ed49722a8a, most likely due to the v8 11.8 upgrade nodejs/node#49639joyeecheung commentedon Oct 28, 2024
Some regressions can also be observed from d8.
joyeecheung commentedon Oct 28, 2024
Whatever it is it seems to be happening at the global level, because
--versionregressed, even after I simply addedto the main function to skip everything of substance later.
lemire commentedon Oct 28, 2024
I checked on that on a Linux server with x64, there is a measurable but tiny regression.
node 20
node 23
lemire commentedon Oct 28, 2024
In my previous comment, regarding linux/x64, the regression is small and possibly irrelevant.
However, the regression under ARM-based macOS seems significant.
Let us create a file with the following content
console.log("hello").Node 20 (linux x64)
Node 23 (linux x64)
Node 20 (macos ARM)
Node 23 (macos ARM)
For reference, bun is under 5 ms in this test on my mac.
joyeecheung commentedon Oct 28, 2024
With this diff applied to skip most things in the main function:
The regression does seem to primarily come from the V8 11.3 -> 11.8 upgrade, and specifically from global initializations as the main function isn't doing much with the patch applied:
lemire commentedon Oct 28, 2024
I started a Neoverse N1 node on AWS to check if the problem might be with ARM.
I think it is not. It looks like a macOS issue OR an LLVM issue OR an LLVM on macOS issue.
Node 20
Node 23
joyeecheung commentedon Oct 28, 2024
By the way testing
console.log()would throw in quite a few other factors than startup alone -console.logis implemented in JS with some complex streams and a custom formatter, so you will also be checking how fast V8 execute the JS code behindconsole.log(there's also the difference from enabling maglev between 20 and 23 that would play a part in JS execution) and whether the JS code gets modified to regress.lemire commentedon Oct 28, 2024
@joyeecheung Sure. But the effect is large (~ 10 ms).
10 ms is quite a large effect. Especially if you account for the fact that bun can print "hello" in 5 ms.
joyeecheung commentedon Oct 28, 2024
That should be a separate issue about the performance of
console.log(), which is kind of a beast of its own due to the compatibility burdens of the underlying (monkey-patchable) stream implementation. This issue is specifically about startup regression, and it probably comes down to figuring out what happened during the V8 11.3 -> 11.8 upgrade to make the program load significantly slower even with a no-op main function. Looking atconsole.logwon't really help us chase down what happened during the V8 upgrade that caused the regression, I am afraid.joyeecheung commentedon Oct 28, 2024
Since we are checking global initializations here...I changed the patch to make it actually an no-op
With the patch applied to make the main function a no-op, the regression continues (86cb23d0 is the last commit updating the gypfiles for 11.8 upgrade, 5633c622 is where main is today).
lemire commentedon Oct 28, 2024
Let us use
node --versionas a reference and let us stretch out to a wide range of versions.Node 16
Node 17
Node 18
Node 19
Node 20
Node 21
Node 22
Node 23
Analysis
So it seems that the last few versions of Node.js have added well over 12 ms to the startup time on some macOS systems.
The regression does not appear to have happened all at once.
joyeecheung commentedon Nov 7, 2024
@just-js mentioned that it might be a binary size threshold, just to comment that I previously tried to build with
--without-intlto shrink the size by ~30MB and it didn't seem to make a difference (though of course others can try to see if that reproduce to make sure that's not a false elimination too)billywhizz commentedon Nov 7, 2024
i've tried running on my 8th generation core i5 laptop on Ubuntu 22.04 (kernel 6.8.0.48-generic) and i can see a regression on linux using both hyperfine and andrew kelley's poop in a couple of node 22 releases before the latest one, which seems to be working normally again.
we can see using poop for those releases the number of cpu instructions executed was far higher than before or after.
i will see if i can run the same benchmarks on macos over the weekend and will report back. i'll also try to capture more detail on the instructions executed if i can repro the issue there.
the script i am running is basically this
billywhizz commentedon Nov 9, 2024
this is what i am seeing on
Apple M3 Maxacross node releases fromv16tov23with a selection ofv22releases for good measure. it's a bit of a mixed picture but overall we can see almost 2x difference between the best (node18.20) and the worst (node23.1). i would imagine the binary size will have some effect here given it's 1.5x bigger betweenv23andv18🥴 . will have a look tomorrow to see if i can capture some more detail on what the differences are using other tools.36 remaining items
joyeecheung commentedon Dec 4, 2024
Following up my theory in #180 (comment) I built V8's Node.js fork and the benchmark result is....amusing:
From Node.js main branch:
From V8's fork
I feel that we should probably take a look at their configuration (granted, it's gn, but something might have been missing in the gyp -> gn porting)
joyeecheung commentedon Dec 4, 2024
Dumping the v8_features.json:
TIL they enabled pointer compression in their CI (I thought they were consistent with the main branch). Maybe that's the key because I did notice a particularly pricy mprotect call in the dtruss output. But I don't know the right way to disable it yet (or it might not build without pointer compression and need to be rolled back a bunch of commits).
Also V8's fork uses their own llvm/clang/libc++ and all that (chromium, you know), so that might be important as well..
joyeecheung commentedon Dec 5, 2024
There were some attempts in nodejs/node#56138 to give LTO a try though I think that's a false positive, see nodejs/node#56138 (comment) and nodejs/node#56138 (comment) - also AFAICT, V8's fork doesn't use LTO, judging by the fact that it takes normal time to build and there aren any flags hinting real LTO in the linker commands (
--icf=all --no-call-graph-profile-sortmight make a bit of difference, but probably not a lot?)joyeecheung commentedon Dec 8, 2024
Tried switching to newer version of clang + lld (build from this commit) and
--icf=all --no-call-graph-profile-sortthough it doesn't seem to make anything faster, or actually it seems to be a tiny bit slower, so we can eliminate those (compared to the V8 build, this doesn't use a custom build of libc++, though I doubt that is the key, because the regression happened between two builds that both dynamically link to system libc++)billywhizz commentedon Dec 9, 2024
FWIW:
i see these results using the head of node-ci and building with default args versus latest node23 downloaded from node.js download page.
so... huge difference on M3 Max but very small difference on Ubuntu 22.04/8th gen i5. on linux, we can see libc++ is statically linked for node-ci but not for v23. seems to be same on macos but node-ci is linking against an extra foundation library.
billywhizz commentedon Dec 9, 2024
if i just do
--versionon same setup:linux/i5
macos/m3max
you can use this shell script to build node-ci on macos or linux:
i did a clean build on the machines i have here. macos M3Max is >10x quicker to do a clean build on 16 threads versus ubuntu 22.04 on 8th gen core i5 / 8 threads.
if there are any other tests i can run over coming days let me know @joyeecheung.
joyeecheung commentedon Dec 16, 2024
I think I found out what's happening, which was along the lines of my theory in #180 (comment) - in the v8 upgrade, there were a lot of templated instantiations added, leading to an explosion of weak symbols that are resolved at run time (I think we weren't excluding them very well in the first place, but the explosion of weak symbols from V8's use of templates made the problem more obvious), and then it continue to regress as V8 adds, er, more templated functions..
It's also easy to understand why V8's fork is free from this regression - their builds applies -fvisibility=hidden properly.
Fix in: nodejs/node#56275 - locally the startup time is slightly faster than 20.x (as we are properly excluding the symbols now).
Also, it produces a smaller binary
build: build v8 with -fvisibility=hidden on macOS
build: build v8 with -fvisibility=hidden on macOS
build: build v8 with -fvisibility=hidden on macOS
lamriabdellah0 commentedon Jun 8, 2025
This is a deep dive into Node.js startup performance regression—super important work! Debugging these low-level issues really highlights the need for a stable environment. I've found ServBay consistently provides a clean, predictable local dev environment, which is invaluable when pinpointing performance quirks like these. Great effort!