From 7e7455cae3d5636298b02cdb3de20b93bf7cafe5 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 16:03:10 +0800 Subject: [PATCH 01/10] 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 e56168ee05..d9488e2878 100644 --- a/crates/ruborist/src/model/manifest.rs +++ b/crates/ruborist/src/model/manifest.rs @@ -149,6 +149,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 4e796d3f8e..fdbd4c3782 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)] @@ -89,11 +118,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 a978be50d8..4e7380c2ff 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -440,7 +440,12 @@ impl UnifiedRegistry { } let resolved_version = resolve_target_version((&*full).into(), 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 0aa631bb2a520a6b620654b07f07676fe11ed9e6 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 17:57:41 +0800 Subject: [PATCH 02/10] 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 d9488e2878..b6bd54c7a0 100644 --- a/crates/ruborist/src/model/manifest.rs +++ b/crates/ruborist/src/model/manifest.rs @@ -154,22 +154,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 4e7380c2ff..ffec5a8250 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -369,9 +369,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(), @@ -432,7 +431,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() { @@ -440,6 +439,14 @@ impl UnifiedRegistry { } let resolved_version = resolve_target_version((&*full).into(), 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(), @@ -475,7 +482,7 @@ impl UnifiedRegistry { }) .await .map_err(RegistryError)?; - Ok((resolved_version, manifest)) + Ok((resolved_version, Arc::new(manifest))) } } } From 83eed3a9da8b3d48408f4c7130d21e371a596f4c Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 18:39:07 +0800 Subject: [PATCH 03/10] =?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 14824b6cec..1230c5bf68 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 75191da90385ff0c07557b306e9b0480e9cf8008 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 21:33:14 +0800 Subject: [PATCH 04/10] chore(pm): post-review cleanup on manifest hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop fully-qualified `std::sync::Arc` in favour of the existing top-of-file `use Arc` in `model/manifest.rs`. - Declare `parse_json_off_runtime`'s `bytes` parameter `mut` and drop the redundant `let mut buf = bytes` rebind in both branches. - Trim narrating comment from `fetch_full_manifest`'s parse-buf clone; keep only the WHY (simd_json mutates the buffer). - Tighten doc comment on `extract_core_version_off_runtime` — drop the perf-snapshot prose ("dominant CPU bucket") that would rot, keep the WHY (Arc return for cache reuse). - Hoist `extract_core_version_off_runtime` into the file-level `use` group instead of inline-qualifying at the call site. - Pass `full: Arc` directly into the rayon helper instead of `Arc::clone(&full)` — the local is unused after, saves one atomic refcount bump per resolve. - Sharpen the short-circuit comment to name the inflight race window it catches (sibling spec resolves to same version while we awaited). - Fix double-space in `Cargo.toml` `libc = "0.2"`. --- crates/ruborist/Cargo.toml | 2 +- crates/ruborist/src/model/manifest.rs | 29 ++++++++++++++----------- crates/ruborist/src/service/manifest.rs | 19 ++++++---------- crates/ruborist/src/service/registry.rs | 22 +++++++++---------- 4 files changed, 34 insertions(+), 38 deletions(-) diff --git a/crates/ruborist/Cargo.toml b/crates/ruborist/Cargo.toml index 58f2b26372..b367b3bf92 100644 --- a/crates/ruborist/Cargo.toml +++ b/crates/ruborist/Cargo.toml @@ -54,7 +54,7 @@ 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. diff --git a/crates/ruborist/src/model/manifest.rs b/crates/ruborist/src/model/manifest.rs index b6bd54c7a0..37e95deb9c 100644 --- a/crates/ruborist/src/model/manifest.rs +++ b/crates/ruborist/src/model/manifest.rs @@ -149,30 +149,33 @@ 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. +/// Extract a single version from `FullManifest` on rayon's CPU pool +/// (native) or inline (wasm32). The native path keeps the tokio runtime +/// free of `simd_json::to_borrowed_value` work so sibling manifest +/// fetches keep driving network IO while this one re-parses. /// -/// 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. +/// Returns `(version, Option>)` — the input +/// `version` is handed back to the caller alongside the result so it +/// can be reused for the error path and the outer return without a +/// clone. The native path requires `'static` for the rayon closure, +/// so the closure owns the string for its duration. pub async fn extract_core_version_off_runtime( - full: std::sync::Arc, + full: Arc, version: String, -) -> Option> { +) -> (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).map(std::sync::Arc::new)); + let core = full.get_core_version(&version).map(Arc::new); + let _ = tx.send((version, core)); }); - rx.await.ok().flatten() + rx.await.expect("rayon parse worker dropped before sending") } #[cfg(target_arch = "wasm32")] { - full.get_core_version(&version).map(std::sync::Arc::new) + let core = full.get_core_version(&version).map(Arc::new); + (version, core) } } diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index fdbd4c3782..74baf3b9ca 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -14,11 +14,10 @@ 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 +/// (wasm32). Keeps the tokio runtime free of `simd_json` work so other +/// in-flight manifest fetches keep driving network IO while this one +/// parses. +async fn parse_json_off_runtime(mut bytes: Vec) -> Result where T: serde::de::DeserializeOwned + Send + 'static, { @@ -26,10 +25,8 @@ where { let (tx, rx) = tokio::sync::oneshot::channel(); rayon::spawn(move || { - let mut buf = bytes; - let result = simd_json::serde::from_slice::(&mut buf) + let result = simd_json::serde::from_slice::(&mut bytes) .map_err(|e| anyhow!("JSON parse error: {e}")); - // Receiver dropped means the awaiting future was cancelled. let _ = tx.send(result); }); rx.await @@ -37,8 +34,7 @@ where } #[cfg(target_arch = "wasm32")] { - let mut buf = bytes; - simd_json::serde::from_slice::(&mut buf).map_err(|e| anyhow!("JSON parse error: {e}")) + simd_json::serde::from_slice::(&mut bytes).map_err(|e| anyhow!("JSON parse error: {e}")) } } @@ -119,8 +115,7 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result u64 { use super::cache::{PackageCache, Versions, VersionsInfo}; use super::manifest; use super::store::{ManifestStore, NoopStore}; -use crate::model::manifest::{CoreVersionManifest, FullManifest}; +use crate::model::manifest::{CoreVersionManifest, FullManifest, extract_core_version_off_runtime}; use crate::resolver::semver::normalize_spec; use crate::resolver::version::resolve_target_version; use crate::traits::registry::{RegistryClient, RegistryError, ResolvedPackage, is_npm_registry}; @@ -439,20 +439,18 @@ impl UnifiedRegistry { } let resolved_version = resolve_target_version((&*full).into(), 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. + // Race window: while we awaited `resolve_full_manifest` (gated + // by `inflight_full`), a sibling spec for the same + // package may have resolved to this same version and populated + // `version_manifests` cache (writer at line 373-387 stores + // both `(name, spec)` and `(name, resolved_version)` keys). + // Reuse the Arc instead of paying the off-runtime reparse. 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(), - ) - .await - .ok_or_else(|| { + let (resolved_version, core) = + extract_core_version_off_runtime(full, resolved_version).await; + let core = core.ok_or_else(|| { RegistryError(anyhow!( "Version {} not found in manifest for {}", resolved_version, From c4decd9c1cbe141c4ab0fdc3c77c2dbefe2bda07 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 30 Apr 2026 21:51:52 +0800 Subject: [PATCH 05/10] chore(pm): tombi format Cargo.toml + use workspace rayon --- crates/ruborist/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ruborist/Cargo.toml b/crates/ruborist/Cargo.toml index b367b3bf92..066ee01efe 100644 --- a/crates/ruborist/Cargo.toml +++ b/crates/ruborist/Cargo.toml @@ -54,11 +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" +rayon = { workspace = true } # Browser handles TLS via the fetch API, so reqwest needs no rustls # feature on this target. From caf06408a45595b1f8bd8181176d4781b78b8390 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 05:46:06 +0800 Subject: [PATCH 06/10] ci: trigger fresh bench (variance sample) Add a sample point on the post-rebase rayon-only config to test whether the 4.79s p1 reading is post-rebase regression or single-run variance. From 2ca294ceecd8aca8d8eb7df2acadfff6c65f06c0 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 17:38:46 +0800 Subject: [PATCH 07/10] perf(ruborist): use HTTP/1.1 + connection pool for manifest fetching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reqwest multiplexes all requests over a single HTTP/2 connection by default, which causes head-of-line blocking on npm registries with high RTT: a slow tail response stalls the whole manifest fetch phase. An HTTP/1.1 pool lets concurrent manifest requests open independent TCP streams, so a single slow response no longer blocks the rest. Locally on ant-design with npmjs, this cut cold deps-resolve from ~121s (H2 single) to ~21s (H1 pool) — 5.75× faster. On low-latency registries (antgroup) the two are neutral, so there is no downside. Co-Authored-By: Claude Opus 4.7 (1M context) --- 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..8623cc39d3 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -159,7 +159,14 @@ pub fn client_builder() -> Result { let mut builder = builder .no_proxy() .dns_resolver(shared_resolver()) - .connect_timeout(CONNECT_TIMEOUT); + .connect_timeout(CONNECT_TIMEOUT) + // Force HTTP/1.1 with a connection pool. reqwest multiplexes all + // requests over a single HTTP/2 connection by default, which + // makes head-of-line blocking on one slow response stall the + // whole manifest fetch phase. An H1 pool lets concurrent + // manifest requests open independent TCP streams instead. + .http1_only() + .pool_max_idle_per_host(64); #[cfg(target_os = "macos")] { From 79be8052505c358dda0ee70e3686dcd12546c8cd Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 18:20:01 +0800 Subject: [PATCH 08/10] perf(ruborist): round-robin DNS addresses across connection pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reqwest pins every new connection to the first resolved IP even when DNS returns multiple A records. On registries backed by a CDN with many IPs (antgroup returns 8, npm/Cloudflare returns 2-4) this means all concurrent pool connections land on one IP, which caps effective parallelism regardless of `pool_max_idle_per_host`. Rotate the returned address list by an atomic counter on every `resolve` call so reqwest's connect loop picks a different IP per new connection. Connections end up uniformly distributed across all A records returned by DNS. Measured on ant-design / antgroup registry (cold deps, local): - utoo-h1 (single IP): 5.38s HTTP phase, 120 conn on 1 IP - utoo-h1 + DNS rotation: 3.95s HTTP phase, 8 IPs × 8 conn each - bun baseline: 3.72s HTTP phase, 4 IPs × 64 conn each Total deps-resolve wall time now matches bun (~3.3s vs 3.3s). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/dns.rs | 44 ++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/crates/ruborist/src/service/dns.rs b/crates/ruborist/src/service/dns.rs index ac61fec1eb..3b0ebf29a9 100644 --- a/crates/ruborist/src/service/dns.rs +++ b/crates/ruborist/src/service/dns.rs @@ -14,6 +14,7 @@ mod native { use std::collections::HashMap; use std::net::SocketAddr; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; @@ -70,6 +71,12 @@ mod native { cache: Arc>>, inflight: Arc>>>, ttl: Duration, + /// Round-robin counter used to rotate the returned address order + /// on every `resolve` call. Spreads concurrent pool connections + /// across all DNS A records (e.g. antgroup registry returns 8 + /// IPs) instead of pinning every new connection to the first IP + /// like reqwest does by default. + rr_counter: AtomicUsize, } impl CachingResolver { @@ -79,10 +86,28 @@ mod native { cache: Arc::new(Mutex::new(HashMap::new())), inflight: Arc::new(Mutex::new(HashMap::new())), ttl, + rr_counter: AtomicUsize::new(0), } } } + /// Rotate `addrs` left by `offset` positions, producing a fresh Vec so + /// reqwest's connect loop tries a different IP on every new connection. + /// + /// e.g. `[A, B, C, D]` with offset 2 → `[C, D, A, B]`. + fn rotate_addrs(addrs: &[SocketAddr], offset: usize) -> Vec { + if addrs.is_empty() { + return Vec::new(); + } + let n = addrs.len(); + let start = offset % n; + addrs[start..] + .iter() + .chain(&addrs[..start]) + .copied() + .collect() + } + /// Perform a system DNS lookup and cache the result. async fn do_lookup( hostname: &str, @@ -132,10 +157,11 @@ mod native { if let Some(entry) = cache.get(&hostname) && entry.expires_at > Instant::now() { - let cached = entry.addrs.to_vec(); - return Box::pin(std::future::ready( - Ok(Box::new(cached.into_iter()) as Addrs), - )); + let offset = self.rr_counter.fetch_add(1, Ordering::Relaxed); + let rotated = rotate_addrs(&entry.addrs, offset); + return Box::pin(std::future::ready(Ok( + Box::new(rotated.into_iter()) as Addrs + ))); } } @@ -155,6 +181,7 @@ mod native { // Clone Arcs for the 'static async block (required by Resolve trait). let cache = Arc::clone(&self.cache); let inflight_map = Arc::clone(&self.inflight); + let offset = self.rr_counter.fetch_add(1, Ordering::Relaxed); Box::pin(async move { let result = inflight @@ -167,13 +194,8 @@ mod native { inflight_map.lock().remove(&hostname); let resolved = result?; - - // resolved is a &Arc> borrowed from the OnceCell, - // but Addrs requires a 'static owned iterator. - // to_vec() + into_iter() creates an owned copy; iter().copied() - // would borrow from the local reference and fail lifetime checks. - #[allow(clippy::unnecessary_to_owned)] - let addrs: Addrs = Box::new(resolved.to_vec().into_iter()); + let rotated = rotate_addrs(resolved.as_ref(), offset); + let addrs: Addrs = Box::new(rotated.into_iter()); Ok(addrs) }) } From 2c279106776237ed5e05b39135b679b116e6c3e0 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 07:36:08 +0800 Subject: [PATCH 09/10] perf(ruborist): drop pool_max_idle_per_host(64) cap reqwest 0.12 defaults `pool_max_idle_per_host` to `usize::MAX` (unlimited idle connections per host). Setting an explicit `64` was a downward cap that hurt our burst-then-quiet access pattern: preload concurrency is 128, so the cap evicted half the established TCP connections immediately after the preload phase, forcing fresh handshakes for the next phase (tgz download on the same host). Drop the cap so all 128 idle connections survive across phases. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/http.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index 8623cc39d3..fd5ca63c48 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -165,8 +165,7 @@ pub fn client_builder() -> Result { // makes head-of-line blocking on one slow response stall the // whole manifest fetch phase. An H1 pool lets concurrent // manifest requests open independent TCP streams instead. - .http1_only() - .pool_max_idle_per_host(64); + .http1_only(); #[cfg(target_os = "macos")] { From f60d30cecd06342b9eb0b33910a8872897487edf Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 1 May 2026 07:39:43 +0800 Subject: [PATCH 10/10] perf(ruborist): dedup store_version_manifest disk writes per resolved version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inflight_version` is keyed by `(name, spec)`, but the disk path written by `store.store_version_manifest` is keyed by `(name, resolved_version)`. Sibling specs that resolve to the same concrete version (e.g. `^1.0.0` and `^1.2.0` both → `1.5.0`) each enter their own inflight slot and issue an independent write to the same disk path. The sibling-cache short-circuit in `resolve_via_full_manifest` further compounds this: even when extraction is skipped, the caller still fires the duplicate store write. Add a `stored_version: DashSet<(String, String)>` keyed by `(name, resolved_version)` and gate the disk write on first insert. Mirrors how `store_versions` is naturally deduped by `inflight_full` on the upstream path. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/registry.rs | 26 ++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 9e1615359a..be2a8b61f7 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -39,6 +39,8 @@ fn current_timestamp_secs() -> u64 { (js_sys::Date::now() / 1000.0) as u64 } +use dashmap::DashSet; + use super::cache::{PackageCache, Versions, VersionsInfo}; use super::manifest; use super::store::{ManifestStore, NoopStore}; @@ -84,6 +86,13 @@ pub struct UnifiedRegistry { /// `(name, spec)`. Same gate-only pattern: the canonical `Arc<…>` /// lives in `PackageCache.version_manifests`; the gate stores `()`. inflight_version: Arc>, + /// Dedup set for `store_version_manifest` disk writes keyed by + /// `(name, resolved_version)`. `inflight_version` is keyed by + /// `(name, spec)`, so sibling specs (e.g. `^1.0.0` and `^1.2.0`) + /// resolving to the same version each fire the gate independently + /// and would otherwise issue duplicate writes for the same path. + /// First insert wins; subsequent specs skip the redundant write. + stored_version: Arc>, } /// Builder for `UnifiedRegistry`. @@ -152,6 +161,7 @@ impl UnifiedRegistryBuilder { supports_semver, inflight_full: Arc::new(OnceMap::new()), inflight_version: Arc::new(OnceMap::new()), + stored_version: Arc::new(DashSet::new()), } } } @@ -171,6 +181,7 @@ impl Clone for UnifiedRegistry { supports_semver: self.supports_semver, inflight_full: Arc::clone(&self.inflight_full), inflight_version: Arc::clone(&self.inflight_version), + stored_version: Arc::clone(&self.stored_version), } } } @@ -383,11 +394,16 @@ impl UnifiedRegistry { Arc::clone(&arc), ); } - self.store.store_version_manifest( - name, - &resolved_version, - Arc::clone(&arc), - ); + if self + .stored_version + .insert((name.to_string(), resolved_version.clone())) + { + self.store.store_version_manifest( + name, + &resolved_version, + Arc::clone(&arc), + ); + } return Ok(()); }