From daecf8765f7437d5a5c6c6901bfe7a0047a5eae2 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Wed, 19 Aug 2026 15:09:53 +0200 Subject: [PATCH] perf(link): prefer hardlink over clone in Auto on Linux (#14012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../auto-import-hardlinks-first-on-linux.md | 7 ++ cspell.json | 5 +- pnpm/crates/config/src/lib.rs | 6 +- .../src/create_virtual_dir_by_snapshot.rs | 10 +- .../create_virtual_dir_by_snapshot/tests.rs | 5 +- pnpm/crates/deps-restorer/src/link_file.rs | 96 +++++++++++++++---- .../deps-restorer/src/link_file/tests.rs | 74 +++++++++++++- 7 files changed, 176 insertions(+), 27 deletions(-) create mode 100644 .changeset/auto-import-hardlinks-first-on-linux.md diff --git a/.changeset/auto-import-hardlinks-first-on-linux.md b/.changeset/auto-import-hardlinks-first-on-linux.md new file mode 100644 index 0000000000..e7c52bc07e --- /dev/null +++ b/.changeset/auto-import-hardlinks-first-on-linux.md @@ -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. diff --git a/cspell.json b/cspell.json index fe4dcc8077..849387cf88 100644 --- a/cspell.json +++ b/cspell.json @@ -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", diff --git a/pnpm/crates/config/src/lib.rs b/pnpm/crates/config/src/lib.rs index b83f2d2688..6b5997b070 100644 --- a/pnpm/crates/config/src/lib.rs +++ b/pnpm/crates/config/src/lib.rs @@ -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, diff --git a/pnpm/crates/deps-restorer/src/create_virtual_dir_by_snapshot.rs b/pnpm/crates/deps-restorer/src/create_virtual_dir_by_snapshot.rs index c48d851f82..597543f138 100644 --- a/pnpm/crates/deps-restorer/src/create_virtual_dir_by_snapshot.rs +++ b/pnpm/crates/deps-restorer/src/create_virtual_dir_by_snapshot.rs @@ -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 . 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, } diff --git a/pnpm/crates/deps-restorer/src/create_virtual_dir_by_snapshot/tests.rs b/pnpm/crates/deps-restorer/src/create_virtual_dir_by_snapshot/tests.rs index dd33d898c6..88b1c76256 100644 --- a/pnpm/crates/deps-restorer/src/create_virtual_dir_by_snapshot/tests.rs +++ b/pnpm/crates/deps-restorer/src/create_virtual_dir_by_snapshot/tests.rs @@ -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); diff --git a/pnpm/crates/deps-restorer/src/link_file.rs b/pnpm/crates/deps-restorer/src/link_file.rs index 114ae44762..8e97dd2777 100644 --- a/pnpm/crates/deps-restorer/src/link_file.rs +++ b/pnpm/crates/deps-restorer/src/link_file.rs @@ -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( ) -> 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::(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( // 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( 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( 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(|()| { diff --git a/pnpm/crates/deps-restorer/src/link_file/tests.rs b/pnpm/crates/deps-restorer/src/link_file/tests.rs index cf16d6f1fb..925a7161f7 100644 --- a/pnpm/crates/deps-restorer/src/link_file/tests.rs +++ b/pnpm/crates/deps-restorer/src/link_file/tests.rs @@ -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::(&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]