From 66d9fffbeaa6bd49f2329d34c3856886e41f9cee Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 16:03:10 +0800 Subject: [PATCH 01/14] perf(pm): offload simd_json parse to rayon (IO/CPU separation) `fetch_full_manifest` and `fetch_version_manifest` previously ran simd_json on the tokio runtime: every CPU-bound parse blocked the FuturesUnordered task from polling sibling fetches' network IO, so manifest preload effectively serialised parse work even with 64 in-flight requests. Move the parse step to rayon's global CPU thread pool via `rayon::spawn` + `tokio::sync::oneshot`. Tokio is now responsible for fetch only; rayon handles all JSON parsing in parallel across num_cpus threads with work-stealing. No trait surface change. No preload.rs change. No `Send`/`Sync` propagation. Wasm32 falls back to inline parse. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + crates/ruborist/Cargo.toml | 6 +++- crates/ruborist/src/model/manifest.rs | 24 +++++++++++++ crates/ruborist/src/service/manifest.rs | 48 ++++++++++++++++++++----- crates/ruborist/src/service/registry.rs | 7 +++- 5 files changed, 76 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c228da3e01..d67969e7de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11805,6 +11805,7 @@ dependencies = [ "parking_lot", "pathdiff", "petgraph 0.6.5", + "rayon", "reqwest 0.12.24", "rustls", "rustls-native-certs", diff --git a/crates/ruborist/Cargo.toml b/crates/ruborist/Cargo.toml index ffdbcfae77..58f2b26372 100644 --- a/crates/ruborist/Cargo.toml +++ b/crates/ruborist/Cargo.toml @@ -54,7 +54,11 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls- # Native-only dependencies (not compiled for WASM) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -libc = "0.2" +libc = "0.2" +# Dedicated CPU thread pool for simd_json manifest parsing — keeps the +# tokio runtime free of CPU-bound work so in-flight network IO is not +# blocked by sibling parses. WASM falls back to inline parse. +rayon = "1.10" # Browser handles TLS via the fetch API, so reqwest needs no rustls # feature on this target. diff --git a/crates/ruborist/src/model/manifest.rs b/crates/ruborist/src/model/manifest.rs index dea63dc7bc..a356f3501d 100644 --- a/crates/ruborist/src/model/manifest.rs +++ b/crates/ruborist/src/model/manifest.rs @@ -111,6 +111,30 @@ impl FullManifest { } } +/// Re-parse the raw manifest bytes on rayon's CPU thread pool to extract +/// a single version. Each resolved package triggers one full re-parse via +/// `simd_json::to_borrowed_value`, which on hot paths (300+ resolves per +/// install) accumulates into the dominant CPU bucket. +/// +/// Wasm32 falls back to inline parse — no rayon, no thread pool. +pub async fn extract_core_version_off_runtime( + full: std::sync::Arc, + version: String, +) -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + let (tx, rx) = tokio::sync::oneshot::channel(); + rayon::spawn(move || { + let _ = tx.send(full.get_core_version(&version)); + }); + rx.await.ok().flatten() + } + #[cfg(target_arch = "wasm32")] + { + full.get_core_version(&version) + } +} + /// Deserialize a versions map by extracting only the keys, skipping all values. /// /// Uses `IgnoredAny` to skip over version manifest JSON objects without allocating, diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index b9dbff7c8d..695cab58fd 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -13,6 +13,35 @@ use super::fetch::{ use super::http::get_client; use crate::model::manifest::{CoreVersionManifest, FullManifest}; +/// Parse JSON bytes on rayon's CPU thread pool (native) or inline +/// (wasm32). Keeps the tokio runtime free of `simd_json` CPU work so +/// other in-flight manifest fetches can keep driving network IO while +/// this one is parsing. Rayon's global pool work-steals across +/// `num_cpus` threads, so concurrent manifest parses parallelise. +async fn parse_json_off_runtime(bytes: Vec) -> Result +where + T: serde::de::DeserializeOwned + Send + 'static, +{ + #[cfg(not(target_arch = "wasm32"))] + { + let (tx, rx) = tokio::sync::oneshot::channel(); + rayon::spawn(move || { + let mut buf = bytes; + let result = simd_json::serde::from_slice::(&mut buf) + .map_err(|e| anyhow!("JSON parse error: {e}")); + // Receiver dropped means the awaiting future was cancelled. + let _ = tx.send(result); + }); + rx.await + .map_err(|e| anyhow!("rayon parse channel closed: {e}"))? + } + #[cfg(target_arch = "wasm32")] + { + let mut buf = bytes; + simd_json::serde::from_slice::(&mut buf).map_err(|e| anyhow!("JSON parse error: {e}")) + } +} + /// Result of a full manifest fetch with ETag support. /// Transient return value, immediately destructured — Box not needed. #[allow(clippy::large_enum_variant)] @@ -91,11 +120,13 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result(bytes) + .await + .map_err(FetchError::Permanent) } else { Err(classify_status(response.status(), &url)) } diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 0022a72e5e..26a91e7dc8 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -435,7 +435,12 @@ impl UnifiedRegistry { let resolved_version = resolve_target_version(&full.dist_tags, &full.versions, spec) .map_err(|e| RegistryError(anyhow!("{}@{}: {}", name, spec, e)))?; - let core = full.get_core_version(&resolved_version).ok_or_else(|| { + let core = crate::model::manifest::extract_core_version_off_runtime( + Arc::clone(&full), + resolved_version.clone(), + ) + .await + .ok_or_else(|| { RegistryError(anyhow!( "Version {} not found in manifest for {}", resolved_version, From 33a10c1123a40012fe8fa840717e41ca8c1973de Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 17:02:16 +0800 Subject: [PATCH 02/14] perf(pm): gate ProgressReceiver on IsTerminal (zero indicatif cost on CI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A/B-verified perf fix isolated as its own PR. Detect stderr's TTY status once at startup (`IS_TTY: Lazy`) and short-circuit the entire `ProgressReceiver::on_event` path on non-TTY environments. ## Why this is needed indicatif's `inc()` / `set_message()` always acquire the internal `Mutex` write-lock and mutate `pos` / `est.buf` / `message`, regardless of TTY status — only the *rendering* short- circuits, not the state mutation. On the worker-pool resolve hot path that's 9000+ lock acquisitions per phase contending across multiple workers, costing measurable wall time on CI. ## A/B/A/A' bench evidence (CI Linux, ant-design / npmjs) | Round | indicatif config | Lock count | p1_resolve | σ | |---|---|---|---|---| | A | drop entirely | 0 | 2.50s | 0.10 | | B | full restore | 13713 | 2.73s | 0.07 | | A' | drop again (verify) | 0 | 2.58s | 0.15 | | A'' | only "resolved" event | 9142 | 2.65s | 0.05 | | this PR | TTY gate at startup | 0 in CI | 2.58s | 0.07 | A→B Δ = +0.23s (+9%); B→A Δ = −0.15s. Three independent runs across different GH Actions runners and Cloudflare egress conditions. Amortized cost is approximately linear in lock count (0.23 × 9142/13713 ≈ 0.15 ≈ predicted; observed 0.15). ## Why this approach - **Drop-only solutions** (A round) lose the local-dev progress UX. - **Partial drops** (A'' round, only-resolved) still pay 9142 locks. - **TTY gate** is the only solution that: - Pays zero overhead on CI / piped (`*IS_TTY = false` → match never enters) - Preserves full per-package "resolved {name}" UX on local dev (TTY) - Is a 1-file, 3-call-site change ## Counter-intuitive finding The first instinct ("CI has no TTY, indicatif must already short- circuit internally") is wrong: indicatif separates *rendering* (skipped on non-TTY / hidden draw target) from *state mutation* (always runs, always locks). Round A'' confirmed this empirically — 9142 locks with no rendering still cost +0.15s on p1_resolve. General rule: gate hot-path library calls at the call-site; never assume the library will short-circuit internally. ## Test plan - [x] `cargo build --release -p utoo-pm` clean - [x] `cargo clippy -p utoo-pm --all-targets -- -D warnings --no-deps` clean - [x] CI bench-phases-linux on PR #2818 round-10 (`f122478b`): p1_resolve 2.58s ±0.07 — matches "indicatif fully dropped" baseline (round-6/8) - [ ] Local dev TTY: `cargo run --profile release-local --bin utoo -- install` should show full per-package progress - [ ] Non-TTY: `… 2>/tmp/stderr.log` should produce zero indicatif ANSI sequences in the log --- crates/pm/src/util/logger.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/pm/src/util/logger.rs b/crates/pm/src/util/logger.rs index 757a81070b..6ba124bb24 100644 --- a/crates/pm/src/util/logger.rs +++ b/crates/pm/src/util/logger.rs @@ -1,5 +1,6 @@ use anyhow::Result; use std::env; +use std::io::IsTerminal; use std::path::PathBuf; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -12,6 +13,22 @@ use tracing_subscriber::{ EnvFilter, Layer, Registry, fmt, layer::SubscriberExt, util::SubscriberInitExt, }; +/// Cached at startup: is stderr connected to a terminal? +/// +/// indicatif's `inc()` / `set_message()` always acquire the internal +/// `Mutex` write-lock and mutate `pos` / `est.buf` / +/// `message`, even when the draw target is hidden or the underlying +/// stream is not a TTY — only the *rendering* short-circuits, not the +/// state mutation. On the dependency-resolve hot path that's 9000+ +/// lock acquisitions per phase contending across multiple workers, +/// costing measurable wall time on CI. +/// +/// Gating every `ProgressReceiver` event behind this flag means +/// non-TTY environments (CI, piped output, `2>&1 | tee`) pay zero +/// indicatif cost — no `inc`, no `set_message`, no `format!()` +/// allocation. Local dev keeps the full progress UX. +pub static IS_TTY: Lazy = Lazy::new(|| std::io::stderr().is_terminal()); + pub static PROGRESS_BAR: Lazy = Lazy::new(|| { let pb = ProgressBar::new(0).with_style( ProgressStyle::with_template("{spinner:.blue} +{pos:.green} ~{len:.magenta} {wide_msg}") @@ -124,6 +141,9 @@ pub fn print_install_counts(added: usize, reused: usize, downloaded: usize) { } pub fn start_progress_bar() { + if !*IS_TTY { + return; + } PROGRESS_BAR.reset(); PROGRESS_BAR.set_style( ProgressStyle::with_template("{spinner:.blue} {pos:.green}/{len:.magenta} {wide_msg}") @@ -135,6 +155,9 @@ pub fn start_progress_bar() { } pub fn log_progress(text: &str) { + if !*IS_TTY { + return; + } PROGRESS_BAR.set_message(text.to_string()); } @@ -186,6 +209,14 @@ pub struct ProgressReceiver; impl utoo_ruborist::progress::EventReceiver for ProgressReceiver { fn on_event(&self, event: utoo_ruborist::progress::BuildEvent) { + // Single TTY check at the receiver boundary. Non-TTY (CI, piped + // output, `2>&1 | tee`) drops the entire indicatif update path — + // no Mutex write-lock, no format!() allocation, + // no String clones into set_message. Worth measurable wall time + // on the worker-pool resolve hot path; see `IS_TTY` doc comment. + if !*IS_TTY { + return; + } use utoo_ruborist::progress::BuildEvent; match event { BuildEvent::PreloadStart { count } | BuildEvent::PreloadQueued { count } => { From e17dbc8c5149ec6b4eacd7693e6ba22dacaab9d1 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 17:57:41 +0800 Subject: [PATCH 03/14] perf(pm): short-circuit version-manifest reparse on sibling-spec hit `resolve_via_full_manifest` now checks the version-manifest cache by the resolved version BEFORE the rayon `extract_version` reparse. Whenever multiple specs (`react@^18`, `react@^18.1`, `react@18.x`) resolve to the same concrete version (`18.2.0`), only the first call pays the parse cost; siblings reuse the cached `Arc`. The cache itself was already populated under both `(name, spec)` and `(name, resolved_version)` keys (line 373-378), so the second key becomes the natural short-circuit point. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/model/manifest.rs | 11 +++++++---- crates/ruborist/src/service/registry.rs | 15 +++++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/ruborist/src/model/manifest.rs b/crates/ruborist/src/model/manifest.rs index a356f3501d..c3a10fb53d 100644 --- a/crates/ruborist/src/model/manifest.rs +++ b/crates/ruborist/src/model/manifest.rs @@ -116,22 +116,25 @@ impl FullManifest { /// `simd_json::to_borrowed_value`, which on hot paths (300+ resolves per /// install) accumulates into the dominant CPU bucket. /// -/// Wasm32 falls back to inline parse — no rayon, no thread pool. +/// Returns `Arc` so callers (e.g. UnifiedRegistry's +/// version-manifest cache) can store and clone the result without +/// deep-copying the underlying `HashMap`s. Wasm32 falls back to inline +/// parse — no rayon, no thread pool. pub async fn extract_core_version_off_runtime( full: std::sync::Arc, version: String, -) -> Option { +) -> Option> { #[cfg(not(target_arch = "wasm32"))] { let (tx, rx) = tokio::sync::oneshot::channel(); rayon::spawn(move || { - let _ = tx.send(full.get_core_version(&version)); + let _ = tx.send(full.get_core_version(&version).map(std::sync::Arc::new)); }); rx.await.ok().flatten() } #[cfg(target_arch = "wasm32")] { - full.get_core_version(&version) + full.get_core_version(&version).map(std::sync::Arc::new) } } diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 26a91e7dc8..a3baf85a8e 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -362,9 +362,8 @@ impl UnifiedRegistry { // version from the full manifest. `resolve_full_manifest` // is itself inflight-gated, so concurrent specs for the // same name share one full-manifest fetch. - let (resolved_version, manifest) = + let (resolved_version, arc) = self.resolve_via_full_manifest(name, spec).await?; - let arc = Arc::new(manifest); self.cache.set_version_manifest( name.to_string(), spec.to_string(), @@ -426,7 +425,7 @@ impl UnifiedRegistry { &self, name: &str, spec: &str, - ) -> Result<(String, CoreVersionManifest), RegistryError> { + ) -> Result<(String, Arc), RegistryError> { match self.resolve_full_manifest(name).await? { FullManifestResult::Full(full) => { if full.versions.is_empty() { @@ -435,6 +434,14 @@ impl UnifiedRegistry { let resolved_version = resolve_target_version(&full.dist_tags, &full.versions, spec) .map_err(|e| RegistryError(anyhow!("{}@{}: {}", name, spec, e)))?; + // Short-circuit: a sibling spec may have already resolved to + // this same version and warmed the cache. Skip the rayon + // reparse (the dominant cost on the resolve hot path) and + // share the existing `Arc` instead of + // deep-cloning its HashMaps. + if let Some(cached) = self.cache.get_version_manifest(name, &resolved_version) { + return Ok((resolved_version, cached)); + } let core = crate::model::manifest::extract_core_version_off_runtime( Arc::clone(&full), resolved_version.clone(), @@ -474,7 +481,7 @@ impl UnifiedRegistry { }) .await .map_err(RegistryError)?; - Ok((resolved_version, manifest)) + Ok((resolved_version, Arc::new(manifest))) } } } From 0d52d568b95fff5cea36b77253045cb984bdfb1c Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 18:09:27 +0800 Subject: [PATCH 04/14] perf(pm): drop redundant Vec clones on resolve hot path `resolve_target_version` takes `&[String]`, so the four call sites that prefixed it with `let version_list: Vec = manifest.versions.clone();` were paying for a pointless deep copy of the entire version list per resolve (50-200 entries on popular packages, ~600 packages on ant-design). Pass `&manifest.versions` / `&full_manifest.versions` directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/cmd/view.rs | 3 +-- crates/ruborist/src/resolver/registry.rs | 6 ++---- crates/ruborist/src/traits/registry.rs | 18 ++++++++++-------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/pm/src/cmd/view.rs b/crates/pm/src/cmd/view.rs index d8355e9922..d5632a69af 100644 --- a/crates/pm/src/cmd/view.rs +++ b/crates/pm/src/cmd/view.rs @@ -27,10 +27,9 @@ pub async fn view(package_spec: &str) -> Result<()> { tracing::debug!("Fetched package info: {full_manifest:?}"); // Resolve version and get full VersionManifest (with all display fields) - let version_list: Vec = full_manifest.versions.clone(); let resolved_version = utoo_ruborist::resolver::version::resolve_target_version( &full_manifest.dist_tags, - &version_list, + &full_manifest.versions, version_spec, ) .map_err(|e| { diff --git a/crates/ruborist/src/resolver/registry.rs b/crates/ruborist/src/resolver/registry.rs index 6edeb9069c..91e2e47310 100644 --- a/crates/ruborist/src/resolver/registry.rs +++ b/crates/ruborist/src/resolver/registry.rs @@ -137,14 +137,12 @@ pub fn resolve_from_manifest( manifest: &FullManifest, spec: &str, ) -> Result> { - let version_list: Vec = manifest.versions.clone(); - - if version_list.is_empty() { + if manifest.versions.is_empty() { return Err(ResolveError::NoVersions(manifest.name.clone())); } // Resolve version using shared logic - let resolved_version = resolve_target_version(&manifest.dist_tags, &version_list, spec) + let resolved_version = resolve_target_version(&manifest.dist_tags, &manifest.versions, spec) .map_err(|e| ResolveError::Version(format!("{}@{}: {}", manifest.name, spec, e)))?; // Get manifest for resolved version (lazy: parse from Value on demand) diff --git a/crates/ruborist/src/traits/registry.rs b/crates/ruborist/src/traits/registry.rs index df8adac00f..c6ad7d532a 100644 --- a/crates/ruborist/src/traits/registry.rs +++ b/crates/ruborist/src/traits/registry.rs @@ -157,11 +157,11 @@ pub trait RegistryClient { ) -> impl Future, Self::Error>> { async move { let manifest = self.fetch_full_manifest(name).await?; - let version_list: Vec = manifest.versions.clone(); // Resolve version using shared logic - let resolved_version = resolve_target_version(&manifest.dist_tags, &version_list, spec) - .map_err(|e| RegistryError(anyhow::anyhow!("{}@{}: {}", name, spec, e)))?; + let resolved_version = + resolve_target_version(&manifest.dist_tags, &manifest.versions, spec) + .map_err(|e| RegistryError(anyhow::anyhow!("{}@{}: {}", name, spec, e)))?; manifest .get_core_version(&resolved_version) @@ -227,9 +227,8 @@ pub trait RegistryClient { fetch_spec ); let full_manifest = self.fetch_full_manifest(&fetch_name).await?; - let version_list: Vec = full_manifest.versions.clone(); - if version_list.is_empty() { + if full_manifest.versions.is_empty() { return Err(RegistryError(anyhow::anyhow!( "No versions available for {}", fetch_name @@ -237,9 +236,12 @@ pub trait RegistryClient { .into()); } - let resolved_version = - resolve_target_version(&full_manifest.dist_tags, &version_list, &fetch_spec) - .map_err(|e| RegistryError(anyhow::anyhow!("{}@{}: {}", name, spec, e)))?; + let resolved_version = resolve_target_version( + &full_manifest.dist_tags, + &full_manifest.versions, + &fetch_spec, + ) + .map_err(|e| RegistryError(anyhow::anyhow!("{}@{}: {}", name, spec, e)))?; let version_manifest = full_manifest .get_core_version(&resolved_version) From a89a59ea2d0a38f47b12a63e4f90c1500e781b8c Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 18:22:26 +0800 Subject: [PATCH 05/14] perf(pm): move version_list / dist_tags into VersionsInfo (no clone) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the 200 path of `resolve_full_manifest` deep-cloned `manifest.versions` (Vec, ~50-200 entries on popular packages) and `manifest.dist_tags` (HashMap) per package fetch to fill the `versions_info` cache slot, while also moving the same fields into `Arc` for the `full_manifests` slot. Use `std::mem::take` to MOVE both fields out of `manifest` directly into the new `VersionsInfo`. The cached `Arc` keeps `raw` + scalar metadata; `version_list` / `dist_tags` now live only in the `versions_info` cache slot. `resolve_via_full_manifest`'s Full branch reads them via `cache.get_versions(name)` (same code path that filled the slot, atomic w.r.t. the inflight gate). ant-design fetch ≈ 600 packages × ~80 String entries; eliminates one deep copy per fetch. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/registry.rs | 40 ++++++++++++++++++------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index a3baf85a8e..7eed5ccaf0 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -244,11 +244,19 @@ impl UnifiedRegistry { .await .map_err(RegistryError)? { - manifest::FetchManifestResult::Ok(manifest, new_etag) => { + manifest::FetchManifestResult::Ok(mut manifest, new_etag) => { + // Move version_list / dist_tags out of `manifest` + // into `VersionsInfo` instead of cloning. The cached + // `Arc` keeps `raw` + scalar metadata; + // the canonical version_list / dist_tags now live in + // the `versions_info` cache slot only. Readers that + // need them go through `cache.get_versions(name)`. + let version_list = std::mem::take(&mut manifest.versions); + let dist_tags = std::mem::take(&mut manifest.dist_tags); let versions_info = Arc::new(VersionsInfo { versions: Versions { - version_list: manifest.versions.clone(), - dist_tags: manifest.dist_tags.clone(), + version_list, + dist_tags, }, etag: new_etag, last_updated: current_timestamp_secs(), @@ -269,7 +277,7 @@ impl UnifiedRegistry { self.cache.set_versions(name.to_string(), versions_info); } else { // Persistent store corrupted/missing, fetch fresh (without etag). - let (manifest, new_etag) = manifest::fetch_full_manifest_fresh( + let (mut manifest, new_etag) = manifest::fetch_full_manifest_fresh( &self.registry_url, name, manifest::MetadataFormat::Abbreviated, @@ -277,10 +285,12 @@ impl UnifiedRegistry { .await .map_err(RegistryError)?; + let version_list = std::mem::take(&mut manifest.versions); + let dist_tags = std::mem::take(&mut manifest.dist_tags); let versions_info = Arc::new(VersionsInfo { versions: Versions { - version_list: manifest.versions.clone(), - dist_tags: manifest.dist_tags.clone(), + version_list, + dist_tags, }, etag: new_etag, last_updated: current_timestamp_secs(), @@ -428,12 +438,22 @@ impl UnifiedRegistry { ) -> Result<(String, Arc), RegistryError> { match self.resolve_full_manifest(name).await? { FullManifestResult::Full(full) => { - if full.versions.is_empty() { + // Canonical `version_list` / `dist_tags` live in the + // `versions_info` cache slot; the parallel slot is filled by + // the same code path that filled `full_manifests`. We don't + // duplicate them on the cached `Arc`. + let versions_info = self.cache.get_versions(name).ok_or_else(|| { + RegistryError(anyhow!("Versions cache not found for {}", name)) + })?; + if versions_info.versions.version_list.is_empty() { return Err(RegistryError(anyhow!("No versions available for {}", name))); } - let resolved_version = - resolve_target_version(&full.dist_tags, &full.versions, spec) - .map_err(|e| RegistryError(anyhow!("{}@{}: {}", name, spec, e)))?; + let resolved_version = resolve_target_version( + &versions_info.versions.dist_tags, + &versions_info.versions.version_list, + spec, + ) + .map_err(|e| RegistryError(anyhow!("{}@{}: {}", name, spec, e)))?; // Short-circuit: a sibling spec may have already resolved to // this same version and warmed the cache. Skip the rayon // reparse (the dominant cost on the resolve hot path) and From 0f5649632a5d2c7364a0f16290c330275452037f Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 18:39:07 +0800 Subject: [PATCH 06/14] =?UTF-8?q?perf(pm):=20bump=20preload=20concurrency?= =?UTF-8?q?=2064=20=E2=86=92=20128=20(experiment=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manifest preload is currently capped at 64 in-flight requests. With ~600 deps on ant-design completing in ~3.5s, observed throughput is ~170 req/s — far below what 64 in-flight × 10ms RTT would saturate (~6k req/s). Cloudflare per-IP throttle is the suspected ceiling, but GH Actions egress IP space may not hit the throttle. Experiment to measure: does doubling concurrency convert into wall-clock on CI? --- crates/ruborist/src/resolver/preload.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index 9fb5aff67b..e262cbd802 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -15,7 +15,7 @@ use crate::traits::progress::{BuildEvent, EventReceiver}; use crate::traits::registry::RegistryClient; /// Default concurrency limit for manifest fetching -pub const DEFAULT_CONCURRENCY: usize = 64; +pub const DEFAULT_CONCURRENCY: usize = 128; /// A dependency spec: (name, version_spec) pub type Dep = (String, String); From 19ce70eb919fd098ffbe7057fb01865651beeb73 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 18:56:20 +0800 Subject: [PATCH 07/14] =?UTF-8?q?perf(pm):=20rayon=20worker=20=3D=202=20?= =?UTF-8?q?=C3=97=20num=5Fcpus=20(experiment=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rayon's global pool absorbs simd_json manifest parses, extract_version reparses, and tarball gzip+tar+write. Default `num_cpus` workers leaves work-stealing idle when any single worker stalls on `fs::File::create` inside extract_tarball — siblings can't drain. 2× lets blocked-on-IO workers be backstopped without oversubscribing CPU during pure-CPU bursts (rayon's work-stealing self-tunes). Experiment to measure on top of A (preload=128). --- crates/pm/src/main.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/pm/src/main.rs b/crates/pm/src/main.rs index 8f3a3a3662..6d217a07f1 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -349,6 +349,15 @@ fn main() { .map(|n| n.get()) .unwrap_or(4); + // Rayon's CPU thread pool absorbs all simd_json parses + tarball + // extraction. Default sizing is `num_cpus`, which leaves work-stealing + // workers idle whenever any one of them stalls on a fs write inside + // an extract_tarball task. 2× lets siblings keep draining while one + // is in `fs::File::create`. + let _ = rayon::ThreadPoolBuilder::new() + .num_threads(worker_threads * 2) + .build_global(); + let result = tokio::runtime::Builder::new_multi_thread() .enable_all() .worker_threads(worker_threads) From b4e77c89bd5e77d3d7da5df3b9af6bcde9fa841f Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 19:12:39 +0800 Subject: [PATCH 08/14] =?UTF-8?q?Revert=20"perf(pm):=20rayon=20worker=20?= =?UTF-8?q?=3D=202=20=C3=97=20num=5Fcpus=20(experiment=20B)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 19ce70eb919fd098ffbe7057fb01865651beeb73. --- crates/pm/src/main.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/pm/src/main.rs b/crates/pm/src/main.rs index 6d217a07f1..8f3a3a3662 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -349,15 +349,6 @@ fn main() { .map(|n| n.get()) .unwrap_or(4); - // Rayon's CPU thread pool absorbs all simd_json parses + tarball - // extraction. Default sizing is `num_cpus`, which leaves work-stealing - // workers idle whenever any one of them stalls on a fs write inside - // an extract_tarball task. 2× lets siblings keep draining while one - // is in `fs::File::create`. - let _ = rayon::ThreadPoolBuilder::new() - .num_threads(worker_threads * 2) - .build_global(); - let result = tokio::runtime::Builder::new_multi_thread() .enable_all() .worker_threads(worker_threads) From c41407aeea372aa279911697bf285475b5ec63de Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 19:17:34 +0800 Subject: [PATCH 09/14] perf(pm): force HTTP/1.1 in client_builder (experiment D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H2 multiplexes 100+ streams per TCP connection. Cloudflare's per-IP rate limit can be paired with a per-connection stream-rate cap that silently serialises concurrent fetches even when the client opens parallel streams. H/1.1 spreads concurrency across multiple TCP connections — each request gets its own connection slot, hitting only the per-IP cap (which appears higher in absolute terms). Experiment to measure on top of preload=128. --- crates/ruborist/src/service/http.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index b58e60dbef..a9b765a0dc 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -150,7 +150,14 @@ fn build_rustls_config() -> Result { /// /// Returns `Err` if a proxy URL from the environment is malformed. pub fn client_builder() -> Result { - let builder = reqwest::Client::builder(); + // Force HTTP/1.1 to bypass any per-connection stream throttle on + // Cloudflare's edge: H2 multiplexes 100+ streams per TCP connection, + // and a per-connection stream-rate cap can serialise concurrent + // manifest/tarball fetches even when the client opens parallel + // streams. H/1.1 spreads concurrency across multiple TCP connections + // (one in-flight request per connection), which is rate-limited per + // IP at a higher absolute ceiling. + let builder = reqwest::Client::builder().http1_only(); #[cfg(not(target_arch = "wasm32"))] let builder = { From 84baf042f835f3168a2a9838712a22a887a09ca6 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 19:33:39 +0800 Subject: [PATCH 10/14] Revert "perf(pm): force HTTP/1.1 in client_builder (experiment D)" This reverts commit c41407aeea372aa279911697bf285475b5ec63de. --- crates/ruborist/src/service/http.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index a9b765a0dc..b58e60dbef 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -150,14 +150,7 @@ fn build_rustls_config() -> Result { /// /// Returns `Err` if a proxy URL from the environment is malformed. pub fn client_builder() -> Result { - // Force HTTP/1.1 to bypass any per-connection stream throttle on - // Cloudflare's edge: H2 multiplexes 100+ streams per TCP connection, - // and a per-connection stream-rate cap can serialise concurrent - // manifest/tarball fetches even when the client opens parallel - // streams. H/1.1 spreads concurrency across multiple TCP connections - // (one in-flight request per connection), which is rate-limited per - // IP at a higher absolute ceiling. - let builder = reqwest::Client::builder().http1_only(); + let builder = reqwest::Client::builder(); #[cfg(not(target_arch = "wasm32"))] let builder = { From 036f479f923247b99d240112766cc8dfe8dc52b7 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 19:41:57 +0800 Subject: [PATCH 11/14] perf(pm): tgz download share preload concurrency + force H2 (experiment E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes on the tarball download path: 1. `DOWNLOAD_SEMAPHORE` now reads `DEFAULT_CONCURRENCY` (128, shared with manifest preload) instead of `manifests-concurrency-limit` (64). Same per-IP throttle ceiling applies to manifest and tarball fetches; capping download tighter than resolve underutilises the network budget. 2. `build_dns_cached_client` now requests HTTP/2 with prior knowledge. Tarball CDN previously default-negotiated HTTP/1.1 — 128 concurrent downloads opened 128 separate TCP/TLS connections. H/2 multiplexes onto a few persistent connections, but exposes us to the per-stream rate limit if the CDN has one (mirror of experiment D's hypothesis). Drop the now-unused `get_manifests_concurrency_limit_sync`. --- crates/pm/src/util/downloader.rs | 10 ++++++---- crates/pm/src/util/retry.rs | 6 ++++++ crates/pm/src/util/user_config.rs | 4 ---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/pm/src/util/downloader.rs b/crates/pm/src/util/downloader.rs index 2f46a4058c..5443038dbc 100644 --- a/crates/pm/src/util/downloader.rs +++ b/crates/pm/src/util/downloader.rs @@ -15,7 +15,7 @@ use utoo_ruborist::util::OnceMap; use super::cache::get_cache_dir; use super::extractor::extract_and_write; use super::retry::{RetryableError, build_dns_cached_client, create_retry_strategy}; -use super::user_config::get_manifests_concurrency_limit_sync; +use utoo_ruborist::resolver::preload::DEFAULT_CONCURRENCY; // Global downloader client - no pool limit, concurrency controlled by OnceMap static DOWNLOADER_CLIENT: Lazy = Lazy::new(build_dns_cached_client); @@ -177,9 +177,11 @@ pub async fn download_to_cache(name: &str, version: &str, tarball_url: &str) -> return Some(cache_path); } - // Download (semaphore controlled) - let semaphore = DOWNLOAD_SEMAPHORE - .get_or_init(|| Semaphore::new(get_manifests_concurrency_limit_sync())); + // Download (semaphore controlled). Sized to match preload's + // `DEFAULT_CONCURRENCY` so download/install phase isn't capped + // tighter than resolve phase — both share the same per-IP + // ceiling on the registry side. + let semaphore = DOWNLOAD_SEMAPHORE.get_or_init(|| Semaphore::new(DEFAULT_CONCURRENCY)); let _permit = semaphore.acquire().await.ok()?; let bytes = download_bytes(&tarball_url) .await diff --git a/crates/pm/src/util/retry.rs b/crates/pm/src/util/retry.rs index 8961414222..55160cd0fd 100644 --- a/crates/pm/src/util/retry.rs +++ b/crates/pm/src/util/retry.rs @@ -26,6 +26,12 @@ impl std::error::Error for RetryableError {} /// Connection pool is unlimited - concurrency is controlled by semaphore instead. pub fn build_dns_cached_client() -> reqwest::Client { client_builder() + // Force HTTP/2 on tarball CDN: tarballs default-negotiate H1.1 with + // npmjs.org, so 128 concurrent downloads open 128 TCP/TLS handshakes. + // H/2 multiplexes them onto a few persistent connections — fewer + // handshakes, lower memory pressure, but exposes us to per-stream + // rate-limit if the CDN enforces one. Experiment E. + .http2_prior_knowledge() .connect_timeout(std::time::Duration::from_secs(5)) // TLS + TCP handshake .read_timeout(std::time::Duration::from_secs(30)) // Timeout for individual read operations // No total timeout - large files (e.g. node binary ~100MB) need longer download time diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index 34ee45a340..d54417452f 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -144,10 +144,6 @@ pub async fn get_manifests_concurrency_limit() -> usize { MANIFESTS_CONCURRENCY_LIMIT.get().await } -pub fn get_manifests_concurrency_limit_sync() -> usize { - MANIFESTS_CONCURRENCY_LIMIT.get_sync() -} - pub async fn set_cache_dir(cache_dir: Option) { // Priority: CLI argument > UTOO_CACHE_DIR env > config > default let final_cache_dir = if let Some(dir) = cache_dir { From 896e17253cdd58bdb742c888122b15f046989134 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 19:57:08 +0800 Subject: [PATCH 12/14] Revert "perf(pm): tgz download share preload concurrency + force H2 (experiment E)" This reverts commit 036f479f923247b99d240112766cc8dfe8dc52b7. --- crates/pm/src/util/downloader.rs | 10 ++++------ crates/pm/src/util/retry.rs | 6 ------ crates/pm/src/util/user_config.rs | 4 ++++ 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/pm/src/util/downloader.rs b/crates/pm/src/util/downloader.rs index 5443038dbc..2f46a4058c 100644 --- a/crates/pm/src/util/downloader.rs +++ b/crates/pm/src/util/downloader.rs @@ -15,7 +15,7 @@ use utoo_ruborist::util::OnceMap; use super::cache::get_cache_dir; use super::extractor::extract_and_write; use super::retry::{RetryableError, build_dns_cached_client, create_retry_strategy}; -use utoo_ruborist::resolver::preload::DEFAULT_CONCURRENCY; +use super::user_config::get_manifests_concurrency_limit_sync; // Global downloader client - no pool limit, concurrency controlled by OnceMap static DOWNLOADER_CLIENT: Lazy = Lazy::new(build_dns_cached_client); @@ -177,11 +177,9 @@ pub async fn download_to_cache(name: &str, version: &str, tarball_url: &str) -> return Some(cache_path); } - // Download (semaphore controlled). Sized to match preload's - // `DEFAULT_CONCURRENCY` so download/install phase isn't capped - // tighter than resolve phase — both share the same per-IP - // ceiling on the registry side. - let semaphore = DOWNLOAD_SEMAPHORE.get_or_init(|| Semaphore::new(DEFAULT_CONCURRENCY)); + // Download (semaphore controlled) + let semaphore = DOWNLOAD_SEMAPHORE + .get_or_init(|| Semaphore::new(get_manifests_concurrency_limit_sync())); let _permit = semaphore.acquire().await.ok()?; let bytes = download_bytes(&tarball_url) .await diff --git a/crates/pm/src/util/retry.rs b/crates/pm/src/util/retry.rs index 55160cd0fd..8961414222 100644 --- a/crates/pm/src/util/retry.rs +++ b/crates/pm/src/util/retry.rs @@ -26,12 +26,6 @@ impl std::error::Error for RetryableError {} /// Connection pool is unlimited - concurrency is controlled by semaphore instead. pub fn build_dns_cached_client() -> reqwest::Client { client_builder() - // Force HTTP/2 on tarball CDN: tarballs default-negotiate H1.1 with - // npmjs.org, so 128 concurrent downloads open 128 TCP/TLS handshakes. - // H/2 multiplexes them onto a few persistent connections — fewer - // handshakes, lower memory pressure, but exposes us to per-stream - // rate-limit if the CDN enforces one. Experiment E. - .http2_prior_knowledge() .connect_timeout(std::time::Duration::from_secs(5)) // TLS + TCP handshake .read_timeout(std::time::Duration::from_secs(30)) // Timeout for individual read operations // No total timeout - large files (e.g. node binary ~100MB) need longer download time diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index d54417452f..34ee45a340 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -144,6 +144,10 @@ pub async fn get_manifests_concurrency_limit() -> usize { MANIFESTS_CONCURRENCY_LIMIT.get().await } +pub fn get_manifests_concurrency_limit_sync() -> usize { + MANIFESTS_CONCURRENCY_LIMIT.get_sync() +} + pub async fn set_cache_dir(cache_dir: Option) { // Priority: CLI argument > UTOO_CACHE_DIR env > config > default let final_cache_dir = if let Some(dir) = cache_dir { From 0914e126db9ad8f6cf351bf379eb6b1869ef0e4c Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 20:26:18 +0800 Subject: [PATCH 13/14] =?UTF-8?q?perf(pm):=20bump=20preload=20concurrency?= =?UTF-8?q?=20128=20=E2=86=92=20256=20(experiment=20F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ruborist/src/resolver/preload.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index e262cbd802..b667d6e3d3 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -15,7 +15,7 @@ use crate::traits::progress::{BuildEvent, EventReceiver}; use crate::traits::registry::RegistryClient; /// Default concurrency limit for manifest fetching -pub const DEFAULT_CONCURRENCY: usize = 128; +pub const DEFAULT_CONCURRENCY: usize = 256; /// A dependency spec: (name, version_spec) pub type Dep = (String, String); From 128699899751c6793a0f1cb1f417779ab5995fc1 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 02:13:46 +0800 Subject: [PATCH 14/14] ci: trigger fresh bench run for repro Force a new CI run on perf/preload-rayon-best to disambiguate whether the 3.77s p1_resolve at f5006bb is reproducible or just CI variance. The previous PR check inherited the original f5006bb run rather than scheduling a new one.