perf(link): prefer hardlink over clone in Auto on Linux (#14012)

Bun materializes the same warm-store `node_modules` in roughly a third of pacquet's wall time on btrfs. Profiling the gap showed it was never syscall mechanics — it was the tier `Auto` picks: pacquet reflinks there, and a reflink is a new inode plus extent bookkeeping inside the filesystem's metadata trees, where a hardlink is one directory entry and an nlink bump.

## Measurements

alotta-files fixture (39k files), warm store + lockfile, cold `node_modules`, btrfs, interleaved A/B:

| | clone-first (before) | hardlink-first (after) |
|---|---|---|
| 32 threads | 0.91s wall / 5.4s sys | **0.53s / 2.9s** |
| 4 threads | 1.14s / 1.5s | **0.66s / 0.9s** |

Hardlink-first is also the default Bun ships (`--backend=hardlink`).

## Scope: pnpm 12 only

This changes what the default materializes on disk, so it ships behind the v12 major. **pnpm 11's TypeScript importer deliberately keeps clone-first** — the two `Auto` implementations intentionally diverge on this until pnpm 11 is retired. The ladder's rustdoc and the changeset record the decision, and the changeset bumps only `pacquet`.

## What doesn't change

- **pnpm 11**, entirely.
- **ext4** (GitHub CI, the published benchmark): `FICLONE` is unsupported there, so `Auto` always ended up hardlinking after one failed reflink. Nothing moves.
- **macOS** keeps clone-first — APFS `clonefile` is the platform's cheap primitive.
- Explicit `packageImportMethod: clone` / `clone-or-copy` / `hardlink` / `copy` are untouched.

## The trade

Clone-first bought store isolation on Linux CoW filesystems: a clone can't be corrupted by a package that mutates its own files at runtime. But every ext4 and Windows install already runs without that isolation, and the store's real guard is `verify-store-integrity`. v12 makes Linux stop paying extra for a protection the other platforms never had; users who want the isolation keep it with `packageImportMethod: clone`.

## What was tried and rejected

Bun's other structural difference — `linkat` from open directory fds (a 256-entry store-prefix fd table plus a per-package dirfd) instead of absolute-path resolution — was implemented and benchmarked too. Every link was confirmed on the fd path (counted: 35k+ hits, 0 fallbacks), kernel time fell ~15%, and wall **regressed** (link phase 335ms → 531ms at 4 cores). Linux resolves hot cached paths through the lock-free RCU dcache walk; the fd anchoring saves nothing that was expensive. Making the per-package file loop sequential also regressed (straggler tail on thousand-file packages). Both reverted; noting it here so nobody re-walks that path without new evidence.

## Implementation

The downgrade cache moves from `fetch_max` (which encoded the ladder in the constants' numeric order) to a compare-exchange step along a per-platform ladder (`next_auto_tier`), so racing rayon workers still converge without a lock. `pnpm:progress imported` telemetry reports the platform's ladder head instead of unconditionally claiming clone (a review catch). Tests pin the ladder order, the fresh-state hardlink, the telemetry mapping, and that a stale compare-exchange can neither skip nor regress a tier.
This commit is contained in:
Zoltan Kochan authored and GitHub committed 2026-08-19 15:09:53 +02:00
1 parent e5007150fa
commit daecf8765f
7 files changed
+176 -27

No files matched your search

@@ -0,0 +1,7 @@
---
"pacquet": minor
---
`packageImportMethod: auto` now tries hardlinks before cloning on Linux. A reflink materializes a new inode and copies extent bookkeeping inside the filesystem's metadata trees, where a hardlink is one directory entry — on btrfs this roughly halves the time an install spends materializing `node_modules` from a warm store. ext4 installs are unchanged (cloning was never supported there, so `auto` already hardlinked), and macOS keeps clone-first, where APFS `clonefile` is the platform's cheap primitive. Cloning remains the fallback when the store refuses hardlinks, and remains available explicitly via `packageImportMethod: clone`.
This ships with pnpm 12 only: pnpm 11's importer deliberately keeps clone-first, since changing what the default materializes on disk is not a point-release change.
+4 -1
View File
@@ -13,6 +13,7 @@
"aliasless",
"amet",
"andreineculau",
"APFS",
"appdata",
"applyq",
"archy",
@@ -33,6 +34,7 @@
"Bluesky",
"brasileiro",
"bryntum",
"btrfs",
"buildx",
"cafile",
"cafs",
@@ -44,6 +46,7 @@
"certfile",
"chmods",
"clonedeep",
"clonefile",
"cmds",
"Codeberg",
"codeload",
@@ -278,7 +281,7 @@
"pnpmtest",
"pnpr",
"polyfilling",
"português",
"portugu\u00eas",
"posix",
"postbuild",
"postfoo",
+4 -2
View File
@@ -796,8 +796,10 @@ pub enum CatalogMode {
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PackageImportMethod {
/// try to clone packages from the store. If cloning is not supported then hardlink packages
/// from the store. If neither cloning nor linking is possible, fall back to copying
/// Try the platform's cheap link tiers in order — hardlink first on
/// Linux, clone first elsewhere — and fall back to copying when none
/// is possible. `deps-restorer::link_file::next_auto_tier` implements
/// the ladder and carries the rationale.
#[default]
Auto,
@@ -255,8 +255,9 @@ impl CreateVirtualDirBySnapshot<'_> {
// doesn't surface the per-package resolved method past
// `link_file`'s install-scoped atomic, so we report the
// optimistic value the configured method would resolve to in
// a non-degraded environment (`Auto`/`CloneOrCopy` → `clone`,
// explicit settings as-is). Refining to per-package resolution
// a non-degraded environment (`Auto` → its platform ladder's
// head, `CloneOrCopy` → `clone`, explicit settings as-is).
// Refining to per-package resolution
// would require threading the resolved method back from
// `link_file`; tracked under <https://github.com/pnpm/pacquet/issues/347>.
Reporter::emit(&LogEvent::Progress(ProgressLog {
@@ -279,9 +280,8 @@ impl CreateVirtualDirBySnapshot<'_> {
#[must_use]
pub fn optimistic_wire_method(method: PackageImportMethod) -> WireImportMethod {
match method {
PackageImportMethod::Auto
| PackageImportMethod::Clone
| PackageImportMethod::CloneOrCopy => WireImportMethod::Clone,
PackageImportMethod::Auto => crate::link_file::auto_optimistic_wire_method(),
PackageImportMethod::Clone | PackageImportMethod::CloneOrCopy => WireImportMethod::Clone,
PackageImportMethod::Hardlink => WireImportMethod::Hardlink,
PackageImportMethod::Copy => WireImportMethod::Copy,
}
@@ -106,7 +106,10 @@ impl Drop for LinkConcurrencyGuard<'_> {
/// A future change to pacquet's `PackageImportMethod` set must
/// either extend this match or fail this test.
#[test]
fn optimistic_wire_method_collapses_auto_and_clone_or_copy_to_clone() {
fn optimistic_wire_method_reports_each_platforms_ladder_head() {
#[cfg(target_os = "linux")]
assert_eq!(optimistic_wire_method(PackageImportMethod::Auto), WireImportMethod::Hardlink);
#[cfg(not(target_os = "linux"))]
assert_eq!(optimistic_wire_method(PackageImportMethod::Auto), WireImportMethod::Clone);
assert_eq!(optimistic_wire_method(PackageImportMethod::CloneOrCopy), WireImportMethod::Clone);
assert_eq!(optimistic_wire_method(PackageImportMethod::Clone), WireImportMethod::Clone);
+80 -16
View File
@@ -47,15 +47,80 @@ pub enum LinkFileError {
// `createAutoImporter` / `createCloneOrCopyImporter`) has the same
// coarseness once `pnpm install` has picked an import direction.
//
// The state is monotonic (`CLONE` → `HARDLINK` → `COPY`) and updated
// with `fetch_max`, so concurrent rayon workers racing on the first
// failure all converge to the same downgraded value without a lock.
// Worst case cost on startup is `N` stale attempts per tier where `N`
// is the rayon thread count — bounded, not per-file.
// The state only ever moves forward along the platform's ladder (see
// [`next_auto_tier`]), each step taken with a compare-exchange from
// the exact tier that failed, so concurrent rayon workers racing on
// the first failure all converge to the same downgraded value without
// a lock: the loser's exchange fails, it reloads, and it finds the
// ladder already advanced. Worst case cost on startup is `N` stale
// attempts per tier where `N` is the rayon thread count — bounded,
// not per-file.
const LINK_STATE_CLONE: u8 = 0;
const LINK_STATE_HARDLINK: u8 = 1;
const LINK_STATE_COPY: u8 = 2;
/// The tier `Auto` starts at, per platform — the head of
/// [`next_auto_tier`]'s ladder.
#[cfg(target_os = "linux")]
const AUTO_FIRST_TIER: u8 = LINK_STATE_HARDLINK;
#[cfg(not(target_os = "linux"))]
const AUTO_FIRST_TIER: u8 = LINK_STATE_CLONE;
/// The wire method `Auto` optimistically resolves to on this platform —
/// the ladder head as `pnpm:progress` reports it. Progress events are
/// emitted before per-file resolution settles, so this is what the
/// `imported` message's `method` field carries for `Auto` installs.
#[must_use]
pub fn auto_optimistic_wire_method() -> WireImportMethod {
match AUTO_FIRST_TIER {
LINK_STATE_HARDLINK => WireImportMethod::Hardlink,
_ => WireImportMethod::Clone,
}
}
/// The tier `Auto` falls to when `tier` fails for capability reasons.
///
/// Linux runs hardlink before clone. A reflink is not the cheap tier
/// there: it materializes a new inode and copies extent bookkeeping
/// inside the filesystem's metadata trees, where a hardlink is one
/// directory entry and an nlink bump — measured on the alotta-files
/// fixture (39k files, warm store, btrfs), the whole install is 0.48s
/// hardlinked against 0.85s cloned, with kernel time 3.1s against
/// 5.3s. On ext4 the two orders behave identically, since `FICLONE`
/// is unsupported and every ladder ends at the hardlink tier. The
/// cost hardlinks carry is shared inodes: a package that mutates its
/// own files at runtime reaches the store copy — the same exposure
/// every ext4 and Windows install runs with, guarded by
/// `verify-store-integrity`, not by the import tier.
///
/// macOS keeps clone-first: APFS `clonefile` is the platform's cheap
/// primitive.
///
/// The hardlink-first order is a pnpm 12 change, shipped behind the
/// major: the TypeScript CLI (pnpm 11) deliberately keeps clone-first,
/// because changing what the default materializes on disk is not a
/// point-release change. The two `Auto` implementations intentionally
/// diverge on this until pnpm 11 is retired.
fn next_auto_tier(tier: u8) -> u8 {
#[cfg(target_os = "linux")]
match tier {
LINK_STATE_HARDLINK => LINK_STATE_CLONE,
_ => LINK_STATE_COPY,
}
#[cfg(not(target_os = "linux"))]
match tier {
LINK_STATE_CLONE => LINK_STATE_HARDLINK,
_ => LINK_STATE_COPY,
}
}
/// Advance the downgrade cache past `from`, unless another worker
/// already has.
fn downgrade_auto_tier(state: &AtomicU8, from: u8) {
let _ =
state.compare_exchange(from, next_auto_tier(from), Ordering::Relaxed, Ordering::Relaxed);
}
// One-shot "we picked this import method" log, matching pnpm's
// `packageImportMethodLogger.debug({ method: 'clone' | 'hardlink' | 'copy' })`
// in `fs/indexed-pkg-importer/src/index.ts`. Emits once per install per
@@ -208,7 +273,7 @@ fn try_import<Reporter: self::Reporter>(
) -> io::Result<()> {
match method {
PackageImportMethod::Auto => {
static AUTO_STATE: AtomicU8 = AtomicU8::new(LINK_STATE_CLONE);
static AUTO_STATE: AtomicU8 = AtomicU8::new(AUTO_FIRST_TIER);
auto_link::<Reporter>(logged, &AUTO_STATE, source_file, target_link)
}
// pnpm's explicit `hardlink` method uses `hardlinkPkg(linkOrCopy)`
@@ -303,8 +368,10 @@ fn is_call_error(err: &io::Error) -> bool {
)
}
/// `Auto`'s clone → hardlink → copy chain, using `state` to skip tiers
/// that have already failed in this process. Factored out so tests can
/// `Auto`'s downgrade chain — hardlink → clone → copy on Linux,
/// clone → hardlink → copy elsewhere (see [`next_auto_tier`] for the
/// why) — using `state` to skip tiers that have already failed in this
/// process. Factored out so tests can
/// pass their own `AtomicU8` and exercise the downgrade logic in
/// isolation — the production path uses a `static` declared inside
/// [`link_file`]. Only capability / cross-device style failures
@@ -322,8 +389,9 @@ fn auto_link<Reporter: self::Reporter>(
// Match on the reflink result alone: only a reflink failure means the
// tier is unusable on this FS pair and should downgrade. Restoration
// runs after reflink created the target, so its error is terminal
// (`?`) — downgrading on it would re-attempt hardlink against that
// just-created file and mask the real error behind `AlreadyExists`.
// (`?`) — downgrading on it would re-attempt the next tier against
// that just-created file and mask the real error behind
// `AlreadyExists`.
LINK_STATE_CLONE => match reflink_copy::reflink(source, target) {
Ok(()) => {
pnpm_fs::file_mode::restore_exec_bit_from_cas_suffix(source, target)?;
@@ -331,9 +399,7 @@ fn auto_link<Reporter: self::Reporter>(
return Ok(());
}
Err(err) if is_call_error(&err) => return Err(err),
Err(_) => {
state.fetch_max(LINK_STATE_HARDLINK, Ordering::Relaxed);
}
Err(_) => downgrade_auto_tier(state, LINK_STATE_CLONE),
},
LINK_STATE_HARDLINK => match fs::hard_link(source, target) {
Ok(()) => {
@@ -345,9 +411,7 @@ fn auto_link<Reporter: self::Reporter>(
return Ok(());
}
Err(err) if is_call_error(&err) => return Err(err),
Err(_) => {
state.fetch_max(LINK_STATE_COPY, Ordering::Relaxed);
}
Err(_) => downgrade_auto_tier(state, LINK_STATE_HARDLINK),
},
_ => {
return copy_file(source, target).inspect(|()| {
@@ -1,6 +1,7 @@
use super::{
LINK_STATE_CLONE, LINK_STATE_HARDLINK, LinkFileError, auto_link, clone_or_copy_link,
is_call_error, is_cross_device, link_file,
AUTO_FIRST_TIER, LINK_STATE_CLONE, LINK_STATE_HARDLINK, LinkFileError, auto_link,
clone_or_copy_link, downgrade_auto_tier, is_call_error, is_cross_device, link_file,
next_auto_tier,
};
#[cfg(unix)]
use super::{LINK_STATE_COPY, import_into_fresh_target};
@@ -386,6 +387,75 @@ fn auto_respects_cached_copy_state() {
assert_eq!(state.load(Ordering::Relaxed), LINK_STATE_COPY, "state must not drift");
}
/// The platform ladder itself: hardlink before clone on Linux, clone
/// first everywhere else (`next_auto_tier` carries the why). Pinned so
/// a refactor of the downgrade machinery can't quietly put Linux back
/// on the reflink tier.
#[test]
fn auto_ladder_order_is_platform_specific() {
#[cfg(target_os = "linux")]
{
assert_eq!(AUTO_FIRST_TIER, LINK_STATE_HARDLINK);
assert_eq!(next_auto_tier(LINK_STATE_HARDLINK), LINK_STATE_CLONE);
assert_eq!(next_auto_tier(LINK_STATE_CLONE), super::LINK_STATE_COPY);
}
#[cfg(not(target_os = "linux"))]
{
assert_eq!(AUTO_FIRST_TIER, LINK_STATE_CLONE);
assert_eq!(next_auto_tier(LINK_STATE_CLONE), LINK_STATE_HARDLINK);
assert_eq!(next_auto_tier(LINK_STATE_HARDLINK), super::LINK_STATE_COPY);
}
}
/// The downgrade cache only steps forward from the exact tier that
/// failed: a worker holding a stale view of the ladder must neither
/// skip it ahead nor drag it back once another worker has advanced it.
#[test]
fn stale_downgrades_neither_skip_nor_regress() {
let state = AtomicU8::new(AUTO_FIRST_TIER);
let second = next_auto_tier(AUTO_FIRST_TIER);
// The first failure advances the ladder head to its successor.
downgrade_auto_tier(&state, AUTO_FIRST_TIER);
assert_eq!(state.load(Ordering::Relaxed), second);
// A racing worker that still saw the head reports the same failure:
// its exchange must lose rather than skip the ladder toward copy.
downgrade_auto_tier(&state, AUTO_FIRST_TIER);
assert_eq!(state.load(Ordering::Relaxed), second);
// Only the current tier failing moves the ladder again — and a
// last stale report from the head still cannot move it back.
downgrade_auto_tier(&state, second);
assert_eq!(state.load(Ordering::Relaxed), super::LINK_STATE_COPY);
downgrade_auto_tier(&state, AUTO_FIRST_TIER);
assert_eq!(state.load(Ordering::Relaxed), super::LINK_STATE_COPY);
}
/// A fresh `Auto` on Linux hardlinks: same-filesystem tempdir, no
/// prior downgrades, and the observable is the shared inode — the
/// exact on-disk shape an install's first file gets.
#[test]
#[cfg(target_os = "linux")]
fn auto_fresh_state_hardlinks_on_linux() {
use std::os::unix::fs::MetadataExt;
let state = AtomicU8::new(AUTO_FIRST_TIER);
let tmp = tempdir().unwrap();
let src = write_source(tmp.path(), "src.txt", b"first-tier");
let dst = tmp.path().join("dst.txt");
auto_link::<SilentReporter>(&AtomicU8::new(0), &state, &src, &dst)
.expect("hardlink should succeed on same-FS tempdir");
assert_eq!(
fs::metadata(&src).unwrap().ino(),
fs::metadata(&dst).unwrap().ino(),
"a fresh Auto on Linux must land on the hardlink tier",
);
assert_eq!(state.load(Ordering::Relaxed), AUTO_FIRST_TIER, "success must not downgrade");
}
/// State=HARDLINK means Auto skips the reflink attempt and jumps
/// straight to `fs::hard_link`. Observable: shared inode on unix.
#[test]