Skip to content

perf(pacquet): port pnpm's peer-resolution optimizations (purePkgs fast path + peersCache) #11907

Description

@zkochan

Follow-up to #11900 and #11906.

Background

#11906 ported pnpm's isNew early-return so revisits in the tree walker no longer re-walk transitive subtrees. That collapsed pacquet's dependencies_tree from ~75k to ~11k entries on the astro@^5 fixture and shaved ~3.3s off a fresh-install (1.41× speedup). The remaining gap to pnpm on the same fixture is in peer resolutionresolve_peers still walks every NodeId in the tree from scratch and recomputes the full peer set, even when the same package id has already been processed with an equivalent parent context.

Pacquet's own resolve_peers.rs doc comment calls out the three optimizations the initial slice deliberately deferred. This is the issue to track them landing.

What pnpm has that pacquet doesn't

1. purePkgs fast path

Upstream tracks a Set<PkgIdWithPatchHash> of pure packages — those whose subtree resolves with zero externally-resolved peers and zero missing peers. A pure package's depPath is literally its pkgIdWithPatchHash (no peer suffix), and the walker returns it without recursing into children. From resolvePeers.ts:395-406:

if (
  ctx.purePkgs.has(resolvedPackage.pkgIdWithPatchHash) &&
  ctx.depGraph[resolvedPackage.pkgIdWithPatchHash as unknown as DepPath].depth <= node.depth &&
  Object.keys(resolvedPackage.peerDependencies).length === 0
) {
  ctx.pathsByNodeId.set(nodeId, resolvedPackage.pkgIdWithPatchHash as unknown as DepPath)
  ctx.pathsByNodeIdPromises.get(nodeId)!.resolve(resolvedPackage.pkgIdWithPatchHash as unknown as DepPath)
  return { resolvedPeers: new Map<string, NodeId>(), missingPeers: new Map<string, MissingPeerInfo>() }
}

The set is populated at the end of each call when a node's resolution turns out to be pure (L509-L510). On the next visit of the same pkgIdWithPatchHash, the whole subtree below is skipped.

For peer-light workloads (most npm packages have no peerDependencies), this is the dominant optimization.

2. peersCache

For non-pure packages, upstream caches the resolved-peer combination so a revisit with a compatible parent context reuses the cached depPath instead of recomputing. Definition at resolvePeers.ts:342-348:

interface PeersCacheItem {
  depPath: DeferredPromise<DepPath>
  resolvedPeers: Map<string, NodeId>
  missingPeers: MissingPeers
}
type PeersCache = Map<PkgIdWithPatchHash, PeersCacheItem[]>

Lookup at findHit: for each cached item under the current pkgIdWithPatchHash, check whether the cached resolvedPeers map to the same parent nodes (or to ones with the same pkgIdWithPatchHash — see parentPackagesMatch) as the current parentPkgs context. If yes, reuse the cached depPath.

The cache is populated at the end of each non-pure resolution (L507-L522).

3. Lazy node.children

In upstream, node.children can be a thunk (() => Record<string, NodeId>) that gets called the first time the peer resolver touches the node — see resolvePeers.ts:407-409:

if (typeof node.children === 'function') {
  node.children = node.children()
}

Combined with purePkgs, this means children of pure subtrees never get expanded.

This one requires changing pacquet's DependenciesTreeNode::children shape and is the most invasive of the three; the first two land without touching the tree-walker side.

Expected impact

Profiling pacquet@main + #11906 on astro@^5 (1.6k unique packages, depth ~10):

phase before #11906 after #11906
dependencies_tree size ~75,000 ~11,000
resolve_peers per pass up to ~614ms up to ~111ms
total resolve_peers across 11 hoist iters + final ~3.8s ~0.7s

The 0.7s left is what purePkgs would shrink further. Most of the ~11,000 nodes belong to pure subtrees in the astro graph (esbuild platform tarballs, rollup platform tarballs, @img/sharp-* platform tarballs — these are leaf-ish packages with no peers and no peer-dep transitives).

A back-of-envelope estimate: if ~80% of tree nodes are pure, purePkgs could shave another ~500ms off resolve_peers total. Combined with peersCache hitting on the non-pure remainder, the peer phase should drop to sub-100ms — closing most of the remaining ~6s gap to pnpm (which finishes the same install in ~1s on this fixture).

Acceptance criteria

Lazy node.children (the third optimization above) is out of scope for this issue — it changes the DependenciesTreeNode shape and is worth its own focused PR.

Related


Written by an agent (Claude Code, claude-opus-4-7).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions