install: global virtual store for isolated linker (7x faster warm installs)

Merged
JA
7mo ago
+1989-132

What

Adds a shared global virtual store for --linker=isolated. Instead of clonefiling every package from the cache into node_modules/.bun/<pkg>@<ver>/node_modules/<pkg>/ on every install, packages are materialized once into <cache>/links/<pkg>@<ver>-<hash>/ and node_modules/.bun/<pkg>@<ver> becomes a symlink into it. The <hash> suffix encodes the entry's resolved dependency closure, so two projects that resolve a package to the same transitive versions share one on-disk entry while a project with a different resolution gets its own.

Warm installs (lockfile present, cache warm, node_modules wiped — the common CI path) now do ~1 symlink() per package instead of ~1 clonefileat() per package.

Also fixes a pre-existing limitation surfaced by this change: bun now synthesizes an implicit "*" optional peer dependency for entries that appear in peerDependenciesMeta but not in peerDependencies (matching pnpm/yarn). webpack relies on this for webpack-cli.

Why

Profiling a warm bun install --linker isolated of a 1,400-package fixture on macOS:

Sort by top of stack:
    clonefileat        891 / 934 samples  (95.4% of main thread)
    __openat            15
    __read_nocancel     10

clonefileat() on APFS holds a volume-wide kernel lock — empirically, parallelizing 2,830 directory clones across 8 threads only improved throughput ~1.25x (959ms → 743ms). The isolated linker already runs the clonefile work on a thread pool and gets no benefit (1255ms system time → 840ms wall). The bottleneck is the syscall itself, not threading. The hoisted linker is also dominated by clonefile (788ms of 824ms wall) and is single-threaded, but parallelizing it would hit the same ceiling.

The fix is to not call clonefile at all on the warm path. Once a (package, version, resolved-deps) triple has been materialized anywhere on the machine, every subsequent install just symlinks to it.

Results

Warm CI install (lockfile present, cache warm, rm -rf node_modules between runs) of a ~1,400-package fixture, Apple Silicon macOS, hyperfine --warmup 3 --runs 10:

wallsystemclonefileattotal syscalls
--linker hoisted823.2 ms478 ms1,3877,857
--linker isolated (before)840.9 ms1,256 ms1,387—
--linker isolated (after)115.4 ms94 ms04,957
Summary
  bun isolated (global store) ran
    7.13 ± 0.34 times faster than bun hoisted (canary)

Syscall mix on the warm path (xctrace System Trace): 2,495 symlink, 1,252 access, 52 mkdirat, 0 clonefileat.

How it works

  • Store.Entry gains an entry_hash: u64, computed by an iterative DFS after the store is built. The hash folds in the entry's own store-path string plus each dependency's entry hash, so it's stable across installs but differs whenever any transitive resolution differs. Declared optional peers that resolve in the project are part of the entry's dependency list and therefore part of the hash. A fixed-point pass after the DFS propagates ineligibility through cycles.
  • An entry is global-store-eligible only when its package comes from an immutable cache source (npm/git/tarball, unpatched, no trusted lifecycle script) and every dependency it links to is also eligible. Ineligibility propagates upward so a global entry can never contain a symlink that points at a project-local path.
  • link_package clones into <cache>/links/<storepath>-<hash>/node_modules/<pkg> (with keep_existing_dest=true so a populated entry is left alone), symlink_dependencies writes sibling-relative dep links there, bin links land in the entry's .bin/, and linkProjectToGlobalStore creates the project-level node_modules/.bun/<storepath> → global symlink.
  • The warm-hit fast path: one access() on the global entry's package.json, one symlink() for the project link, done.
  • Concurrent installs sharing a cache are safe: EEXIST on the post-NOENT retry in FileCloner/Symlinker/bin-linker/linkProjectToGlobalStore is treated as success (the other writer produced the same content-addressed bytes).
  • [install] globalStore = false in bunfig.toml or BUN_INSTALL_GLOBAL_STORE=0 restores the per-project clonefile layout.

Behavior change

Packages now realpath into <cache>/links/, so the node_modules/.bun/node_modules/ hidden hoisted layer is no longer reachable from inside a package. Declared optional peers (including the newly-synthesized meta-only ones) are folded into each entry, so the cases that previously relied on the hoisted fallback now resolve directly. True phantom dependencies — require('x') for something not declared anywhere — still fail, as they do under pnpm.

Tests

  • 29 existing isolated-install tests pass (8 link-target assertions updated to strip the new hash suffix via withoutEntryHash()).
  • 10 new tests under describe("global virtual store"): warm-hit reuse after rm -rf node_modules, env-var and bunfig opt-outs, hash determinism, hash divergence under overrides, cross-project sharing, workspace-dep and trusted-script fallbacks to project-local, concurrent cold-store installs sharing one cache, and in-place upgrade of a pre-global-store node_modules.
  • New docs page at docs/pm/global-store.mdx with layout, benchmarks, eligibility rules, and tradeoffs; linked from bunfig reference, isolated-installs page, and nav.