install: global virtual store for isolated linker (7x faster warm installs)
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:
| wall | system | clonefileat | total syscalls | |
|---|---|---|---|---|
--linker hoisted | 823.2 ms | 478 ms | 1,387 | 7,857 |
--linker isolated (before) | 840.9 ms | 1,256 ms | 1,387 | — |
--linker isolated (after) | 115.4 ms | 94 ms | 0 | 4,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.Entrygains anentry_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_packageclones into<cache>/links/<storepath>-<hash>/node_modules/<pkg>(withkeep_existing_dest=trueso a populated entry is left alone),symlink_dependencieswrites sibling-relative dep links there, bin links land in the entry's.bin/, andlinkProjectToGlobalStorecreates the project-levelnode_modules/.bun/<storepath>→ global symlink.- The warm-hit fast path: one
access()on the global entry'spackage.json, onesymlink()for the project link, done. - Concurrent installs sharing a cache are safe: EEXIST on the post-NOENT retry in
FileCloner/Symlinker/bin-linker/linkProjectToGlobalStoreis treated as success (the other writer produced the same content-addressed bytes). [install] globalStore = falseinbunfig.tomlorBUN_INSTALL_GLOBAL_STORE=0restores 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 afterrm -rf node_modules, env-var and bunfig opt-outs, hash determinism, hash divergence underoverrides, 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-storenode_modules. - New docs page at
docs/pm/global-store.mdxwith layout, benchmarks, eligibility rules, and tradeoffs; linked from bunfig reference, isolated-installs page, and nav.