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:

Screenshot 2023-04-21 at 17 06 02

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

Screenshot 2023-04-21 at 17 09 14

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

image

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

Most upvoted comments

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:

# In a project with the script "echo" in package.json
node --prof ../pnpm/pnpm/lib/pnpm.js echo
node --prof-process --preprocess -j isolate-*.log > pnpm.json

Open it with flamebearer, and here’s the flame graph (the green parts are the functions in PNPM package):

image

It looks normal, and there’s no time-consuming operation, but some packages interested me, like plugin-commands-deploy and tarball-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 and exec/plugin-commands-script-runners/lib/run.js:runScript, but I didn’t find too much time elapsed between them, which means once the main function is called, PNPM will finish its job quickly.

But if I set the breakpoints at both pnpm/lib/main.js:1 and pnpm/lib/main.js:main, I found a 1-second gap between them, which means the time is spent on import statements in main.js.

Time Spent in import

Using some simple code, I can figure out how much time each import statement costs:

image

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:

image

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:

image

The result of import-graph-visualizer --entry-points packages/yarnpkg-cli/sources/cli.ts:

image

Though it may not be accurate, it can tell that Yarn has fewer imports than PNPM.

The Solution

  • Use dynamic import instead of top-level import, which has been applied in the file pnpm/src/pnpm.ts, but it’s not enough.
  • Separate some constants/utils into smaller files to prevent always importing the whole module.

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:

image

You can see the shape of “the ‘require’ hell” part looks almost the same as the development version shown below:

image

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:

image

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

If we can improve performance by using dynamic requires then we should try to do that. I think actually there is a babel plugin to automatically convert all requires to dynamic ones.

@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)

[
    require.resolve('@babel/plugin-transform-modules-commonjs'),
    {
      lazy: () => true,
    },
  ],

Standalone and @pnpm/exe are the same executable. They are just installed differently.