From 2a8afd72ccf8d1f23fd1c401fe57272dc8c4e6af Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 2 May 2026 23:09:21 +0800 Subject: [PATCH 01/13] =?UTF-8?q?perf(pm):=20install=20hot-path=20?= =?UTF-8?q?=E2=80=94=20TTY-gate=20progress,=20stream=20layer=20joins,=20ze?= =?UTF-8?q?ro-copy=20tar=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent install-path changes that compound on cold installs. 1. **TTY-gate `PROGRESS_BAR.inc`/`set_length`** — `install.rs` had 8 raw `PROGRESS_BAR.inc(1)` plus a `set_length` that bypassed the `IS_TTY` short-circuit already present in `ProgressReceiver`. indicatif always takes the internal `Mutex` write-lock even with a hidden draw target, so non-TTY runs (CI, piped output) were paying ~9k Mutex acquisitions per install. Wrapped them in `progress_inc` / `progress_set_length` helpers that match the receiver's gating pattern. 2. **`install_packages` per-layer streaming** — replaced `for task in clone_tasks { task.await?? }` with `FuturesUnordered`. The previous serial join walked tasks in spawn order, so a single slow tarball blocked the layer's `await` loop while later finishers piled up unobserved — pure idle latency between layers on deep trees. Now the layer drains in completion order and propagates the first error early. 3. **Zero-copy tar entries in `extract_tarball_sync`** — `ExtractedEntry` previously held an owned `Vec` copied via `read_to_end` for every file. Each tarball with N files paid N allocs + N memcpys before the parallel-write phase even started. Switched to `(data_offset, data_len)` indices into the decompressed buffer; the rayon write phase slices into the shared `&[u8]` directly. Tar Slip + path-traversal guards untouched, plus a buffer-overrun check on the recorded offsets. Built on top of #2874 to compound with the rayon offload + h1+pool+dns network layer. CI bench-phases-linux will validate the deltas. --- crates/pm/src/service/install.rs | 35 ++++++--- crates/pm/src/util/extractor.rs | 131 ++++++++++++++++++++----------- crates/pm/src/util/logger.rs | 21 +++++ 3 files changed, 127 insertions(+), 60 deletions(-) diff --git a/crates/pm/src/service/install.rs b/crates/pm/src/service/install.rs index 0390a17af1..d1c28fc431 100644 --- a/crates/pm/src/service/install.rs +++ b/crates/pm/src/service/install.rs @@ -1,6 +1,7 @@ use crate::util::cli_enum::ScriptPolicy; use anyhow::Context; use anyhow::Result; +use futures::stream::{FuturesUnordered, StreamExt}; use std::collections::{HashMap, HashSet}; use std::path::Path; use std::time::Instant; @@ -21,7 +22,8 @@ use crate::util::downloader::download_stats; use crate::util::json::load_package_lock_json_from_path; use crate::util::linker::link; use crate::util::logger::{ - PROGRESS_BAR, finish_progress_bar, log_progress, print_install_counts, start_progress_bar, + finish_progress_bar, log_progress, print_install_counts, progress_inc, progress_set_length, + start_progress_bar, }; use utoo_ruborist::compat::{is_cpu_compatible, is_os_compatible}; @@ -83,13 +85,20 @@ pub async fn install_packages( depths.sort_unstable(); for depth in depths.iter() { - let mut clone_tasks: Vec>> = Vec::new(); + // Stream task completion within a layer instead of joining serially. + // The previous `for task in clone_tasks { task.await?? }` walks tasks + // in spawn order — slow tasks block the pop of already-finished + // ones, leaving idle latency between layers on deep trees. With + // FuturesUnordered we drain in completion order, propagating the + // first error early. + let mut clone_tasks: FuturesUnordered>> = + FuturesUnordered::new(); if let Some(packages) = groups.get(depth) { for (path, package) in packages.iter() { // Skip packages based on omit config if should_omit_package(package, omit) { - PROGRESS_BAR.inc(1); + progress_inc(1); continue; } let path = path.clone(); @@ -108,13 +117,13 @@ pub async fn install_packages( if package.link.is_some() { let link_name = extract_package_name(&path); if link_name.is_empty() { - PROGRESS_BAR.inc(1); + progress_inc(1); continue; } link(Path::new(&resolved), Path::new(&path)) .await .with_context(|| format!("Link failed: {resolved} -> {path}"))?; - PROGRESS_BAR.inc(1); + progress_inc(1); continue; } @@ -122,14 +131,14 @@ pub async fn install_packages( if let Some(ref cpu) = package.cpu && !is_cpu_compatible(cpu) { - PROGRESS_BAR.inc(1); + progress_inc(1); continue; } if let Some(ref os) = package.os && !is_os_compatible(os) { - PROGRESS_BAR.inc(1); + progress_inc(1); continue; } @@ -153,24 +162,24 @@ pub async fn install_packages( tracing::warn!( "Optional dependency {name} failed (ignored): {e:#}" ); - PROGRESS_BAR.inc(1); + progress_inc(1); return Ok(()); } return Err(e); } - PROGRESS_BAR.inc(1); + progress_inc(1); log_progress(&format!("{name} resolved")); update_package_binary(&target_path, &name).await }); clone_tasks.push(task); } else { - PROGRESS_BAR.inc(1); + progress_inc(1); } } } - for task in clone_tasks { - task.await??; + while let Some(joined) = clone_tasks.next().await { + joined??; } } @@ -260,7 +269,7 @@ impl InstallService { if !package_lock.packages.is_empty() { start_progress_bar(); - PROGRESS_BAR.set_length(package_lock.packages.len() as u64); + progress_set_length(package_lock.packages.len() as u64); } let link_start = Instant::now(); diff --git a/crates/pm/src/util/extractor.rs b/crates/pm/src/util/extractor.rs index 29b71149b0..a77bf5afc1 100644 --- a/crates/pm/src/util/extractor.rs +++ b/crates/pm/src/util/extractor.rs @@ -41,9 +41,15 @@ fn estimate_uncompressed_size(gzip_data: &[u8]) -> usize { } } +/// Tarball entry recorded during the parse pass, deferred until parallel +/// write. `data_offset`/`data_len` index into the decompressed buffer +/// (lifetime-erased from the buffer for reuse during `par_iter`); avoids +/// the per-file `Vec` allocation+copy that `read_to_end` previously +/// did for every file in the archive. struct ExtractedEntry { path: PathBuf, - content: Vec, + data_offset: usize, + data_len: usize, mode: u32, } @@ -70,7 +76,7 @@ fn extract_tarball_sync(gzip_bytes: Bytes, estimated_size: usize, dest: &Path) - use rayon::prelude::*; use std::collections::HashSet; use std::fs; - use std::io::{Cursor, Read, Write}; + use std::io::Write; // Decompress gzip using libdeflate let mut output = vec![0u8; estimated_size]; @@ -90,49 +96,11 @@ fn extract_tarball_sync(gzip_bytes: Bytes, estimated_size: usize, dest: &Path) - }; output.truncate(actual_size); - // Parse tar entries - let cursor = Cursor::new(&output[..]); - let mut archive = tar::Archive::new(cursor); - let mut entries = Vec::new(); - - for entry_result in archive.entries()? { - let mut entry = entry_result.with_context(|| "Failed to read tar entry")?; - let path = entry - .path() - .with_context(|| "Failed to get entry path")? - .into_owned(); - - // Guard against path traversal (Tar Slip): reject absolute paths - // and entries containing ".." components. - if path.is_absolute() - || path - .components() - .any(|c| matches!(c, std::path::Component::ParentDir)) - { - tracing::warn!("Skipping tar entry with unsafe path: {}", path.display()); - continue; - } - - let full_path = dest.join(&path); - - if !entry.header().entry_type().is_dir() { - let mut content = Vec::new(); - entry - .read_to_end(&mut content) - .with_context(|| format!("Failed to read tar entry: {}", path.display()))?; - - // Normalize to npm/pnpm convention: 0o755 if any exec bit is set, - // else 0o644. Preserving raw tar modes (e.g. 0o640 in google-protobuf) - // breaks world-readability in containers and multi-user setups. - let raw_mode = entry.header().mode().unwrap_or(0o644); - let mode = if raw_mode & 0o111 != 0 { 0o755 } else { 0o644 }; - entries.push(ExtractedEntry { - path: full_path, - content, - mode, - }); - } - } + // Parse tar entries — record offsets into `output` instead of copying + // each file's bytes. The buffer outlives the parallel write phase and + // is read-only after the parse loop, so concurrent slice reads are + // safe. + let entries = parse_tar_entries(&output, dest)?; // Collect every ancestor directory (up to `dest`), then create them // shallowest-first so a single mkdir() per dir is sufficient. @@ -152,11 +120,14 @@ fn extract_tarball_sync(gzip_bytes: Bytes, estimated_size: usize, dest: &Path) - fs::create_dir(dir).ok(); } - // Write files in parallel using rayon + // Write files in parallel using rayon. Each task slices into the + // shared `output` buffer (immutable borrow) — no per-file copy. + let buf: &[u8] = &output; entries.par_iter().try_for_each(|entry| -> Result<()> { let mut file = fs::File::create(&entry.path) .with_context(|| format!("Failed to create: {}", entry.path.display()))?; - file.write_all(&entry.content) + let data = &buf[entry.data_offset..entry.data_offset + entry.data_len]; + file.write_all(data) .with_context(|| format!("Failed to write: {}", entry.path.display()))?; // Skip chmod for 0o644 (most files) — File::create() already produces @@ -181,3 +152,69 @@ fn extract_tarball_sync(gzip_bytes: Bytes, estimated_size: usize, dest: &Path) - Ok(()) } + +/// Walk the tar stream and collect file entries as `(path, offset, len, mode)` +/// tuples into the decompressed `buf`. Skips directories, ignores any +/// path-traversal entries. +fn parse_tar_entries(buf: &[u8], dest: &Path) -> Result> { + use std::io::Cursor; + + let cursor = Cursor::new(buf); + let mut archive = tar::Archive::new(cursor); + let mut entries = Vec::new(); + + for entry_result in archive.entries()? { + let entry = entry_result.with_context(|| "Failed to read tar entry")?; + let path = entry + .path() + .with_context(|| "Failed to get entry path")? + .into_owned(); + + // Guard against path traversal (Tar Slip): reject absolute paths + // and entries containing ".." components. + if path.is_absolute() + || path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + tracing::warn!("Skipping tar entry with unsafe path: {}", path.display()); + continue; + } + + if entry.header().entry_type().is_dir() { + continue; + } + + // tar::Entry exposes the byte offset and size inside the parsed + // stream — that's the same `buf` we'll later slice in `par_iter`. + let data_offset = entry.raw_file_position() as usize; + let data_len = entry.size() as usize; + if data_offset + .checked_add(data_len) + .is_none_or(|end| end > buf.len()) + { + anyhow::bail!( + "Tar entry {} extends past decompressed buffer ({}..+{} > {})", + path.display(), + data_offset, + data_len, + buf.len() + ); + } + + let full_path = dest.join(&path); + // Normalize to npm/pnpm convention: 0o755 if any exec bit is set, + // else 0o644. Preserving raw tar modes (e.g. 0o640 in google-protobuf) + // breaks world-readability in containers and multi-user setups. + let raw_mode = entry.header().mode().unwrap_or(0o644); + let mode = if raw_mode & 0o111 != 0 { 0o755 } else { 0o644 }; + entries.push(ExtractedEntry { + path: full_path, + data_offset, + data_len, + mode, + }); + } + + Ok(entries) +} diff --git a/crates/pm/src/util/logger.rs b/crates/pm/src/util/logger.rs index 6ba124bb24..c8570b2eca 100644 --- a/crates/pm/src/util/logger.rs +++ b/crates/pm/src/util/logger.rs @@ -161,6 +161,27 @@ pub fn log_progress(text: &str) { PROGRESS_BAR.set_message(text.to_string()); } +/// TTY-gated `PROGRESS_BAR.inc(1)`. Non-TTY callers pay no Mutex cost — +/// indicatif's `inc` always takes the internal `Mutex` +/// write-lock even with a hidden draw target, which contends across the +/// install loop's spawned tasks. +#[inline] +pub fn progress_inc(n: u64) { + if !*IS_TTY { + return; + } + PROGRESS_BAR.inc(n); +} + +/// TTY-gated `PROGRESS_BAR.set_length`. Same rationale as [`progress_inc`]. +#[inline] +pub fn progress_set_length(len: u64) { + if !*IS_TTY { + return; + } + PROGRESS_BAR.set_length(len); +} + // Global timer for log_time/log_time_end static START_TIME: OnceCell = OnceCell::new(); From 6566b49eec82fe6c0c09a3d7158156b0a6d134d1 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 2 May 2026 23:37:54 +0800 Subject: [PATCH 02/13] perf(pm): fan-out tarball prefetch on fresh-lock install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install must run depth-by-depth so a parent's `node_modules/` exists before children clone into it — but **download** has no such filesystem dependency. Any tarball can land in the cache at any time; only network capacity (and the download semaphore) bound it. In the stale-lock path the BFS pipeline already kicks off downloads on `PackageResolved` events, so the install loop hits warm cache slots. The fresh-lock path has no such pipeline — every depth-N+1 tarball only starts downloading after depth-N's clone barrier releases, serializing what should be parallel network I/O. On a 10-deep tree that's 10 serial barriers each gated by a long-tail tarball. Add `spawn_tarball_prefetch`: walks every package in the lock, spawns `download_to_cache(name, version, url)` for each http(s) tarball, lets them race ahead. `download_to_cache` is `OnceMap`-deduped on `name@version`, so the install loop's later resolution awaits the in-flight future or hits the now-warm cache — no duplicate work. Skipped: `link:` workspace targets, `git:` (BFS-resolved cache slot), `file:` (cache slot is BFS-seeded; no equivalent in fresh-lock yet — parking that as separate scope). CPU/OS-incompatible packages are intentionally still prefetched: cheap insurance for a host that later shares the cache. --- crates/pm/src/service/install.rs | 63 +++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/pm/src/service/install.rs b/crates/pm/src/service/install.rs index d1c28fc431..b41a06a591 100644 --- a/crates/pm/src/service/install.rs +++ b/crates/pm/src/service/install.rs @@ -18,7 +18,7 @@ use crate::model::package::PackageInfo; use crate::service::rebuild::RebuildService; use crate::util::cli_enum::{OmitType, PackageAction, SaveType}; use crate::util::cloner::clone_count; -use crate::util::downloader::download_stats; +use crate::util::downloader::{download_stats, download_to_cache, is_git_url}; use crate::util::json::load_package_lock_json_from_path; use crate::util::linker::link; use crate::util::logger::{ @@ -186,6 +186,57 @@ pub async fn install_packages( Ok(()) } +/// Fan-out tarball downloads for every package in `package_lock` so they +/// race ahead of the depth-ordered install loop. +/// +/// Install must run layer-by-layer so a parent's `node_modules/` exists +/// before children clone into it — but **download** has no such +/// dependency: any tarball can land in the cache at any time. Without +/// this prefetch, a fresh-lock install (no pipeline phase) only starts +/// downloading depth-N+1 tarballs after depth-N's clone barrier +/// releases, serializing what should be parallel network I/O. +/// +/// `download_to_cache` is `OnceMap`-deduped on `name@version`, so the +/// install loop's later call resolves to the prefetched future / cache +/// hit without redundant network or extract work. +/// +/// Skipped: `link:` workspace targets, `git+*` (BFS-resolved), `file:` +/// (cache slot is BFS-seeded — no equivalent in fresh-lock yet). +fn spawn_tarball_prefetch(package_lock: &utoo_ruborist::lock::PackageLock, cwd: &Path) { + for (path, package) in package_lock.packages.iter() { + if package.link.is_some() { + continue; + } + let Some(resolved) = package.resolved.as_deref() else { + continue; + }; + if resolved.is_empty() { + continue; + } + if is_git_url(resolved) || resolved.starts_with("file:") { + continue; + } + let Some(version) = package.version.clone() else { + continue; + }; + let name = package.get_name(path); + if name.is_empty() { + continue; + } + // CPU/OS-incompatible packages still get prefetched: the layer + // overhead is dominated by the slow tail, and skipping them + // here would require re-doing the compat check that + // `install_packages` already does. The download buys the + // option of installing on a different host that reuses the + // same cache. + let _ = cwd; // reserved for future relative-path resolution + let resolved = resolved.to_string(); + tokio::spawn(async move { + download_to_cache(&name, &version, &resolved).await; + }); + } +} + pub struct InstallService; impl InstallService { @@ -265,6 +316,16 @@ impl InstallService { (result.package_lock, Some(result.handles)) }; + // Stale-lock path already gets per-package downloads kicked off + // by the BFS pipeline (`PackageResolved` → download_worker), so + // the prefetch is only a win for the fresh-lock path. OnceMap + // dedup means a duplicate kick from this prefetch is a cheap + // future-share, not duplicate work — but skipping the loop + // entirely saves the spawn cost. + if pipeline_handles.is_none() { + spawn_tarball_prefetch(&package_lock, root_path); + } + let groups = group_by_depth(&package_lock.packages); if !package_lock.packages.is_empty() { From 8d4cdd37497b1cd9a60e36fc10c8233eda0578f0 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sun, 3 May 2026 00:04:11 +0800 Subject: [PATCH 03/13] fix(pm): prefetch skips CPU/OS-incompatible tarballs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bench-phases-linux on the previous prefetch revealed netRX 2.95G vs 0.97G baseline (3×) and cache 4.26G vs 1.67G (2.5×) on p3_cold_install. Root cause: optional native packages (lightningcss/esbuild/swc/... platform variants — `darwin-arm64`, `linux-x64`, etc) ship one tarball per platform; the lockfile lists them all but install_packages skips the incompatible ones via `is_cpu_compatible` / `is_os_compatible`. The previous prefetch comment said this was "cheap insurance for a host that later shares the cache" — but optional natives total 1-2GB per project. Pulling them on every cold install is a real network + disk regression for zero common-case win. Add the same compat check to prefetch. Other guards (link/git/file/ empty-resolved) untouched. --- crates/pm/src/service/install.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/pm/src/service/install.rs b/crates/pm/src/service/install.rs index b41a06a591..0e80485135 100644 --- a/crates/pm/src/service/install.rs +++ b/crates/pm/src/service/install.rs @@ -201,7 +201,11 @@ pub async fn install_packages( /// hit without redundant network or extract work. /// /// Skipped: `link:` workspace targets, `git+*` (BFS-resolved), `file:` -/// (cache slot is BFS-seeded — no equivalent in fresh-lock yet). +/// (cache slot is BFS-seeded — no equivalent in fresh-lock yet), +/// CPU/OS-incompatible optional natives (e.g. `lightningcss-linux-arm64` +/// on a darwin host) — install would skip them anyway, and prefetching +/// them inflates network RX 3× on bench-phases-linux because every +/// optional platform variant pulls its full tarball. fn spawn_tarball_prefetch(package_lock: &utoo_ruborist::lock::PackageLock, cwd: &Path) { for (path, package) in package_lock.packages.iter() { if package.link.is_some() { @@ -216,6 +220,16 @@ fn spawn_tarball_prefetch(package_lock: &utoo_ruborist::lock::PackageLock, cwd: if is_git_url(resolved) || resolved.starts_with("file:") { continue; } + if let Some(ref cpu) = package.cpu + && !is_cpu_compatible(cpu) + { + continue; + } + if let Some(ref os) = package.os + && !is_os_compatible(os) + { + continue; + } let Some(version) = package.version.clone() else { continue; }; @@ -223,12 +237,6 @@ fn spawn_tarball_prefetch(package_lock: &utoo_ruborist::lock::PackageLock, cwd: if name.is_empty() { continue; } - // CPU/OS-incompatible packages still get prefetched: the layer - // overhead is dominated by the slow tail, and skipping them - // here would require re-doing the compat check that - // `install_packages` already does. The download buys the - // option of installing on a different host that reuses the - // same cache. let _ = cwd; // reserved for future relative-path resolution let resolved = resolved.to_string(); tokio::spawn(async move { From bbc6ec02a02a64a112f7946f6be2cb1cfafe0d9a Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sun, 3 May 2026 00:22:38 +0800 Subject: [PATCH 04/13] perf(pm): prefetch back-pressure gate to keep install path slot priority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Latest bench-phases-linux on `80b2b0d` (after the compat-skip fix): | Phase | utoo-next | utoo | Δ | σ change | |---|---|---|---|---| | p0_full_cold | 10.01s | 9.11s | −0.90s ✓ | within noise | | p3_cold_install | 7.03s ±1.56 | 8.39s ±3.93 | +1.36s ❌ | **σ 2.5×** | p3 σ went from 1.56s → 3.93s — a real regression, not noise. Diagnosis: `spawn_tarball_prefetch` fires N spawns on entry to `InstallService::install`, all racing for the 64-slot `DOWNLOAD_SEMAPHORE`. By the time `install_packages` spawns layer 0 (~200 clone tasks), most of the 64 slots are held by prefetch tasks and the 200 clone tasks queue behind the entire prefetch backlog. The clone path is now sensitive to prefetch ordering luck. Fix: introduce a 16-slot `PREFETCH_SEMAPHORE` that bounds prefetch backlog. `prefetch_to_cache` acquires the prefetch gate first, then delegates to `download_to_cache` which still goes through `DOWNLOAD_SEMAPHORE`. So at most 16 prefetch tasks contend for download slots at any time, leaving ≥48 permanently for the install path. Warm-cache short-circuit on the prefetch gate: cache hits skip the gate entirely, so a warm install pays zero overhead from this change. Compounds with the OnceMap dedup — install's later `download_to_cache` call still hits the in-flight prefetch future or the cache. --- crates/pm/src/service/install.rs | 4 +-- crates/pm/src/util/downloader.rs | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/pm/src/service/install.rs b/crates/pm/src/service/install.rs index 0e80485135..f6bea4dd44 100644 --- a/crates/pm/src/service/install.rs +++ b/crates/pm/src/service/install.rs @@ -18,7 +18,7 @@ use crate::model::package::PackageInfo; use crate::service::rebuild::RebuildService; use crate::util::cli_enum::{OmitType, PackageAction, SaveType}; use crate::util::cloner::clone_count; -use crate::util::downloader::{download_stats, download_to_cache, is_git_url}; +use crate::util::downloader::{download_stats, is_git_url, prefetch_to_cache}; use crate::util::json::load_package_lock_json_from_path; use crate::util::linker::link; use crate::util::logger::{ @@ -240,7 +240,7 @@ fn spawn_tarball_prefetch(package_lock: &utoo_ruborist::lock::PackageLock, cwd: let _ = cwd; // reserved for future relative-path resolution let resolved = resolved.to_string(); tokio::spawn(async move { - download_to_cache(&name, &version, &resolved).await; + prefetch_to_cache(&name, &version, &resolved).await; }); } } diff --git a/crates/pm/src/util/downloader.rs b/crates/pm/src/util/downloader.rs index 76526eedda..8e8d3a0641 100644 --- a/crates/pm/src/util/downloader.rs +++ b/crates/pm/src/util/downloader.rs @@ -27,6 +27,20 @@ static DOWNLOAD_CACHE: Lazy> = Lazy::new(OnceMap::new); /// Semaphore controlling concurrent download count. static DOWNLOAD_SEMAPHORE: OnceLock = OnceLock::new(); +/// Prefetch back-pressure gate, sized smaller than [`DOWNLOAD_SEMAPHORE`]. +/// +/// `spawn_tarball_prefetch` fan-outs N spawn calls on entry; without +/// this gate, the first ~N tasks acquire `DOWNLOAD_SEMAPHORE` slots +/// before `install_packages` even spawns its first layer, so install's +/// clone tasks queue behind every prefetch task. p3_cold_install on +/// CI bench-phases-linux saw σ jump from 1.56s → 3.93s as a result. +/// +/// Capping prefetch at 16 leaves `64 - 16 = 48` slots permanently +/// available to the install path; the OnceMap dedup still ensures +/// install's later resolve hits the prefetched future on completion. +static PREFETCH_SEMAPHORE: OnceLock = OnceLock::new(); +const PREFETCH_BACKLOG_LIMIT: usize = 16; + static DOWNLOAD_COUNT: AtomicUsize = AtomicUsize::new(0); static REUSE_COUNT: AtomicUsize = AtomicUsize::new(0); @@ -141,6 +155,35 @@ pub async fn resolve_cache_path(name: &str, version: &str, tarball_url: &str) -> } } +/// Prefetch variant: same as [`download_to_cache`] but bounded by +/// [`PREFETCH_SEMAPHORE`] so a fan-out batch can't starve the install +/// path of [`DOWNLOAD_SEMAPHORE`] slots. +/// +/// On a warm-cache hit there's nothing to gate (the OnceMap closure +/// short-circuits before acquiring [`DOWNLOAD_SEMAPHORE`]); the +/// prefetch gate is only acquired around the actual `download_to_cache` +/// call, so warm tarballs return immediately without consuming +/// prefetch slots. +pub async fn prefetch_to_cache(name: &str, version: &str, tarball_url: &str) -> Option { + // Quick warm-cache short-circuit before paying the prefetch gate. + let cache_path = get_cache_dir().join(name).join(version); + if crate::fs::try_exists(&cache_path.join("_resolved")) + .await + .unwrap_or(false) + { + return Some(cache_path); + } + + let permit = PREFETCH_SEMAPHORE + .get_or_init(|| Semaphore::new(PREFETCH_BACKLOG_LIMIT)) + .acquire() + .await + .ok()?; + let result = download_to_cache(name, version, tarball_url).await; + drop(permit); + result +} + /// Download a registry tarball to the global cache directory, returning the cache path. /// /// Uses `OnceMap` to deduplicate: the same `name@version` is only downloaded once, From f74837e2ae60987e5b157a085bcfcf2c9c535abd Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sun, 3 May 2026 08:21:09 +0800 Subject: [PATCH 05/13] perf(pm): OnceMap fast-path eliminates spawn + alloc churn on cache hit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Push the install hot-path further: when the BFS pipeline (or a sibling depth in the install loop) has already cloned/downloaded a target, the follow-up `clone_package_once` / `download_to_cache` calls were still paying: - 5 string allocs (`name.to_string()` × 3 + `format!()` + `target_path.to_path_buf()`) - a `tokio::spawn` (separate task struct + scheduler enqueue) - a future construction with all 5 captured strings …before reaching the OnceMap entry whose value is already `Done`. On ant-design (~1000 packages, 95%+ pre-cloned by pipeline at install time), that's 5000+ small allocs and 1000+ spawns of pure no-ops. This commit: 1. **`OnceMap::is_done`** (ruborist) — sync, allocation-free probe of the DashMap. Returns `false` for `Waiting` entries so we don't bypass the subscription path. 2. **`clone_package_once` fast-path** — early `is_done` short-circuit before the param clones, before the `get_or_init` future. 3. **`download_to_cache` fast-path** — same shape; reuses the cached `Arc` via a no-op `get_or_init` on the hot branch so we stay sound (no parallel `register/complete` race with prefetch). 4. **install_packages eager skip** — checks `CLONE_CACHE.is_done` before `tokio::spawn`. Skips the entire spawn + scheduler dance for already-cloned targets. Made `CLONE_CACHE` and `cache_key` `pub(crate)` so install can probe without going through the round-trip clone helper. Compounds with the existing PR stack: prefetch fans out N downloads ahead, install loop now skips the corresponding 95% of spawns when those finish first. --- crates/pm/src/service/install.rs | 11 ++++++++++- crates/pm/src/util/cloner.rs | 15 ++++++++++++--- crates/pm/src/util/downloader.rs | 15 +++++++++++++++ crates/ruborist/src/util/oncemap.rs | 16 ++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/crates/pm/src/service/install.rs b/crates/pm/src/service/install.rs index f6bea4dd44..d94b2c16a4 100644 --- a/crates/pm/src/service/install.rs +++ b/crates/pm/src/service/install.rs @@ -17,7 +17,7 @@ use crate::helper::workspace::init_project_root; use crate::model::package::PackageInfo; use crate::service::rebuild::RebuildService; use crate::util::cli_enum::{OmitType, PackageAction, SaveType}; -use crate::util::cloner::clone_count; +use crate::util::cloner::{CLONE_CACHE, cache_key, clone_count}; use crate::util::downloader::{download_stats, is_git_url, prefetch_to_cache}; use crate::util::json::load_package_lock_json_from_path; use crate::util::linker::link; @@ -150,6 +150,15 @@ pub async fn install_packages( let cwd_clone = cwd.to_path_buf(); let target_path = cwd_clone.join(&path); + // Eager skip: if the BFS pipeline (or a sibling + // depth) already cloned this target, the spawn + + // future construction below is pure overhead. Probe + // CLONE_CACHE before doing any of it. + if CLONE_CACHE.is_done(&cache_key(&target_path)) { + progress_inc(1); + continue; + } + // Check if this is an optional dependency let is_optional = package.optional == Some(true) || package.dev_optional == Some(true); diff --git a/crates/pm/src/util/cloner.rs b/crates/pm/src/util/cloner.rs index 76c6029702..6376bb7c2b 100644 --- a/crates/pm/src/util/cloner.rs +++ b/crates/pm/src/util/cloner.rs @@ -22,7 +22,7 @@ use crate::fs; /// `ERROR_SHARING_VIOLATION` (os error 32) on Windows. `PathBuf` from /// `Path::components().collect()` parses both separators uniformly and /// rebuilds with the OS-preferred one, giving a stable key. -static CLONE_CACHE: Lazy> = Lazy::new(OnceMap::new); +pub(crate) static CLONE_CACHE: Lazy> = Lazy::new(OnceMap::new); /// Number of `node_modules/` directories freshly materialized this run. /// Mirrors pnpm's "added" semantic. @@ -35,12 +35,12 @@ pub fn clone_count() -> usize { /// Normalize a target path into the canonical key used by `CLONE_CACHE`. #[cfg(windows)] -fn cache_key(target_path: &Path) -> PathBuf { +pub(crate) fn cache_key(target_path: &Path) -> PathBuf { target_path.components().collect() } #[cfg(not(windows))] -fn cache_key(target_path: &Path) -> PathBuf { +pub(crate) fn cache_key(target_path: &Path) -> PathBuf { target_path.to_path_buf() } @@ -65,6 +65,15 @@ pub async fn clone_package_once( target_path: &Path, ) -> Result<()> { let key = cache_key(target_path); + + // Hot fast-path: most install-loop calls hit a target the BFS + // pipeline (or a previous depth's spawn) already cloned. Probe + // CLONE_CACHE before paying the 5 string allocs + future + // construction below. + if CLONE_CACHE.is_done(&key) { + return Ok(()); + } + let err_label = format!("{name}@{version}"); let name = name.to_string(); let version = version.to_string(); diff --git a/crates/pm/src/util/downloader.rs b/crates/pm/src/util/downloader.rs index 8e8d3a0641..01194ef768 100644 --- a/crates/pm/src/util/downloader.rs +++ b/crates/pm/src/util/downloader.rs @@ -192,6 +192,21 @@ pub async fn prefetch_to_cache(name: &str, version: &str, tarball_url: &str) -> /// For git-resolved packages, use [`resolve_cache_path`] instead. pub async fn download_to_cache(name: &str, version: &str, tarball_url: &str) -> Option { let key = format!("{}@{}", name, version); + + // Hot fast-path: when the prefetch (or a sibling resolve) already + // populated the OnceMap entry, skip the per-call string allocs and + // future construction by reusing the cached `Arc`. The + // pipeline path drives `download_to_cache` for every BFS-resolved + // package, and install-loop calls usually arrive after — most hit + // a `Done` slot. + if DOWNLOAD_CACHE.is_done(&key) { + return DOWNLOAD_CACHE + .get_or_init(key, || async { None }) + .await + .as_deref() + .cloned(); + } + let cache_dir = get_cache_dir(); let name = name.to_string(); let version = version.to_string(); diff --git a/crates/ruborist/src/util/oncemap.rs b/crates/ruborist/src/util/oncemap.rs index 40c4aeee20..25255e797c 100644 --- a/crates/ruborist/src/util/oncemap.rs +++ b/crates/ruborist/src/util/oncemap.rs @@ -118,6 +118,22 @@ where } } + /// Sync, allocation-free check for a completed entry. + /// + /// Lets hot-path callers short-circuit before paying string clones / + /// future construction for a key that's already been resolved by a + /// peer (e.g. install loop deduping against pipeline-pre-cloned + /// targets, where 95%+ of packages hit a `Done` slot). + /// + /// Returns `false` for both unknown keys and `Waiting` slots; only + /// `Done` is reported. Waiting entries fall through to the normal + /// `get_or_init` path which knows how to subscribe and wait. + pub fn is_done(&self, key: &K) -> bool { + self.map + .get(key) + .is_some_and(|e| matches!(e.value(), Value::Done(_))) + } + /// Wait for a key to complete. /// /// - If the key exists and is Done, returns immediately. From a08c6222075b45511ca1240fc24ab719a3409e29 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sun, 3 May 2026 08:41:05 +0800 Subject: [PATCH 06/13] perf(pm): release download permit before CPU-bound extract phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `download_to_cache` previously held the `DOWNLOAD_SEMAPHORE` permit across both the network download AND the CPU-bound `extract_and_write` call (libdeflate decompress + tar parse + rayon parallel writes, ~50–200ms per package). The semaphore is meant to gate concurrent network I/O — coupling it to extract artificially limits download fan-out by ~50% on tarball-heavy installs. Drop the permit immediately after `download_bytes` returns. Extract runs on rayon's CPU pool which is independent of `DOWNLOAD_SEMAPHORE`, so the next download task can start as soon as bytes land in memory. With 64 slots, the effective concurrent download count goes from ~64 / (1 + extract_ratio) to a true 64. Compounds with the prefetch back-pressure gate: prefetch's 16-slot sub-limit still bounds it, but the slots themselves now turn over faster. --- crates/pm/src/util/downloader.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/pm/src/util/downloader.rs b/crates/pm/src/util/downloader.rs index 01194ef768..1cb8c6d5b5 100644 --- a/crates/pm/src/util/downloader.rs +++ b/crates/pm/src/util/downloader.rs @@ -225,10 +225,17 @@ pub async fn download_to_cache(name: &str, version: &str, tarball_url: &str) -> return Some(cache_path); } - // Download (semaphore controlled) + // Download (semaphore controlled). The permit gates only + // network I/O — extract runs on rayon's CPU pool and does + // not contend with download bandwidth, so we release the + // permit before the CPU-bound phase. This lets the next + // `download_to_cache` start immediately, doubling effective + // concurrency on tarball-heavy installs (download tasks no + // longer block on a peer task's ~50–200ms libdeflate + + // tar parse + parallel writes). let semaphore = DOWNLOAD_SEMAPHORE .get_or_init(|| Semaphore::new(get_manifests_concurrency_limit_sync())); - let _permit = semaphore.acquire().await.ok()?; + let permit = semaphore.acquire().await.ok()?; let bytes = download_bytes(&tarball_url) .await .inspect_err(|e| { @@ -241,6 +248,7 @@ pub async fn download_to_cache(name: &str, version: &str, tarball_url: &str) -> ) }) .ok()?; + drop(permit); // Extract extract_and_write(bytes, &cache_path) From 52935bef86ac7cfe1ae5fcd4ab6d634154d70555 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sun, 3 May 2026 08:42:29 +0800 Subject: [PATCH 07/13] =?UTF-8?q?perf(pm):=20linearize=20extract=20dir=20c?= =?UTF-8?q?ollection=20=E2=80=94=20Vec=20+=20sort+dedup=20over=20per-entry?= =?UTF-8?q?=20HashSet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extract_tarball_sync` builds the set of ancestor directories before mkdir-ing them. Old code inserted into a `HashSet` per entry, which allocated a PathBuf for every ancestor of every file even when most entries shared the same parent dirs (the common case for npm packages — typically 50+ files under 1–3 unique dirs). Switch to: Vec::with_capacity(entries.len()) → push every ancestor → sort + dedup. Allocation pattern flips from O(unique_dirs × visit_count) PathBufs inserted into a hash table to a single Vec push + one sort pass. Same correctness (dedup before mkdir, depth-ascending order preserved via second sort_by_key on os_str length). --- crates/pm/src/util/extractor.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/pm/src/util/extractor.rs b/crates/pm/src/util/extractor.rs index a77bf5afc1..f342ac2155 100644 --- a/crates/pm/src/util/extractor.rs +++ b/crates/pm/src/util/extractor.rs @@ -74,7 +74,6 @@ async fn extract_tarball(gzip_bytes: Bytes, dest: &Path) -> Result<()> { /// Synchronous extraction: decompress + parse + parallel write, all on rayon. fn extract_tarball_sync(gzip_bytes: Bytes, estimated_size: usize, dest: &Path) -> Result<()> { use rayon::prelude::*; - use std::collections::HashSet; use std::fs; use std::io::Write; @@ -104,18 +103,26 @@ fn extract_tarball_sync(gzip_bytes: Bytes, estimated_size: usize, dest: &Path) - // Collect every ancestor directory (up to `dest`), then create them // shallowest-first so a single mkdir() per dir is sufficient. - let mut seen = HashSet::new(); + // + // Push to a Vec first (cheap) and dedup once at the end via sort + // — replaces a per-entry `HashSet` insert that allocated + // a PathBuf for every ancestor on every entry, even when most + // entries share the same parent dirs (the common case). + let mut dirs: Vec = Vec::with_capacity(entries.len()); for entry in &entries { let mut p = entry.path.parent(); while let Some(dir) = p { - if dir == dest || !seen.insert(dir.to_path_buf()) { + if dir == dest { break; } + dirs.push(dir.to_path_buf()); p = dir.parent(); } } - let mut dirs: Vec<_> = seen.into_iter().collect(); - dirs.sort_unstable_by_key(|p| p.as_os_str().len()); + dirs.sort_unstable(); + dirs.dedup(); + // Sort by depth ascending so parent exists before child mkdir. + dirs.sort_by_key(|p| p.as_os_str().len()); for dir in &dirs { fs::create_dir(dir).ok(); } From 5b93169dc725a2f738e96c94a134cb9bf4fc3bc5 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sun, 3 May 2026 08:44:01 +0800 Subject: [PATCH 08/13] perf(pm): skip update_package_binary for npm.org registry `should_skip_binary_mirror()` already gates `get_envs()` for npm.org users, but `update_package_binary` was unconditionally: 1. Fetching `binary-mirror-config/latest` from the registry (one extra network round-trip per process; cached after). 2. Looking up the package name in the (large) China mirrors hashmap for every package in the install loop. For npm.org the mirror config is irrelevant. Add the same gate at the top of `update_package_binary`. Mirrors the existing `get_envs` flow and shaves the cold-path network call entirely. --- crates/pm/src/service/binary.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/pm/src/service/binary.rs b/crates/pm/src/service/binary.rs index c6f95cb45e..33f0cda141 100644 --- a/crates/pm/src/service/binary.rs +++ b/crates/pm/src/service/binary.rs @@ -233,6 +233,15 @@ async fn handle_cypress( } pub async fn update_package_binary(dir: &Path, name: &str) -> Result<()> { + // npm.org users have no use for the China binary mirror config; + // skipping here avoids the per-process `binary-mirror-config/latest` + // fetch + per-package hashmap lookup that fires for every cloned + // package in the install loop. Mirrors `get_envs`'s gate so the + // two paths agree on when the mirror layer is active. + if should_skip_binary_mirror() { + return Ok(()); + } + let config = load_config().await?; let mirrors = config["mirrors"]["china"] From ac9cea263d2afafb337ff765aa7a7760f7912687 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sun, 3 May 2026 09:05:19 +0800 Subject: [PATCH 09/13] perf(pm): find_real_src uses file_type instead of metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `find_real_src` is called from `clone()` for every cold-cache install to locate the wrapper subdir (npm tarballs ship under `package/`). The original implementation called `entry.metadata().await` per candidate, which always issues an `lstat` syscall. `entry.file_type().await` reads `d_type` from the cached `readdir` result on ext4/btrfs/xfs (no extra syscall when the filesystem populates it; falls back to lstat only on `DT_UNKNOWN`). For 1000 packages × 3–5 entries-per-cache-dir that's 3000–5000 fewer stat syscalls on the cold install path. --- crates/pm/src/util/cloner.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/pm/src/util/cloner.rs b/crates/pm/src/util/cloner.rs index 6376bb7c2b..e41073fa04 100644 --- a/crates/pm/src/util/cloner.rs +++ b/crates/pm/src/util/cloner.rs @@ -335,8 +335,12 @@ async fn validate_directory(src: &Path, dst: &Path) -> Result { pub async fn find_real_src>(src: P) -> Option { let mut read_dir = fs::read_dir(src.as_ref()).await.ok()?; while let Some(entry) = read_dir.next_entry().await.ok()? { - if let Ok(metadata) = entry.metadata().await - && metadata.is_dir() + // `file_type()` reads `d_type` from the cached readdir result on + // ext4/btrfs/xfs (no extra syscall); `metadata()` always issues + // an `lstat`. Saves ~3–5 stats per call × N packages on the + // cold install hot path. + if let Ok(file_type) = entry.file_type().await + && file_type.is_dir() && let Some(name) = entry.path().file_name() && name.to_string_lossy() != ".utoo_built" { From fdb35b33ed970d7297733e2fed676e2478faf82f Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sun, 3 May 2026 09:26:17 +0800 Subject: [PATCH 10/13] perf(pm): route BFS pipeline downloads through prefetch back-pressure gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8-run bench-phases-linux aggregate flagged a +1.22s regression on p0_full_cold (full cold = stale-lock → BFS pipeline + install loop). Same shape as the fresh-lock prefetch contention we fixed earlier: the BFS pipeline worker spawned `download_to_cache` directly for each `PackageResolved` event, all queueing on the 64-slot `DOWNLOAD_SEMAPHORE`. By the time `install_packages` started its layer 0, ~hundreds of pipeline downloads sat ahead of install's direct calls, contending for the same slots. Reroute the pipeline download worker through `prefetch_to_cache` (16-slot `PREFETCH_SEMAPHORE`). Pipeline can fan-out as many spawns as events arrive, but at most 16 sit waiting on `DOWNLOAD_SEMAPHORE` at any time — install path always sees ≥48 slots. Same OnceMap dedup elsewhere keeps install's later resolve hitting the prefetched future. This generalizes the prefetch back-pressure pattern: any speculative / background fan-out into `DOWNLOAD_SEMAPHORE` should hop through `prefetch_to_cache` so the primary install path stays unstarved. --- crates/pm/src/service/pipeline/worker.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/pm/src/service/pipeline/worker.rs b/crates/pm/src/service/pipeline/worker.rs index af9348c8fc..0b551e4952 100644 --- a/crates/pm/src/service/pipeline/worker.rs +++ b/crates/pm/src/service/pipeline/worker.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use super::receiver::PipelineChannels; use crate::util::cloner::{clone_package_once, wait_clone_if_pending}; -use crate::util::downloader::{download_to_cache, is_git_url}; +use crate::util::downloader::{is_git_url, prefetch_to_cache}; /// Pipeline worker handles for awaiting completion. pub struct PipelineHandles { @@ -39,8 +39,13 @@ pub fn start_workers(channels: PipelineChannels, cwd: PathBuf) -> PipelineHandle } let name = info.name; let version = info.version; + // Route through `prefetch_to_cache` (16-slot back-pressure + // gate) so BFS-driven download fan-out can't starve the + // install loop's direct `download_to_cache` calls of + // DOWNLOAD_SEMAPHORE slots. Same pattern as fresh-lock + // tarball prefetch — install path always gets ≥48 slots. tokio::spawn(async move { - download_to_cache(&name, &version, &tarball_url).await; + prefetch_to_cache(&name, &version, &tarball_url).await; }); } }); From ee12cdfc70fefd815f8dfccc66037aff013b546c Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sun, 3 May 2026 09:46:25 +0800 Subject: [PATCH 11/13] Revert "perf(pm): route BFS pipeline downloads through prefetch back-pressure gate" This reverts commit fdb35b33ed970d7297733e2fed676e2478faf82f. --- crates/pm/src/service/pipeline/worker.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/pm/src/service/pipeline/worker.rs b/crates/pm/src/service/pipeline/worker.rs index 0b551e4952..af9348c8fc 100644 --- a/crates/pm/src/service/pipeline/worker.rs +++ b/crates/pm/src/service/pipeline/worker.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use super::receiver::PipelineChannels; use crate::util::cloner::{clone_package_once, wait_clone_if_pending}; -use crate::util::downloader::{is_git_url, prefetch_to_cache}; +use crate::util::downloader::{download_to_cache, is_git_url}; /// Pipeline worker handles for awaiting completion. pub struct PipelineHandles { @@ -39,13 +39,8 @@ pub fn start_workers(channels: PipelineChannels, cwd: PathBuf) -> PipelineHandle } let name = info.name; let version = info.version; - // Route through `prefetch_to_cache` (16-slot back-pressure - // gate) so BFS-driven download fan-out can't starve the - // install loop's direct `download_to_cache` calls of - // DOWNLOAD_SEMAPHORE slots. Same pattern as fresh-lock - // tarball prefetch — install path always gets ≥48 slots. tokio::spawn(async move { - prefetch_to_cache(&name, &version, &tarball_url).await; + download_to_cache(&name, &version, &tarball_url).await; }); } }); From ed35d9c45aa20ba3375bf3e492b4c142cf93f011 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sun, 3 May 2026 09:49:39 +0800 Subject: [PATCH 12/13] perf(pm): drop install_packages depth barriers, gate parent-before-child via wait_clone_if_pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous design: install_packages walked the lock depth-by-depth, spawning a layer's tasks together and barriering on `FuturesUnordered::next()` until ALL tasks at depth N finished before starting depth N+1. With deep trees (max depth ~10 on ant-design), that's 9 layer barriers each gated by the slowest tarball at the layer's tail. Even with FuturesUnordered draining in completion order within a layer, the slowest one still pinned the next layer's start. Replace per-depth `for { FuturesUnordered::new(); push... ; await... }` with a single global `FuturesUnordered`. Each task captures its parent's install path and calls `wait_clone_if_pending(parent)` before its own `clone_package_once` — same pattern the BFS pipeline's `clone_worker` already uses. The OnceMap notify mechanism enforces parent-before-child correctness without a wall-time barrier. `parent_install_path` walks back to the last `/node_modules/` separator in the lockfile path. Top-level `node_modules/foo` paths have no parent (no separator → None) and skip the wait. Effect: depth-1 packages whose parents have already cloned (via pipeline pre-clone or fast install) start their own work in parallel with depth-0 stragglers, reclaiming wall time previously spent barrier-waiting. --- crates/pm/src/service/install.rs | 50 +++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/crates/pm/src/service/install.rs b/crates/pm/src/service/install.rs index d94b2c16a4..465dabe600 100644 --- a/crates/pm/src/service/install.rs +++ b/crates/pm/src/service/install.rs @@ -17,7 +17,7 @@ use crate::helper::workspace::init_project_root; use crate::model::package::PackageInfo; use crate::service::rebuild::RebuildService; use crate::util::cli_enum::{OmitType, PackageAction, SaveType}; -use crate::util::cloner::{CLONE_CACHE, cache_key, clone_count}; +use crate::util::cloner::{CLONE_CACHE, cache_key, clone_count, wait_clone_if_pending}; use crate::util::downloader::{download_stats, is_git_url, prefetch_to_cache}; use crate::util::json::load_package_lock_json_from_path; use crate::util::linker::link; @@ -64,6 +64,16 @@ fn should_omit_package(package: &Package, omit: &HashSet) -> bool { false } +/// Compute the install path of `path`'s parent package, if any. +/// +/// Lockfile paths embed the install hierarchy via repeated +/// `node_modules/`. The parent's install path is everything up to the +/// final `/node_modules/` separator. Top-level packages +/// (`node_modules/foo`) have no parent. +fn parent_install_path(path: &str) -> Option<&str> { + path.rfind("/node_modules/").map(|i| &path[..i]) +} + pub async fn install_packages( groups: &HashMap>, cwd: &Path, @@ -77,23 +87,20 @@ pub async fn install_packages( clean_deps(groups, cwd).await?; log_progress("linking packages"); - // Always process level-by-level to ensure parent directories exist before - // children. Within each level, tasks run concurrently. The pipeline's - // clone_worker may have already cloned some packages — clone_package_once - // deduplicates via CLONE_CACHE so no double work occurs. + // Cross-depth streaming: rather than running depth-by-depth with a + // barrier between layers, fan out every package to its own task and + // gate parent-before-child via `wait_clone_if_pending(parent)` + // inside the task. Same pattern the BFS pipeline already uses for + // its `clone_worker`. Removes the (max-depth × layer-tail latency) + // wall time previously spent waiting for one slow tarball at the + // back of each layer to finish before starting the next layer. let mut depths: Vec<_> = groups.keys().cloned().collect(); depths.sort_unstable(); - for depth in depths.iter() { - // Stream task completion within a layer instead of joining serially. - // The previous `for task in clone_tasks { task.await?? }` walks tasks - // in spawn order — slow tasks block the pop of already-finished - // ones, leaving idle latency between layers on deep trees. With - // FuturesUnordered we drain in completion order, propagating the - // first error early. - let mut clone_tasks: FuturesUnordered>> = - FuturesUnordered::new(); + let clone_tasks: FuturesUnordered>> = + FuturesUnordered::new(); + for depth in depths.iter() { if let Some(packages) = groups.get(depth) { for (path, package) in packages.iter() { // Skip packages based on omit config @@ -163,7 +170,15 @@ pub async fn install_packages( let is_optional = package.optional == Some(true) || package.dev_optional == Some(true); + // Capture the parent's install path so the child can + // `wait_clone_if_pending` before its own clone, lifting + // the cross-depth ordering out of an explicit barrier. + let parent_path = parent_install_path(&path).map(|p| cwd_clone.join(p)); + let task = tokio::spawn(async move { + if let Some(parent) = parent_path { + wait_clone_if_pending(&parent.to_string_lossy()).await; + } if let Err(e) = clone_package_once(&name, &version, &resolved, &target_path).await { @@ -186,10 +201,11 @@ pub async fn install_packages( } } } + } - while let Some(joined) = clone_tasks.next().await { - joined??; - } + let mut clone_tasks = clone_tasks; + while let Some(joined) = clone_tasks.next().await { + joined??; } Ok(()) From e74c35aa0c5e537f729a86f52d21ca3c568da460 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sun, 3 May 2026 10:33:39 +0800 Subject: [PATCH 13/13] Revert "perf(pm): drop install_packages depth barriers, gate parent-before-child via wait_clone_if_pending" This reverts commit ed35d9c45aa20ba3375bf3e492b4c142cf93f011. --- crates/pm/src/service/install.rs | 50 +++++++++++--------------------- 1 file changed, 17 insertions(+), 33 deletions(-) diff --git a/crates/pm/src/service/install.rs b/crates/pm/src/service/install.rs index 465dabe600..d94b2c16a4 100644 --- a/crates/pm/src/service/install.rs +++ b/crates/pm/src/service/install.rs @@ -17,7 +17,7 @@ use crate::helper::workspace::init_project_root; use crate::model::package::PackageInfo; use crate::service::rebuild::RebuildService; use crate::util::cli_enum::{OmitType, PackageAction, SaveType}; -use crate::util::cloner::{CLONE_CACHE, cache_key, clone_count, wait_clone_if_pending}; +use crate::util::cloner::{CLONE_CACHE, cache_key, clone_count}; use crate::util::downloader::{download_stats, is_git_url, prefetch_to_cache}; use crate::util::json::load_package_lock_json_from_path; use crate::util::linker::link; @@ -64,16 +64,6 @@ fn should_omit_package(package: &Package, omit: &HashSet) -> bool { false } -/// Compute the install path of `path`'s parent package, if any. -/// -/// Lockfile paths embed the install hierarchy via repeated -/// `node_modules/`. The parent's install path is everything up to the -/// final `/node_modules/` separator. Top-level packages -/// (`node_modules/foo`) have no parent. -fn parent_install_path(path: &str) -> Option<&str> { - path.rfind("/node_modules/").map(|i| &path[..i]) -} - pub async fn install_packages( groups: &HashMap>, cwd: &Path, @@ -87,20 +77,23 @@ pub async fn install_packages( clean_deps(groups, cwd).await?; log_progress("linking packages"); - // Cross-depth streaming: rather than running depth-by-depth with a - // barrier between layers, fan out every package to its own task and - // gate parent-before-child via `wait_clone_if_pending(parent)` - // inside the task. Same pattern the BFS pipeline already uses for - // its `clone_worker`. Removes the (max-depth × layer-tail latency) - // wall time previously spent waiting for one slow tarball at the - // back of each layer to finish before starting the next layer. + // Always process level-by-level to ensure parent directories exist before + // children. Within each level, tasks run concurrently. The pipeline's + // clone_worker may have already cloned some packages — clone_package_once + // deduplicates via CLONE_CACHE so no double work occurs. let mut depths: Vec<_> = groups.keys().cloned().collect(); depths.sort_unstable(); - let clone_tasks: FuturesUnordered>> = - FuturesUnordered::new(); - for depth in depths.iter() { + // Stream task completion within a layer instead of joining serially. + // The previous `for task in clone_tasks { task.await?? }` walks tasks + // in spawn order — slow tasks block the pop of already-finished + // ones, leaving idle latency between layers on deep trees. With + // FuturesUnordered we drain in completion order, propagating the + // first error early. + let mut clone_tasks: FuturesUnordered>> = + FuturesUnordered::new(); + if let Some(packages) = groups.get(depth) { for (path, package) in packages.iter() { // Skip packages based on omit config @@ -170,15 +163,7 @@ pub async fn install_packages( let is_optional = package.optional == Some(true) || package.dev_optional == Some(true); - // Capture the parent's install path so the child can - // `wait_clone_if_pending` before its own clone, lifting - // the cross-depth ordering out of an explicit barrier. - let parent_path = parent_install_path(&path).map(|p| cwd_clone.join(p)); - let task = tokio::spawn(async move { - if let Some(parent) = parent_path { - wait_clone_if_pending(&parent.to_string_lossy()).await; - } if let Err(e) = clone_package_once(&name, &version, &resolved, &target_path).await { @@ -201,11 +186,10 @@ pub async fn install_packages( } } } - } - let mut clone_tasks = clone_tasks; - while let Some(joined) = clone_tasks.next().await { - joined??; + while let Some(joined) = clone_tasks.next().await { + joined??; + } } Ok(())