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:
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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user