pnpm: Pnpm seems to be consistently slower than yarn (classic)
At nx, we test our tools with major package managers - npm, yarn and pnpm.
Our nightly tests are running a matrix and recently we added reporting of the duration of each package run. The result were surprising:
We have a test branch where our repo is migrated to pnpm
, but that branch gives similar results where pnpm
keeps running significantly slower.
I did some troubleshooting today running SELECTED_PM=pnpm yarn nx e2e e2e-rollup
on our repo which does the following:
- generates a new repo with
create-nx-workspace
- generates a library and installs necessary dependencies
- runs builds
The newProject
is just:
- creating an empty temp folder with
package.json
- runs
yarn/pnpm add -D
for a group of packages - moves that folder to a new destination
Is there something crucial we are missing?
We even tested building typical create-react-app
, removing node_modules
and running yarn --frozen-lockfile
vs pnpm i --frozen-lockfile
and yarn is consistently faster.
Please note that there are no special flags in .npmrc
.
pnpm version: 8.2
Code to reproduce the issue:
Expected behavior:
I would expect pnpm
to be faster or at least comparable in speed
Actual behavior:
The pnpm
is consistently slower, even for running custom commands e.g. yarn nx ...
vs pnpm exec nx ...
Additional information:
node -v
prints: v18.12.1- Windows, macOS, or Linux?: Nightly runs on Ubuntu, Locally reproducible on Mac M1
About this issue
- Original URL
- State: open
- Created a year ago
- Reactions: 5
- Comments: 29 (20 by maintainers)
Commits related to this issue
- perf: lazy load dependencies ref #6447 — committed to pnpm/pnpm by zkochan 10 months ago
I tried to figure out why PNPM starts slower than Yarn. Here are some conclusions.
TL;DR: it’s due to the large number of modules imported when PNPM starts. There IS a solution, but it can’t be done quickly.
Using Flame Graph
At first, I was to figure out what PNPM does when performing
pnpm run ...
. I used the built-in--prof
in Node.js to collect and transform data:Open it with flamebearer, and here’s the flame graph (the green parts are the functions in PNPM package):
It looks normal, and there’s no time-consuming operation, but some packages interested me, like
plugin-commands-deploy
andtarball-fetcher
. Does PNPM really need these packages each time it starts?But when I set breakpoints in their main functions, no break at all, which means they’re not executed. That’s weird.
Using Breakpoints
I set breakpoints at both
pnpm/lib/main.js:main
andexec/plugin-commands-script-runners/lib/run.js:runScript
, but I didn’t find too much time elapsed between them, which means once themain
function is called, PNPM will finish its job quickly.But if I set the breakpoints at both
pnpm/lib/main.js:1
andpnpm/lib/main.js:main
, I found a 1-second gap between them, which means the time is spent onimport
statements inmain.js
.Time Spent in
import
Using some simple code, I can figure out how much time each
import
statement costs:If you keep tracing, the call graph looks just like the flame graph before! This means there is no problem with the PNPM functions; it’s because PNPM has too many modules to import, which causes it starts slowly.
I tried to comment out some imports that will not be used in
pnpm run
, and the executed time decreased from 1300 ms to 300 ms:Why Yarn is Faster
I used import-graph-visualizer to visualize the dependency in PNPM & Yarn repo.
The result of
import-graph-visualizer --entry-points pnpm/src/pnpm.ts
:The result of
import-graph-visualizer --entry-points packages/yarnpkg-cli/sources/cli.ts
:Though it may not be accurate, it can tell that Yarn has fewer imports than PNPM.
The Solution
import
instead of top-levelimport
, which has been applied in the filepnpm/src/pnpm.ts
, but it’s not enough.Unfortunately, it’s huge work and can’t be done quickly.
@zkochan Whether bundling them into a single file doesn’t change top-level and dynamic import behaviour too much. On my laptop (M1 Mac), PNPM and Yarn have a similar compilation time (~120ms), but PNPM has much more execution time.
Here’s the flame graph of the CPU profile of the command
node --cpu-prof ~/.nvm/versions/node/v16.19.0/lib/node_modules/pnpm/bin/pnpm.cjs echo
:You can see the shape of “the ‘require’ hell” part looks almost the same as the development version shown below:
The reason is that both top-level and dynamic imports will be transformed to
require
. Node.js will execute the top-level codes when it requires a module for the first time. Using top-level import will lead to the import hell before the function is executed, while using dynamic import will let Node.js execute the codes only when necessary.As a comparison, here’s the flame graph of the CPU profile of the command
node --cpu-prof ~/.yarn/lib/cli.js echo
:I understand that PNPM has its own design, and changing the top-level import to dynamic import is not that easy. It needs help from the dependency analytic tools, and sometimes we must change a file’s structure or a function’s logic.
Using other PMs to execute a script also costs extra time (for Yarn it’s ~160ms each time on my laptop), so I think maybe we should not use PMs to execute scripts if they’ll be executed so many times.
BTW it’s the first time I realize that import will harm the performance…😂
whit each new version of pnpm it get slower & slower
@zkochan @RexSkz I’ve created a esbuild Feature Request (https://github.com/evanw/esbuild/issues/3488). If esbuild implement this, we could solve the “require hell” overhead with a single build option.
yes we do use such plugin @zkochan and is dramatically improve the performence. here is the relevant config (the
lazy
option)Standalone and
@pnpm/exe
are the same executable. They are just installed differently.