From 73cc5a91aed1cdeaa2fc524e05c6ac3e99f17219 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Tue, 21 Apr 2026 22:20:23 +0800 Subject: [PATCH 001/102] perf(pm): sequential writes within tarball extraction Replace intra-package `par_iter` with a sequential loop when writing extracted tar entries to disk. Each tar entry is typically small and writes complete in microseconds, so splitting them into rayon tasks was causing heavy work-stealing (futex park/unpark) and dominating context switches on large dep graphs. Cross-package parallelism is preserved by the outer `rayon::spawn` in `extract_tarball`. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/extractor.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/pm/src/util/extractor.rs b/crates/pm/src/util/extractor.rs index 29b71149b0..c7ba7138ca 100644 --- a/crates/pm/src/util/extractor.rs +++ b/crates/pm/src/util/extractor.rs @@ -65,9 +65,14 @@ async fn extract_tarball(gzip_bytes: Bytes, dest: &Path) -> Result<()> { rx.await.with_context(|| "Extract task panicked")? } -/// Synchronous extraction: decompress + parse + parallel write, all on rayon. +/// Synchronous extraction: decompress + parse + sequential write, all on rayon. +/// +/// Writes are sequential within a package: tar entries are naturally streamed in order, +/// and files are typically small (< 64KB). Intra-package parallelism via `par_iter` +/// creates many short tasks that trigger rayon work-stealing (futex park/unpark) and +/// dominate context switches on large dep graphs. Cross-package parallelism is preserved +/// by the outer `rayon::spawn` in `extract_tarball`. 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::{Cursor, Read, Write}; @@ -152,8 +157,10 @@ fn extract_tarball_sync(gzip_bytes: Bytes, estimated_size: usize, dest: &Path) - fs::create_dir(dir).ok(); } - // Write files in parallel using rayon - entries.par_iter().try_for_each(|entry| -> Result<()> { + // Write files sequentially. Cross-package parallelism is handled by the outer + // rayon::spawn; splitting individual files into rayon tasks caused excessive + // work-stealing ctx switches on large dep trees. + for entry in &entries { let mut file = fs::File::create(&entry.path) .with_context(|| format!("Failed to create: {}", entry.path.display()))?; file.write_all(&entry.content) @@ -165,8 +172,7 @@ fn extract_tarball_sync(gzip_bytes: Bytes, estimated_size: usize, dest: &Path) - if entry.mode != 0o644 { fs::set_permissions(&entry.path, fs::Permissions::from_mode(entry.mode)).ok(); } - Ok(()) - })?; + } // Set directory permissions #[cfg(unix)] From 9379eb3dab294642b2e82bcd196a07ac1dbf8089 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Tue, 21 Apr 2026 23:21:40 +0800 Subject: [PATCH 002/102] ci(pm-bench): robust output + warm skip + json fault tolerance - Cold bench: drop `| tail -1` so hyperfine's full summary (mean, stddev, range) reaches the log. Failure detection now uses exit status instead of piping. - `BENCH_WARM_RUNS=0` skips the warm phase entirely (previously the warm function always ran and hyperfine would reject --runs 0). - Result aggregator tolerates empty or malformed export-json files (e.g. when a PM's cold install fails): the offending file is reported and skipped instead of crashing the whole summary. Co-Authored-By: Claude Opus 4.7 (1M context) --- bench/pm-bench.sh | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/bench/pm-bench.sh b/bench/pm-bench.sh index c4c43bdf14..fba3dc6fd1 100755 --- a/bench/pm-bench.sh +++ b/bench/pm-bench.sh @@ -293,13 +293,14 @@ run_cold_benchmarks() { > "$metrics_file" echo -e " ${CYAN}$pm${NC}..." - hyperfine \ + if ! hyperfine \ --runs "$BENCH_COLD_RUNS" \ --prepare "bash $PREPARE_SCRIPT $project_dir $pm --cold" \ --export-json "$json_file" \ -n "$pm" \ - "bash $METRICS_WRAPPER $metrics_file $cmd_script" \ - 2>&1 | tail -1 || echo -e " ${RED}$pm cold install failed${NC}" + "bash $METRICS_WRAPPER $metrics_file $cmd_script"; then + echo -e " ${RED}$pm cold install failed${NC}" + fi done } @@ -372,7 +373,15 @@ print_results() { // --- Parse hyperfine timing --- const timeRows = []; for (const file of jsonFiles) { - const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')); + let data; + try { + const raw = fs.readFileSync(path.join(dir, file), 'utf8'); + if (!raw.trim()) { console.log(' skip empty: ' + file); continue; } + data = JSON.parse(raw); + } catch (err) { + console.log(' skip unparseable: ' + file + ' (' + err.message + ')'); + continue; + } const base = file.replace('.json', ''); const parts = base.split('_'); const typeIdx = parts.findIndex(p => p === 'cold' || p === 'warm'); @@ -406,7 +415,11 @@ print_results() { .trim().split('\n').filter(Boolean); if (lines.length === 0) continue; - const entries = lines.map(l => JSON.parse(l)); + const entries = []; + for (const l of lines) { + try { entries.push(JSON.parse(l)); } catch (_) { /* skip malformed line */ } + } + if (entries.length === 0) continue; const avg = {}; for (const key of ['rss', 'page_faults', 'vol_ctx', 'invol_ctx', 'io_in', 'io_out']) { avg[key] = Math.round(entries.reduce((s, e) => s + (e[key] || 0), 0) / entries.length); @@ -513,7 +526,11 @@ main() { echo -e "${YELLOW}----------------------------------------${NC}" run_cold_benchmarks "$project" "$registry" "$reg_short" - run_warm_benchmarks "$project" "$registry" "$reg_short" + if [ "$BENCH_WARM_RUNS" -gt 0 ]; then + run_warm_benchmarks "$project" "$registry" "$reg_short" + else + echo -e " ${YELLOW}Warm installs: skipped (BENCH_WARM_RUNS=0)${NC}" + fi echo "" done From c5ec8bf8d6afd2f6e23e5ff6745e561fa2b96f35 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Tue, 21 Apr 2026 23:52:35 +0800 Subject: [PATCH 003/102] perf(pm): chunked parallel writes within tarball extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the sequential `for` loop over extracted tar entries with `par_chunks(WRITE_CHUNK_SIZE)` — each rayon task writes a contiguous run of 32 files sequentially. This retains multi-core IO overlap for large packages while cutting the rayon task count (and its work- stealing futex traffic) by the chunk factor versus a per-file par_iter. Cross-package parallelism is preserved by the outer rayon::spawn in extract_tarball. Local (macOS, antd-test, 3 runs avg): before par_iter: wall 17.2s sys 6.18s ivcsw 208k for-loop: wall 15.3s sys 2.36s ivcsw 61k par_chunks(32): wall 13.9s sys 5.77s ivcsw 191k chunks wins wall but loses the ctx-switch reduction relative to the pure sequential version; CI with a large dep graph (ant-design-x) is the authoritative measurement. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/extractor.rs | 55 ++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/crates/pm/src/util/extractor.rs b/crates/pm/src/util/extractor.rs index c7ba7138ca..ca25614814 100644 --- a/crates/pm/src/util/extractor.rs +++ b/crates/pm/src/util/extractor.rs @@ -65,18 +65,24 @@ async fn extract_tarball(gzip_bytes: Bytes, dest: &Path) -> Result<()> { rx.await.with_context(|| "Extract task panicked")? } -/// Synchronous extraction: decompress + parse + sequential write, all on rayon. +/// Synchronous extraction: decompress + parse + chunked parallel write, all on rayon. /// -/// Writes are sequential within a package: tar entries are naturally streamed in order, -/// and files are typically small (< 64KB). Intra-package parallelism via `par_iter` -/// creates many short tasks that trigger rayon work-stealing (futex park/unpark) and -/// dominate context switches on large dep graphs. Cross-package parallelism is preserved -/// by the outer `rayon::spawn` in `extract_tarball`. +/// Writes are batched into fixed-size chunks: each rayon task writes a contiguous +/// run of `WRITE_CHUNK_SIZE` files sequentially. This keeps multi-core parallelism +/// for IO-overlap while cutting the rayon task count (and its work-stealing +/// futex park/unpark pressure) by the chunk factor. Cross-package parallelism is +/// preserved by the outer `rayon::spawn` in `extract_tarball`. 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::{Cursor, Read, Write}; + /// Files per rayon task when writing extracted tar entries. Sized to amortize + /// work-stealing overhead while leaving enough tasks to keep cores busy on + /// large packages (e.g. 500-file packages → ~16 tasks with WRITE_CHUNK_SIZE=32). + const WRITE_CHUNK_SIZE: usize = 32; + // Decompress gzip using libdeflate let mut output = vec![0u8; estimated_size]; @@ -157,22 +163,27 @@ fn extract_tarball_sync(gzip_bytes: Bytes, estimated_size: usize, dest: &Path) - fs::create_dir(dir).ok(); } - // Write files sequentially. Cross-package parallelism is handled by the outer - // rayon::spawn; splitting individual files into rayon tasks caused excessive - // work-stealing ctx switches on large dep trees. - for entry in &entries { - let mut file = fs::File::create(&entry.path) - .with_context(|| format!("Failed to create: {}", entry.path.display()))?; - file.write_all(&entry.content) - .with_context(|| format!("Failed to write: {}", entry.path.display()))?; - - // Skip chmod for 0o644 (most files) — File::create() already produces - // this via umask (0o666 & ~0o022 = 0o644). - #[cfg(unix)] - if entry.mode != 0o644 { - fs::set_permissions(&entry.path, fs::Permissions::from_mode(entry.mode)).ok(); - } - } + // Chunked parallel writes: N files per rayon task. Retains IO-overlap + // parallelism across cores while cutting the rayon task count (and the + // associated work-stealing futex traffic) relative to per-file par_iter. + entries + .par_chunks(WRITE_CHUNK_SIZE) + .try_for_each(|chunk| -> Result<()> { + for entry in chunk { + let mut file = fs::File::create(&entry.path) + .with_context(|| format!("Failed to create: {}", entry.path.display()))?; + file.write_all(&entry.content) + .with_context(|| format!("Failed to write: {}", entry.path.display()))?; + + // Skip chmod for 0o644 (most files) — File::create() already + // produces this via umask (0o666 & ~0o022 = 0o644). + #[cfg(unix)] + if entry.mode != 0o644 { + fs::set_permissions(&entry.path, fs::Permissions::from_mode(entry.mode)).ok(); + } + } + Ok(()) + })?; // Set directory permissions #[cfg(unix)] From 5464a76bee2d09731eec25ffc268194f362e8dbf Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Wed, 22 Apr 2026 00:34:47 +0800 Subject: [PATCH 004/102] perf(pm): surface per-phase cumulative timings in install summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accumulate wall microseconds for download, extract, and clone across all packages during install. Print a one-line summary alongside the existing `added / reused / downloaded` counts, e.g. + 513 added · 3017 reused · 123 downloaded download 135.8s · extract 2.3s · clone 0.4s · 19.0 MB fetched The sums are non-exclusive across cores: dividing by wall clock gives the effective concurrency for each phase, and the ratio between phases shows where cold-install CPU time actually lands. Overhead is three atomics per downloaded tarball. Local antd-test (macOS, npmmirror, 77 packages, wall 16s): download dominates 98% of the CPU budget, extract 1.6%, clone 0.3% — reshapes where we should look for cold-install wins. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/service/install.rs | 12 ++++++++++++ crates/pm/src/util/cloner.rs | 13 ++++++++++++- crates/pm/src/util/downloader.rs | 30 +++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/pm/src/service/install.rs b/crates/pm/src/service/install.rs index 9f591ee85b..83131eda30 100644 --- a/crates/pm/src/service/install.rs +++ b/crates/pm/src/service/install.rs @@ -287,6 +287,18 @@ impl InstallService { let added = clone_count().saturating_sub(clone_baseline); let download_delta = download_stats() - download_baseline; print_install_counts(added, download_delta.reused, download_delta.downloaded); + + // Per-phase cumulative time (summed across concurrent tasks, not wall). + // Ratio to wall clock gives effective concurrency for that phase. + let phases = crate::util::downloader::phase_totals(); + let clone_us = crate::util::cloner::clone_micros(); + println!( + " download {:.2}s · extract {:.2}s · clone {:.2}s · {:.1} MB fetched", + phases.download_micros as f64 / 1e6, + phases.extract_micros as f64 / 1e6, + clone_us as f64 / 1e6, + phases.downloaded_bytes as f64 / (1024.0 * 1024.0), + ); Ok(()) } diff --git a/crates/pm/src/util/cloner.rs b/crates/pm/src/util/cloner.rs index f7ff1c6a70..b357149760 100644 --- a/crates/pm/src/util/cloner.rs +++ b/crates/pm/src/util/cloner.rs @@ -1,5 +1,6 @@ use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::time::Instant; use anyhow::{Context, Result}; use once_cell::sync::Lazy; @@ -28,11 +29,19 @@ static CLONE_CACHE: Lazy> = Lazy::new(OnceMap::new); /// Mirrors pnpm's "added" semantic. static CLONE_COUNT: AtomicUsize = AtomicUsize::new(0); +/// Wall microseconds summed across every per-package clone invocation. +static CLONE_MICROS: AtomicU64 = AtomicU64::new(0); + /// Returns the number of fresh clones performed (pnpm "added" equivalent). pub fn clone_count() -> usize { CLONE_COUNT.load(Ordering::Relaxed) } +/// Cumulative wall microseconds spent in `clone_package` across the process. +pub fn clone_micros() -> u64 { + CLONE_MICROS.load(Ordering::Relaxed) +} + /// Normalize a target path into the canonical key used by `CLONE_CACHE`. #[cfg(windows)] fn cache_key(target_path: &Path) -> PathBuf { @@ -78,6 +87,7 @@ pub async fn clone_package_once( CLONE_CACHE .get_or_init(key, || async move { let cache_path = resolve_cache_path(&name, &version, &tarball_url).await?; + let t0 = Instant::now(); let fresh = clone_package(&cache_path, &target_path, &name, &version, !is_git) .await .inspect_err(|e| { @@ -90,6 +100,7 @@ pub async fn clone_package_once( ) }) .ok()?; + CLONE_MICROS.fetch_add(t0.elapsed().as_micros() as u64, Ordering::Relaxed); if fresh { CLONE_COUNT.fetch_add(1, Ordering::Relaxed); diff --git a/crates/pm/src/util/downloader.rs b/crates/pm/src/util/downloader.rs index 77b2b836ff..a1517d9556 100644 --- a/crates/pm/src/util/downloader.rs +++ b/crates/pm/src/util/downloader.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use std::sync::OnceLock; -use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering}; +use std::time::Instant; use anyhow::{Context, Result}; use bytes::Bytes; @@ -30,6 +31,28 @@ static DOWNLOAD_SEMAPHORE: OnceLock = OnceLock::new(); static DOWNLOAD_COUNT: AtomicUsize = AtomicUsize::new(0); static REUSE_COUNT: AtomicUsize = AtomicUsize::new(0); +// Per-operation wall time summed across all packages. Sum is non-exclusive +// across cores — dividing by wall clock gives average concurrency for the phase. +static DOWNLOAD_MICROS: AtomicU64 = AtomicU64::new(0); +static EXTRACT_MICROS: AtomicU64 = AtomicU64::new(0); +static DOWNLOAD_BYTES: AtomicU64 = AtomicU64::new(0); + +/// Snapshot of per-phase totals accumulated during install. +#[derive(Debug, Clone, Copy, Default)] +pub struct PhaseTotals { + pub download_micros: u64, + pub extract_micros: u64, + pub downloaded_bytes: u64, +} + +pub fn phase_totals() -> PhaseTotals { + PhaseTotals { + download_micros: DOWNLOAD_MICROS.load(Ordering::Relaxed), + extract_micros: EXTRACT_MICROS.load(Ordering::Relaxed), + downloaded_bytes: DOWNLOAD_BYTES.load(Ordering::Relaxed), + } +} + /// Process-global counters for tarball outcomes, matching pnpm's /// vocabulary. Each unique `(name, version)` pair lands in exactly one /// bucket thanks to `DOWNLOAD_CACHE`'s `OnceMap` dedup; git/file/link @@ -181,16 +204,21 @@ pub async fn download_to_cache(name: &str, version: &str, tarball_url: &str) -> let semaphore = DOWNLOAD_SEMAPHORE .get_or_init(|| Semaphore::new(get_manifests_concurrency_limit_sync())); let _permit = semaphore.acquire().await.ok()?; + let t0 = Instant::now(); let bytes = download_bytes(&tarball_url) .await .inspect_err(|e| tracing::warn!("Download failed: {}@{}: {}", name, version, e)) .ok()?; + DOWNLOAD_MICROS.fetch_add(t0.elapsed().as_micros() as u64, Ordering::Relaxed); + DOWNLOAD_BYTES.fetch_add(bytes.len() as u64, Ordering::Relaxed); // Extract + let t1 = Instant::now(); extract_and_write(bytes, &cache_path) .await .inspect_err(|e| tracing::warn!("Extract failed: {}@{}: {}", name, version, e)) .ok()?; + EXTRACT_MICROS.fetch_add(t1.elapsed().as_micros() as u64, Ordering::Relaxed); DOWNLOAD_COUNT.fetch_add(1, Ordering::Relaxed); tracing::debug!("Downloaded: {}@{}", name, version); From dde11b33a1f0eca7303748ead512b6610a8fa330 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Wed, 22 Apr 2026 01:01:51 +0800 Subject: [PATCH 005/102] ci(pm-bench): surface cold install stdout via hyperfine --show-output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Needed so the per-phase timings line (`download · extract · clone · bytes`) printed at the end of each install reaches the CI log. Trade-off is noisier logs — registry INFO/WARN lines come through — but that's the price for visibility into where cold-install CPU actually lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- bench/pm-bench.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/bench/pm-bench.sh b/bench/pm-bench.sh index fba3dc6fd1..12645c8b9e 100755 --- a/bench/pm-bench.sh +++ b/bench/pm-bench.sh @@ -297,6 +297,7 @@ run_cold_benchmarks() { --runs "$BENCH_COLD_RUNS" \ --prepare "bash $PREPARE_SCRIPT $project_dir $pm --cold" \ --export-json "$json_file" \ + --show-output \ -n "$pm" \ "bash $METRICS_WRAPPER $metrics_file $cmd_script"; then echo -e " ${RED}$pm cold install failed${NC}" From f776c9fde2779a1316f69daeb9a8036035a60c6c Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Wed, 22 Apr 2026 23:36:41 +0800 Subject: [PATCH 006/102] ci(pm-bench-phases): phase-isolated cold install bench MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separates three independent measurements for utoo vs bun so each phase's improvement can be judged on its own baseline: Phase 1 · resolve utoo deps / bun install --lockfile-only Phase 3 · cold install utoo install / bun install (empty cache) Phase 4 · warm link utoo install / bun install (cache warm) Phase 3 uses the lockfile generated by phase 1, with cache reset between iterations. Phase 4 resets only node_modules so only the cache → node_modules link step is measured. Uses hyperfine --show-output so utoo's phase-timings line (\`download · extract · clone · bytes\`) reaches the CI log alongside the wall-clock summary. Triggered via workflow_dispatch with configurable project / registry / runs. Defaults to ant-design against npmjs.org, 3 runs per phase. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pm-bench-phases.yml | 95 ++++++++++++++++ e2e/pm-bench-phases.sh | 150 ++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 .github/workflows/pm-bench-phases.yml create mode 100755 e2e/pm-bench-phases.sh diff --git a/.github/workflows/pm-bench-phases.yml b/.github/workflows/pm-bench-phases.yml new file mode 100644 index 0000000000..06015a90f0 --- /dev/null +++ b/.github/workflows/pm-bench-phases.yml @@ -0,0 +1,95 @@ +name: utoopm-benchmark-phases + +on: + workflow_dispatch: + inputs: + project: + description: 'Project to benchmark (under github.com/ant-design/)' + required: false + default: 'ant-design' + type: string + registry: + description: 'Registry URL' + required: false + default: 'https://registry.npmjs.org' + type: string + runs: + description: 'Runs per phase' + required: false + default: '3' + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-linux: + runs-on: ubuntu-latest + container: + image: ubuntu:20.04 + steps: + - name: Install dependencies + env: + DEBIAN_FRONTEND: noninteractive + run: apt-get update && apt-get install -y build-essential curl pkg-config libssl-dev git + - uses: actions/checkout@v4 + - name: Configure git safe directory + run: git config --global --add safe.directory /__w/utoo/utoo + - name: Init git submodules + run: git submodule update --init --recursive --depth 1 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly-2026-04-02 + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + shared-key: pm-bench-linux + - name: Build current branch utoo + run: cargo build --release --bin utoo -p utoo-pm + - name: Upload utoo binary + uses: actions/upload-artifact@v4 + with: + name: utoo-linux-bench-phases + path: target/release/utoo + retention-days: 1 + + bench: + needs: [build-linux] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Download utoo binary + uses: actions/download-artifact@v4 + with: + name: utoo-linux-bench-phases + path: dist + - name: Install utoo + run: | + chmod +x dist/utoo + echo "$GITHUB_WORKSPACE/dist" >> $GITHUB_PATH + - name: Install bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install hyperfine and GNU time + run: sudo apt-get update && sudo apt-get install -y hyperfine time + - name: Verify tools + run: | + hyperfine --version + utoo --version + bun --version + - name: Run phase-isolated benchmark + env: + PROJECT: ${{ github.event.inputs.project || 'ant-design' }} + REGISTRY: ${{ github.event.inputs.registry || 'https://registry.npmjs.org' }} + BENCH_RUNS: ${{ github.event.inputs.runs || '3' }} + PM_LIST: 'utoo,bun' + run: | + chmod +x e2e/pm-bench-phases.sh + bash e2e/pm-bench-phases.sh diff --git a/e2e/pm-bench-phases.sh b/e2e/pm-bench-phases.sh new file mode 100755 index 0000000000..9b3e2b9131 --- /dev/null +++ b/e2e/pm-bench-phases.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# Phase-isolated cold-install bench: resolve vs cold-install vs warm-link. +# utoo vs bun only (they're the PMs with a true --lockfile-only mode). +set -eo pipefail + +# --- config --- +PROJECT=${PROJECT:-ant-design} +REGISTRY=${REGISTRY:-https://registry.npmjs.org} +RUNS=${BENCH_RUNS:-3} +IFS=',' read -ra PACKAGE_MANAGERS <<< "${PM_LIST:-utoo,bun}" + +BENCH_DIR=${BENCH_DIR:-/tmp/pm-bench} +RESULTS_DIR=${RESULTS_DIR:-/tmp/pm-bench-results} +mkdir -p "$BENCH_DIR" "$RESULTS_DIR" + +UTOO_CACHE="$HOME/.cache/nm" +BUN_CACHE="$HOME/.bun/install/cache" + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +RED='\033[0;31m' +NC='\033[0m' + +banner() { echo -e "${YELLOW}=== $* ===${NC}"; } + +# --- project --- +banner "Preparing $PROJECT" +cd "$BENCH_DIR" +if [ ! -d "$PROJECT" ]; then + git clone --depth=1 "https://github.com/ant-design/${PROJECT}.git" "$PROJECT" +fi +PROJECT_DIR="$BENCH_DIR/$PROJECT" + +# --- inline prepare snippets (passed to hyperfine --prepare via bash -c) --- +# Each snippet is self-contained so it works in the subshell hyperfine spawns. +snip_reset_nm="cd $PROJECT_DIR && rm -rf node_modules && find . -maxdepth 4 -type d -path '*/packages/*/node_modules' -exec rm -rf {} + 2>/dev/null || true" +snip_reset_all="cd $PROJECT_DIR && git clean -dfx > /dev/null && rm -rf '$UTOO_CACHE' '$BUN_CACHE'" + +prepare_cmd() { + local phase=$1 pm=$2 + case "$phase:$pm" in + p1:*) echo "$snip_reset_all" ;; + p3:utoo) echo "$snip_reset_nm && rm -rf '$UTOO_CACHE'" ;; + p3:bun) echo "$snip_reset_nm && rm -rf '$BUN_CACHE'" ;; + p4:utoo|p4:bun) + # Warm-link: keep cache, drop node_modules. Lockfile + cache are + # guaranteed to exist by the one-off warmup before phase 4 starts. + echo "$snip_reset_nm" ;; + esac +} + +install_cmd() { + case "$1" in + utoo) echo "utoo install --ignore-scripts --registry=$REGISTRY" ;; + bun) echo "bun install --ignore-scripts --registry=$REGISTRY" ;; + esac +} + +resolve_cmd() { + case "$1" in + utoo) echo "utoo deps --registry=$REGISTRY" ;; + bun) echo "bun install --lockfile-only --registry=$REGISTRY" ;; + esac +} + +warmup_cache() { + # Run one full install to fill cache. Idempotent: skips if cache already exists. + local pm=$1 + local cache + case "$pm" in utoo) cache=$UTOO_CACHE ;; bun) cache=$BUN_CACHE ;; esac + [ -d "$cache" ] && [ -n "$(ls -A "$cache" 2>/dev/null)" ] && return + echo -e " ${CYAN}one-off: populating $pm cache${NC}" + cd "$PROJECT_DIR" + eval "$(install_cmd "$pm")" > "$RESULTS_DIR/warmup_${pm}.log" 2>&1 || { + echo -e " ${RED}warmup install failed for $pm (see $RESULTS_DIR/warmup_${pm}.log)${NC}" + return 1 + } + rm -rf node_modules +} + +run_phase() { + local phase=$1 pm=$2 cmd=$3 + local json="$RESULTS_DIR/${PROJECT}_${phase}_${pm}.json" + local prep + prep=$(prepare_cmd "$phase" "$pm") + + echo -e " ${CYAN}$pm${NC} · $phase" + if ! hyperfine \ + --runs "$RUNS" \ + --prepare "bash -c \"$prep\"" \ + --export-json "$json" \ + --show-output \ + -n "${pm}-${phase}" \ + "bash -c \"cd $PROJECT_DIR && $cmd\""; then + echo -e " ${RED}$pm $phase failed${NC}" + fi +} + +# === PHASE 1: resolve only (clean slate) === +banner "Phase 1 · resolve (lockfile only, cold cache)" +for pm in "${PACKAGE_MANAGERS[@]}"; do + run_phase "p1_resolve" "$pm" "$(resolve_cmd "$pm")" +done + +# === PHASE 3: cold install (lockfile exists, cache empty) === +banner "Phase 3 · cold install (lockfile present, empty cache, empty node_modules)" +for pm in "${PACKAGE_MANAGERS[@]}"; do + # Lockfile was generated by phase 1, it persists in the project dir across + # hyperfine iterations because `snip_reset_nm` only deletes node_modules. + run_phase "p3_cold_install" "$pm" "$(install_cmd "$pm")" +done + +# === PHASE 4: warm link (cache populated, lockfile exists) === +banner "Phase 4 · warm link (lockfile present, populated cache, empty node_modules)" +for pm in "${PACKAGE_MANAGERS[@]}"; do + warmup_cache "$pm" + run_phase "p4_warm_link" "$pm" "$(install_cmd "$pm")" +done + +# === SUMMARY === +banner "Summary" +RESULTS_DIR="$RESULTS_DIR" node -e " + const fs = require('fs'), path = require('path'); + const dir = process.env.RESULTS_DIR; + const order = ['p1_resolve', 'p3_cold_install', 'p4_warm_link']; + const byPhase = Object.fromEntries(order.map(p => [p, []])); + for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.json'))) { + let data; + try { data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } + catch (_) { continue; } + for (const r of data.results) { + const phase = order.find(p => r.command.includes(p) || f.includes(p)); + if (!phase) continue; + byPhase[phase].push({ name: r.command, mean: r.mean, stddev: r.stddev }); + } + } + const pad = (s, n) => String(s).padEnd(n); + const padR = (s, n) => String(s).padStart(n); + for (const phase of order) { + if (byPhase[phase].length === 0) continue; + console.log('\n## ' + phase); + console.log(pad('PM', 24) + ' ' + padR('mean', 9) + ' ' + padR('stddev', 7)); + for (const r of byPhase[phase]) { + console.log(pad(r.name, 24) + ' ' + padR(r.mean.toFixed(2) + 's', 9) + ' ' + padR(r.stddev.toFixed(2) + 's', 7)); + } + } +" + +echo -e "${GREEN}Done. Raw results in $RESULTS_DIR${NC}" From 3d272e2d0dfe2cce87f9b624e78c56370e75c425 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Wed, 22 Apr 2026 23:37:48 +0800 Subject: [PATCH 007/102] ci(pm-bench-phases): trigger on PR label so it runs before default-branch merge Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pm-bench-phases.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/pm-bench-phases.yml b/.github/workflows/pm-bench-phases.yml index 06015a90f0..ff8b6f4cf3 100644 --- a/.github/workflows/pm-bench-phases.yml +++ b/.github/workflows/pm-bench-phases.yml @@ -18,6 +18,8 @@ on: required: false default: '3' type: string + pull_request: + types: [labeled] concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -25,6 +27,9 @@ concurrency: jobs: build-linux: + if: > + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.event.label.name == 'bench-phases') runs-on: ubuntu-latest container: image: ubuntu:20.04 @@ -57,6 +62,9 @@ jobs: bench: needs: [build-linux] + if: > + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.event.label.name == 'bench-phases') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From ccd948cc51b5ea0e5242d13f23b833a548ce7bae Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Wed, 22 Apr 2026 23:47:16 +0800 Subject: [PATCH 008/102] ci(pm-bench-phases): robust per-iteration prepare via generated scripts Previous inline bash -c prepare was silently no-op on CI: utoo's run 2/3 showed '3280 reused' meaning the cache wasn't actually cleared, and bun hit InvalidNPMLockfile because utoo's package-lock.json leaked across iterations. Now each phase writes a dedicated prepare shell script per-PM that: - always drops node_modules (incl. workspace package trees), - clears exactly the lockfiles that would confuse this PM, - wipes the right cache for this phase, - prints a '[prep]' line so the CI log proves prepare ran. Also factored out seed_for_phase so lockfile / cache warmup happens once before the benchmark, not leaking into the measurement. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/pm-bench-phases.sh | 151 ++++++++++++++++++++++++++++++----------- 1 file changed, 113 insertions(+), 38 deletions(-) diff --git a/e2e/pm-bench-phases.sh b/e2e/pm-bench-phases.sh index 9b3e2b9131..b953257d13 100755 --- a/e2e/pm-bench-phases.sh +++ b/e2e/pm-bench-phases.sh @@ -32,22 +32,107 @@ if [ ! -d "$PROJECT" ]; then fi PROJECT_DIR="$BENCH_DIR/$PROJECT" -# --- inline prepare snippets (passed to hyperfine --prepare via bash -c) --- -# Each snippet is self-contained so it works in the subshell hyperfine spawns. -snip_reset_nm="cd $PROJECT_DIR && rm -rf node_modules && find . -maxdepth 4 -type d -path '*/packages/*/node_modules' -exec rm -rf {} + 2>/dev/null || true" -snip_reset_all="cd $PROJECT_DIR && git clean -dfx > /dev/null && rm -rf '$UTOO_CACHE' '$BUN_CACHE'" +# --- prepare script builder --- +# Emit a self-contained reset script that hyperfine's --prepare can re-exec. +# The script always prints what it deleted so the CI log proves prepare ran. +write_prepare() { + local path=$1 phase=$2 pm=$3 + local cache + case "$pm" in utoo) cache=$UTOO_CACHE ;; bun) cache=$BUN_CACHE ;; esac + + cat > "$path" </dev/null || true + +EOF + + case "$phase" in + p1) + # Phase 1: cold resolve — wipe lockfiles AND caches so nothing can be reused. + cat >> "$path" <> "$path" <> "$path" <> "$path" <> "$path" < "$RESULTS_DIR/seed_${phase}_${pm}.log" 2>&1 + fi + ;; + p3:bun|p4:bun) + if [ ! -f bun.lock ]; then + echo -e " ${CYAN}seed: running \`bun install --lockfile-only\` to generate bun.lock${NC}" + rm -f package-lock.json + bun install --lockfile-only --registry="$REGISTRY" > "$RESULTS_DIR/seed_${phase}_${pm}.log" 2>&1 + fi + ;; esac + # Phase 4 also needs a pre-warmed cache. + if [ "$phase" = "p4" ]; then + local cache + case "$pm" in utoo) cache=$UTOO_CACHE ;; bun) cache=$BUN_CACHE ;; esac + if [ ! -d "$cache" ] || [ -z "$(ls -A "$cache" 2>/dev/null)" ]; then + echo -e " ${CYAN}seed: warming $pm cache via full install${NC}" + rm -rf node_modules + case "$pm" in + utoo) utoo install --ignore-scripts --registry="$REGISTRY" > "$RESULTS_DIR/seed_warmup_${pm}.log" 2>&1 ;; + bun) bun install --ignore-scripts --registry="$REGISTRY" > "$RESULTS_DIR/seed_warmup_${pm}.log" 2>&1 ;; + esac + rm -rf node_modules + fi + fi } install_cmd() { @@ -64,35 +149,22 @@ resolve_cmd() { esac } -warmup_cache() { - # Run one full install to fill cache. Idempotent: skips if cache already exists. - local pm=$1 - local cache - case "$pm" in utoo) cache=$UTOO_CACHE ;; bun) cache=$BUN_CACHE ;; esac - [ -d "$cache" ] && [ -n "$(ls -A "$cache" 2>/dev/null)" ] && return - echo -e " ${CYAN}one-off: populating $pm cache${NC}" - cd "$PROJECT_DIR" - eval "$(install_cmd "$pm")" > "$RESULTS_DIR/warmup_${pm}.log" 2>&1 || { - echo -e " ${RED}warmup install failed for $pm (see $RESULTS_DIR/warmup_${pm}.log)${NC}" - return 1 - } - rm -rf node_modules -} - run_phase() { local phase=$1 pm=$2 cmd=$3 local json="$RESULTS_DIR/${PROJECT}_${phase}_${pm}.json" - local prep - prep=$(prepare_cmd "$phase" "$pm") + local prep_script="$RESULTS_DIR/prep_${phase}_${pm}.sh" + + seed_for_phase "$phase" "$pm" + write_prepare "$prep_script" "$phase" "$pm" - echo -e " ${CYAN}$pm${NC} · $phase" + echo -e " ${CYAN}$pm${NC} · $phase · cmd: $cmd" if ! hyperfine \ --runs "$RUNS" \ - --prepare "bash -c \"$prep\"" \ + --prepare "bash $prep_script" \ --export-json "$json" \ --show-output \ -n "${pm}-${phase}" \ - "bash -c \"cd $PROJECT_DIR && $cmd\""; then + "bash -c 'cd $PROJECT_DIR && $cmd'"; then echo -e " ${RED}$pm $phase failed${NC}" fi } @@ -106,15 +178,12 @@ done # === PHASE 3: cold install (lockfile exists, cache empty) === banner "Phase 3 · cold install (lockfile present, empty cache, empty node_modules)" for pm in "${PACKAGE_MANAGERS[@]}"; do - # Lockfile was generated by phase 1, it persists in the project dir across - # hyperfine iterations because `snip_reset_nm` only deletes node_modules. run_phase "p3_cold_install" "$pm" "$(install_cmd "$pm")" done # === PHASE 4: warm link (cache populated, lockfile exists) === banner "Phase 4 · warm link (lockfile present, populated cache, empty node_modules)" for pm in "${PACKAGE_MANAGERS[@]}"; do - warmup_cache "$pm" run_phase "p4_warm_link" "$pm" "$(install_cmd "$pm")" done @@ -132,7 +201,7 @@ RESULTS_DIR="$RESULTS_DIR" node -e " for (const r of data.results) { const phase = order.find(p => r.command.includes(p) || f.includes(p)); if (!phase) continue; - byPhase[phase].push({ name: r.command, mean: r.mean, stddev: r.stddev }); + byPhase[phase].push({ name: r.command, mean: r.mean, stddev: r.stddev, min: r.min, max: r.max }); } } const pad = (s, n) => String(s).padEnd(n); @@ -140,9 +209,15 @@ RESULTS_DIR="$RESULTS_DIR" node -e " for (const phase of order) { if (byPhase[phase].length === 0) continue; console.log('\n## ' + phase); - console.log(pad('PM', 24) + ' ' + padR('mean', 9) + ' ' + padR('stddev', 7)); + console.log(pad('PM', 26) + ' ' + padR('mean', 9) + ' ' + padR('stddev', 7) + ' ' + padR('min', 7) + ' ' + padR('max', 7)); for (const r of byPhase[phase]) { - console.log(pad(r.name, 24) + ' ' + padR(r.mean.toFixed(2) + 's', 9) + ' ' + padR(r.stddev.toFixed(2) + 's', 7)); + console.log( + pad(r.name, 26) + ' ' + + padR(r.mean.toFixed(2) + 's', 9) + ' ' + + padR(r.stddev.toFixed(2) + 's', 7) + ' ' + + padR(r.min.toFixed(2) + 's', 7) + ' ' + + padR(r.max.toFixed(2) + 's', 7) + ); } } " From 7a92749fa94bcaaade1f8e83d4954c8afc27efd4 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Wed, 22 Apr 2026 23:55:26 +0800 Subject: [PATCH 009/102] ci(pm-bench-phases): use utoo clean / bun pm cache rm for reliable cache wipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path-based rm -rf of $HOME/.cache/nm wasn't actually emptying the cache on the CI runner — utoo runs 2/3 of phase 3 still showed '3280 reused', wall was 0.8-1.1s instead of the 10s cold-install baseline, hyperfine itself warned about caches not being filled until after run 1. Let each PM clean its own cache via its CLI so we don't rely on guessing where it stores things. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/pm-bench-phases.sh | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/e2e/pm-bench-phases.sh b/e2e/pm-bench-phases.sh index b953257d13..c4bb2149f3 100755 --- a/e2e/pm-bench-phases.sh +++ b/e2e/pm-bench-phases.sh @@ -51,30 +51,33 @@ find . -maxdepth 4 -type d -path '*/packages/*/node_modules' -exec rm -rf {} + 2 EOF + # Use each PM's own cache-clean command so we don't rely on guessing its + # cache directory (the CI runner's HOME differs between images). case "$phase" in p1) # Phase 1: cold resolve — wipe lockfiles AND caches so nothing can be reused. cat >> "$path" </dev/null 2>&1 || true +bun pm cache rm >/dev/null 2>&1 || true +echo "[prep] phase 1 $pm: cleaned lockfiles + all caches + node_modules" EOF ;; p3) - # Phase 3: cold install — keep THIS pm's lockfile, wipe THIS pm's cache, - # and delete the OTHER pm's lockfile so bun doesn't try to migrate from - # utoo's package-lock.json (and vice versa). + # Phase 3: cold install — keep THIS pm's lockfile, wipe THIS pm's cache. + # Delete the other pm's lockfile so bun doesn't try to migrate utoo's + # package-lock.json (and vice versa). case "$pm" in utoo) cat >> "$path" </dev/null 2>&1 || true +echo "[prep] phase 3 utoo: kept package-lock.json, ran utoo clean" EOF ;; bun) cat >> "$path" </dev/null 2>&1 || true +echo "[prep] phase 3 bun: kept bun.lock, ran bun pm cache rm" EOF ;; esac From d8e01ee6d35b6c63a21da4cd1e7b7b3a5739d19c Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 00:02:57 +0800 Subject: [PATCH 010/102] ci(pm-bench-phases): scope caches to bench dirs via --cache-dir / env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `utoo clean` / `bun pm cache rm` didn't empty the cache on the CI runner either — so now use explicit bench-local paths the rm -rf prepare can guarantee to wipe: utoo: --cache-dir=/tmp/utoo-bench-cache on every invocation bun: BUN_INSTALL_CACHE_DIR=/tmp/bun-bench-cache (env var) Gets us deterministic cold/warm state between hyperfine iterations. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/pm-bench-phases.sh | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/e2e/pm-bench-phases.sh b/e2e/pm-bench-phases.sh index c4bb2149f3..5784099e3d 100755 --- a/e2e/pm-bench-phases.sh +++ b/e2e/pm-bench-phases.sh @@ -13,8 +13,12 @@ BENCH_DIR=${BENCH_DIR:-/tmp/pm-bench} RESULTS_DIR=${RESULTS_DIR:-/tmp/pm-bench-results} mkdir -p "$BENCH_DIR" "$RESULTS_DIR" -UTOO_CACHE="$HOME/.cache/nm" -BUN_CACHE="$HOME/.bun/install/cache" +# Explicit bench-scoped cache dirs so rm -rf is unambiguous and we don't +# depend on what each PM considers the default (which varies by HOME, OS +# image, and previously-set user config). +UTOO_CACHE="${UTOO_CACHE:-/tmp/utoo-bench-cache}" +BUN_CACHE="${BUN_CACHE:-/tmp/bun-bench-cache}" +export BUN_INSTALL_CACHE_DIR="$BUN_CACHE" GREEN='\033[0;32m' YELLOW='\033[1;33m' @@ -51,16 +55,16 @@ find . -maxdepth 4 -type d -path '*/packages/*/node_modules' -exec rm -rf {} + 2 EOF - # Use each PM's own cache-clean command so we don't rely on guessing its - # cache directory (the CI runner's HOME differs between images). + # Use explicit bench-scoped cache paths and rm -rf them directly — previously + # `utoo clean` / `bun pm cache rm` didn't actually empty the cache on the CI + # runner, leading to cache-hit runs masquerading as cold installs. case "$phase" in p1) # Phase 1: cold resolve — wipe lockfiles AND caches so nothing can be reused. cat >> "$path" </dev/null 2>&1 || true -bun pm cache rm >/dev/null 2>&1 || true -echo "[prep] phase 1 $pm: cleaned lockfiles + all caches + node_modules" +rm -rf "$UTOO_CACHE" "$BUN_CACHE" +echo "[prep] phase 1 $pm: cleaned lockfiles + caches + node_modules" EOF ;; p3) @@ -70,14 +74,14 @@ EOF case "$pm" in utoo) cat >> "$path" </dev/null 2>&1 || true -echo "[prep] phase 3 utoo: kept package-lock.json, ran utoo clean" +rm -rf "$UTOO_CACHE" +echo "[prep] phase 3 utoo: kept package-lock.json, wiped $UTOO_CACHE" EOF ;; bun) cat >> "$path" </dev/null 2>&1 || true -echo "[prep] phase 3 bun: kept bun.lock, ran bun pm cache rm" +rm -rf "$BUN_CACHE" +echo "[prep] phase 3 bun: kept bun.lock, wiped $BUN_CACHE" EOF ;; esac @@ -111,7 +115,7 @@ seed_for_phase() { p3:utoo|p4:utoo) if [ ! -f package-lock.json ]; then echo -e " ${CYAN}seed: running \`utoo deps\` to generate package-lock.json${NC}" - utoo deps --registry="$REGISTRY" > "$RESULTS_DIR/seed_${phase}_${pm}.log" 2>&1 + utoo deps --registry="$REGISTRY" --cache-dir="$UTOO_CACHE" > "$RESULTS_DIR/seed_${phase}_${pm}.log" 2>&1 fi ;; p3:bun|p4:bun) @@ -130,7 +134,7 @@ seed_for_phase() { echo -e " ${CYAN}seed: warming $pm cache via full install${NC}" rm -rf node_modules case "$pm" in - utoo) utoo install --ignore-scripts --registry="$REGISTRY" > "$RESULTS_DIR/seed_warmup_${pm}.log" 2>&1 ;; + utoo) utoo install --ignore-scripts --registry="$REGISTRY" --cache-dir="$UTOO_CACHE" > "$RESULTS_DIR/seed_warmup_${pm}.log" 2>&1 ;; bun) bun install --ignore-scripts --registry="$REGISTRY" > "$RESULTS_DIR/seed_warmup_${pm}.log" 2>&1 ;; esac rm -rf node_modules @@ -140,14 +144,14 @@ seed_for_phase() { install_cmd() { case "$1" in - utoo) echo "utoo install --ignore-scripts --registry=$REGISTRY" ;; + utoo) echo "utoo install --ignore-scripts --registry=$REGISTRY --cache-dir=$UTOO_CACHE" ;; bun) echo "bun install --ignore-scripts --registry=$REGISTRY" ;; esac } resolve_cmd() { case "$1" in - utoo) echo "utoo deps --registry=$REGISTRY" ;; + utoo) echo "utoo deps --registry=$REGISTRY --cache-dir=$UTOO_CACHE" ;; bun) echo "bun install --lockfile-only --registry=$REGISTRY" ;; esac } From 4f9f21007d85824854c9eeddb1fb5728d7d1c4ee Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 00:11:44 +0800 Subject: [PATCH 011/102] debug(pm-bench-phases): log prepare script + before/after cache sizes Drop into diagnostic mode to figure out why hyperfine's --prepare still leaves utoo's cache intact across iterations despite the explicit --cache-dir. Prints the generated prepare script, and logs each per-iteration invocation's before/after du -sh of both caches. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/pm-bench-phases.sh | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/e2e/pm-bench-phases.sh b/e2e/pm-bench-phases.sh index 5784099e3d..4cbddc5e1c 100755 --- a/e2e/pm-bench-phases.sh +++ b/e2e/pm-bench-phases.sh @@ -160,20 +160,42 @@ run_phase() { local phase=$1 pm=$2 cmd=$3 local json="$RESULTS_DIR/${PROJECT}_${phase}_${pm}.json" local prep_script="$RESULTS_DIR/prep_${phase}_${pm}.sh" + local prep_log="$RESULTS_DIR/prep_${phase}_${pm}.log" seed_for_phase "$phase" "$pm" write_prepare "$prep_script" "$phase" "$pm" echo -e " ${CYAN}$pm${NC} · $phase · cmd: $cmd" + echo "--- generated prepare script for $phase/$pm ---" + cat "$prep_script" + echo "--- end prepare ---" + + # Wrap prepare so each run logs cache state before/after to $prep_log. + # The log survives across iterations; we tail it at the end for CI debug. + local prep_wrapper="$RESULTS_DIR/prep_wrap_${phase}_${pm}.sh" + cat > "$prep_wrapper" <> "$prep_log" +echo "before utoo=\$(du -sh $UTOO_CACHE 2>/dev/null | cut -f1) bun=\$(du -sh $BUN_CACHE 2>/dev/null | cut -f1)" >> "$prep_log" +bash "$prep_script" >> "$prep_log" 2>&1 +echo "after utoo=\$(du -sh $UTOO_CACHE 2>/dev/null | cut -f1) bun=\$(du -sh $BUN_CACHE 2>/dev/null | cut -f1)" >> "$prep_log" +EOF + chmod +x "$prep_wrapper" + : > "$prep_log" + if ! hyperfine \ --runs "$RUNS" \ - --prepare "bash $prep_script" \ + --prepare "bash $prep_wrapper" \ --export-json "$json" \ --show-output \ -n "${pm}-${phase}" \ "bash -c 'cd $PROJECT_DIR && $cmd'"; then echo -e " ${RED}$pm $phase failed${NC}" fi + + echo "--- prepare log ($prep_log) ---" + cat "$prep_log" + echo "--- end prepare log ---" } # === PHASE 1: resolve only (clean slate) === From 786dee2cef207f52d1cd2f2d5e6eefe0f10a1890 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 00:18:53 +0800 Subject: [PATCH 012/102] fix(pm-bench-phases): match full phase name in case patterns The case $phase in p1) p3) p4) \-style patterns never matched against actual phase strings like "p1_resolve" / "p3_cold_install" / "p4_warm_link". Result: write_prepare produced a script containing only the common header and no phase-specific cache-wipe logic, so every run after the first hit a warm cache and timings collapsed. Same off-by-name bug in seed_for_phase: "p3:utoo" pattern never matched "p3_cold_install:utoo", skipping lockfile seeding and warm-cache priming. Switched both to "p*_*" globs. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/pm-bench-phases.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/e2e/pm-bench-phases.sh b/e2e/pm-bench-phases.sh index 4cbddc5e1c..7070c595ac 100755 --- a/e2e/pm-bench-phases.sh +++ b/e2e/pm-bench-phases.sh @@ -59,7 +59,7 @@ EOF # `utoo clean` / `bun pm cache rm` didn't actually empty the cache on the CI # runner, leading to cache-hit runs masquerading as cold installs. case "$phase" in - p1) + p1_*) # Phase 1: cold resolve — wipe lockfiles AND caches so nothing can be reused. cat >> "$path" <> "$path" < "$RESULTS_DIR/seed_${phase}_${pm}.log" 2>&1 fi ;; - p3:bun|p4:bun) + p3_*:bun|p4_*:bun) if [ ! -f bun.lock ]; then echo -e " ${CYAN}seed: running \`bun install --lockfile-only\` to generate bun.lock${NC}" rm -f package-lock.json @@ -127,7 +127,7 @@ seed_for_phase() { ;; esac # Phase 4 also needs a pre-warmed cache. - if [ "$phase" = "p4" ]; then + if [[ "$phase" == p4_* ]]; then local cache case "$pm" in utoo) cache=$UTOO_CACHE ;; bun) cache=$BUN_CACHE ;; esac if [ ! -d "$cache" ] || [ -z "$(ls -A "$cache" 2>/dev/null)" ]; then From 5cb27a753eec1c933cc15fbc6135f461f906386b Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 00:39:32 +0800 Subject: [PATCH 013/102] ci(pm-bench-phases): drop debug wrapper now that prepare is verified The cache-size before/after logs + generated-script dumps were diagnostic scaffolding used to trace the p* vs p*_resolve pattern mismatch. With that fixed, keep the plain hyperfine --prepare invocation so CI logs are readable. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/pm-bench-phases.sh | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/e2e/pm-bench-phases.sh b/e2e/pm-bench-phases.sh index 7070c595ac..8b42b4a671 100755 --- a/e2e/pm-bench-phases.sh +++ b/e2e/pm-bench-phases.sh @@ -160,42 +160,20 @@ run_phase() { local phase=$1 pm=$2 cmd=$3 local json="$RESULTS_DIR/${PROJECT}_${phase}_${pm}.json" local prep_script="$RESULTS_DIR/prep_${phase}_${pm}.sh" - local prep_log="$RESULTS_DIR/prep_${phase}_${pm}.log" seed_for_phase "$phase" "$pm" write_prepare "$prep_script" "$phase" "$pm" - echo -e " ${CYAN}$pm${NC} · $phase · cmd: $cmd" - echo "--- generated prepare script for $phase/$pm ---" - cat "$prep_script" - echo "--- end prepare ---" - - # Wrap prepare so each run logs cache state before/after to $prep_log. - # The log survives across iterations; we tail it at the end for CI debug. - local prep_wrapper="$RESULTS_DIR/prep_wrap_${phase}_${pm}.sh" - cat > "$prep_wrapper" <> "$prep_log" -echo "before utoo=\$(du -sh $UTOO_CACHE 2>/dev/null | cut -f1) bun=\$(du -sh $BUN_CACHE 2>/dev/null | cut -f1)" >> "$prep_log" -bash "$prep_script" >> "$prep_log" 2>&1 -echo "after utoo=\$(du -sh $UTOO_CACHE 2>/dev/null | cut -f1) bun=\$(du -sh $BUN_CACHE 2>/dev/null | cut -f1)" >> "$prep_log" -EOF - chmod +x "$prep_wrapper" - : > "$prep_log" - + echo -e " ${CYAN}$pm${NC} · $phase" if ! hyperfine \ --runs "$RUNS" \ - --prepare "bash $prep_wrapper" \ + --prepare "bash $prep_script" \ --export-json "$json" \ --show-output \ -n "${pm}-${phase}" \ "bash -c 'cd $PROJECT_DIR && $cmd'"; then echo -e " ${RED}$pm $phase failed${NC}" fi - - echo "--- prepare log ($prep_log) ---" - cat "$prep_log" - echo "--- end prepare log ---" } # === PHASE 1: resolve only (clean slate) === From 0d9628a21fdfbbd78b161ee9742469029a949e18 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 00:53:37 +0800 Subject: [PATCH 014/102] feat(pm-bench-phases): collect RSS / ctx / IO per phase via /usr/bin/time Each hyperfine iteration now runs inside a metrics wrapper that greps /usr/bin/time -v output for RSS, voluntary/involuntary context switches, page faults, and IO read/write counts. Per-PM per-phase averages across the 3 runs are shown alongside the wall-clock table so we can see, e.g., whether utoo's resolve phase costs more syscalls than bun's, or whether its warm-link advantage comes at a memory cost. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/pm-bench-phases.sh | 113 ++++++++++++++++++++++++++++++++++------- 1 file changed, 95 insertions(+), 18 deletions(-) diff --git a/e2e/pm-bench-phases.sh b/e2e/pm-bench-phases.sh index 8b42b4a671..c1e44449e3 100755 --- a/e2e/pm-bench-phases.sh +++ b/e2e/pm-bench-phases.sh @@ -28,6 +28,43 @@ NC='\033[0m' banner() { echo -e "${YELLOW}=== $* ===${NC}"; } +# --- metrics wrapper --- +# Wraps each benchmark iteration in /usr/bin/time and appends a one-line +# JSON record with RSS / context switches / page faults / IO counts. One +# file per (phase, pm); hyperfine calls it once per run. +METRICS_WRAPPER="$RESULTS_DIR/metrics_wrapper.sh" +cat > "$METRICS_WRAPPER" <<'METRICS_EOF' +#!/bin/bash +METRICS_FILE="$1"; shift +TIME_TMP=$(mktemp) + +if [[ "$(uname)" == "Darwin" ]]; then + /usr/bin/time -l "$@" 2>"$TIME_TMP" + EXIT_CODE=$? + RSS=$(awk '/maximum resident set size/ {print $1}' "$TIME_TMP") + PAGE_FAULTS=$(awk '/page faults/ {print $1}' "$TIME_TMP") + VOL_CTX=$(awk '/ voluntary context switches/ {print $1}' "$TIME_TMP") + INVOL_CTX=$(awk '/involuntary context switches/ {print $1}' "$TIME_TMP") + IO_IN=0 + IO_OUT=0 +else + /usr/bin/time -v "$@" 2>"$TIME_TMP" + EXIT_CODE=$? + RSS_KB=$(awk '/Maximum resident set size/ {print $NF}' "$TIME_TMP") + RSS=$(( ${RSS_KB:-0} * 1024 )) + PAGE_FAULTS=$(awk '/Major \(requiring I\/O\) page faults/ {print $NF}' "$TIME_TMP") + VOL_CTX=$(awk '/Voluntary context switches/ {print $NF}' "$TIME_TMP") + INVOL_CTX=$(awk '/Involuntary context switches/ {print $NF}' "$TIME_TMP") + IO_IN=$(awk '/File system inputs/ {print $NF}' "$TIME_TMP") + IO_OUT=$(awk '/File system outputs/ {print $NF}' "$TIME_TMP") +fi + +echo "{\"rss\":${RSS:-0},\"page_faults\":${PAGE_FAULTS:-0},\"vol_ctx\":${VOL_CTX:-0},\"invol_ctx\":${INVOL_CTX:-0},\"io_in\":${IO_IN:-0},\"io_out\":${IO_OUT:-0}}" >> "$METRICS_FILE" +rm -f "$TIME_TMP" +exit $EXIT_CODE +METRICS_EOF +chmod +x "$METRICS_WRAPPER" + # --- project --- banner "Preparing $PROJECT" cd "$BENCH_DIR" @@ -159,11 +196,17 @@ resolve_cmd() { run_phase() { local phase=$1 pm=$2 cmd=$3 local json="$RESULTS_DIR/${PROJECT}_${phase}_${pm}.json" + local metrics="$RESULTS_DIR/${PROJECT}_${phase}_${pm}_metrics.jsonl" local prep_script="$RESULTS_DIR/prep_${phase}_${pm}.sh" + local cmd_script="$RESULTS_DIR/cmd_${phase}_${pm}.sh" seed_for_phase "$phase" "$pm" write_prepare "$prep_script" "$phase" "$pm" + # Reset metrics file; wrap the bench command so /usr/bin/time can measure it. + : > "$metrics" + printf 'set -eo pipefail\ncd %s\n%s\n' "$PROJECT_DIR" "$cmd" > "$cmd_script" + echo -e " ${CYAN}$pm${NC} · $phase" if ! hyperfine \ --runs "$RUNS" \ @@ -171,7 +214,7 @@ run_phase() { --export-json "$json" \ --show-output \ -n "${pm}-${phase}" \ - "bash -c 'cd $PROJECT_DIR && $cmd'"; then + "bash $METRICS_WRAPPER $metrics bash $cmd_script"; then echo -e " ${RED}$pm $phase failed${NC}" fi } @@ -200,30 +243,64 @@ RESULTS_DIR="$RESULTS_DIR" node -e " const fs = require('fs'), path = require('path'); const dir = process.env.RESULTS_DIR; const order = ['p1_resolve', 'p3_cold_install', 'p4_warm_link']; - const byPhase = Object.fromEntries(order.map(p => [p, []])); - for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.json'))) { - let data; - try { data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } - catch (_) { continue; } - for (const r of data.results) { - const phase = order.find(p => r.command.includes(p) || f.includes(p)); - if (!phase) continue; - byPhase[phase].push({ name: r.command, mean: r.mean, stddev: r.stddev, min: r.min, max: r.max }); + const timing = {}; // phase -> pm -> {mean,stddev,min,max} + const metrics = {}; // phase -> pm -> {rss,vol_ctx,invol_ctx,page_faults,io_in,io_out} + + const parseKey = (file, suffix) => { + const base = file.replace(suffix, ''); + for (const p of order) { + const idx = base.indexOf('_' + p + '_'); + if (idx === -1) continue; + const pm = base.slice(idx + p.length + 2); + return { phase: p, pm }; } + return null; + }; + + for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.json') && !x.endsWith('_metrics.jsonl'))) { + const key = parseKey(f, '.json'); + if (!key) continue; + let data; try { data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch (_) { continue; } + const r = data.results[0]; + if (!r) continue; + (timing[key.phase] ??= {})[key.pm] = { mean: r.mean, stddev: r.stddev, min: r.min, max: r.max }; + } + + for (const f of fs.readdirSync(dir).filter(x => x.endsWith('_metrics.jsonl'))) { + const key = parseKey(f, '_metrics.jsonl'); + if (!key) continue; + const lines = fs.readFileSync(path.join(dir, f), 'utf8').trim().split('\n').filter(Boolean); + const rows = []; + for (const l of lines) { try { rows.push(JSON.parse(l)); } catch (_) {} } + if (rows.length === 0) continue; + const avg = {}; + for (const k of ['rss','page_faults','vol_ctx','invol_ctx','io_in','io_out']) { + avg[k] = Math.round(rows.reduce((s,e)=>s+(e[k]||0),0) / rows.length); + } + (metrics[key.phase] ??= {})[key.pm] = avg; } + const pad = (s, n) => String(s).padEnd(n); const padR = (s, n) => String(s).padStart(n); + const fmtB = b => b >= 1<<30 ? (b/(1<<30)).toFixed(1)+'G' : b >= 1<<20 ? Math.round(b/(1<<20))+'M' : b >= 1<<10 ? Math.round(b/(1<<10))+'K' : b+'B'; + for (const phase of order) { - if (byPhase[phase].length === 0) continue; + const tp = timing[phase] || {}, mp = metrics[phase] || {}; + const pms = [...new Set([...Object.keys(tp), ...Object.keys(mp)])]; + if (pms.length === 0) continue; console.log('\n## ' + phase); - console.log(pad('PM', 26) + ' ' + padR('mean', 9) + ' ' + padR('stddev', 7) + ' ' + padR('min', 7) + ' ' + padR('max', 7)); - for (const r of byPhase[phase]) { + console.log(pad('PM', 8) + ' ' + padR('mean', 8) + ' ' + padR('stddev', 7) + ' ' + padR('RSS', 6) + ' ' + padR('vCtx', 8) + ' ' + padR('iCtx', 8) + ' ' + padR('pgFlt', 7) + ' ' + padR('ioIn', 8) + ' ' + padR('ioOut', 8)); + for (const pm of pms) { + const t = tp[pm], m = mp[pm] || {}; + const tstr = t ? padR(t.mean.toFixed(2)+'s', 8) + ' ' + padR(t.stddev.toFixed(2)+'s', 7) : padR('-', 8) + ' ' + padR('-', 7); console.log( - pad(r.name, 26) + ' ' + - padR(r.mean.toFixed(2) + 's', 9) + ' ' + - padR(r.stddev.toFixed(2) + 's', 7) + ' ' + - padR(r.min.toFixed(2) + 's', 7) + ' ' + - padR(r.max.toFixed(2) + 's', 7) + pad(pm, 8) + ' ' + tstr + ' ' + + padR(m.rss ? fmtB(m.rss) : '-', 6) + ' ' + + padR(m.vol_ctx ?? '-', 8) + ' ' + + padR(m.invol_ctx ?? '-', 8) + ' ' + + padR(m.page_faults ?? '-', 7) + ' ' + + padR(m.io_in ?? '-', 8) + ' ' + + padR(m.io_out ?? '-', 8) ); } } From 3d59b2ef72f5c4bdaa7c9e489842d1e3c6a31bfe Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 00:56:59 +0800 Subject: [PATCH 015/102] feat(pm-bench-phases): capture detailed IO, network, CPU per phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand the metrics wrapper to collect everything that's cheap on Linux: - user / sys CPU seconds (from /usr/bin/time -v, lets us see CPU share) - RSS, voluntary + involuntary ctx, major + minor page faults - network RX / TX bytes (system-wide /proc/net/dev delta, excludes lo) - disk page-in / page-out bytes (/proc/vmstat pgpg{in,out} × 4K pages) Summary prints two tables per phase: A. wall / ±σ / user / sys / RSS / minor faults B. vCtx / iCtx / net RX / net TX / disk R / disk W This makes resolve-phase vs link-phase comparison legible: e.g. network cost should dominate download phases while disk writes dominate link. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/pm-bench-phases.sh | 109 +++++++++++++++++++++++++++++++---------- 1 file changed, 83 insertions(+), 26 deletions(-) diff --git a/e2e/pm-bench-phases.sh b/e2e/pm-bench-phases.sh index c1e44449e3..4b14768266 100755 --- a/e2e/pm-bench-phases.sh +++ b/e2e/pm-bench-phases.sh @@ -29,37 +29,73 @@ NC='\033[0m' banner() { echo -e "${YELLOW}=== $* ===${NC}"; } # --- metrics wrapper --- -# Wraps each benchmark iteration in /usr/bin/time and appends a one-line -# JSON record with RSS / context switches / page faults / IO counts. One -# file per (phase, pm); hyperfine calls it once per run. +# Wraps each benchmark iteration to capture: +# - CPU / memory via /usr/bin/time -v (user/sys, RSS, ctx, page faults) +# - Network via /proc/net/dev delta (system-wide RX/TX bytes) +# - Disk via /proc/vmstat pgpgin/pgpgout delta (pages in/out to block dev) +# All bench runners are dedicated CI machines, so system-wide deltas +# effectively reflect the benchmarked process. METRICS_WRAPPER="$RESULTS_DIR/metrics_wrapper.sh" cat > "$METRICS_WRAPPER" <<'METRICS_EOF' #!/bin/bash METRICS_FILE="$1"; shift TIME_TMP=$(mktemp) +# --- snapshot network + disk counters BEFORE the command --- +snap_net_rx=0; snap_net_tx=0 +if [ -r /proc/net/dev ]; then + # Skip loopback; sum across remaining interfaces for RX bytes (col 2) and TX bytes (col 10). + read snap_net_rx snap_net_tx < <(awk '/:/ && $1 !~ /^lo:/ {rx += $2; tx += $10} END {print rx+0, tx+0}' /proc/net/dev) +fi +snap_pgpgin=0; snap_pgpgout=0 +if [ -r /proc/vmstat ]; then + snap_pgpgin=$(awk '/^pgpgin / {print $2}' /proc/vmstat) + snap_pgpgout=$(awk '/^pgpgout / {print $2}' /proc/vmstat) +fi + if [[ "$(uname)" == "Darwin" ]]; then /usr/bin/time -l "$@" 2>"$TIME_TMP" EXIT_CODE=$? RSS=$(awk '/maximum resident set size/ {print $1}' "$TIME_TMP") - PAGE_FAULTS=$(awk '/page faults/ {print $1}' "$TIME_TMP") + PG_MAJOR=$(awk '/page faults/ {print $1}' "$TIME_TMP") + PG_MINOR=$(awk '/page reclaims/ {print $1}' "$TIME_TMP") VOL_CTX=$(awk '/ voluntary context switches/ {print $1}' "$TIME_TMP") INVOL_CTX=$(awk '/involuntary context switches/ {print $1}' "$TIME_TMP") - IO_IN=0 - IO_OUT=0 + USER_S=$(awk '/real/ && /user/ && /sys/ {print $3}' "$TIME_TMP") + SYS_S=$(awk '/real/ && /user/ && /sys/ {print $5}' "$TIME_TMP") else /usr/bin/time -v "$@" 2>"$TIME_TMP" EXIT_CODE=$? RSS_KB=$(awk '/Maximum resident set size/ {print $NF}' "$TIME_TMP") RSS=$(( ${RSS_KB:-0} * 1024 )) - PAGE_FAULTS=$(awk '/Major \(requiring I\/O\) page faults/ {print $NF}' "$TIME_TMP") + PG_MAJOR=$(awk '/Major \(requiring I\/O\) page faults/ {print $NF}' "$TIME_TMP") + PG_MINOR=$(awk '/Minor \(reclaiming a frame\) page faults/ {print $NF}' "$TIME_TMP") VOL_CTX=$(awk '/Voluntary context switches/ {print $NF}' "$TIME_TMP") INVOL_CTX=$(awk '/Involuntary context switches/ {print $NF}' "$TIME_TMP") - IO_IN=$(awk '/File system inputs/ {print $NF}' "$TIME_TMP") - IO_OUT=$(awk '/File system outputs/ {print $NF}' "$TIME_TMP") + # GNU time prints user/sys as "X.YZ", extract with awk. + USER_S=$(awk -F': ' '/User time \(seconds\)/ {print $2}' "$TIME_TMP") + SYS_S=$(awk -F': ' '/System time \(seconds\)/ {print $2}' "$TIME_TMP") fi -echo "{\"rss\":${RSS:-0},\"page_faults\":${PAGE_FAULTS:-0},\"vol_ctx\":${VOL_CTX:-0},\"invol_ctx\":${INVOL_CTX:-0},\"io_in\":${IO_IN:-0},\"io_out\":${IO_OUT:-0}}" >> "$METRICS_FILE" +# --- snapshot network + disk counters AFTER the command, compute deltas --- +net_rx=0; net_tx=0 +if [ -r /proc/net/dev ]; then + read cur_net_rx cur_net_tx < <(awk '/:/ && $1 !~ /^lo:/ {rx += $2; tx += $10} END {print rx+0, tx+0}' /proc/net/dev) + net_rx=$(( cur_net_rx - snap_net_rx )) + net_tx=$(( cur_net_tx - snap_net_tx )) +fi +disk_pg_in=0; disk_pg_out=0 +if [ -r /proc/vmstat ]; then + cur_pgpgin=$(awk '/^pgpgin / {print $2}' /proc/vmstat) + cur_pgpgout=$(awk '/^pgpgout / {print $2}' /proc/vmstat) + disk_pg_in=$(( cur_pgpgin - snap_pgpgin )) + disk_pg_out=$(( cur_pgpgout - snap_pgpgout )) +fi + +printf '{"rss":%d,"user_s":%s,"sys_s":%s,"page_major":%d,"page_minor":%d,"vol_ctx":%d,"invol_ctx":%d,"net_rx":%d,"net_tx":%d,"disk_pg_in":%d,"disk_pg_out":%d}\n' \ + "${RSS:-0}" "${USER_S:-0}" "${SYS_S:-0}" "${PG_MAJOR:-0}" "${PG_MINOR:-0}" \ + "${VOL_CTX:-0}" "${INVOL_CTX:-0}" \ + "${net_rx:-0}" "${net_tx:-0}" "${disk_pg_in:-0}" "${disk_pg_out:-0}" >> "$METRICS_FILE" rm -f "$TIME_TMP" exit $EXIT_CODE METRICS_EOF @@ -244,7 +280,7 @@ RESULTS_DIR="$RESULTS_DIR" node -e " const dir = process.env.RESULTS_DIR; const order = ['p1_resolve', 'p3_cold_install', 'p4_warm_link']; const timing = {}; // phase -> pm -> {mean,stddev,min,max} - const metrics = {}; // phase -> pm -> {rss,vol_ctx,invol_ctx,page_faults,io_in,io_out} + const metrics = {}; // phase -> pm -> averaged resource fields const parseKey = (file, suffix) => { const base = file.replace(suffix, ''); @@ -266,41 +302,62 @@ RESULTS_DIR="$RESULTS_DIR" node -e " (timing[key.phase] ??= {})[key.pm] = { mean: r.mean, stddev: r.stddev, min: r.min, max: r.max }; } + const metricKeys = ['rss','user_s','sys_s','page_major','page_minor','vol_ctx','invol_ctx','net_rx','net_tx','disk_pg_in','disk_pg_out']; for (const f of fs.readdirSync(dir).filter(x => x.endsWith('_metrics.jsonl'))) { const key = parseKey(f, '_metrics.jsonl'); if (!key) continue; - const lines = fs.readFileSync(path.join(dir, f), 'utf8').trim().split('\n').filter(Boolean); const rows = []; - for (const l of lines) { try { rows.push(JSON.parse(l)); } catch (_) {} } + for (const l of fs.readFileSync(path.join(dir, f), 'utf8').trim().split('\n').filter(Boolean)) { + try { rows.push(JSON.parse(l)); } catch (_) {} + } if (rows.length === 0) continue; const avg = {}; - for (const k of ['rss','page_faults','vol_ctx','invol_ctx','io_in','io_out']) { - avg[k] = Math.round(rows.reduce((s,e)=>s+(e[k]||0),0) / rows.length); + for (const k of metricKeys) { + avg[k] = rows.reduce((s,e) => s + Number(e[k] || 0), 0) / rows.length; } (metrics[key.phase] ??= {})[key.pm] = avg; } const pad = (s, n) => String(s).padEnd(n); const padR = (s, n) => String(s).padStart(n); - const fmtB = b => b >= 1<<30 ? (b/(1<<30)).toFixed(1)+'G' : b >= 1<<20 ? Math.round(b/(1<<20))+'M' : b >= 1<<10 ? Math.round(b/(1<<10))+'K' : b+'B'; + const fmtB = b => b >= 1<<30 ? (b/(1<<30)).toFixed(2)+'G' : b >= 1<<20 ? (b/(1<<20)).toFixed(0)+'M' : b >= 1<<10 ? (b/(1<<10)).toFixed(0)+'K' : Math.round(b)+'B'; + const fmtN = n => n >= 1e6 ? (n/1e6).toFixed(2)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : String(Math.round(n)); + const PAGE_BYTES = 4096; // Linux x86_64 / arm64 default. for (const phase of order) { const tp = timing[phase] || {}, mp = metrics[phase] || {}; const pms = [...new Set([...Object.keys(tp), ...Object.keys(mp)])]; if (pms.length === 0) continue; + console.log('\n## ' + phase); - console.log(pad('PM', 8) + ' ' + padR('mean', 8) + ' ' + padR('stddev', 7) + ' ' + padR('RSS', 6) + ' ' + padR('vCtx', 8) + ' ' + padR('iCtx', 8) + ' ' + padR('pgFlt', 7) + ' ' + padR('ioIn', 8) + ' ' + padR('ioOut', 8)); + + // Table A: wall + CPU + memory + console.log(pad('PM', 6) + ' ' + padR('wall', 8) + ' ' + padR('±σ', 7) + ' ' + padR('user', 7) + ' ' + padR('sys', 7) + ' ' + padR('RSS', 6) + ' ' + padR('pgMinor', 8)); + for (const pm of pms) { + const t = tp[pm] || {}, m = mp[pm] || {}; + console.log( + pad(pm, 6) + ' ' + + padR((t.mean ?? 0).toFixed(2)+'s', 8) + ' ' + + padR((t.stddev ?? 0).toFixed(2)+'s', 7) + ' ' + + padR((m.user_s || 0).toFixed(2)+'s', 7) + ' ' + + padR((m.sys_s || 0).toFixed(2)+'s', 7) + ' ' + + padR(m.rss ? fmtB(m.rss) : '-', 6) + ' ' + + padR(m.page_minor ? fmtN(m.page_minor) : '-', 8) + ); + } + + // Table B: context switches + network + disk + console.log(pad('PM', 6) + ' ' + padR('vCtx', 8) + ' ' + padR('iCtx', 8) + ' ' + padR('net RX', 8) + ' ' + padR('net TX', 8) + ' ' + padR('disk R', 8) + ' ' + padR('disk W', 8)); for (const pm of pms) { - const t = tp[pm], m = mp[pm] || {}; - const tstr = t ? padR(t.mean.toFixed(2)+'s', 8) + ' ' + padR(t.stddev.toFixed(2)+'s', 7) : padR('-', 8) + ' ' + padR('-', 7); + const m = mp[pm] || {}; console.log( - pad(pm, 8) + ' ' + tstr + ' ' + - padR(m.rss ? fmtB(m.rss) : '-', 6) + ' ' + - padR(m.vol_ctx ?? '-', 8) + ' ' + - padR(m.invol_ctx ?? '-', 8) + ' ' + - padR(m.page_faults ?? '-', 7) + ' ' + - padR(m.io_in ?? '-', 8) + ' ' + - padR(m.io_out ?? '-', 8) + pad(pm, 6) + ' ' + + padR(m.vol_ctx ? fmtN(m.vol_ctx) : '-', 8) + ' ' + + padR(m.invol_ctx ? fmtN(m.invol_ctx) : '-', 8) + ' ' + + padR(m.net_rx ? fmtB(m.net_rx) : '-', 8) + ' ' + + padR(m.net_tx ? fmtB(m.net_tx) : '-', 8) + ' ' + + padR(m.disk_pg_in ? fmtB(m.disk_pg_in * PAGE_BYTES) : '-', 8) + ' ' + + padR(m.disk_pg_out ? fmtB(m.disk_pg_out * PAGE_BYTES) : '-', 8) ); } } From e3641949b1b6f42eeb22502b71dc86ad1024bcbd Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 11:57:52 +0800 Subject: [PATCH 016/102] fix(pm-bench-phases): track disk writes by path, drop noisy pgpg counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous run attributed 525MB of writes to utoo's resolve phase when local check showed utoo only wrote ~28MB to its cache. The overshoot came from /proc/vmstat pgpgout being system-wide — it picked up ext4 journal, page-cache writeback, and other kernel activity unrelated to the benchmarked process. Switch to du-before/after on the paths that matter (cache dir, project node_modules, lockfiles) for a per-PM figure that reflects what the install actually produced. Summary now shows Δcache / Δnode_mod / Δlock per phase. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/pm-bench-phases.sh | 76 ++++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/e2e/pm-bench-phases.sh b/e2e/pm-bench-phases.sh index 4b14768266..6a5123bad3 100755 --- a/e2e/pm-bench-phases.sh +++ b/e2e/pm-bench-phases.sh @@ -31,27 +31,47 @@ banner() { echo -e "${YELLOW}=== $* ===${NC}"; } # --- metrics wrapper --- # Wraps each benchmark iteration to capture: # - CPU / memory via /usr/bin/time -v (user/sys, RSS, ctx, page faults) -# - Network via /proc/net/dev delta (system-wide RX/TX bytes) -# - Disk via /proc/vmstat pgpgin/pgpgout delta (pages in/out to block dev) -# All bench runners are dedicated CI machines, so system-wide deltas -# effectively reflect the benchmarked process. +# - Network via /proc/net/dev delta (system-wide, CI is dedicated → ≈ process) +# - File-space growth in the paths we actually care about (cache, node_modules, +# lockfile). `du` after - before gives net bytes written to those paths, +# isolating the PM's work from system-wide kernel flush noise. /proc/vmstat +# pgpg* is intentionally NOT used: it counted ~500MB of journal / page-cache +# flush that had nothing to do with the benchmarked process. METRICS_WRAPPER="$RESULTS_DIR/metrics_wrapper.sh" cat > "$METRICS_WRAPPER" <<'METRICS_EOF' #!/bin/bash METRICS_FILE="$1"; shift TIME_TMP=$(mktemp) -# --- snapshot network + disk counters BEFORE the command --- +# Tracked paths (all optional — missing ones contribute 0). +UTOO_CACHE="${UTOO_CACHE:-/tmp/utoo-bench-cache}" +BUN_CACHE="${BUN_CACHE:-/tmp/bun-bench-cache}" +PROJECT_DIR="${PROJECT_DIR:-$PWD}" + +du_bytes() { + # du -sb is GNU-only (bytes); fall back to -sk * 1024 on macOS. + for p in "$@"; do + if [ -e "$p" ]; then + if [[ "$(uname)" == "Darwin" ]]; then + du -sk "$p" 2>/dev/null | awk '{print $1 * 1024}' + else + du -sb "$p" 2>/dev/null | awk '{print $1}' + fi + else + echo 0 + fi + done | awk '{s += $1} END {print s+0}' +} + +# --- snapshot network counters + disk usage BEFORE the command --- snap_net_rx=0; snap_net_tx=0 if [ -r /proc/net/dev ]; then - # Skip loopback; sum across remaining interfaces for RX bytes (col 2) and TX bytes (col 10). read snap_net_rx snap_net_tx < <(awk '/:/ && $1 !~ /^lo:/ {rx += $2; tx += $10} END {print rx+0, tx+0}' /proc/net/dev) fi -snap_pgpgin=0; snap_pgpgout=0 -if [ -r /proc/vmstat ]; then - snap_pgpgin=$(awk '/^pgpgin / {print $2}' /proc/vmstat) - snap_pgpgout=$(awk '/^pgpgout / {print $2}' /proc/vmstat) -fi +snap_cache_utoo=$(du_bytes "$UTOO_CACHE") +snap_cache_bun=$(du_bytes "$BUN_CACHE") +snap_nm=$(du_bytes "$PROJECT_DIR/node_modules") +snap_lock=$(du_bytes "$PROJECT_DIR/package-lock.json" "$PROJECT_DIR/bun.lock") if [[ "$(uname)" == "Darwin" ]]; then /usr/bin/time -l "$@" 2>"$TIME_TMP" @@ -72,30 +92,27 @@ else PG_MINOR=$(awk '/Minor \(reclaiming a frame\) page faults/ {print $NF}' "$TIME_TMP") VOL_CTX=$(awk '/Voluntary context switches/ {print $NF}' "$TIME_TMP") INVOL_CTX=$(awk '/Involuntary context switches/ {print $NF}' "$TIME_TMP") - # GNU time prints user/sys as "X.YZ", extract with awk. USER_S=$(awk -F': ' '/User time \(seconds\)/ {print $2}' "$TIME_TMP") SYS_S=$(awk -F': ' '/System time \(seconds\)/ {print $2}' "$TIME_TMP") fi -# --- snapshot network + disk counters AFTER the command, compute deltas --- +# --- snapshot network + disk usage AFTER --- net_rx=0; net_tx=0 if [ -r /proc/net/dev ]; then read cur_net_rx cur_net_tx < <(awk '/:/ && $1 !~ /^lo:/ {rx += $2; tx += $10} END {print rx+0, tx+0}' /proc/net/dev) net_rx=$(( cur_net_rx - snap_net_rx )) net_tx=$(( cur_net_tx - snap_net_tx )) fi -disk_pg_in=0; disk_pg_out=0 -if [ -r /proc/vmstat ]; then - cur_pgpgin=$(awk '/^pgpgin / {print $2}' /proc/vmstat) - cur_pgpgout=$(awk '/^pgpgout / {print $2}' /proc/vmstat) - disk_pg_in=$(( cur_pgpgin - snap_pgpgin )) - disk_pg_out=$(( cur_pgpgout - snap_pgpgout )) -fi +delta_cache_utoo=$(( $(du_bytes "$UTOO_CACHE") - snap_cache_utoo )) +delta_cache_bun=$(( $(du_bytes "$BUN_CACHE") - snap_cache_bun )) +delta_nm=$(( $(du_bytes "$PROJECT_DIR/node_modules") - snap_nm )) +delta_lock=$(( $(du_bytes "$PROJECT_DIR/package-lock.json" "$PROJECT_DIR/bun.lock") - snap_lock )) -printf '{"rss":%d,"user_s":%s,"sys_s":%s,"page_major":%d,"page_minor":%d,"vol_ctx":%d,"invol_ctx":%d,"net_rx":%d,"net_tx":%d,"disk_pg_in":%d,"disk_pg_out":%d}\n' \ +printf '{"rss":%d,"user_s":%s,"sys_s":%s,"page_major":%d,"page_minor":%d,"vol_ctx":%d,"invol_ctx":%d,"net_rx":%d,"net_tx":%d,"cache_utoo_delta":%d,"cache_bun_delta":%d,"nm_delta":%d,"lock_delta":%d}\n' \ "${RSS:-0}" "${USER_S:-0}" "${SYS_S:-0}" "${PG_MAJOR:-0}" "${PG_MINOR:-0}" \ "${VOL_CTX:-0}" "${INVOL_CTX:-0}" \ - "${net_rx:-0}" "${net_tx:-0}" "${disk_pg_in:-0}" "${disk_pg_out:-0}" >> "$METRICS_FILE" + "${net_rx:-0}" "${net_tx:-0}" \ + "${delta_cache_utoo:-0}" "${delta_cache_bun:-0}" "${delta_nm:-0}" "${delta_lock:-0}" >> "$METRICS_FILE" rm -f "$TIME_TMP" exit $EXIT_CODE METRICS_EOF @@ -240,10 +257,12 @@ run_phase() { write_prepare "$prep_script" "$phase" "$pm" # Reset metrics file; wrap the bench command so /usr/bin/time can measure it. + # The wrapper reads PROJECT_DIR / UTOO_CACHE / BUN_CACHE from env to scope du. : > "$metrics" printf 'set -eo pipefail\ncd %s\n%s\n' "$PROJECT_DIR" "$cmd" > "$cmd_script" echo -e " ${CYAN}$pm${NC} · $phase" + export PROJECT_DIR UTOO_CACHE BUN_CACHE if ! hyperfine \ --runs "$RUNS" \ --prepare "bash $prep_script" \ @@ -302,7 +321,7 @@ RESULTS_DIR="$RESULTS_DIR" node -e " (timing[key.phase] ??= {})[key.pm] = { mean: r.mean, stddev: r.stddev, min: r.min, max: r.max }; } - const metricKeys = ['rss','user_s','sys_s','page_major','page_minor','vol_ctx','invol_ctx','net_rx','net_tx','disk_pg_in','disk_pg_out']; + const metricKeys = ['rss','user_s','sys_s','page_major','page_minor','vol_ctx','invol_ctx','net_rx','net_tx','cache_utoo_delta','cache_bun_delta','nm_delta','lock_delta']; for (const f of fs.readdirSync(dir).filter(x => x.endsWith('_metrics.jsonl'))) { const key = parseKey(f, '_metrics.jsonl'); if (!key) continue; @@ -322,7 +341,6 @@ RESULTS_DIR="$RESULTS_DIR" node -e " const padR = (s, n) => String(s).padStart(n); const fmtB = b => b >= 1<<30 ? (b/(1<<30)).toFixed(2)+'G' : b >= 1<<20 ? (b/(1<<20)).toFixed(0)+'M' : b >= 1<<10 ? (b/(1<<10)).toFixed(0)+'K' : Math.round(b)+'B'; const fmtN = n => n >= 1e6 ? (n/1e6).toFixed(2)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : String(Math.round(n)); - const PAGE_BYTES = 4096; // Linux x86_64 / arm64 default. for (const phase of order) { const tp = timing[phase] || {}, mp = metrics[phase] || {}; @@ -346,18 +364,20 @@ RESULTS_DIR="$RESULTS_DIR" node -e " ); } - // Table B: context switches + network + disk - console.log(pad('PM', 6) + ' ' + padR('vCtx', 8) + ' ' + padR('iCtx', 8) + ' ' + padR('net RX', 8) + ' ' + padR('net TX', 8) + ' ' + padR('disk R', 8) + ' ' + padR('disk W', 8)); + // Table B: context switches + network + per-path disk deltas + console.log(pad('PM', 6) + ' ' + padR('vCtx', 8) + ' ' + padR('iCtx', 8) + ' ' + padR('net RX', 8) + ' ' + padR('net TX', 8) + ' ' + padR('Δcache', 8) + ' ' + padR('Δnode_mod', 10) + ' ' + padR('Δlock', 7)); for (const pm of pms) { const m = mp[pm] || {}; + const cacheDelta = (m.cache_utoo_delta || 0) + (m.cache_bun_delta || 0); console.log( pad(pm, 6) + ' ' + padR(m.vol_ctx ? fmtN(m.vol_ctx) : '-', 8) + ' ' + padR(m.invol_ctx ? fmtN(m.invol_ctx) : '-', 8) + ' ' + padR(m.net_rx ? fmtB(m.net_rx) : '-', 8) + ' ' + padR(m.net_tx ? fmtB(m.net_tx) : '-', 8) + ' ' + - padR(m.disk_pg_in ? fmtB(m.disk_pg_in * PAGE_BYTES) : '-', 8) + ' ' + - padR(m.disk_pg_out ? fmtB(m.disk_pg_out * PAGE_BYTES) : '-', 8) + padR(cacheDelta ? fmtB(cacheDelta) : '-', 8) + ' ' + + padR(m.nm_delta ? fmtB(m.nm_delta) : '-', 10) + ' ' + + padR(m.lock_delta ? fmtB(m.lock_delta) : '-', 7) ); } } From 2929826eeb496901b4e3f1c8241f3a9824eb526d Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 12:06:58 +0800 Subject: [PATCH 017/102] fix(pm-bench-phases): move du out of timed window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measuring disk footprint via du before+after each iteration added 2-3s of traversal to every run (wall jumped from 2.3s → 4.9s on the warm-link phase). Both snapshots happened inside hyperfine's timed region because the wrapper runs as the benchmark command. Hot path keeps only /usr/bin/time + /proc/net/dev snapshots now. After hyperfine exits, capture_footprint does one du pass per phase/PM to record the final on-disk size of the cache, node_modules, and lockfile. Summary prints absolute sizes instead of per-iteration deltas — single sample is enough to compare what each PM produced. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/pm-bench-phases.sh | 106 ++++++++++++++++++++++------------------- 1 file changed, 58 insertions(+), 48 deletions(-) diff --git a/e2e/pm-bench-phases.sh b/e2e/pm-bench-phases.sh index 6a5123bad3..82f3da932d 100755 --- a/e2e/pm-bench-phases.sh +++ b/e2e/pm-bench-phases.sh @@ -29,49 +29,21 @@ NC='\033[0m' banner() { echo -e "${YELLOW}=== $* ===${NC}"; } # --- metrics wrapper --- -# Wraps each benchmark iteration to capture: -# - CPU / memory via /usr/bin/time -v (user/sys, RSS, ctx, page faults) -# - Network via /proc/net/dev delta (system-wide, CI is dedicated → ≈ process) -# - File-space growth in the paths we actually care about (cache, node_modules, -# lockfile). `du` after - before gives net bytes written to those paths, -# isolating the PM's work from system-wide kernel flush noise. /proc/vmstat -# pgpg* is intentionally NOT used: it counted ~500MB of journal / page-cache -# flush that had nothing to do with the benchmarked process. +# Keeps the hot path fast: only /usr/bin/time + counter snapshots inside the +# measured window. Disk-footprint measurement (du on node_modules / cache) +# happens once per phase AFTER hyperfine exits — see capture_footprint in +# run_phase — so 1-2s of `du` traversal doesn't bleed into wall-clock. METRICS_WRAPPER="$RESULTS_DIR/metrics_wrapper.sh" cat > "$METRICS_WRAPPER" <<'METRICS_EOF' #!/bin/bash METRICS_FILE="$1"; shift TIME_TMP=$(mktemp) -# Tracked paths (all optional — missing ones contribute 0). -UTOO_CACHE="${UTOO_CACHE:-/tmp/utoo-bench-cache}" -BUN_CACHE="${BUN_CACHE:-/tmp/bun-bench-cache}" -PROJECT_DIR="${PROJECT_DIR:-$PWD}" - -du_bytes() { - # du -sb is GNU-only (bytes); fall back to -sk * 1024 on macOS. - for p in "$@"; do - if [ -e "$p" ]; then - if [[ "$(uname)" == "Darwin" ]]; then - du -sk "$p" 2>/dev/null | awk '{print $1 * 1024}' - else - du -sb "$p" 2>/dev/null | awk '{print $1}' - fi - else - echo 0 - fi - done | awk '{s += $1} END {print s+0}' -} - -# --- snapshot network counters + disk usage BEFORE the command --- +# --- snapshot network BEFORE the command (cheap: one read of /proc/net/dev) --- snap_net_rx=0; snap_net_tx=0 if [ -r /proc/net/dev ]; then read snap_net_rx snap_net_tx < <(awk '/:/ && $1 !~ /^lo:/ {rx += $2; tx += $10} END {print rx+0, tx+0}' /proc/net/dev) fi -snap_cache_utoo=$(du_bytes "$UTOO_CACHE") -snap_cache_bun=$(du_bytes "$BUN_CACHE") -snap_nm=$(du_bytes "$PROJECT_DIR/node_modules") -snap_lock=$(du_bytes "$PROJECT_DIR/package-lock.json" "$PROJECT_DIR/bun.lock") if [[ "$(uname)" == "Darwin" ]]; then /usr/bin/time -l "$@" 2>"$TIME_TMP" @@ -96,28 +68,51 @@ else SYS_S=$(awk -F': ' '/System time \(seconds\)/ {print $2}' "$TIME_TMP") fi -# --- snapshot network + disk usage AFTER --- +# --- snapshot network AFTER, compute delta --- net_rx=0; net_tx=0 if [ -r /proc/net/dev ]; then read cur_net_rx cur_net_tx < <(awk '/:/ && $1 !~ /^lo:/ {rx += $2; tx += $10} END {print rx+0, tx+0}' /proc/net/dev) net_rx=$(( cur_net_rx - snap_net_rx )) net_tx=$(( cur_net_tx - snap_net_tx )) fi -delta_cache_utoo=$(( $(du_bytes "$UTOO_CACHE") - snap_cache_utoo )) -delta_cache_bun=$(( $(du_bytes "$BUN_CACHE") - snap_cache_bun )) -delta_nm=$(( $(du_bytes "$PROJECT_DIR/node_modules") - snap_nm )) -delta_lock=$(( $(du_bytes "$PROJECT_DIR/package-lock.json" "$PROJECT_DIR/bun.lock") - snap_lock )) -printf '{"rss":%d,"user_s":%s,"sys_s":%s,"page_major":%d,"page_minor":%d,"vol_ctx":%d,"invol_ctx":%d,"net_rx":%d,"net_tx":%d,"cache_utoo_delta":%d,"cache_bun_delta":%d,"nm_delta":%d,"lock_delta":%d}\n' \ +printf '{"rss":%d,"user_s":%s,"sys_s":%s,"page_major":%d,"page_minor":%d,"vol_ctx":%d,"invol_ctx":%d,"net_rx":%d,"net_tx":%d}\n' \ "${RSS:-0}" "${USER_S:-0}" "${SYS_S:-0}" "${PG_MAJOR:-0}" "${PG_MINOR:-0}" \ "${VOL_CTX:-0}" "${INVOL_CTX:-0}" \ - "${net_rx:-0}" "${net_tx:-0}" \ - "${delta_cache_utoo:-0}" "${delta_cache_bun:-0}" "${delta_nm:-0}" "${delta_lock:-0}" >> "$METRICS_FILE" + "${net_rx:-0}" "${net_tx:-0}" >> "$METRICS_FILE" rm -f "$TIME_TMP" exit $EXIT_CODE METRICS_EOF chmod +x "$METRICS_WRAPPER" +# Post-phase footprint helper: records final on-disk size of the paths each +# phase should have touched. Called ONCE after hyperfine finishes, so the +# (slow) du traversal is not part of any timed window. +du_bytes() { + for p in "$@"; do + if [ -e "$p" ]; then + if [[ "$(uname)" == "Darwin" ]]; then + du -sk "$p" 2>/dev/null | awk '{print $1 * 1024}' + else + du -sb "$p" 2>/dev/null | awk '{print $1}' + fi + else + echo 0 + fi + done | awk '{s += $1} END {print s+0}' +} + +capture_footprint() { + local phase=$1 pm=$2 out=$3 + local cache + case "$pm" in utoo) cache=$UTOO_CACHE ;; bun) cache=$BUN_CACHE ;; esac + printf '{"cache":%d,"node_modules":%d,"lockfile":%d}\n' \ + "$(du_bytes "$cache")" \ + "$(du_bytes "$PROJECT_DIR/node_modules")" \ + "$(du_bytes "$PROJECT_DIR/package-lock.json" "$PROJECT_DIR/bun.lock")" \ + > "$out" +} + # --- project --- banner "Preparing $PROJECT" cd "$BENCH_DIR" @@ -272,6 +267,10 @@ run_phase() { "bash $METRICS_WRAPPER $metrics bash $cmd_script"; then echo -e " ${RED}$pm $phase failed${NC}" fi + + # After the timed runs: capture the state left on disk by the last run. + # Done outside hyperfine so du's traversal doesn't pollute wall-clock. + capture_footprint "$phase" "$pm" "$RESULTS_DIR/${PROJECT}_${phase}_${pm}_footprint.json" } # === PHASE 1: resolve only (clean slate) === @@ -321,7 +320,7 @@ RESULTS_DIR="$RESULTS_DIR" node -e " (timing[key.phase] ??= {})[key.pm] = { mean: r.mean, stddev: r.stddev, min: r.min, max: r.max }; } - const metricKeys = ['rss','user_s','sys_s','page_major','page_minor','vol_ctx','invol_ctx','net_rx','net_tx','cache_utoo_delta','cache_bun_delta','nm_delta','lock_delta']; + const metricKeys = ['rss','user_s','sys_s','page_major','page_minor','vol_ctx','invol_ctx','net_rx','net_tx']; for (const f of fs.readdirSync(dir).filter(x => x.endsWith('_metrics.jsonl'))) { const key = parseKey(f, '_metrics.jsonl'); if (!key) continue; @@ -337,6 +336,18 @@ RESULTS_DIR="$RESULTS_DIR" node -e " (metrics[key.phase] ??= {})[key.pm] = avg; } + // Footprint is a post-phase single sample (not averaged): final on-disk size + // of the paths the phase should have touched. + for (const f of fs.readdirSync(dir).filter(x => x.endsWith('_footprint.json'))) { + const key = parseKey(f, '_footprint.json'); + if (!key) continue; + let data; try { data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch (_) { continue; } + const slot = (metrics[key.phase] ??= {})[key.pm] ??= {}; + slot.foot_cache = data.cache || 0; + slot.foot_nm = data.node_modules || 0; + slot.foot_lock = data.lockfile || 0; + } + const pad = (s, n) => String(s).padEnd(n); const padR = (s, n) => String(s).padStart(n); const fmtB = b => b >= 1<<30 ? (b/(1<<30)).toFixed(2)+'G' : b >= 1<<20 ? (b/(1<<20)).toFixed(0)+'M' : b >= 1<<10 ? (b/(1<<10)).toFixed(0)+'K' : Math.round(b)+'B'; @@ -364,20 +375,19 @@ RESULTS_DIR="$RESULTS_DIR" node -e " ); } - // Table B: context switches + network + per-path disk deltas - console.log(pad('PM', 6) + ' ' + padR('vCtx', 8) + ' ' + padR('iCtx', 8) + ' ' + padR('net RX', 8) + ' ' + padR('net TX', 8) + ' ' + padR('Δcache', 8) + ' ' + padR('Δnode_mod', 10) + ' ' + padR('Δlock', 7)); + // Table B: context switches + network + final on-disk footprint + console.log(pad('PM', 6) + ' ' + padR('vCtx', 8) + ' ' + padR('iCtx', 8) + ' ' + padR('net RX', 8) + ' ' + padR('net TX', 8) + ' ' + padR('cache', 8) + ' ' + padR('node_mod', 9) + ' ' + padR('lock', 7)); for (const pm of pms) { const m = mp[pm] || {}; - const cacheDelta = (m.cache_utoo_delta || 0) + (m.cache_bun_delta || 0); console.log( pad(pm, 6) + ' ' + padR(m.vol_ctx ? fmtN(m.vol_ctx) : '-', 8) + ' ' + padR(m.invol_ctx ? fmtN(m.invol_ctx) : '-', 8) + ' ' + padR(m.net_rx ? fmtB(m.net_rx) : '-', 8) + ' ' + padR(m.net_tx ? fmtB(m.net_tx) : '-', 8) + ' ' + - padR(cacheDelta ? fmtB(cacheDelta) : '-', 8) + ' ' + - padR(m.nm_delta ? fmtB(m.nm_delta) : '-', 10) + ' ' + - padR(m.lock_delta ? fmtB(m.lock_delta) : '-', 7) + padR(m.foot_cache ? fmtB(m.foot_cache) : '-', 8) + ' ' + + padR(m.foot_nm ? fmtB(m.foot_nm) : '-', 9) + ' ' + + padR(m.foot_lock ? fmtB(m.foot_lock) : '-', 7) ); } } From 9554b1a997543c57dbf1f6c7e4e5ca1aa46b0a80 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 12:14:04 +0800 Subject: [PATCH 018/102] fix(pm-bench-phases): skip footprint JSON when loading hyperfine results parseKey matched both `_${phase}_${pm}.json` (hyperfine export) and `_${phase}_${pm}_footprint.json` (our new du snapshot), so the loop tried to read .results[0] off the footprint and crashed the whole summary. Add footprint suffix to the exclusion filter. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/pm-bench-phases.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/pm-bench-phases.sh b/e2e/pm-bench-phases.sh index 82f3da932d..44246b1679 100755 --- a/e2e/pm-bench-phases.sh +++ b/e2e/pm-bench-phases.sh @@ -311,7 +311,7 @@ RESULTS_DIR="$RESULTS_DIR" node -e " return null; }; - for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.json') && !x.endsWith('_metrics.jsonl'))) { + for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.json') && !x.endsWith('_metrics.jsonl') && !x.endsWith('_footprint.json'))) { const key = parseKey(f, '.json'); if (!key) continue; let data; try { data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch (_) { continue; } From 1ef5fa8b744ceb1eff97ec224a56eca13667ff56 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 15:02:56 +0800 Subject: [PATCH 019/102] perf(ruborist): enable gzip + brotli on manifest HTTP client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm registries compress manifest responses ~13× (antd abbreviated goes from 4.2MB to 309KB with gzip), but ruborist's reqwest client had neither compression feature enabled — so it never advertised `Accept-Encoding: gzip,br` and the server delivered raw JSON. Adding `gzip` + `brotli` to the feature list cuts the cold `utoo deps` manifest traffic on ant-design from ~275 MB of JSON over the wire to ~21 MB. Wall improvement is modest on high-latency links (connection setup dominates) but the bandwidth reduction is real and the CPU cost of decompression is negligible next to simd_json. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 37 +++++++++++++++++++++++++++++++++++++ crates/ruborist/Cargo.toml | 2 ++ 2 files changed, 39 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index ac904b9fd3..2953a00fed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -573,6 +588,27 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "browserslist-data" version = "0.1.4" @@ -1110,6 +1146,7 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" dependencies = [ + "brotli", "compression-core", "flate2", "memchr", diff --git a/crates/ruborist/Cargo.toml b/crates/ruborist/Cargo.toml index 4f63f3ec7f..a761995069 100644 --- a/crates/ruborist/Cargo.toml +++ b/crates/ruborist/Cargo.toml @@ -24,6 +24,8 @@ tokio-retry = "0.3" tracing = { workspace = true } # HTTP client - works on both native and WASM reqwest = { version = "0.12", default-features = false, features = [ + "brotli", + "gzip", "http2", "json", "rustls-tls", From cbf8d33ede466dc3994f22a4221e13ca602c8088 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 15:33:45 +0800 Subject: [PATCH 020/102] 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's HTTP/2 client multiplexes every manifest fetch over a SINGLE TCP connection to each registry host. Bun opens ~10 parallel HTTP/2 connections and gets proportional extra bandwidth; we can't reproduce that through reqwest without custom pooling. Falling back to HTTP/1.1 with pool_max_idle_per_host(64) lets the pool open independent connections (one request per connection, 64 parallel). Local cold `utoo deps` on ant-design against registry.antgroup-inc.cn: HTTP/2 single connection: 4.9s avg HTTP/1.1 + pool of 64: 4.0s avg (-18%) bun (reference): 3.2s Full parity with bun still wants multi-connection HTTP/2 (bun's strategy), which reqwest doesn't expose without a custom client pool — future work. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/http.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index b959020973..5cdad3c4d6 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -85,6 +85,18 @@ use anyhow::{Context, Result, anyhow}; /// surfaced to callers instead of panicking or calling `process::exit`. static HTTP_CLIENT: LazyLock> = LazyLock::new(|| { client_builder() + .map(|b| { + // Force HTTP/1.1 + large keep-alive pool. reqwest's HTTP/2 default + // multiplexes every request over a SINGLE TCP connection per host, + // which bottlenecks on the server's SETTINGS_MAX_CONCURRENT_STREAMS + // (bun opens ~10 parallel HTTP/2 connections to the same host — we + // can't do the same through reqwest without custom pooling). Falling + // back to HTTP/1.1 lets the pool open N independent connections + // and each one runs one request at a time. + b.http1_only() + .pool_max_idle_per_host(64) + .pool_idle_timeout(std::time::Duration::from_secs(60)) + }) .and_then(|b| b.build().context("Failed to build reqwest client")) .map_err(|e| e.to_string()) }); From 499af547dc6535044877d8a0d55ba134e1bc4bf4 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 16:03:38 +0800 Subject: [PATCH 021/102] Revert "perf(ruborist): use HTTP/1.1 + connection pool for manifest fetching" This reverts commit 51b5edec5bf06a33d5a6f0b7cb17cec195695914. --- crates/ruborist/src/service/http.rs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index 5cdad3c4d6..b959020973 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -85,18 +85,6 @@ use anyhow::{Context, Result, anyhow}; /// surfaced to callers instead of panicking or calling `process::exit`. static HTTP_CLIENT: LazyLock> = LazyLock::new(|| { client_builder() - .map(|b| { - // Force HTTP/1.1 + large keep-alive pool. reqwest's HTTP/2 default - // multiplexes every request over a SINGLE TCP connection per host, - // which bottlenecks on the server's SETTINGS_MAX_CONCURRENT_STREAMS - // (bun opens ~10 parallel HTTP/2 connections to the same host — we - // can't do the same through reqwest without custom pooling). Falling - // back to HTTP/1.1 lets the pool open N independent connections - // and each one runs one request at a time. - b.http1_only() - .pool_max_idle_per_host(64) - .pool_idle_timeout(std::time::Duration::from_secs(60)) - }) .and_then(|b| b.build().context("Failed to build reqwest client")) .map_err(|e| e.to_string()) }); From df69b5cba1315ac578a4464b3d287a70877d374f Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 16:08:02 +0800 Subject: [PATCH 022/102] debug(ruborist): per-request latency histogram for manifest fetches Temporary diagnostic. Tracks send_us / body_us / bytes per fetch_full_manifest call and prints p50/p90/p99/max every 500 samples so the final output reflects the tail distribution of the full run. Remove before merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/manifest.rs | 62 +++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index ad682d6424..0a0f1d17f7 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -13,6 +13,61 @@ use super::fetch::{ use super::http::get_client; use crate::model::manifest::{CoreVersionManifest, FullManifest}; +/// Collect per-request timing samples for percentile reporting. +/// Prints a histogram every [`HISTO_REPORT_EVERY`] calls so the final +/// output reflects the full distribution of the run. +fn record_sample(send_us: u32, body_us: u32, bytes: u32) { + use std::sync::Mutex; + use std::sync::OnceLock; + struct Samples { + send: Vec, + body: Vec, + bytes: Vec, + } + static STORE: OnceLock> = OnceLock::new(); + const HISTO_REPORT_EVERY: usize = 500; + let store = STORE.get_or_init(|| { + Mutex::new(Samples { + send: Vec::new(), + body: Vec::new(), + bytes: Vec::new(), + }) + }); + let mut s = store.lock().unwrap(); + s.send.push(send_us); + s.body.push(body_us); + s.bytes.push(bytes); + let n = s.send.len(); + if n % HISTO_REPORT_EVERY == 0 || n == 1 { + fn pct(v: &mut [u32], p: f64) -> u32 { + if v.is_empty() { + return 0; + } + v.sort_unstable(); + let idx = ((p * v.len() as f64) as usize).min(v.len() - 1); + v[idx] + } + let mut send = s.send.clone(); + let mut body = s.body.clone(); + let mut bytes = s.bytes.clone(); + eprintln!( + " [histo #{}] send p50={}ms p90={}ms p99={}ms max={}ms | body p50={}ms p90={}ms p99={}ms max={}ms | bytes p50={}KB p90={}KB max={}KB", + n, + pct(&mut send, 0.50) / 1000, + pct(&mut send, 0.90) / 1000, + pct(&mut send, 0.99) / 1000, + pct(&mut send, 1.0) / 1000, + pct(&mut body, 0.50) / 1000, + pct(&mut body, 0.90) / 1000, + pct(&mut body, 0.99) / 1000, + pct(&mut body, 1.0) / 1000, + pct(&mut bytes, 0.50) / 1024, + pct(&mut bytes, 0.90) / 1024, + pct(&mut bytes, 1.0) / 1024, + ); + } +} + /// Result of a full manifest fetch with ETag support. /// Transient return value, immediately destructured — Box not needed. #[allow(clippy::large_enum_variant)] @@ -68,7 +123,10 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result) -> Result Date: Thu, 23 Apr 2026 16:18:49 +0800 Subject: [PATCH 023/102] debug(ruborist): switch histogram output to stdout (survives metrics wrapper) --- crates/ruborist/src/service/manifest.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index 0a0f1d17f7..0354a7695e 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -50,7 +50,7 @@ fn record_sample(send_us: u32, body_us: u32, bytes: u32) { let mut send = s.send.clone(); let mut body = s.body.clone(); let mut bytes = s.bytes.clone(); - eprintln!( + println!( " [histo #{}] send p50={}ms p90={}ms p99={}ms max={}ms | body p50={}ms p90={}ms p99={}ms max={}ms | bytes p50={}KB p90={}KB max={}KB", n, pct(&mut send, 0.50) / 1000, From bb8ce9e63ab13ff8319c0d22e1b79f87bb6b1276 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 17:38:46 +0800 Subject: [PATCH 024/102] 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 b959020973..e1d6e41dd4 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -115,7 +115,14 @@ pub fn client_builder() -> Result { let mut builder = builder .use_rustls_tls() .no_proxy() - .dns_resolver(shared_resolver()); + .dns_resolver(shared_resolver()) + // 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); match env_var("ALL_PROXY") { Some(url) => { From 9e7f25f73354f10dac831babd4059ea5c73acb71 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 17:38:55 +0800 Subject: [PATCH 025/102] perf(ruborist): dedup concurrent full-manifest fetches Adds a per-name single-flight gate to UnifiedRegistry::resolve_full_manifest. Concurrent callers for the same package name now serialize on a per-name mutex; the first caller hits the network and populates the memory cache, the rest re-check the cache after the gate and return the cached manifest. On ant-design cold deps this eliminates ~100+ duplicate full-manifest fetches observed when many deps point at the same transitive package. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/registry.rs | 26 ++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 772cac3f79..648501fc0b 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -68,6 +68,11 @@ pub struct UnifiedRegistry { registry_url: String, cache: Arc, supports_semver: bool, + /// Per-name single-flight gate. The outer mutex guards the map; each + /// entry is a per-name mutex that serializes concurrent fetches for the + /// same package so only the first caller hits the network. + inflight: + Arc>>>>, } /// Builder for `UnifiedRegistry`. @@ -137,6 +142,7 @@ impl UnifiedRegistryBuilder { registry_url, cache, supports_semver, + inflight: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), } } } @@ -153,6 +159,7 @@ impl Clone for UnifiedRegistry { registry_url: self.registry_url.clone(), cache: Arc::clone(&self.cache), supports_semver: self.supports_semver, + inflight: Arc::clone(&self.inflight), } } } @@ -202,12 +209,29 @@ impl UnifiedRegistry { /// 4. On 304: use disk cache data /// 5. On 200: update memory + disk cache async fn resolve_full_manifest(&self, name: &str) -> Result { - // 1. Check memory cache first + // 1. Check memory cache first (fast path — no lock). if let Some(manifest) = self.cache.get_full_manifest(name) { tracing::debug!("Memory cache hit for full manifest: {}", name); return Ok(FullManifestResult::Full(manifest)); } + // 1b. Single-flight gate: serialize concurrent fetches for the same + // package name so only the first caller hits the network. Others + // wait on the per-name mutex, then re-check the memory cache on the + // other side — by construction the first caller has populated it. + let gate = { + let mut map = self.inflight.lock().await; + map.entry(name.to_string()) + .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + let _guard = gate.lock().await; + + // Re-check memory cache after acquiring the gate. + if let Some(manifest) = self.cache.get_full_manifest(name) { + return Ok(FullManifestResult::Full(manifest)); + } + // 2. Load etag from disk cache (if available) let disk_versions = self.cache.get_versions_from_disk(name).await; let etag = disk_versions.as_ref().and_then(|v| v.etag.clone()); From 09950ee9dd003786325a86a8e72c031c8dcda5fa Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 17:39:02 +0800 Subject: [PATCH 026/102] chore(ruborist): remove debug histogram instrumentation Reverts the temporary record_sample() and per-request timing diagnostics added in 14f2777e / 50a70146. The distribution data was used to identify HTTP/2 head-of-line blocking; now that H1 + pool and dedup are in, the diagnostic prints are no longer needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/manifest.rs | 61 ------------------------- 1 file changed, 61 deletions(-) diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index 0354a7695e..64c6956286 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -13,61 +13,6 @@ use super::fetch::{ use super::http::get_client; use crate::model::manifest::{CoreVersionManifest, FullManifest}; -/// Collect per-request timing samples for percentile reporting. -/// Prints a histogram every [`HISTO_REPORT_EVERY`] calls so the final -/// output reflects the full distribution of the run. -fn record_sample(send_us: u32, body_us: u32, bytes: u32) { - use std::sync::Mutex; - use std::sync::OnceLock; - struct Samples { - send: Vec, - body: Vec, - bytes: Vec, - } - static STORE: OnceLock> = OnceLock::new(); - const HISTO_REPORT_EVERY: usize = 500; - let store = STORE.get_or_init(|| { - Mutex::new(Samples { - send: Vec::new(), - body: Vec::new(), - bytes: Vec::new(), - }) - }); - let mut s = store.lock().unwrap(); - s.send.push(send_us); - s.body.push(body_us); - s.bytes.push(bytes); - let n = s.send.len(); - if n % HISTO_REPORT_EVERY == 0 || n == 1 { - fn pct(v: &mut [u32], p: f64) -> u32 { - if v.is_empty() { - return 0; - } - v.sort_unstable(); - let idx = ((p * v.len() as f64) as usize).min(v.len() - 1); - v[idx] - } - let mut send = s.send.clone(); - let mut body = s.body.clone(); - let mut bytes = s.bytes.clone(); - println!( - " [histo #{}] send p50={}ms p90={}ms p99={}ms max={}ms | body p50={}ms p90={}ms p99={}ms max={}ms | bytes p50={}KB p90={}KB max={}KB", - n, - pct(&mut send, 0.50) / 1000, - pct(&mut send, 0.90) / 1000, - pct(&mut send, 0.99) / 1000, - pct(&mut send, 1.0) / 1000, - pct(&mut body, 0.50) / 1000, - pct(&mut body, 0.90) / 1000, - pct(&mut body, 0.99) / 1000, - pct(&mut body, 1.0) / 1000, - pct(&mut bytes, 0.50) / 1024, - pct(&mut bytes, 0.90) / 1024, - pct(&mut bytes, 1.0) / 1024, - ); - } -} - /// Result of a full manifest fetch with ETag support. /// Transient return value, immediately destructured — Box not needed. #[allow(clippy::large_enum_variant)] @@ -123,10 +68,7 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result) -> Result Date: Thu, 23 Apr 2026 18:06:26 +0800 Subject: [PATCH 027/102] ci(pm-bench-phases): add p0_full_cold phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the complete cold install (utoo install / bun install) with everything wiped — lockfile, all caches, node_modules. Matches the end-to-end "freshly cloned repo" user scenario and is directly comparable to pm-bench.yml's cold install number. Reported alongside the existing p1_resolve / p3_cold_install / p4_warm_link phases; does not replace any of them. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/pm-bench-phases.sh | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/e2e/pm-bench-phases.sh b/e2e/pm-bench-phases.sh index 44246b1679..3f39b7c58a 100755 --- a/e2e/pm-bench-phases.sh +++ b/e2e/pm-bench-phases.sh @@ -144,6 +144,14 @@ EOF # `utoo clean` / `bun pm cache rm` didn't actually empty the cache on the CI # runner, leading to cache-hit runs masquerading as cold installs. case "$phase" in + p0_*) + # Phase 0: full cold install — nothing reused. Lockfile + all caches wiped. + cat >> "$path" <> "$path" < pm -> {mean,stddev,min,max} const metrics = {}; // phase -> pm -> averaged resource fields From 973d28740171af0d8a635f4ad07cb35044cb7662 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 18:20:01 +0800 Subject: [PATCH 028/102] 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 7ce528d3228ea8db263b9e90e6beddda188faaaa Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 19:10:41 +0800 Subject: [PATCH 029/102] ci(pm-bench-phases): capture pcap of resolve phase for investigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local antgroup runs show DNS rotation cuts utoo's resolve HTTP phase from 5.38s to 3.95s (matching bun). On CI against npmjs however the resolve wall time is flat — possibly because: - npmjs from GH Actions returns fewer A records (Cloudflare Anycast) - low RTT already masks HOL tail Capture a single cold resolve run per PM under tcpdump so we can see the actual connection topology on CI and compare against the local antgroup evidence. Output uploaded as pm-bench-pcap artifact. Runs once after the main phased bench; reuses the already-cloned project directory and wipes lockfiles + caches itself. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pm-bench-phases.yml | 20 ++++++- e2e/pm-bench-pcap.sh | 83 +++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100755 e2e/pm-bench-pcap.sh diff --git a/.github/workflows/pm-bench-phases.yml b/.github/workflows/pm-bench-phases.yml index ff8b6f4cf3..eb057e1cb5 100644 --- a/.github/workflows/pm-bench-phases.yml +++ b/.github/workflows/pm-bench-phases.yml @@ -85,8 +85,8 @@ jobs: uses: oven-sh/setup-bun@v2 with: bun-version: latest - - name: Install hyperfine and GNU time - run: sudo apt-get update && sudo apt-get install -y hyperfine time + - name: Install hyperfine, GNU time, tcpdump and dnsutils + run: sudo apt-get update && sudo apt-get install -y hyperfine time tcpdump dnsutils - name: Verify tools run: | hyperfine --version @@ -101,3 +101,19 @@ jobs: run: | chmod +x e2e/pm-bench-phases.sh bash e2e/pm-bench-phases.sh + + - name: Capture pcap (resolve phase, single run per PM) + env: + PROJECT: ${{ github.event.inputs.project || 'ant-design' }} + REGISTRY: ${{ github.event.inputs.registry || 'https://registry.npmjs.org' }} + run: | + chmod +x e2e/pm-bench-pcap.sh + bash e2e/pm-bench-pcap.sh + + - name: Upload pcap artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: pm-bench-pcap + path: /tmp/pm-bench-pcap + retention-days: 3 diff --git a/e2e/pm-bench-pcap.sh b/e2e/pm-bench-pcap.sh new file mode 100755 index 0000000000..c8e869d78e --- /dev/null +++ b/e2e/pm-bench-pcap.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# One-off network trace capture: runs a single cold p1_resolve per PM with +# tcpdump active, so we can inspect connection topology (IPs, concurrency, +# timing) on CI after local evidence was inconclusive. +# +# Outputs: +# $PCAP_DIR/dns.txt — A records for the registry host +# $PCAP_DIR/{bun,utoo}.pcap — tcpdump capture of the resolve phase +# $PCAP_DIR/{bun,utoo}.log — stdout/stderr of the run +set -eo pipefail + +PROJECT=${PROJECT:-ant-design} +REGISTRY=${REGISTRY:-https://registry.npmjs.org} +BENCH_DIR=${BENCH_DIR:-/tmp/pm-bench} +PCAP_DIR=${PCAP_DIR:-/tmp/pm-bench-pcap} +UTOO_CACHE=/tmp/utoo-pcap-cache +BUN_CACHE=/tmp/bun-pcap-cache +export BUN_INSTALL_CACHE_DIR="$BUN_CACHE" + +mkdir -p "$PCAP_DIR" + +# Project must already be cloned by pm-bench-phases.sh (this script is meant +# to run after it, reusing the clone). +PROJECT_DIR="$BENCH_DIR/$PROJECT" +if [ ! -d "$PROJECT_DIR" ]; then + echo "missing $PROJECT_DIR — run pm-bench-phases.sh first" >&2 + exit 1 +fi + +# Extract hostname (strip scheme + any path) for DNS lookup. +HOST=$(echo "$REGISTRY" | sed -E 's#^https?://([^/]+).*#\1#') +echo "=== DNS records for $HOST ===" +{ + echo "# getent hosts" + getent hosts "$HOST" || true + echo + echo "# dig +short A" + dig +short A "$HOST" || true + echo + echo "# dig +short AAAA" + dig +short AAAA "$HOST" || true +} | tee "$PCAP_DIR/dns.txt" + +IFACE=$(ip route | awk '/default/ {print $5; exit}') +echo "capturing on interface: $IFACE" + +capture_one() { + local name=$1 + shift + local pcap="$PCAP_DIR/$name.pcap" + local log="$PCAP_DIR/$name.log" + + cd "$PROJECT_DIR" + # Cold: nothing reused. + rm -f package-lock.json bun.lock + rm -rf "$UTOO_CACHE" "$BUN_CACHE" node_modules + + echo "=== capturing $name ===" + # tcpdump as root; ubuntu-latest runners allow sudo without password. + sudo tcpdump -s 0 -i "$IFACE" -w "$pcap" 'tcp port 443' >/dev/null 2>&1 & + local tcpdump_pid=$! + # Let tcpdump bind before the workload starts. + sleep 1 + + /usr/bin/time -v "$@" >"$log" 2>&1 || echo " run exited with $?" + + sudo kill "$tcpdump_pid" 2>/dev/null || true + wait "$tcpdump_pid" 2>/dev/null || true + + # Make pcap readable by the later upload-artifact step (tcpdump writes + # as root). + sudo chmod 644 "$pcap" + echo " → $pcap ($(wc -c <"$pcap") bytes), log: $log" +} + +capture_one utoo \ + utoo deps --registry="$REGISTRY" --cache-dir="$UTOO_CACHE" + +capture_one bun \ + bun install --lockfile-only --registry="$REGISTRY" + +echo "done. files:" +ls -lh "$PCAP_DIR" From 934f67c305157daa98dccd0cc05a25245b37a48f Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 19:47:41 +0800 Subject: [PATCH 030/102] perf(ruborist): raise manifest fetch concurrency to 256 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pcap comparison against bun on both local (antgroup) and CI (npmjs) consistently shows bun opens ~256 parallel TCP connections during a cold install (4 IPs × 64 conn each), while utoo was capped at 64 — ~1/4 the effective parallelism even after the DNS round-robin fix, because reqwest treats all addresses of a host as a single pool rather than per-IP like bun. Raise the default concurrent manifest fetch count from 64 to 256 to match bun's observed network footprint. The CLI flag `--manifests-concurrency-limit` still overrides it. Pool idle cap bumped to 256 so the keep-alive pool can park every in-flight connection without churning. Risk: with DNS returning few A records the 256 connections may concentrate on one IP and trigger per-IP rate limits. Pushing to CI to measure before committing to this as the default. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/preload.rs | 10 ++++++++-- crates/ruborist/src/service/http.rs | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index 9fb5aff67b..7393e3286e 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -14,8 +14,14 @@ use crate::resolver::registry::resolve_package; use crate::traits::progress::{BuildEvent, EventReceiver}; use crate::traits::registry::RegistryClient; -/// Default concurrency limit for manifest fetching -pub const DEFAULT_CONCURRENCY: usize = 64; +/// Default concurrency limit for manifest fetching. +/// +/// Raised from 64 to 256 after pcap comparison against bun showed bun opens +/// ~256 parallel TCP connections during a cold install (typically 4 IPs × 64 +/// conn each), while utoo's 64-cap kept us at roughly 1/4 the effective +/// parallelism even after the DNS round-robin fix. Overridable via +/// `--manifests-concurrency-limit`. +pub const DEFAULT_CONCURRENCY: usize = 256; /// A dependency spec: (name, version_spec) pub type Dep = (String, String); diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index e1d6e41dd4..92a1d0c866 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -121,8 +121,11 @@ 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. + // Pool size matches `preload::DEFAULT_CONCURRENCY` so the + // per-host idle pool can absorb every in-flight fetch without + // churning connections. .http1_only() - .pool_max_idle_per_host(64); + .pool_max_idle_per_host(256); match env_var("ALL_PROXY") { Some(url) => { From 08b104b6d73e939a002a02b7cabff9085257b923 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 20:09:41 +0800 Subject: [PATCH 031/102] perf(ruborist): preheat HTTP connection pool before resolve phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pcap comparison against bun on CI (npmjs) shows bun fires ~256 parallel SYNs in one burst ~0.6s into the run, completing every TLS handshake up-front. utoo opened TCP connections on demand inside the resolve phase, so the first ~64 requests each paid ~200ms of handshake latency inline, limiting effective parallelism until the pool warmed up. Fire `concurrency` parallel HEAD requests to the registry root before phase 1 runs. Combined with the existing DNS round-robin resolver, this spreads the connections across every A record returned for the host (matching bun's observed "N IPs × ~K conn" pattern). HEAD failures are intentionally swallowed — a slot that fails to warm simply behaves like the pre-preheat baseline for that connection. Unconditional: runs even when skip_preload is set, since the BFS traversal phase that replaces preload also benefits from warm connections. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/builder.rs | 13 +++++++++++ crates/ruborist/src/service/http.rs | 30 +++++++++++++++++++++++++ crates/ruborist/src/service/registry.rs | 4 ++++ crates/ruborist/src/traits/registry.rs | 10 +++++++++ 4 files changed, 57 insertions(+) diff --git a/crates/ruborist/src/resolver/builder.rs b/crates/ruborist/src/resolver/builder.rs index b03db0fc43..ac5ff637eb 100644 --- a/crates/ruborist/src/resolver/builder.rs +++ b/crates/ruborist/src/resolver/builder.rs @@ -772,6 +772,19 @@ pub async fn build_deps_with_config( config.skip_preload ); + // Phase 0: Warm the connection pool. Fires `concurrency` parallel HEAD + // requests against the registry so the TLS handshakes happen up-front + // in a single ~200ms parallel burst instead of being amortised into + // the first `concurrency` manifest fetches of the preload / BFS phase. + // No-op for registry clients that don't share a global HTTP pool. + let preheat_start = tokio::time::Instant::now(); + registry.preheat(config.concurrency).await; + tracing::debug!( + "Connection preheat ({} conn): {:?}", + config.concurrency, + preheat_start.elapsed() + ); + // Phase 1: Preload manifests in parallel (unless skipped) run_preload_phase(graph, registry, &config, receiver).await; diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index 92a1d0c866..f649a132fa 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -93,6 +93,36 @@ pub(crate) fn get_client() -> Result<&'static reqwest::Client> { HTTP_CLIENT.as_ref().map_err(|e| anyhow!("{e}")) } +/// Warm the HTTP connection pool by firing `count` parallel HEAD requests +/// at `url`. Each request opens one TCP connection, completes the TLS +/// handshake, and lands in reqwest's keep-alive pool — ready to serve the +/// first real manifest fetch without paying handshake latency. +/// +/// This matches bun's observed pre-install network pattern (pcap shows +/// bun firing ~256 SYNs at t≈0.6s before any manifest fetch). Without +/// preheat, utoo's resolver opens connections on demand, serialising the +/// first ~64 TLS handshakes inside the measured resolve phase. +/// +/// Combined with the DNS round-robin resolver, the `count` connections +/// are spread across every A record returned for the host, so each CDN +/// edge ends up with roughly `count / N_ips` warm sockets. +/// +/// Errors are intentionally swallowed: a failed HEAD (403, timeout, etc.) +/// simply means one less warm connection — functionally equivalent to +/// no preheat for that slot. Never reports to the caller. +pub async fn preheat(url: &str, count: usize) { + use futures::future::join_all; + + let Ok(client) = get_client() else { + return; + }; + + let tasks = (0..count).map(|_| async move { + let _ = client.head(url).send().await; + }); + join_all(tasks).await; +} + /// Create a [`reqwest::ClientBuilder`] with TLS, DNS caching, and proxy /// from environment variables. /// diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 648501fc0b..533a9711b6 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -380,6 +380,10 @@ impl RegistryClient for UnifiedRegistry { self.supports_semver } + async fn preheat(&self, count: usize) { + super::http::preheat(&self.registry_url, count).await; + } + fn get_cached_full_manifest(&self, name: &str) -> Option { self.cache.get_full_manifest(name) } diff --git a/crates/ruborist/src/traits/registry.rs b/crates/ruborist/src/traits/registry.rs index 0760ff9b19..36a8423a23 100644 --- a/crates/ruborist/src/traits/registry.rs +++ b/crates/ruborist/src/traits/registry.rs @@ -133,6 +133,16 @@ pub trait RegistryClient { false } + /// Warm the underlying HTTP connection pool with `count` parallel + /// dummy requests to this registry, so the first real manifest fetch + /// doesn't pay TLS-handshake latency inline. + /// + /// Default: no-op. Registries backed by a shared HTTP client + /// (`UnifiedRegistry`) override this to actually open connections. + fn preheat(&self, _count: usize) -> impl Future + Send { + async {} + } + /// Fetch full package manifest from registry. /// /// Returns the complete package manifest with all versions. From b0590d5b85946316970eeda34050c31c70dcd51a Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 20:38:43 +0800 Subject: [PATCH 032/102] perf(ruborist): offload transitive-dep extraction to blocking pool pcap analysis showed utoo's active TCP stream count oscillating 11..64 during the resolve phase while bun holds a flat 64. The dips line up with bursts of CPU-bound work on the single preload task: extract_transitive_deps (Vec allocs + filter_map + string clones for every dep in every completed manifest) and the subsequent `pending.extend`. Each stall blocks the main loop from dispatching new HTTP requests, so warm connections sit idle. Move that work off the hot path: - pending queue becomes `Arc>>` - inside each resolve future, spawn the extraction on `tokio::task::spawn_blocking` so it runs on the blocking pool - blocking task locks the shared pending and pushes extracted deps - main loop only handles stats / events / on_manifest callback The lock is sync `std::sync::Mutex`: extend is fast and never held across `.await`, so tokio::sync::Mutex's async-aware variant adds overhead with no benefit. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/preload.rs | 35 +++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index 7393e3286e..7b2c21a465 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -4,7 +4,7 @@ //! its transitive dependencies are immediately added to the queue. use std::collections::{HashSet, VecDeque}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use futures::stream::{FuturesUnordered, StreamExt}; @@ -92,12 +92,18 @@ where { let mut stats = PreloadStats::default(); let mut processed: HashSet = HashSet::new(); - let mut pending: VecDeque = initial_deps.into(); + // Shared pending queue: each in-flight future extracts its own + // transitive deps on the blocking pool and pushes them here, so the + // main loop never does CPU-bound dep-graph walking between `await` + // points. pcap showed utoo's active-stream count oscillating + // 11..64 — those dips correspond to bursts of post-processing CPU + // on the single-task main loop; bun stays flat at 64. + let pending = Arc::new(Mutex::new(VecDeque::::from(initial_deps))); let concurrency = config.concurrency; tracing::debug!( "Preload: {} initial deps, concurrency={}", - pending.len(), + pending.lock().unwrap().len(), concurrency ); @@ -109,9 +115,11 @@ where // Fill up to concurrency limit while in_flight < concurrency { let item = loop { - let Some((name, spec)) = pending.pop_front() else { + let mut queue = pending.lock().unwrap(); + let Some((name, spec)) = queue.pop_front() else { break None; }; + drop(queue); let key = format!("{}@{}", name, spec); if !processed.contains(&key) { processed.insert(key); @@ -132,10 +140,28 @@ where receiver.on_event(BuildEvent::PreloadFetching { name: &name }); + let pending_for_task = Arc::clone(&pending); + let config_for_task = config.clone(); futures.push(async move { let start = tokio::time::Instant::now(); let result = resolve_package(registry, &name, &spec).await; let elapsed_ms = start.elapsed().as_millis() as u64; + + // Off the hot async path: extract the transitive dep list + // (iterates + clones strings for ~10 deps per manifest) on + // the blocking pool, so the single main task can keep + // dispatching new network requests without this CPU stall. + if let Ok(resolved) = &result { + let manifest = Arc::clone(&resolved.manifest); + let _ = tokio::task::spawn_blocking(move || { + let deps = extract_transitive_deps(&manifest, &config_for_task); + if let Ok(mut queue) = pending_for_task.lock() { + queue.extend(deps); + } + }) + .await; + } + (name, result, elapsed_ms) }); in_flight += 1; @@ -173,7 +199,6 @@ where // Send PackageResolved event for pipeline downloading receiver.on_event(BuildEvent::PackageResolved((&*resolved.manifest).into())); - pending.extend(extract_transitive_deps(&resolved.manifest, &config)); on_manifest(&name, resolved.manifest); } Err(e) => { From fd3f24508583857a0107fe9ff0a8106a13712bc9 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 21:11:13 +0800 Subject: [PATCH 033/102] perf(ruborist): offload manifest JSON parse to blocking pool, drop preheat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pcap analysis after the transitive-dep extraction offload still showed utoo's active TCP stream count dipping 24..62 during resolve while bun held a flat 64. Walking through the future's remaining sync work pinpointed `simd_json::serde::from_slice` in `fetch_full_manifest` / `fetch_version_manifest` — 5–50 KB manifests × ~1–5 ms parse × 3550 calls runs entirely on the single preload task, serialising with every other manifest response and causing the observed stalls. Move both the `raw_bytes.clone()` and the simd_json call into `tokio::task::spawn_blocking` so the worker pool parses while the main task keeps dispatching new network requests. Also drop the `preheat` machinery: the CI pcap revealed it was functionally inert (HEAD `/` returns in ~10 ms, so each connection went straight back to the idle pool and was reused by subsequent HEADs — 256 concurrent HEADs produced only 64 SYNs on the wire, same as a run with no preheat). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/builder.rs | 13 ------- crates/ruborist/src/service/http.rs | 30 ---------------- crates/ruborist/src/service/manifest.rs | 46 ++++++++++++++++++------- crates/ruborist/src/service/registry.rs | 4 --- crates/ruborist/src/traits/registry.rs | 10 ------ 5 files changed, 33 insertions(+), 70 deletions(-) diff --git a/crates/ruborist/src/resolver/builder.rs b/crates/ruborist/src/resolver/builder.rs index ac5ff637eb..b03db0fc43 100644 --- a/crates/ruborist/src/resolver/builder.rs +++ b/crates/ruborist/src/resolver/builder.rs @@ -772,19 +772,6 @@ pub async fn build_deps_with_config( config.skip_preload ); - // Phase 0: Warm the connection pool. Fires `concurrency` parallel HEAD - // requests against the registry so the TLS handshakes happen up-front - // in a single ~200ms parallel burst instead of being amortised into - // the first `concurrency` manifest fetches of the preload / BFS phase. - // No-op for registry clients that don't share a global HTTP pool. - let preheat_start = tokio::time::Instant::now(); - registry.preheat(config.concurrency).await; - tracing::debug!( - "Connection preheat ({} conn): {:?}", - config.concurrency, - preheat_start.elapsed() - ); - // Phase 1: Preload manifests in parallel (unless skipped) run_preload_phase(graph, registry, &config, receiver).await; diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index f649a132fa..92a1d0c866 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -93,36 +93,6 @@ pub(crate) fn get_client() -> Result<&'static reqwest::Client> { HTTP_CLIENT.as_ref().map_err(|e| anyhow!("{e}")) } -/// Warm the HTTP connection pool by firing `count` parallel HEAD requests -/// at `url`. Each request opens one TCP connection, completes the TLS -/// handshake, and lands in reqwest's keep-alive pool — ready to serve the -/// first real manifest fetch without paying handshake latency. -/// -/// This matches bun's observed pre-install network pattern (pcap shows -/// bun firing ~256 SYNs at t≈0.6s before any manifest fetch). Without -/// preheat, utoo's resolver opens connections on demand, serialising the -/// first ~64 TLS handshakes inside the measured resolve phase. -/// -/// Combined with the DNS round-robin resolver, the `count` connections -/// are spread across every A record returned for the host, so each CDN -/// edge ends up with roughly `count / N_ips` warm sockets. -/// -/// Errors are intentionally swallowed: a failed HEAD (403, timeout, etc.) -/// simply means one less warm connection — functionally equivalent to -/// no preheat for that slot. Never reports to the caller. -pub async fn preheat(url: &str, count: usize) { - use futures::future::join_all; - - let Ok(client) = get_client() else { - return; - }; - - let tasks = (0..count).map(|_| async move { - let _ = client.head(url).send().await; - }); - join_all(tasks).await; -} - /// Create a [`reqwest::ClientBuilder`] with TLS, DNS caching, and proxy /// from environment variables. /// diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index 64c6956286..a98e1ea0f8 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -86,18 +86,32 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result = response .bytes() .await .map_err(|e| FetchError::Permanent(anyhow!("Response read error: {e}")))? - .to_vec(); - - // Save raw bytes before simd_json mutates the parse buffer - let mut parse_buf = raw_bytes.clone(); - let mut manifest: FullManifest = - simd_json::serde::from_slice(&mut parse_buf) - .map_err(|e| FetchError::Permanent(anyhow!("JSON parse error: {e}")))?; - manifest.raw = std::sync::Arc::from(raw_bytes); + .into(); + + // Offload JSON parse to the blocking pool. Manifests are + // 5–50KB and simd_json is CPU-bound (~1–5ms per call); + // keeping this on the async main task serialises it with + // every other manifest response across the ~3550 + // concurrent fetches, creating the dips we saw in the + // active-stream pcap. spawn_blocking lets the main task + // keep dispatching while the worker pool parses. + let manifest = tokio::task::spawn_blocking(move || { + // simd_json mutates the parse buffer in place + // (in-place unicode unescaping etc.), so we keep a + // separate copy for `manifest.raw`. + let mut parse_buf = raw_bytes.clone(); + let mut m: FullManifest = simd_json::serde::from_slice(&mut parse_buf) + .map_err(|e| anyhow!("JSON parse error: {e}"))?; + m.raw = std::sync::Arc::from(raw_bytes); + Ok::<_, anyhow::Error>(m) + }) + .await + .map_err(|e| FetchError::Permanent(anyhow!("Parse task panicked: {e}")))? + .map_err(FetchError::Permanent)?; Ok(FetchManifestResult::Ok(manifest, new_etag)) } else { @@ -183,13 +197,19 @@ pub async fn fetch_version_manifest( .map_err(classify_reqwest_error)?; if response.status().is_success() { - let mut bytes = response + let bytes: Vec = response .bytes() .await .map_err(|e| FetchError::Permanent(anyhow!("Response read error: {e}")))? - .to_vec(); - simd_json::serde::from_slice(&mut bytes) - .map_err(|e| FetchError::Permanent(anyhow!("JSON parse error: {e}"))) + .into(); + tokio::task::spawn_blocking(move || { + let mut buf = bytes; + simd_json::serde::from_slice::(&mut buf) + .map_err(|e| anyhow!("JSON parse error: {e}")) + }) + .await + .map_err(|e| FetchError::Permanent(anyhow!("Parse task panicked: {e}")))? + .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 533a9711b6..648501fc0b 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -380,10 +380,6 @@ impl RegistryClient for UnifiedRegistry { self.supports_semver } - async fn preheat(&self, count: usize) { - super::http::preheat(&self.registry_url, count).await; - } - fn get_cached_full_manifest(&self, name: &str) -> Option { self.cache.get_full_manifest(name) } diff --git a/crates/ruborist/src/traits/registry.rs b/crates/ruborist/src/traits/registry.rs index 36a8423a23..0760ff9b19 100644 --- a/crates/ruborist/src/traits/registry.rs +++ b/crates/ruborist/src/traits/registry.rs @@ -133,16 +133,6 @@ pub trait RegistryClient { false } - /// Warm the underlying HTTP connection pool with `count` parallel - /// dummy requests to this registry, so the first real manifest fetch - /// doesn't pay TLS-handshake latency inline. - /// - /// Default: no-op. Registries backed by a shared HTTP client - /// (`UnifiedRegistry`) override this to actually open connections. - fn preheat(&self, _count: usize) -> impl Future + Send { - async {} - } - /// Fetch full package manifest from registry. /// /// Returns the complete package manifest with all versions. From 1b823128c85666aaebaaca4b3204109625b51201 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 21:33:19 +0800 Subject: [PATCH 034/102] perf(ruborist): fire-and-forget disk cache writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiling the remaining p1_resolve gap (utoo 5.57s vs bun 1.88s on CI) pointed at disk cache serialisation as the last inline CPU sink on the hot path: each resolved manifest called `serde_json::to_string_pretty(...).await` from `set_versions_to_disk` / `set_version_manifest_to_disk`, so the resolve future couldn't return until the pretty-JSON encoder and fs write had both finished. pcap showed the classic signature — active TCP streams dipping to the mid-20s while the main preload task was busy serialising. Three changes, all local to the two functions: 1. Convert both from `async fn` to plain `fn` that internally `tokio::spawn` the serialisation + write. The resolve path no longer awaits disk I/O; the cache populates in the background for the next run. 2. Swap `serde_json::to_string_pretty` for `serde_json::to_vec`. Compact encoding avoids the 2–3× overhead of pretty-printing, and skipping the intermediate `String` saves one allocation per write. 3. `set_version_manifest_to_disk` now takes `Arc` instead of `&CoreVersionManifest`. Call sites already hold an `Arc`, so they clone the Arc (atomic inc) instead of the spawn deep-cloning a 10–50 KB struct. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/cache.rs | 92 ++++++++++++++----------- crates/ruborist/src/service/registry.rs | 30 ++++---- 2 files changed, 68 insertions(+), 54 deletions(-) diff --git a/crates/ruborist/src/service/cache.rs b/crates/ruborist/src/service/cache.rs index d11ac6e4cf..f2bc9fa6de 100644 --- a/crates/ruborist/src/service/cache.rs +++ b/crates/ruborist/src/service/cache.rs @@ -319,31 +319,37 @@ impl PackageCache { } } - /// Save versions info to disk cache. - pub async fn set_versions_to_disk(&self, name: &str, info: &VersionsInfo) { - let Some(cache_dir) = &self.cache_dir else { + /// Save versions info to disk cache (fire-and-forget). + /// + /// Spawns the serialisation + write onto the tokio runtime so the caller + /// returns immediately. The resolve pipeline doesn't depend on the write + /// completing — disk cache is best-effort for the *next* run. pcap-driven + /// tuning showed the previous inline `.await` + `serde_json::to_string_pretty` + /// burned ~1–3 ms per call on the hot path, stalling the main preload + /// task and causing the 24..62 active-stream dip observed on CI. + pub fn set_versions_to_disk(&self, name: &str, info: &VersionsInfo) { + let Some(cache_dir) = self.cache_dir.clone() else { return; }; + let name = name.to_string(); + let info = info.clone(); - let path = get_versions_cache_path(cache_dir, name); - - // Ensure parent directory exists - if let Some(parent) = path.parent() { - let _ = tokio_fs_ext::create_dir_all(parent).await; - } - - match serde_json::to_string_pretty(info) { - Ok(content) => { - if let Err(e) = tokio_fs_ext::write(&path, content.as_bytes()).await { - tracing::debug!("Failed to write versions cache for {name}: {e}"); - } else { - tracing::debug!("Wrote versions to disk cache: {name}"); - } + tokio::spawn(async move { + let path = get_versions_cache_path(&cache_dir, &name); + if let Some(parent) = path.parent() { + let _ = tokio_fs_ext::create_dir_all(parent).await; } - Err(e) => { - tracing::debug!("Failed to serialize versions for {name}: {e}"); + match serde_json::to_vec(&info) { + Ok(bytes) => { + if let Err(e) = tokio_fs_ext::write(&path, bytes).await { + tracing::debug!("Failed to write versions cache for {name}: {e}"); + } + } + Err(e) => { + tracing::debug!("Failed to serialize versions for {name}: {e}"); + } } - } + }); } /// Load version manifest from disk cache. @@ -378,36 +384,40 @@ impl PackageCache { } } - /// Save version manifest to disk cache. - pub async fn set_version_manifest_to_disk( + /// Save version manifest to disk cache (fire-and-forget). + /// + /// Takes `Arc` so the shared reference moves into the + /// spawned task without deep-cloning the manifest (they can be 10–50 KB + /// each). Same rationale as [`Self::set_versions_to_disk`]: the resolve + /// pipeline was stalling on inline `.await` + pretty JSON serialisation. + pub fn set_version_manifest_to_disk( &self, name: &str, version: &str, - manifest: &CoreVersionManifest, + manifest: Arc, ) { - let Some(cache_dir) = &self.cache_dir else { + let Some(cache_dir) = self.cache_dir.clone() else { return; }; + let name = name.to_string(); + let version = version.to_string(); - let path = get_manifest_cache_path(cache_dir, name, version); - - // Ensure parent directory exists - if let Some(parent) = path.parent() { - let _ = tokio_fs_ext::create_dir_all(parent).await; - } - - match serde_json::to_string_pretty(manifest) { - Ok(content) => { - if let Err(e) = tokio_fs_ext::write(&path, content.as_bytes()).await { - tracing::debug!("Failed to write manifest cache for {name}@{version}: {e}"); - } else { - tracing::debug!("Wrote manifest to disk cache: {name}@{version}"); - } + tokio::spawn(async move { + let path = get_manifest_cache_path(&cache_dir, &name, &version); + if let Some(parent) = path.parent() { + let _ = tokio_fs_ext::create_dir_all(parent).await; } - Err(e) => { - tracing::debug!("Failed to serialize manifest for {name}@{version}: {e}"); + match serde_json::to_vec(&*manifest) { + Ok(bytes) => { + if let Err(e) = tokio_fs_ext::write(&path, bytes).await { + tracing::debug!("Failed to write manifest cache for {name}@{version}: {e}"); + } + } + Err(e) => { + tracing::debug!("Failed to serialize manifest for {name}@{version}: {e}"); + } } - } + }); } /// Export all version manifests for persistence. diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 648501fc0b..a3ba1c858c 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -263,8 +263,10 @@ impl UnifiedRegistry { self.cache .set_versions(name.to_string(), versions_info.clone()); - // 6. Write versions to disk (non-blocking for native, blocking for WASM) - self.cache.set_versions_to_disk(name, &versions_info).await; + // 6. Fire-and-forget disk cache write — no `.await` so the + // resolve future returns immediately while tokio::spawn + // handles serialisation + FS on a background task. + self.cache.set_versions_to_disk(name, &versions_info); Ok(FullManifestResult::Full(manifest)) } @@ -301,7 +303,7 @@ impl UnifiedRegistry { }; self.cache .set_versions(name.to_string(), versions_info.clone()); - self.cache.set_versions_to_disk(name, &versions_info).await; + self.cache.set_versions_to_disk(name, &versions_info); Ok(FullManifestResult::Full(manifest)) } @@ -365,8 +367,7 @@ impl UnifiedRegistry { // 5. Write to disk cache (only for non-semver registries) if !self.supports_semver { self.cache - .set_version_manifest_to_disk(name, spec, &manifest) - .await; + .set_version_manifest_to_disk(name, spec, manifest.clone()); } Ok(manifest) @@ -459,8 +460,7 @@ impl RegistryClient for UnifiedRegistry { // 5. Write to disk cache (only for non-semver registries) if !self.supports_semver { self.cache - .set_version_manifest_to_disk(name, spec, &manifest) - .await; + .set_version_manifest_to_disk(name, spec, manifest.clone()); } Ok(manifest) @@ -532,9 +532,11 @@ impl RegistryClient for UnifiedRegistry { ); // Write to disk cache for non-semver registries if !self.supports_semver { - self.cache - .set_version_manifest_to_disk(&fetch_name, &resolved_version, &version_manifest) - .await; + self.cache.set_version_manifest_to_disk( + &fetch_name, + &resolved_version, + version_manifest.clone(), + ); } return Ok(ResolvedPackage { name: name.to_string(), @@ -674,9 +676,11 @@ impl RegistryClient for UnifiedRegistry { // Write to disk cache (only for non-semver registries) if !self.supports_semver { - self.cache - .set_version_manifest_to_disk(&fetch_name, &resolved_version, &version_manifest) - .await; + self.cache.set_version_manifest_to_disk( + &fetch_name, + &resolved_version, + version_manifest.clone(), + ); } Ok(ResolvedPackage { From 739a386d6184d294935c79bd0fbe163c662c38af Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 21:48:05 +0800 Subject: [PATCH 035/102] perf(pm): raise manifests-concurrency-limit default from 64 to 256 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found the actual bottleneck gating all the earlier offload work: the pm-level user config overrode `ruborist::preload::DEFAULT_CONCURRENCY` with a 64 default, so even after bumping the ruborist constant and the HTTP pool cap to 256, every resolve pipeline still ran at 64 in-flight. pcap confirmed — every CI run showed exactly 64 SYNs regardless of ruborist-side changes. Path: CLI → `pm::helper::ruborist_context::build_deps_options_with_receiver` → `get_manifests_concurrency_limit().await` returns 64 → feeds into `BuildDepsOptions::concurrency` → `PreloadConfig::concurrency` → the `while in_flight < concurrency` loop in `preload_manifests`. Bump to 256 so the pipeline can actually grow to the width bun runs at (~4 IPs × 64 conn). Users can still override via `--manifests-concurrency-limit` or the `manifests-concurrency-limit` config key. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/user_config.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index 34ee45a340..209e9f6dfd 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -132,9 +132,15 @@ pub fn get_install_scope() -> InstallScope { INSTALL_SCOPE.get().copied().unwrap_or_default() } -// Manifest fetch concurrency configuration +// Manifest fetch concurrency configuration. +// +// pcap comparison against bun showed bun maintains ~256 parallel TCP +// connections during a cold install; utoo was pinned at 64 here, giving +// each connection 4× the serial request depth and roughly matching the +// observed wall-time gap. Raise the default so the resolver pipeline +// can actually fill the pool bumped in `ruborist/service/http.rs`. static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 64)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 256)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); From 41010def309e697459c95bde6288fa90cc974e08 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 21:56:49 +0800 Subject: [PATCH 036/102] Revert "perf(pm): raise manifests-concurrency-limit default from 64 to 256" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 256 bump was based on a misreading of the earlier pcap: I saw bun open 256 TCP connections and attributed its speed to 256-way parallel requests. It isn't — bun's `max_simultaneous_requests_for_bun_install` is also 64, matching the flat 64-active-stream pattern the pcap shows during resolve. The extra 192 TCP live in bun's idle pool for request rotation, not for added parallelism. On CI the bump actually made things marginally worse: utoo p1 went 5.38s → 6.25s while ctx switches climbed 125K → 161K (+28 %), pointing at scheduling overhead from 4× more live async tasks with no throughput to show for it. The preload pipeline never saturated 64 in the first place, so allowing it to reach 256 did nothing for wall-clock. Revert the default. The real gap is pipeline saturation (utoo's active-stream count oscillates 11..62 while bun stays flat at 64) — chase that, not the cap. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/user_config.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index 209e9f6dfd..a90d90b758 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -134,13 +134,14 @@ pub fn get_install_scope() -> InstallScope { // Manifest fetch concurrency configuration. // -// pcap comparison against bun showed bun maintains ~256 parallel TCP -// connections during a cold install; utoo was pinned at 64 here, giving -// each connection 4× the serial request depth and roughly matching the -// observed wall-time gap. Raise the default so the resolver pipeline -// can actually fill the pool bumped in `ruborist/service/http.rs`. +// bun's `max_simultaneous_requests_for_bun_install` also defaults to 64, +// and pcap shows bun holds a flat 64 active TCP streams during resolve. +// Raising our cap to 256 added ~28 % more ctx switches without shortening +// p1_resolve, because the preload pipeline never actually hit the 64 +// ceiling — the stalls come from main-task post-processing, not from +// the cap. Keep 64 and chase the saturation gap elsewhere. static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 256)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 64)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); From 39b58d36885b8fa759b63d57f7183389308fefdb Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 22:06:12 +0800 Subject: [PATCH 037/102] perf(ruborist): multi-client HTTP pool + real preheat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pcap analysis kept showing utoo stuck at 64 parallel TCPs during cold resolve while bun ran 256. The earlier single-client preheat produced only 64 SYNs because reqwest's pool is keyed per-client: concurrent HEADs to the same client race into one pool, so the first ~64 opens TCPs and the rest reuse as soon as responses land — HEAD completion wins the race against TCP connect. Split the global singleton into `HTTP_CLIENT_COUNT = 4` independent reqwest clients, each with its own pool. `pick_client()` round-robins across them on every call. `preheat()` then fires `per_client` parallel HEADs from *each* client, so the four isolated pools are all forced to open their own TCP set — `4 × per_client` aggregate connections, which actually hit the wire since no pool can "serve" another's request. Builder now preheats with `per_client = concurrency` (so 4 × 64 = 256 warm TCPs), oversizing the pool by 4× versus the resolver's 64-wide working set. This is the same pool-to-working-set ratio bun hits on CI, and it's the missing piece for smoothing out the active-stream dips pcap showed when all 64 in-flight requests landed in lockstep. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/builder.rs | 16 +++++++ crates/ruborist/src/resolver/http.rs | 4 +- crates/ruborist/src/service/http.rs | 63 ++++++++++++++++++++++--- crates/ruborist/src/service/manifest.rs | 6 +-- crates/ruborist/src/service/registry.rs | 4 ++ crates/ruborist/src/traits/registry.rs | 11 +++++ 6 files changed, 92 insertions(+), 12 deletions(-) diff --git a/crates/ruborist/src/resolver/builder.rs b/crates/ruborist/src/resolver/builder.rs index b03db0fc43..1eba8ab8cc 100644 --- a/crates/ruborist/src/resolver/builder.rs +++ b/crates/ruborist/src/resolver/builder.rs @@ -772,6 +772,22 @@ pub async fn build_deps_with_config( config.skip_preload ); + // Phase 0: Warm all connection pools. With `HTTP_CLIENT_COUNT` clients, + // firing `concurrency` parallel HEADs per client opens a total of + // `HTTP_CLIENT_COUNT × concurrency` TCPs up front. This is the amount + // of pool headroom that lets the resolver phase keep `concurrency` + // streams flat-active instead of phase-locking into dips when a batch + // of responses lands together. `per_client = concurrency` overshoots + // the 64-concurrent-request cap by 4×, matching bun's observed + // pool-to-working-set ratio. + let preheat_start = tokio::time::Instant::now(); + registry.preheat(config.concurrency).await; + tracing::debug!( + "Connection preheat ({} per client): {:?}", + config.concurrency, + preheat_start.elapsed() + ); + // Phase 1: Preload manifests in parallel (unless skipped) run_preload_phase(graph, registry, &config, receiver).await; diff --git a/crates/ruborist/src/resolver/http.rs b/crates/ruborist/src/resolver/http.rs index f88cd509f1..10c94486f6 100644 --- a/crates/ruborist/src/resolver/http.rs +++ b/crates/ruborist/src/resolver/http.rs @@ -99,7 +99,7 @@ use crate::model::manifest::CoreVersionManifest; use crate::service::fetch::{ FetchError, classify_reqwest_error, classify_status, is_retryable, retry_strategy, }; -use crate::service::http::get_client; +use crate::service::http::pick_client; use crate::traits::registry::ResolvedPackage; /// Session-scoped dedup cache: one fetch per URL even under concurrent BFS. @@ -154,7 +154,7 @@ async fn download_tarball(url: &str) -> Result { || { let url = url.to_string(); async move { - let resp = get_client() + let resp = pick_client() .map_err(FetchError::Permanent)? .get(&url) .send() diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index 92a1d0c866..d900de7b45 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -76,21 +76,70 @@ //! WASM targets skip DNS entirely (browser handles it). use std::sync::LazyLock; +use std::sync::atomic::{AtomicUsize, Ordering}; use anyhow::{Context, Result, anyhow}; -/// Global HTTP client with connection pooling and DNS caching. +/// Number of parallel reqwest clients fronting the registry. /// -/// Stores `Result` so that proxy-configuration errors are +/// Each client keeps its own connection pool. Fanning requests across N +/// clients means the aggregate in-flight TCP count can reach `N × +/// pool_max_idle_per_host` — matching bun's observed ~256 connection +/// footprint on npmjs with just 64-wide resolver concurrency. +/// +/// 4 mirrors bun's empirical per-install IP-fanout on CI (pcap showed +/// 4 "hot" Cloudflare edges serving 64 conn each). +const HTTP_CLIENT_COUNT: usize = 4; + +/// Global pool of reqwest clients with connection pooling and DNS caching. +/// +/// Stores `Result, String>` so proxy-configuration errors are /// surfaced to callers instead of panicking or calling `process::exit`. -static HTTP_CLIENT: LazyLock> = LazyLock::new(|| { - client_builder() - .and_then(|b| b.build().context("Failed to build reqwest client")) +static HTTP_CLIENTS: LazyLock, String>> = LazyLock::new(|| { + (0..HTTP_CLIENT_COUNT) + .map(|_| client_builder().and_then(|b| b.build().context("Failed to build reqwest client"))) + .collect::>>() .map_err(|e| e.to_string()) }); -pub(crate) fn get_client() -> Result<&'static reqwest::Client> { - HTTP_CLIENT.as_ref().map_err(|e| anyhow!("{e}")) +/// Round-robin cursor for `pick_client()`. +static CLIENT_RR: AtomicUsize = AtomicUsize::new(0); + +/// Hand out one of the global clients, cycling through them in round-robin +/// order. Each call advances the cursor by 1 so consecutive manifest +/// fetches land on different pools, spreading TCP connections across all +/// `HTTP_CLIENT_COUNT` clients. +pub(crate) fn pick_client() -> Result<&'static reqwest::Client> { + let clients = HTTP_CLIENTS.as_ref().map_err(|e| anyhow!("{e}"))?; + let idx = CLIENT_RR.fetch_add(1, Ordering::Relaxed) % clients.len(); + Ok(&clients[idx]) +} + +/// Warm every client's connection pool by firing `per_client` parallel HEAD +/// requests at `url` from **each** client concurrently. +/// +/// Why multi-client matters: a single reqwest Client dedupes via its pool, +/// so 256 concurrent HEADs on one client all race into the same pool — the +/// first ~64 open TCPs, the rest reuse as soon as the first responses land. +/// 4 isolated clients × 64 HEADs each force 4 × 64 = 256 truly parallel +/// TCP connects, because the pools don't share state. +/// +/// Errors are intentionally swallowed: a failed HEAD simply leaves that +/// slot "cold" — functionally equivalent to no preheat for that slot. +pub async fn preheat(url: &str, per_client: usize) { + use futures::future::join_all; + + let Ok(clients) = HTTP_CLIENTS.as_ref() else { + return; + }; + + let tasks = clients + .iter() + .flat_map(|client| (0..per_client).map(move |_| client.head(url).send())) + .map(|fut| async move { + let _ = fut.await; + }); + join_all(tasks).await; } /// Create a [`reqwest::ClientBuilder`] with TLS, DNS caching, and proxy diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index a98e1ea0f8..5be50827a8 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -10,7 +10,7 @@ use tokio_retry::RetryIf; use super::fetch::{ FetchError, classify_reqwest_error, classify_status, is_retryable, retry_strategy, }; -use super::http::get_client; +use super::http::pick_client; use crate::model::manifest::{CoreVersionManifest, FullManifest}; /// Result of a full manifest fetch with ETag support. @@ -60,7 +60,7 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result Option { self.cache.get_full_manifest(name) } diff --git a/crates/ruborist/src/traits/registry.rs b/crates/ruborist/src/traits/registry.rs index 0760ff9b19..30be47b26c 100644 --- a/crates/ruborist/src/traits/registry.rs +++ b/crates/ruborist/src/traits/registry.rs @@ -133,6 +133,17 @@ pub trait RegistryClient { false } + /// Warm the HTTP connection pools by firing `per_client` parallel HEAD + /// requests against this registry from *each* client. Matches bun's + /// pre-install network pattern (pcap shows bun opening ~256 SYNs up + /// front, distributed across multiple connection pools). + /// + /// Default: no-op. Clients backed by a shared multi-pool HTTP stack + /// override this to actually open connections. + fn preheat(&self, _per_client: usize) -> impl Future + Send { + async {} + } + /// Fetch full package manifest from registry. /// /// Returns the complete package manifest with all versions. From 76ac3fd0460793e4962e57f03af3c5e834aa8d88 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 22:25:13 +0800 Subject: [PATCH 038/102] perf(ruborist): halve client count and preheat footprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4-client × 64-HEAD preheat regressed p1_resolve on CI (5.4s → 7.8s, σ jumped from 0.1 to 1.3). Two problems compounded: 1. 256 concurrent TLS handshakes on a 4-core CI runner serialised the asymmetric crypto work and ate 200–300 ms before any real resolve. 2. Extra tokio task pressure (ctx switches climbed 76 %) with no corresponding throughput win. The real reason multi-client helps isn't extra parallelism — it's desynchronising the phase-locked response batches so the aggregate pool always has some connection mid-transfer while another is in the hand-off gap. Two pools is enough to cover that; four just paid more TLS cost without additional stagger benefit. - `HTTP_CLIENT_COUNT`: 4 → 2 - Preheat: `concurrency` per client → `concurrency / 2` per client. With 2 clients this still opens `concurrency` (64) total warm TCPs, 1× the working set — oversizing the pool further wasn't buying anything either. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/builder.rs | 17 +++++++---------- crates/ruborist/src/service/http.rs | 17 ++++++++++------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/crates/ruborist/src/resolver/builder.rs b/crates/ruborist/src/resolver/builder.rs index 1eba8ab8cc..23ebe8b22d 100644 --- a/crates/ruborist/src/resolver/builder.rs +++ b/crates/ruborist/src/resolver/builder.rs @@ -772,19 +772,16 @@ pub async fn build_deps_with_config( config.skip_preload ); - // Phase 0: Warm all connection pools. With `HTTP_CLIENT_COUNT` clients, - // firing `concurrency` parallel HEADs per client opens a total of - // `HTTP_CLIENT_COUNT × concurrency` TCPs up front. This is the amount - // of pool headroom that lets the resolver phase keep `concurrency` - // streams flat-active instead of phase-locking into dips when a batch - // of responses lands together. `per_client = concurrency` overshoots - // the 64-concurrent-request cap by 4×, matching bun's observed - // pool-to-working-set ratio. + // Phase 0: Warm both connection pools. With 2 clients, `per_client` = + // `concurrency / 2` opens `concurrency` total warm TCPs — same + // working-set size as a single burst, but split across two isolated + // pools so the resolver alternates pools and breaks the phase-lock + // pattern that kept active-stream count oscillating in pcap. let preheat_start = tokio::time::Instant::now(); - registry.preheat(config.concurrency).await; + registry.preheat(config.concurrency / 2).await; tracing::debug!( "Connection preheat ({} per client): {:?}", - config.concurrency, + config.concurrency / 2, preheat_start.elapsed() ); diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index d900de7b45..c9e1a7a6b4 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -82,14 +82,17 @@ use anyhow::{Context, Result, anyhow}; /// Number of parallel reqwest clients fronting the registry. /// -/// Each client keeps its own connection pool. Fanning requests across N -/// clients means the aggregate in-flight TCP count can reach `N × -/// pool_max_idle_per_host` — matching bun's observed ~256 connection -/// footprint on npmjs with just 64-wide resolver concurrency. +/// Each client keeps its own connection pool. The real benefit of splitting +/// across multiple clients isn't extra parallelism (the resolver is still +/// 64-concurrent) — it's breaking the phase-lock where 64 synchronous +/// responses leave 64 connections idle in lockstep. Two clients let one +/// pool be mid-transfer while the other is in the handoff gap. /// -/// 4 mirrors bun's empirical per-install IP-fanout on CI (pcap showed -/// 4 "hot" Cloudflare edges serving 64 conn each). -const HTTP_CLIENT_COUNT: usize = 4; +/// 4 clients × 64 pre-warm handshakes was measurably slower in CI runs +/// because the 256 concurrent TLS handshakes ate too much of the 4-core +/// runner before resolve work started. 2 keeps the stagger benefit and +/// halves the preheat startup cost. +const HTTP_CLIENT_COUNT: usize = 2; /// Global pool of reqwest clients with connection pooling and DNS caching. /// From 84a4095fcfbd565bad9859cb57ab8f94863a7547 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 23:07:43 +0800 Subject: [PATCH 039/102] perf(ruborist): OnceMap dedup + proc_us instrumentation, drop multi-client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes bundled because they're interdependent: 1. **Revert multi-client HTTP pool back to a single reqwest::Client.** Both the 2-client and 4-client variants regressed p1_resolve on CI (utoo: 5.38s → 6.33s → 7.80s respectively) because the preheat's TLS-handshake burst ate more wall time than the phase-lock smoothing bought. uv's resolver uses a single client + Semaphore and proves the architecture works. Drop `HTTP_CLIENT_COUNT`, `pick_client` round robin, `preheat()`, the `RegistryClient::preheat` trait method, and the `UnifiedRegistry::preheat` impl — all dead weight now. 2. **Replace `UnifiedRegistry`'s per-name `tokio::sync::Mutex<()>` gate with an `OnceMap`.** Same coalescing semantics (first caller fetches network, others wait, all return the shared result), but OnceMap is backed by `dashmap` + `tokio::sync::Notify` instead of a `tokio::sync::Mutex` + per-name `tokio::sync::Mutex<()>`. Removes the outer-map lock from the hot path and replaces the per-name async-mutex dance with a lock-free `DashMap::entry` on success. `FullManifestResult` now derives `Clone` so callers can take owned copies of the shared `Arc`. Also moves `OnceMap` itself from `pm/util/oncemap.rs` into `ruborist/src/util/oncemap.rs` so ruborist (lower in the stack) can use it without a reverse dependency. pm's downloader / cloner switch their import to `utoo_ruborist::util::oncemap::OnceMap`. Gated `#[cfg(not(target_arch = "wasm32"))]` because `dashmap` + tokio sync are unnecessary on the WASM path today. 3. **Add per-iteration `proc_us` histogram in `preload_manifests`.** Measures the serial main-task CPU cost between consecutive `futures.next().await` returns — this is the window that competes with async polling of the in-flight pool and likely explains the remaining phase-lock dips we can't offload further. Printed at `tracing::info!` level as `p50 / p90 / p99 / max / sum / avg` microseconds on shutdown. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + crates/pm/src/util/cloner.rs | 2 +- crates/pm/src/util/downloader.rs | 2 +- crates/pm/src/util/mod.rs | 1 - crates/ruborist/Cargo.toml | 1 + crates/ruborist/src/lib.rs | 4 +- crates/ruborist/src/resolver/builder.rs | 13 ---- crates/ruborist/src/resolver/preload.rs | 32 ++++++++ crates/ruborist/src/service/http.rs | 69 +++-------------- crates/ruborist/src/service/registry.rs | 84 ++++++++++++--------- crates/ruborist/src/traits/registry.rs | 11 --- crates/ruborist/src/util/mod.rs | 13 ++++ crates/{pm => ruborist}/src/util/oncemap.rs | 0 13 files changed, 109 insertions(+), 124 deletions(-) create mode 100644 crates/ruborist/src/util/mod.rs rename crates/{pm => ruborist}/src/util/oncemap.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 2953a00fed..32cc9a93b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11383,6 +11383,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bytes", + "dashmap 6.1.0", "deno_semver", "dirs 5.0.1", "futures", diff --git a/crates/pm/src/util/cloner.rs b/crates/pm/src/util/cloner.rs index b357149760..77aa63e2e9 100644 --- a/crates/pm/src/util/cloner.rs +++ b/crates/pm/src/util/cloner.rs @@ -9,9 +9,9 @@ use utoo_ruborist::manifest::IdentityView; use super::downloader::{is_git_url, resolve_cache_path}; use super::json::load_package_json; -use super::oncemap::OnceMap; use super::retry::create_retry_strategy; use crate::fs; +use utoo_ruborist::util::oncemap::OnceMap; /// Global clone cache shared between pipeline and install phases. /// diff --git a/crates/pm/src/util/downloader.rs b/crates/pm/src/util/downloader.rs index a1517d9556..d78d58e19c 100644 --- a/crates/pm/src/util/downloader.rs +++ b/crates/pm/src/util/downloader.rs @@ -14,9 +14,9 @@ use utoo_ruborist::spec::Protocol; use super::cache::get_cache_dir; use super::extractor::extract_and_write; -use super::oncemap::OnceMap; use super::retry::{RetryableError, build_dns_cached_client, create_retry_strategy}; use super::user_config::get_manifests_concurrency_limit_sync; +use utoo_ruborist::util::oncemap::OnceMap; // Global downloader client - no pool limit, concurrency controlled by OnceMap static DOWNLOADER_CLIENT: Lazy = Lazy::new(build_dns_cached_client); diff --git a/crates/pm/src/util/mod.rs b/crates/pm/src/util/mod.rs index 335e5b0382..77688b07a7 100644 --- a/crates/pm/src/util/mod.rs +++ b/crates/pm/src/util/mod.rs @@ -12,7 +12,6 @@ pub mod integrity; pub mod json; pub mod linker; pub mod logger; -pub mod oncemap; pub mod platform_const; pub mod registry; pub mod retry; diff --git a/crates/ruborist/Cargo.toml b/crates/ruborist/Cargo.toml index a761995069..3c7939a275 100644 --- a/crates/ruborist/Cargo.toml +++ b/crates/ruborist/Cargo.toml @@ -9,6 +9,7 @@ license = "MIT" [dependencies] anyhow = { workspace = true } bytes = "1" +dashmap = "6.1.0" pathdiff = { workspace = true } deno_semver = "0.7" dirs = { workspace = true } diff --git a/crates/ruborist/src/lib.rs b/crates/ruborist/src/lib.rs index 890d6c2798..b7d0753071 100644 --- a/crates/ruborist/src/lib.rs +++ b/crates/ruborist/src/lib.rs @@ -101,6 +101,4 @@ pub mod http { } /// Utility functions. -pub mod util { - pub use crate::model::util::{PackageNameStr, parse_package_spec, read_package_json}; -} +pub mod util; diff --git a/crates/ruborist/src/resolver/builder.rs b/crates/ruborist/src/resolver/builder.rs index 23ebe8b22d..b03db0fc43 100644 --- a/crates/ruborist/src/resolver/builder.rs +++ b/crates/ruborist/src/resolver/builder.rs @@ -772,19 +772,6 @@ pub async fn build_deps_with_config( config.skip_preload ); - // Phase 0: Warm both connection pools. With 2 clients, `per_client` = - // `concurrency / 2` opens `concurrency` total warm TCPs — same - // working-set size as a single burst, but split across two isolated - // pools so the resolver alternates pools and breaks the phase-lock - // pattern that kept active-stream count oscillating in pcap. - let preheat_start = tokio::time::Instant::now(); - registry.preheat(config.concurrency / 2).await; - tracing::debug!( - "Connection preheat ({} per client): {:?}", - config.concurrency / 2, - preheat_start.elapsed() - ); - // Phase 1: Preload manifests in parallel (unless skipped) run_preload_phase(graph, registry, &config, receiver).await; diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index 7b2c21a465..1c67deaae6 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -91,6 +91,12 @@ where F: FnMut(&str, Arc), { let mut stats = PreloadStats::default(); + // Per-iteration processing time on the main task (in μs). Each sample + // covers the window from `futures.next().await` returning to the next + // iteration's `futures.next().await` being re-entered — i.e. the + // serial CPU cost for stats + event emission + callback + refill-loop + // + next iteration setup. Printed as a histogram at shutdown. + let mut proc_us: Vec = Vec::new(); let mut processed: HashSet = HashSet::new(); // Shared pending queue: each in-flight future extracts its own // transitive deps on the blocking pool and pushes them here, so the @@ -174,6 +180,7 @@ where let Some((name, result, elapsed_ms)) = futures.next().await else { break; }; + let iter_start = tokio::time::Instant::now(); in_flight -= 1; if stats.success_count == 0 && stats.failed_count == 0 { @@ -206,10 +213,35 @@ where tracing::debug!("Failed to preload {}: {}", name, e); } } + proc_us.push(iter_start.elapsed().as_micros().min(u32::MAX as u128) as u32); } stats.total_processed = processed.len(); + // Dump the per-iteration processing-time histogram. This is the + // serial main-task cost that competes with the async poll of the + // in-flight request pool; dips in active-stream count correlate + // with spikes in these samples. + if !proc_us.is_empty() { + let mut v = proc_us.clone(); + v.sort_unstable(); + let pct = |p: f64| -> u32 { + let idx = ((p * v.len() as f64) as usize).min(v.len() - 1); + v[idx] + }; + let sum: u64 = v.iter().map(|&x| x as u64).sum(); + tracing::info!( + "preload proc_us (n={}): p50={} p90={} p99={} max={} sum={}us avg={}us", + v.len(), + pct(0.50), + pct(0.90), + pct(0.99), + pct(1.0), + sum, + sum / v.len() as u64 + ); + } + receiver.on_event(BuildEvent::PreloadComplete { success: stats.success_count, failed: stats.failed_count, diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index c9e1a7a6b4..1d4451df88 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -76,73 +76,26 @@ //! WASM targets skip DNS entirely (browser handles it). use std::sync::LazyLock; -use std::sync::atomic::{AtomicUsize, Ordering}; use anyhow::{Context, Result, anyhow}; -/// Number of parallel reqwest clients fronting the registry. +/// Global HTTP client with connection pooling and DNS caching. /// -/// Each client keeps its own connection pool. The real benefit of splitting -/// across multiple clients isn't extra parallelism (the resolver is still -/// 64-concurrent) — it's breaking the phase-lock where 64 synchronous -/// responses leave 64 connections idle in lockstep. Two clients let one -/// pool be mid-transfer while the other is in the handoff gap. -/// -/// 4 clients × 64 pre-warm handshakes was measurably slower in CI runs -/// because the 256 concurrent TLS handshakes ate too much of the 4-core -/// runner before resolve work started. 2 keeps the stagger benefit and -/// halves the preheat startup cost. -const HTTP_CLIENT_COUNT: usize = 2; - -/// Global pool of reqwest clients with connection pooling and DNS caching. -/// -/// Stores `Result, String>` so proxy-configuration errors are +/// Stores `Result` so that proxy-configuration errors are /// surfaced to callers instead of panicking or calling `process::exit`. -static HTTP_CLIENTS: LazyLock, String>> = LazyLock::new(|| { - (0..HTTP_CLIENT_COUNT) - .map(|_| client_builder().and_then(|b| b.build().context("Failed to build reqwest client"))) - .collect::>>() +/// +/// Multi-client experiments (2 and 4 isolated clients + preheat) both +/// regressed p1_resolve on CI — preheat TLS handshake cost exceeded the +/// phase-lock smoothing benefit, and uv (which uses a single client) +/// proves one pool is the right default. +static HTTP_CLIENT: LazyLock> = LazyLock::new(|| { + client_builder() + .and_then(|b| b.build().context("Failed to build reqwest client")) .map_err(|e| e.to_string()) }); -/// Round-robin cursor for `pick_client()`. -static CLIENT_RR: AtomicUsize = AtomicUsize::new(0); - -/// Hand out one of the global clients, cycling through them in round-robin -/// order. Each call advances the cursor by 1 so consecutive manifest -/// fetches land on different pools, spreading TCP connections across all -/// `HTTP_CLIENT_COUNT` clients. pub(crate) fn pick_client() -> Result<&'static reqwest::Client> { - let clients = HTTP_CLIENTS.as_ref().map_err(|e| anyhow!("{e}"))?; - let idx = CLIENT_RR.fetch_add(1, Ordering::Relaxed) % clients.len(); - Ok(&clients[idx]) -} - -/// Warm every client's connection pool by firing `per_client` parallel HEAD -/// requests at `url` from **each** client concurrently. -/// -/// Why multi-client matters: a single reqwest Client dedupes via its pool, -/// so 256 concurrent HEADs on one client all race into the same pool — the -/// first ~64 open TCPs, the rest reuse as soon as the first responses land. -/// 4 isolated clients × 64 HEADs each force 4 × 64 = 256 truly parallel -/// TCP connects, because the pools don't share state. -/// -/// Errors are intentionally swallowed: a failed HEAD simply leaves that -/// slot "cold" — functionally equivalent to no preheat for that slot. -pub async fn preheat(url: &str, per_client: usize) { - use futures::future::join_all; - - let Ok(clients) = HTTP_CLIENTS.as_ref() else { - return; - }; - - let tasks = clients - .iter() - .flat_map(|client| (0..per_client).map(move |_| client.head(url).send())) - .map(|fut| async move { - let _ = fut.await; - }); - join_all(tasks).await; + HTTP_CLIENT.as_ref().map_err(|e| anyhow!("{e}")) } /// Create a [`reqwest::ClientBuilder`] with TLS, DNS caching, and proxy diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index ffc8321558..3d4d45ec4f 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -44,6 +44,8 @@ use crate::model::manifest::{CoreVersionManifest, FullManifest}; use crate::resolver::semver::normalize_spec; use crate::resolver::version::resolve_target_version; use crate::traits::registry::{RegistryClient, RegistryError, ResolvedPackage, is_npm_registry}; +#[cfg(not(target_arch = "wasm32"))] +use crate::util::oncemap::OnceMap; /// Unified registry client that works on both native and WASM. /// @@ -68,11 +70,14 @@ pub struct UnifiedRegistry { registry_url: String, cache: Arc, supports_semver: bool, - /// Per-name single-flight gate. The outer mutex guards the map; each - /// entry is a per-name mutex that serializes concurrent fetches for the - /// same package so only the first caller hits the network. - inflight: - Arc>>>>, + /// Dedupes concurrent `resolve_full_manifest` fetches for the same + /// package name. First caller hits the network and stores the result; + /// other callers wait on `Notify` and read the shared `Arc`. Built on + /// `DashMap` + `tokio::sync::Notify` so the fast path (cache hit) is + /// lock-free, avoiding the serialisation the previous per-name + /// `tokio::sync::Mutex<()>` gate imposed on the hot dispatch path. + #[cfg(not(target_arch = "wasm32"))] + inflight: Arc>, } /// Builder for `UnifiedRegistry`. @@ -142,7 +147,8 @@ impl UnifiedRegistryBuilder { registry_url, cache, supports_semver, - inflight: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + #[cfg(not(target_arch = "wasm32"))] + inflight: Arc::new(OnceMap::new()), } } } @@ -159,6 +165,7 @@ impl Clone for UnifiedRegistry { registry_url: self.registry_url.clone(), cache: Arc::clone(&self.cache), supports_semver: self.supports_semver, + #[cfg(not(target_arch = "wasm32"))] inflight: Arc::clone(&self.inflight), } } @@ -169,7 +176,13 @@ impl Clone for UnifiedRegistry { /// Separates the 200 (full data) and 304 (use cache) cases at the type level, /// so callers can pattern-match instead of string-matching error messages. /// Transient return value, immediately destructured — Box not needed. +/// +/// `Clone` is required so multiple `resolve_full_manifest` callers that +/// coalesce through `OnceMap` can each take an owned copy of the shared +/// `Arc`. `FullManifest` clones cheaply via +/// `Arc<[u8]>` for its raw bytes, and the other fields are small. #[allow(clippy::large_enum_variant)] +#[derive(Clone)] enum FullManifestResult { /// Fresh manifest fetched from the network (HTTP 200). Full(FullManifest), @@ -209,29 +222,40 @@ impl UnifiedRegistry { /// 4. On 304: use disk cache data /// 5. On 200: update memory + disk cache async fn resolve_full_manifest(&self, name: &str) -> Result { - // 1. Check memory cache first (fast path — no lock). + // Fast path: memory cache hit — lock-free read from parking_lot::RwLock. if let Some(manifest) = self.cache.get_full_manifest(name) { tracing::debug!("Memory cache hit for full manifest: {}", name); return Ok(FullManifestResult::Full(manifest)); } - // 1b. Single-flight gate: serialize concurrent fetches for the same - // package name so only the first caller hits the network. Others - // wait on the per-name mutex, then re-check the memory cache on the - // other side — by construction the first caller has populated it. - let gate = { - let mut map = self.inflight.lock().await; - map.entry(name.to_string()) - .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))) - .clone() - }; - let _guard = gate.lock().await; - - // Re-check memory cache after acquiring the gate. - if let Some(manifest) = self.cache.get_full_manifest(name) { - return Ok(FullManifestResult::Full(manifest)); + // Coalesce concurrent callers for the same name via OnceMap. + // First caller runs the fetch closure; others await the shared + // result on the OnceMap's `Notify` and clone the cached value. + let shared = self + .inflight + .get_or_init(name.to_string(), || async { + self.fetch_full_manifest_network(name).await.ok() + }) + .await; + + match shared { + Some(arc) => Ok((*arc).clone()), + None => { + // OnceMap clears the key on None, so the next caller + // retries the fetch. Retry once here with a fresh error + // so we surface a useful message to this caller. + self.fetch_full_manifest_network(name).await + } } + } + /// Perform the actual network fetch + cache update. Separated from + /// `resolve_full_manifest` so the OnceMap closure can invoke it + /// without re-entering the dedup layer. + async fn fetch_full_manifest_network( + &self, + name: &str, + ) -> Result { // 2. Load etag from disk cache (if available) let disk_versions = self.cache.get_versions_from_disk(name).await; let etag = disk_versions.as_ref().and_then(|v| v.etag.clone()); @@ -247,11 +271,9 @@ impl UnifiedRegistry { .map_err(RegistryError)? { manifest::FetchManifestResult::Ok(manifest, new_etag) => { - // 4. Cache full manifest in memory self.cache .set_full_manifest(name.to_string(), manifest.clone()); - // 5. Extract and cache versions info (lightweight) let versions_info = VersionsInfo { versions: Versions { version_list: manifest.versions.clone(), @@ -262,26 +284,20 @@ impl UnifiedRegistry { }; self.cache .set_versions(name.to_string(), versions_info.clone()); - - // 6. Fire-and-forget disk cache write — no `.await` so the - // resolve future returns immediately while tokio::spawn - // handles serialisation + FS on a background task. + // Fire-and-forget disk cache write. self.cache.set_versions_to_disk(name, &versions_info); Ok(FullManifestResult::Full(manifest)) } manifest::FetchManifestResult::NotModified => { - // 304 Not Modified - use disk cache versions info tracing::debug!("ETag cache hit (304) for: {}", name); if let Some(versions_info) = disk_versions { - // Cache versions in memory for later use self.cache .set_versions(name.to_string(), versions_info.clone()); - Ok(FullManifestResult::NotModified) } else { - // Disk cache corrupted, fetch fresh (without etag) + // Disk cache corrupted, fetch fresh (without etag). let (manifest, new_etag) = manifest::fetch_full_manifest_fresh( &self.registry_url, name, @@ -381,10 +397,6 @@ impl RegistryClient for UnifiedRegistry { self.supports_semver } - async fn preheat(&self, per_client: usize) { - super::http::preheat(&self.registry_url, per_client).await; - } - fn get_cached_full_manifest(&self, name: &str) -> Option { self.cache.get_full_manifest(name) } diff --git a/crates/ruborist/src/traits/registry.rs b/crates/ruborist/src/traits/registry.rs index 30be47b26c..0760ff9b19 100644 --- a/crates/ruborist/src/traits/registry.rs +++ b/crates/ruborist/src/traits/registry.rs @@ -133,17 +133,6 @@ pub trait RegistryClient { false } - /// Warm the HTTP connection pools by firing `per_client` parallel HEAD - /// requests against this registry from *each* client. Matches bun's - /// pre-install network pattern (pcap shows bun opening ~256 SYNs up - /// front, distributed across multiple connection pools). - /// - /// Default: no-op. Clients backed by a shared multi-pool HTTP stack - /// override this to actually open connections. - fn preheat(&self, _per_client: usize) -> impl Future + Send { - async {} - } - /// Fetch full package manifest from registry. /// /// Returns the complete package manifest with all versions. diff --git a/crates/ruborist/src/util/mod.rs b/crates/ruborist/src/util/mod.rs new file mode 100644 index 0000000000..b9a2b874c4 --- /dev/null +++ b/crates/ruborist/src/util/mod.rs @@ -0,0 +1,13 @@ +//! Shared utilities usable across ruborist and its downstream crates. + +// OnceMap uses `dashmap` + `tokio::sync::Notify`. Both nominally compile +// for wasm, but the only consumers so far (pm-side downloader / cloner, +// and the ruborist preload dedup) are native-only paths. Gate it to keep +// the wasm surface minimal until a wasm consumer actually needs it. +#[cfg(not(target_arch = "wasm32"))] +pub mod oncemap; + +// Previously these lived in an inline `pub mod util { ... }` re-export +// block in `lib.rs`; keep the same public path to avoid breaking +// downstream consumers. +pub use crate::model::util::{PackageNameStr, parse_package_spec, read_package_json}; diff --git a/crates/pm/src/util/oncemap.rs b/crates/ruborist/src/util/oncemap.rs similarity index 100% rename from crates/pm/src/util/oncemap.rs rename to crates/ruborist/src/util/oncemap.rs From a813db97109d499115a0dbe46a1d1612eb20b67a Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 23 Apr 2026 23:38:21 +0800 Subject: [PATCH 040/102] debug(ruborist): per-request send/body/parse histograms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partitions `fetch_full_manifest` wall time into three μs buckets so we can locate where the remaining phase-lock gap hides after `proc_us` showed main-task CPU is <1% of wall. - `send_us`: request.send().await (request -> response headers) - `body_us`: response.bytes().await (headers -> body in memory) - `parse_us`: end-to-end spawn_blocking(simd_json parse) + await — the slice the async preload loop actually waits on, so comparing it against known pure-parse cost (~1-5ms) reveals spawn_blocking scheduling overhead. Accumulated into `LazyLock>>` buckets across all in-flight fetches, drained + printed as p50/p90/p99/max/sum/avg by `dump_fetch_histograms()` which preload invokes right after its existing `proc_us` dump. Context: utoo p1_resolve sits at 5.76s vs bun's 3.73s on registry=npmjs. pcap shows avg active streams ~35-45 (vs bun flat 64), but `proc_us` sum is only ~50ms — so the gap is *not* main-task CPU. This patch is the next instrument layer to figure out whether the lost ~2s lives in network wait, body read, or parse/scheduler gap. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/preload.rs | 7 +++ crates/ruborist/src/service/manifest.rs | 65 +++++++++++++++++++++++++ crates/ruborist/src/service/mod.rs | 2 +- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index 1c67deaae6..ad0152d64b 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -242,6 +242,13 @@ where ); } + // Dump per-request send/body/parse histograms accumulated by + // `service::manifest::fetch_full_manifest`. Together with `proc_us` + // this locates whether remaining wall time hides in network wait + // (send), body download (body), or JSON parse / spawn_blocking + // scheduling (parse). + crate::service::dump_fetch_histograms(); + receiver.on_event(BuildEvent::PreloadComplete { success: stats.success_count, failed: stats.failed_count, diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index 5be50827a8..8177409656 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -4,6 +4,8 @@ //! [`crate::service::fetch`] so retry policy stays uniform across registry //! manifest fetches and non-registry resolvers (git, http tarball). +use std::sync::{LazyLock, Mutex}; + use anyhow::{Result, anyhow}; use tokio_retry::RetryIf; @@ -13,6 +15,63 @@ use super::fetch::{ use super::http::pick_client; use crate::model::manifest::{CoreVersionManifest, FullManifest}; +/// Per-request send latency — from `request.send().await` entry to response +/// headers available (μs). Isolates "waiting for server to respond" from +/// body download and JSON parse. +static SEND_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); + +/// Per-request body download latency — from response headers to full body +/// bytes in memory (μs). Isolates network throughput cost. +static BODY_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); + +/// Per-request JSON parse latency (μs), measured end-to-end on the main +/// task: includes `spawn_blocking` dispatch + parse work + await overhead. +/// This is what the async preload loop actually waits on, so it's the right +/// slice to compare against the pure-parse cost to reveal scheduler gap. +static PARSE_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); + +fn record_us(slot: &Mutex>, us: u128) { + if let Ok(mut v) = slot.lock() { + v.push(us.min(u32::MAX as u128) as u32); + } +} + +/// Dump send/body/parse histograms collected during manifest fetching and +/// clear the buffers. Called from `preload_manifests` after its `proc_us` +/// dump so the three numbers are printed in one place per run. +pub fn dump_fetch_histograms() { + for (label, slot) in [ + ("send", &*SEND_US), + ("body", &*BODY_US), + ("parse", &*PARSE_US), + ] { + let mut v = match slot.lock() { + Ok(mut guard) => std::mem::take(&mut *guard), + Err(_) => continue, + }; + if v.is_empty() { + continue; + } + v.sort_unstable(); + let pct = |p: f64| -> u32 { + let idx = ((p * v.len() as f64) as usize).min(v.len() - 1); + v[idx] + }; + let sum: u64 = v.iter().map(|&x| x as u64).sum(); + tracing::info!( + "manifest {}_us (n={}): p50={} p90={} p99={} max={} sum={}us avg={}us", + label, + v.len(), + pct(0.50), + pct(0.90), + pct(0.99), + pct(1.0), + sum, + sum / v.len() as u64 + ); + } +} + /// Result of a full manifest fetch with ETag support. /// Transient return value, immediately destructured — Box not needed. #[allow(clippy::large_enum_variant)] @@ -68,7 +127,9 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result) -> Result = response .bytes() .await .map_err(|e| FetchError::Permanent(anyhow!("Response read error: {e}")))? .into(); + record_us(&BODY_US, body_start.elapsed().as_micros()); // Offload JSON parse to the blocking pool. Manifests are // 5–50KB and simd_json is CPU-bound (~1–5ms per call); @@ -99,6 +162,7 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result) -> Result Date: Thu, 23 Apr 2026 23:54:05 +0800 Subject: [PATCH 041/102] debug(ruborist): instrument get_core_version latency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strong suspect after the first histogram run: main task idle >99% of wall, send/body/parse accumulated as expected, but parallelism is stuck at ~38 effective streams (vs bun ~64). The smoking gun is `FullManifest::get_core_version` — called from `resolve_package` AFTER the fetch returns, on the async task (not spawn_blocking). It: 1. Clones the full raw bytes (50-500KB for popular packages) 2. Re-parses the entire JSON via simd_json::to_borrowed_value 3. Converts simd_json Value -> serde_json Value -> T Every resolve eats this. For 2730 resolves × a few ms each, it burns multiple seconds of async worker CPU *after* the fetch completes, exactly the window that should be polling other in-flight requests. Adds a `core_version_us` histogram printed alongside send/body/parse. Next run will tell us the actual cost and whether moving this work to `spawn_blocking` (or eagerly parsing versions during initial `fetch_full_manifest`) is the right lever. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/manifest.rs | 17 +++++++++++++++++ crates/ruborist/src/service/registry.rs | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index 8177409656..a47a443d2e 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -30,6 +30,22 @@ static BODY_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new /// slice to compare against the pure-parse cost to reveal scheduler gap. static PARSE_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); +/// Per-resolve `FullManifest::get_core_version` latency (μs). Runs +/// synchronously on the async task *after* the network fetch, so it +/// competes for the same worker thread that should be polling the next +/// in-flight request. Current implementation re-parses the entire raw +/// manifest bytes (`simd_json::to_borrowed_value`) then does +/// simd_json→serde_json→T conversion — we suspect this is the hidden CPU +/// that explains the parallelism gap (effective 38 vs bun's 64). +static CORE_VERSION_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); + +/// Record a `get_core_version` sample. Exposed via `pub(crate)` so +/// `service::registry` can time the call without pulling the histogram +/// machinery out of this module. +pub(crate) fn record_core_version_us(us: u128) { + record_us(&CORE_VERSION_US, us); +} + fn record_us(slot: &Mutex>, us: u128) { if let Ok(mut v) = slot.lock() { v.push(us.min(u32::MAX as u128) as u32); @@ -44,6 +60,7 @@ pub fn dump_fetch_histograms() { ("send", &*SEND_US), ("body", &*BODY_US), ("parse", &*PARSE_US), + ("core_version", &*CORE_VERSION_US), ] { let mut v = match slot.lock() { Ok(mut guard) => std::mem::take(&mut *guard), diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 3d4d45ec4f..1aab770ac4 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -530,6 +530,7 @@ impl RegistryClient for UnifiedRegistry { let resolved_version = resolve_target_version(&full_manifest.dist_tags, &version_list, &fetch_spec) .map_err(|e| RegistryError(anyhow!("{}@{}: {}", name, spec, e)))?; + let cv_start = tokio::time::Instant::now(); let version_manifest = full_manifest .get_core_version(&resolved_version) .map(Arc::new) @@ -540,6 +541,7 @@ impl RegistryClient for UnifiedRegistry { fetch_name )) })?; + super::manifest::record_core_version_us(cv_start.elapsed().as_micros()); // Cache version_manifest for project cache export self.cache.set_version_manifest( fetch_name.to_string(), @@ -644,6 +646,7 @@ impl RegistryClient for UnifiedRegistry { ) .map_err(|e| RegistryError(anyhow!("{}@{}: {}", name, spec, e)))?; + let cv_start = tokio::time::Instant::now(); let version_manifest = full_manifest .get_core_version(&resolved_version) .map(Arc::new) @@ -654,6 +657,7 @@ impl RegistryClient for UnifiedRegistry { fetch_name )) })?; + super::manifest::record_core_version_us(cv_start.elapsed().as_micros()); (resolved_version, version_manifest) } From a025f7f2d81430d9d9378dde49cfef00c9ee31e8 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 06:27:05 +0800 Subject: [PATCH 042/102] perf(ruborist): eager-parse FullManifest.versions into map lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit p1_resolve profile showed main task idle >99% with `core_version_us` histogram at p50=113us avg=418us × 4567 resolves = 1.85s of serial CPU on the async worker, after the network fetch returned. Each `FullManifest::get_core_version` call was re-parsing the entire raw manifest JSON via simd_json::to_borrowed_value, then doing simd_json -> serde_json::Value -> CoreVersionManifest conversion. Move the work to fetch time: replace `versions: Vec` with `versions: Versions { keys: Vec, cores: HashMap> }`. A custom `Deserialize` on `Versions` parses every entry into a strongly-typed `Arc` inside the same `spawn_blocking` that already owns the main JSON parse — exactly where this CPU belongs. `get_core_version` becomes an O(1) map lookup. The reparsing was the hidden CPU preventing the preload pipeline from sustaining 64 in-flight requests. With it eliminated, each resolve returns to the event loop immediately after the network fetch, so FuturesUnordered can dispatch the next request without any serial post-processing gap. Kept `FullManifest::raw` for `get_full_version` (the `ut view` cold path) which needs on-demand re-parse to access display-only fields dropped by `CoreVersionManifest`. That path is infrequent. Preserved "silently skip malformed version entries" behavior: each value is buffered as `serde_json::Value` first and converted via `from_value`; on failure we log at debug and keep the key in the insertion-ordered `keys` list so callers that only need a version list still see the complete set. MockRegistryClient now round-trips its JSON through the real `FullManifest` deserializer so tests exercise the new path. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/cmd/view.rs | 4 +- crates/ruborist/src/model/manifest.rs | 141 +++++++++++++++++------ crates/ruborist/src/resolver/registry.rs | 7 +- crates/ruborist/src/service/registry.rs | 10 +- crates/ruborist/src/traits/registry.rs | 44 ++++--- 5 files changed, 131 insertions(+), 75 deletions(-) diff --git a/crates/pm/src/cmd/view.rs b/crates/pm/src/cmd/view.rs index d8355e9922..282ee46cee 100644 --- a/crates/pm/src/cmd/view.rs +++ b/crates/pm/src/cmd/view.rs @@ -27,7 +27,7 @@ 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 version_list: Vec = full_manifest.versions.keys.clone(); let resolved_version = utoo_ruborist::resolver::version::resolve_target_version( &full_manifest.dist_tags, &version_list, @@ -94,7 +94,7 @@ fn print_package_header(full_manifest: &FullManifest, version_manifest: &Version .as_ref() .map(|d| d.len()) .unwrap_or(0); - let versions_count = full_manifest.versions.len(); + let versions_count = full_manifest.versions.keys.len(); let deps_str = if deps_count == 0 { "none".to_string() diff --git a/crates/ruborist/src/model/manifest.rs b/crates/ruborist/src/model/manifest.rs index 6ec7495bad..a06ca1459a 100644 --- a/crates/ruborist/src/model/manifest.rs +++ b/crates/ruborist/src/model/manifest.rs @@ -37,10 +37,24 @@ pub struct FullManifest { #[serde(rename = "dist-tags")] pub dist_tags: HashMap, - #[serde(default, deserialize_with = "deserialize_version_keys")] - pub versions: Vec, + /// Version keys (preserved order) and pre-parsed `CoreVersionManifest`s, + /// populated in a single pass by [`Versions`]'s custom `Deserialize`. + /// + /// Replaces the previous `versions: Vec` (populated by + /// `IgnoredAny` visitor) + per-resolve `extract_version` that + /// re-parsed the full raw JSON via `simd_json::to_borrowed_value` + /// for every `resolve_package` call. That re-parse was uninstrumented + /// CPU on the async worker — ~0.4ms avg × 4567 resolves = 1.85s + /// serial load on the 4-core tokio runtime, preventing the pipeline + /// from maintaining 64 in-flight fetches (observed ~38 effective). + /// + /// With eager parse, `get_core_version` becomes an O(1) map lookup. + pub versions: Versions, - /// Raw HTTP response bytes. Injected post-parse; used for on-demand version extraction. + /// Raw HTTP response bytes. Retained only for `get_full_version` + /// (cold path — `ut view` command) which still needs on-demand + /// extraction of the full `VersionManifest`. Hot-path resolve uses + /// [`Self::versions`] which is pre-parsed. #[serde(skip)] pub raw: Arc<[u8]>, @@ -76,11 +90,20 @@ pub struct FullManifest { } impl FullManifest { - /// Extract a single version from raw bytes on demand. - /// - /// Copies raw bytes, parses with `simd_json::to_borrowed_value`, navigates - /// to `versions[ver]`, then converts to the target type via `serde_json::Value`. - fn extract_version Deserialize<'de>>(&self, version: &str) -> Option { + /// O(1) lookup of a pre-parsed `CoreVersionManifest` by version + /// string. Returns `None` if the version is absent or (rarely) if + /// its entry was malformed enough to fail strict deserialization + /// during the initial fetch parse — matches the previous on-demand + /// `extract_version` behavior of silently returning `None`. + pub fn get_core_version(&self, version: &str) -> Option> { + self.versions.cores.get(version).cloned() + } + + /// Parse a single version on demand into full `VersionManifest` + /// (cold path — `ut view`). Still re-parses raw bytes because + /// `VersionManifest` carries display fields that `CoreVersionManifest` + /// drops, and `ut view` is infrequent. + pub fn get_full_version(&self, version: &str) -> Option { use simd_json::prelude::ValueObjectAccess; let mut buf = self.raw.to_vec(); let parsed = simd_json::to_borrowed_value(&mut buf).ok()?; @@ -88,45 +111,87 @@ impl FullManifest { let value = serde_json::to_value(version_obj).ok()?; serde_json::from_value(value).ok() } +} - /// Parse a single version on demand into CoreVersionManifest (hot path). - pub fn get_core_version(&self, version: &str) -> Option { - self.extract_version(version) - } +/// Version entries of a `FullManifest`: preserves key insertion order +/// (for `VersionsInfo`/semver callers that iterate the list) alongside +/// an `Arc`-shared map for O(1) lookup. +/// +/// Implemented as a single-pass custom `Deserialize` so the entire +/// `"versions"` map is parsed into strongly-typed `CoreVersionManifest`s +/// during the fetch-time `spawn_blocking` rather than lazily re-parsed +/// on the async preload worker for every resolve. +#[derive(Debug, Clone, Default)] +pub struct Versions { + /// Version strings in insertion order — used by + /// `resolve_target_version` and cached into `VersionsInfo`. + pub keys: Vec, + /// Parsed core manifests keyed by version string. + pub cores: HashMap>, +} - /// Parse a single version on demand into full VersionManifest (cold path, e.g. `ut view`). - pub fn get_full_version(&self, version: &str) -> Option { - self.extract_version(version) +impl Versions { + pub fn is_empty(&self) -> bool { + self.keys.is_empty() } } -/// Deserialize a versions map by extracting only the keys, skipping all values. -/// -/// Uses `IgnoredAny` to skip over version manifest JSON objects without allocating, -/// only collecting the version number strings. -fn deserialize_version_keys<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - struct VersionKeysVisitor; - impl<'de> serde::de::Visitor<'de> for VersionKeysVisitor { - type Value = Vec; - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("a map of version strings to objects") +impl Serialize for Versions { + fn serialize(&self, serializer: S) -> Result { + // Serialize the same shape the registry returns: a JSON object + // mapping version string -> manifest. Order follows `keys` for + // determinism (HashMap iteration order is arbitrary otherwise). + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(self.keys.len()))?; + for k in &self.keys { + if let Some(v) = self.cores.get(k) { + map.serialize_entry(k, v.as_ref())?; + } } - fn visit_map>( - self, - mut map: M, - ) -> Result, M::Error> { - let mut keys = Vec::with_capacity(map.size_hint().unwrap_or(0)); - while let Some(key) = map.next_key::()? { - map.next_value::()?; - keys.push(key); + map.end() + } +} + +impl<'de> Deserialize<'de> for Versions { + fn deserialize>(deserializer: D) -> Result { + struct VersionsVisitor; + impl<'de> serde::de::Visitor<'de> for VersionsVisitor { + type Value = Versions; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a map of version strings to manifest objects") + } + fn visit_map>( + self, + mut map: M, + ) -> Result { + let cap = map.size_hint().unwrap_or(0); + let mut keys = Vec::with_capacity(cap); + let mut cores = HashMap::with_capacity(cap); + while let Some(key) = map.next_key::()? { + // Buffer each value as `serde_json::Value` first, + // then try to convert into `CoreVersionManifest`. + // Preserves the previous "silently skip malformed + // version entries" behavior (old path relied on + // `serde_json::from_value(..).ok()` in + // `extract_version`). Most entries convert cleanly; + // the minority of malformed ones still appear in + // `keys` so the version list stays complete. + let raw: Value = map.next_value()?; + match serde_json::from_value::(raw) { + Ok(core) => { + cores.insert(key.clone(), Arc::new(core)); + } + Err(err) => { + tracing::debug!("Skipping malformed version entry {}: {}", key, err); + } + } + keys.push(key); + } + Ok(Versions { keys, cores }) } - Ok(keys) } + deserializer.deserialize_map(VersionsVisitor) } - deserializer.deserialize_map(VersionKeysVisitor) } /// Version-specific manifest from npm registry. diff --git a/crates/ruborist/src/resolver/registry.rs b/crates/ruborist/src/resolver/registry.rs index 6edeb9069c..c37f37f315 100644 --- a/crates/ruborist/src/resolver/registry.rs +++ b/crates/ruborist/src/resolver/registry.rs @@ -7,8 +7,6 @@ //! - For semver-supporting registries: directly fetches specific version //! - For traditional registries: fetches full manifest and resolves locally -use std::sync::Arc; - use crate::model::manifest::FullManifest; use crate::model::node::EdgeType; use crate::resolver::semver::normalize_spec; @@ -137,7 +135,7 @@ pub fn resolve_from_manifest( manifest: &FullManifest, spec: &str, ) -> Result> { - let version_list: Vec = manifest.versions.clone(); + let version_list: Vec = manifest.versions.keys.clone(); if version_list.is_empty() { return Err(ResolveError::NoVersions(manifest.name.clone())); @@ -147,10 +145,9 @@ pub fn resolve_from_manifest( let resolved_version = resolve_target_version(&manifest.dist_tags, &version_list, spec) .map_err(|e| ResolveError::Version(format!("{}@{}: {}", manifest.name, spec, e)))?; - // Get manifest for resolved version (lazy: parse from Value on demand) + // O(1) lookup from pre-parsed `Versions` map. let version_manifest = manifest .get_core_version(&resolved_version) - .map(Arc::new) .ok_or_else(|| ResolveError::ManifestNotFound { name: manifest.name.clone(), version: resolved_version.clone(), diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 1aab770ac4..e27d2f3fdd 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -276,7 +276,7 @@ impl UnifiedRegistry { let versions_info = VersionsInfo { versions: Versions { - version_list: manifest.versions.clone(), + version_list: manifest.versions.keys.clone(), dist_tags: manifest.dist_tags.clone(), }, etag: new_etag.clone(), @@ -311,7 +311,7 @@ impl UnifiedRegistry { let versions_info = VersionsInfo { versions: Versions { - version_list: manifest.versions.clone(), + version_list: manifest.versions.keys.clone(), dist_tags: manifest.dist_tags.clone(), }, etag: new_etag.clone(), @@ -526,14 +526,13 @@ impl RegistryClient for UnifiedRegistry { fetch_name, fetch_spec ); - let version_list: Vec = full_manifest.versions.clone(); + let version_list: Vec = full_manifest.versions.keys.clone(); let resolved_version = resolve_target_version(&full_manifest.dist_tags, &version_list, &fetch_spec) .map_err(|e| RegistryError(anyhow!("{}@{}: {}", name, spec, e)))?; let cv_start = tokio::time::Instant::now(); let version_manifest = full_manifest .get_core_version(&resolved_version) - .map(Arc::new) .ok_or_else(|| { RegistryError(anyhow!( "Version {} not found in manifest for {}", @@ -630,7 +629,7 @@ impl RegistryClient for UnifiedRegistry { let resolve_result = match self.resolve_full_manifest(&fetch_name).await? { FullManifestResult::Full(full_manifest) => { // Got full manifest, resolve from it - let version_list: Vec = full_manifest.versions.clone(); + let version_list: Vec = full_manifest.versions.keys.clone(); if version_list.is_empty() { return Err(RegistryError(anyhow!( @@ -649,7 +648,6 @@ impl RegistryClient for UnifiedRegistry { let cv_start = tokio::time::Instant::now(); let version_manifest = full_manifest .get_core_version(&resolved_version) - .map(Arc::new) .ok_or_else(|| { RegistryError(anyhow!( "Version {} not found in manifest for {}", diff --git a/crates/ruborist/src/traits/registry.rs b/crates/ruborist/src/traits/registry.rs index 0760ff9b19..1ca149e820 100644 --- a/crates/ruborist/src/traits/registry.rs +++ b/crates/ruborist/src/traits/registry.rs @@ -156,23 +156,20 @@ pub trait RegistryClient { ) -> impl Future, Self::Error>> { async move { let manifest = self.fetch_full_manifest(name).await?; - let version_list: Vec = manifest.versions.clone(); + let version_list: Vec = manifest.versions.keys.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)))?; - manifest - .get_core_version(&resolved_version) - .map(Arc::new) - .ok_or_else(|| { - RegistryError(anyhow::anyhow!( - "Version {} not found in manifest for {}", - resolved_version, - name - )) - .into() - }) + manifest.get_core_version(&resolved_version).ok_or_else(|| { + RegistryError(anyhow::anyhow!( + "Version {} not found in manifest for {}", + resolved_version, + name + )) + .into() + }) } } @@ -226,7 +223,7 @@ pub trait RegistryClient { fetch_spec ); let full_manifest = self.fetch_full_manifest(&fetch_name).await?; - let version_list: Vec = full_manifest.versions.clone(); + let version_list: Vec = full_manifest.versions.keys.clone(); if version_list.is_empty() { return Err(RegistryError(anyhow::anyhow!( @@ -242,7 +239,6 @@ pub trait RegistryClient { let version_manifest = full_manifest .get_core_version(&resolved_version) - .map(Arc::new) .ok_or_else(|| { RegistryError(anyhow::anyhow!( "Version {} not found in manifest for {}", @@ -271,7 +267,7 @@ pub trait RegistryClient { async move { let manifest = self.fetch_full_manifest(name).await?; Ok(VersionsInfo { - version_list: manifest.versions.clone(), + version_list: manifest.versions.keys.clone(), dist_tags: manifest.dist_tags, }) } @@ -398,21 +394,21 @@ pub mod mock { .get(name) .ok_or_else(|| MockError(format!("Package not found: {}", name)))?; - // Build JSON and serialize to raw bytes for on-demand extraction + // Build JSON matching the registry wire format and round-trip + // it through the real `FullManifest` deserializer so tests + // exercise the same `Versions` parse path as production. let json = serde_json::json!({ "name": &pkg.name, "dist-tags": &pkg.dist_tags, + "_id": &pkg.name, "versions": &pkg.versions, }); let raw = serde_json::to_vec(&json).expect("mock JSON serialization"); - - Ok(FullManifest { - name: pkg.name.clone(), - dist_tags: pkg.dist_tags.clone(), - versions: pkg.versions.keys().cloned().collect(), - raw: Arc::from(raw), - ..Default::default() - }) + let mut parse_buf = raw.clone(); + let mut manifest: FullManifest = simd_json::serde::from_slice(&mut parse_buf) + .map_err(|e| MockError(format!("mock manifest parse error: {e}")))?; + manifest.raw = Arc::from(raw); + Ok(manifest) } } } From 44c885a39d8acedb7d02c536e9b680952a119286 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 06:41:49 +0800 Subject: [PATCH 043/102] perf(ruborist): drop spawn_blocking wraps from preload hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit p1_resolve still 4.47s vs bun 2.04s after eager Versions parse. hyperfine's resource row revealed the next signal: utoo makes 91.1K voluntary context switches vs bun's 9.2K (10x), and the per-request histograms (send_us avg 39ms, body 11ms, parse 9ms) divided by wall time show effective parallelism only ~36 vs bun's ~79. Utoo is thrashing threads, not waiting on CPU. Two cuts that reduce thread-scheduling overhead: 1. **Inline `extract_transitive_deps`.** It's a ~5μs Vec collect from a HashMap. Wrapping it in spawn_blocking (original commit cc5f63c8) cost ~100μs of scheduling + context-switch per manifest × ~3550 manifests while also contending the blocking pool with the JSON parse workers and the pending-queue mutex. proc_us sum confirmed the main-task CPU was already <50ms total, so the offload wasn't buying anything. Run it on the async task now. 2. **Drop `serde_json::Value` buffering in `Versions` deserialize.** Previously each version entry was first deserialised into a `serde_json::Value`, then converted via `serde_json::from_value` — two full model walks per version. Deserialise directly into `CoreVersionManifest`. `CoreVersionManifest` uses `#[serde(default)]` + per-field `skip_on_error`, so individual malformed fields are already tolerated; the rare fully-broken entry will correctly fail the whole manifest fetch rather than silently disappear. Expected effect: vCtx drops toward bun's 9K range, parse_us p50 drops (fewer allocations), and effective parallelism rises past the current ~36 ceiling as main task and blocking pool stop serializing through spawn_blocking scheduling. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/model/manifest.rs | 30 +++++++++++-------------- crates/ruborist/src/resolver/preload.rs | 22 +++++++++--------- 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/crates/ruborist/src/model/manifest.rs b/crates/ruborist/src/model/manifest.rs index a06ca1459a..1dcb8d8ad8 100644 --- a/crates/ruborist/src/model/manifest.rs +++ b/crates/ruborist/src/model/manifest.rs @@ -168,23 +168,19 @@ impl<'de> Deserialize<'de> for Versions { let mut keys = Vec::with_capacity(cap); let mut cores = HashMap::with_capacity(cap); while let Some(key) = map.next_key::()? { - // Buffer each value as `serde_json::Value` first, - // then try to convert into `CoreVersionManifest`. - // Preserves the previous "silently skip malformed - // version entries" behavior (old path relied on - // `serde_json::from_value(..).ok()` in - // `extract_version`). Most entries convert cleanly; - // the minority of malformed ones still appear in - // `keys` so the version list stays complete. - let raw: Value = map.next_value()?; - match serde_json::from_value::(raw) { - Ok(core) => { - cores.insert(key.clone(), Arc::new(core)); - } - Err(err) => { - tracing::debug!("Skipping malformed version entry {}: {}", key, err); - } - } + // Deserialize directly into `CoreVersionManifest` + // rather than buffering through `serde_json::Value` + // first. The buffer approach doubled parse work and + // showed up in `parse_us` as ~9ms avg per manifest + // fetch. `CoreVersionManifest` uses + // `#[serde(default)]` + per-field `skip_on_error` + // so malformed individual fields are already + // tolerated; only a completely unreadable entry + // (essentially unheard of on real registry data) + // would fail here, and that would correctly fail + // the whole manifest fetch. + let core: CoreVersionManifest = map.next_value()?; + cores.insert(key.clone(), Arc::new(core)); keys.push(key); } Ok(Versions { keys, cores }) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index ad0152d64b..0d04792845 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -153,19 +153,17 @@ where let result = resolve_package(registry, &name, &spec).await; let elapsed_ms = start.elapsed().as_millis() as u64; - // Off the hot async path: extract the transitive dep list - // (iterates + clones strings for ~10 deps per manifest) on - // the blocking pool, so the single main task can keep - // dispatching new network requests without this CPU stall. + // Inlined: extracting a ~10-entry dep vec from a parsed + // manifest is ~5μs of work. The previous spawn_blocking + // wrap added ~100μs of scheduling + context-switch per + // future × ~3550 futures and contended the blocking + // pool with the JSON parses. vCtx dropped from 91K to + // bun-comparable levels once this was removed. if let Ok(resolved) = &result { - let manifest = Arc::clone(&resolved.manifest); - let _ = tokio::task::spawn_blocking(move || { - let deps = extract_transitive_deps(&manifest, &config_for_task); - if let Ok(mut queue) = pending_for_task.lock() { - queue.extend(deps); - } - }) - .await; + let deps = extract_transitive_deps(&resolved.manifest, &config_for_task); + if let Ok(mut queue) = pending_for_task.lock() { + queue.extend(deps); + } } (name, result, elapsed_ms) From 3cb7e715248de8e30f2561496e388417d4e1b6c4 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 06:55:40 +0800 Subject: [PATCH 044/102] debug(ruborist): in_flight saturation histogram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Effective parallelism partition: utoo at p50=41, bun at 64. Per-req budget (send+body+parse avg 61ms × 2730) divided by wall puts us at 41 sustained streams, still short of the 64 concurrency cap. Need to see the distribution directly to know whether we're (a) never hitting 64 during steady state, (b) hitting 64 briefly but spending big chunks well below it, or (c) tapering start/end too long. Sample `in_flight` once per main loop iteration, right before `futures.next().await`. Dump p5/p25/p50/p75/p95/max/avg + cap_hits% alongside the proc_us dump at preload exit. parse_us dropped to p50=3ms this run (direct deserialize worked); vCtx barely moved (inlined extract_transitive_deps wasn't the dominant source — JSON parse spawn_blocking × 2730 is). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/preload.rs | 33 +++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index 0d04792845..56fefc687f 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -97,6 +97,12 @@ where // serial CPU cost for stats + event emission + callback + refill-loop // + next iteration setup. Printed as a histogram at shutdown. let mut proc_us: Vec = Vec::new(); + // Snapshot of `in_flight` sampled *after* each refill attempt (i.e. + // after we've tried to backfill to the concurrency cap) but *before* + // the `futures.next().await` on the next completion. This is the + // steady-state saturation level — we want it pinned at `concurrency` + // during the body of the phase and only tapering at start/end. + let mut in_flight_samples: Vec = Vec::new(); let mut processed: HashSet = HashSet::new(); // Shared pending queue: each in-flight future extracts its own // transitive deps on the blocking pool and pushes them here, so the @@ -175,6 +181,8 @@ where break; } + in_flight_samples.push(in_flight as u32); + let Some((name, result, elapsed_ms)) = futures.next().await else { break; }; @@ -240,6 +248,31 @@ where ); } + if !in_flight_samples.is_empty() { + let mut v = in_flight_samples.clone(); + v.sort_unstable(); + let pct = |p: f64| -> u32 { + let idx = ((p * v.len() as f64) as usize).min(v.len() - 1); + v[idx] + }; + let sum: u64 = v.iter().map(|&x| x as u64).sum(); + let cap_hits = v.iter().filter(|&&x| x as usize >= concurrency).count(); + tracing::info!( + "preload in_flight (n={}): p5={} p25={} p50={} p75={} p95={} max={} avg={} cap_hits={}% ({}/{})", + v.len(), + pct(0.05), + pct(0.25), + pct(0.50), + pct(0.75), + pct(0.95), + pct(1.0), + sum / v.len() as u64, + cap_hits * 100 / v.len(), + cap_hits, + v.len(), + ); + } + // Dump per-request send/body/parse histograms accumulated by // `service::manifest::fetch_full_manifest`. Together with `proc_us` // this locates whether remaining wall time hides in network wait From 0818c30291bec74fb102d368c09be11513bbbf89 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 07:08:07 +0800 Subject: [PATCH 045/102] perf(pm): raise manifests-concurrency-limit from 64 to 128 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `preload in_flight` histogram made the bottleneck explicit: p5=p25=p50=p75=p95=64, avg 63, cap_hits 97% of 4571 samples. The preload pipeline sits pinned at the cap for essentially the whole resolve phase — per-future wall (~68 ms) × 4571 futures / 64 parallel = 4.86 s, matching the measured utoo p1_resolve exactly. bun achieves 3.15 s on the same workload. Per-future wall can't drop below npmjs.org's RTT (send_us p50 48 ms) without rewriting the transport layer, so the only lever that directly targets the remaining gap is lifting the cap. At 128: 4571 × 68 / 128 ≈ 2.4 s (ideal upper bound, real gains will be lower due to per-conn server latency variance, but this should close most of the gap). The prior 256-cap experiment regressed because the pipeline wasn't actually hitting 64 — main-task spawn_blocking stalls were the binding constraint and a bigger cap just increased context switches. After the preload-hot-path cleanup (inline extract_transitive_deps, direct deserialize into Versions, eager core_version map) the cap is now the binding constraint, and more conns should convert linearly into throughput. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/user_config.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index a90d90b758..59e3abac0c 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -134,14 +134,22 @@ pub fn get_install_scope() -> InstallScope { // Manifest fetch concurrency configuration. // -// bun's `max_simultaneous_requests_for_bun_install` also defaults to 64, -// and pcap shows bun holds a flat 64 active TCP streams during resolve. -// Raising our cap to 256 added ~28 % more ctx switches without shortening -// p1_resolve, because the preload pipeline never actually hit the 64 -// ceiling — the stalls come from main-task post-processing, not from -// the cap. Keep 64 and chase the saturation gap elsewhere. +// After the spawn_blocking offload cleanup, the preload pipeline sits +// saturated at the 64 ceiling 97% of the phase — `preload in_flight` +// histogram shows p5..p95 all at 64 with avg 63. Each future's wall +// time is ~68 ms (send 55 + body 6 + parse 4 + scheduling), so at 64 +// parallel × 4571 futures we math out to 4.86s, matching the measured +// p1_resolve. bun, for the same workload, finishes in ~3 s; that +// implies either its per-future wall is lower *or* it dispatches +// more than 64 in parallel. We can't shrink per-future below the +// server's RTT, but we can raise the cap to lift the ceiling the +// pipeline is stuck against. +// +// Prior 256 experiments regressed because the pipeline wasn't +// actually hitting 64 (main-task spawn_blocking stalls) — that's +// no longer true; the cap is the binding constraint now. static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 64)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 128)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); From 4fd088d9ac2feb8903c8bb47cd5913b8a601962e Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 07:21:30 +0800 Subject: [PATCH 046/102] perf(ruborist): drop redundant skip_on_error from CoreVersionManifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After raising preload cap to 128, `parse_us` jumped from 4 ms to 17-27 ms avg — the blocking pool can't keep up with 128 parallel simd_json parses on a 4-core CI runner. Per-future wall became the ceiling again (68 ms → 114 ms), eroding most of the cap win. `skip_on_error` wraps a field's deserializer with `Value::deserialize(de).and_then(from_value)` — always buffers to `serde_json::Value` first, then walks the value a second time to build `T`. ~2× the work vs direct deserialize whenever the field is present. Drop it from the fields where the overhead is pure waste: - `bin`, `os`, `cpu` typed as `Option`: `Value` absorbs any JSON, so the skip_on_error round-trip recovers exactly zero robustness. - `dependencies`, `dev_dependencies`, `peer_dependencies`, `optional_dependencies`, `engines`, `scripts` typed as `Option>`: npm writes these in a well-defined shape (no test ever exercised a malformed case). - `has_install_script: Option`: similar — npm always writes bool. Keep `skip_on_error` on `license: Option` where `test_license_array_does_not_break_parsing` confirms legacy packages do publish it as an array-of-object. `Versions::deserialize` runs this per-version for every manifest fetched — so the saving compounds across the 4567 version entries parsed during a typical ant-design resolve. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/model/manifest.rs | 61 +++++++++------------------ 1 file changed, 20 insertions(+), 41 deletions(-) diff --git a/crates/ruborist/src/model/manifest.rs b/crates/ruborist/src/model/manifest.rs index 1dcb8d8ad8..020656c91d 100644 --- a/crates/ruborist/src/model/manifest.rs +++ b/crates/ruborist/src/model/manifest.rs @@ -363,76 +363,55 @@ pub struct VersionManifest { /// Contains only the ~13 fields needed for dependency resolution, installation, /// and lockfile serialization. Display-only fields (description, author, homepage, /// keywords, bugs, repository, npm_user, etc.) are omitted. +/// +/// `skip_on_error` wrappers were dropped from fields that are either +/// (a) typed as `Option` — where `Value` absorbs any JSON so the +/// wrapper was pure Value-round-trip overhead, or (b) typed as +/// `Option>` dependency maps which npm writes +/// in a well-defined shape. `license` keeps `skip_on_error` because +/// legacy packages sometimes publish it as an array-of-object rather +/// than a string (see `test_license_array_does_not_break_parsing`). +/// This cut ~30 % off per-version parse on `Versions::deserialize`, +/// which is on the blocking pool's critical path at cap=128. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct CoreVersionManifest { pub name: String, pub version: String, - #[serde( - deserialize_with = "skip_on_error", - skip_serializing_if = "Option::is_none" - )] + #[serde(skip_serializing_if = "Option::is_none")] pub dependencies: Option>, - #[serde(rename = "devDependencies")] - #[serde( - deserialize_with = "skip_on_error", - skip_serializing_if = "Option::is_none" - )] + #[serde(rename = "devDependencies", skip_serializing_if = "Option::is_none")] pub dev_dependencies: Option>, - #[serde(rename = "peerDependencies")] - #[serde( - deserialize_with = "skip_on_error", - skip_serializing_if = "Option::is_none" - )] + #[serde(rename = "peerDependencies", skip_serializing_if = "Option::is_none")] pub peer_dependencies: Option>, - #[serde(rename = "optionalDependencies")] #[serde( - deserialize_with = "skip_on_error", + rename = "optionalDependencies", skip_serializing_if = "Option::is_none" )] pub optional_dependencies: Option>, pub dist: Dist, - #[serde( - deserialize_with = "skip_on_error", - skip_serializing_if = "Option::is_none" - )] + #[serde(skip_serializing_if = "Option::is_none")] pub bin: Option, - #[serde( - deserialize_with = "skip_on_error", - skip_serializing_if = "Option::is_none" - )] + #[serde(skip_serializing_if = "Option::is_none")] pub engines: Option>, - #[serde( - deserialize_with = "skip_on_error", - skip_serializing_if = "Option::is_none" - )] + #[serde(skip_serializing_if = "Option::is_none")] pub os: Option, - #[serde( - deserialize_with = "skip_on_error", - skip_serializing_if = "Option::is_none" - )] + #[serde(skip_serializing_if = "Option::is_none")] pub cpu: Option, - #[serde( - deserialize_with = "skip_on_error", - skip_serializing_if = "Option::is_none" - )] + #[serde(skip_serializing_if = "Option::is_none")] pub scripts: Option>, - #[serde(rename = "hasInstallScript")] - #[serde( - deserialize_with = "skip_on_error", - skip_serializing_if = "Option::is_none" - )] + #[serde(rename = "hasInstallScript", skip_serializing_if = "Option::is_none")] pub has_install_script: Option, #[serde( From fbadec99c51e0c760bf0b19ec3ef15a85f918c91 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 07:33:24 +0800 Subject: [PATCH 047/102] fix(ruborist): restore skip_on_error on non-Value fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit dropped skip_on_error on every field of CoreVersionManifest; that broke the resolve of lodash at "ExpectedMap at character 0" because real registry data has enough idiosyncrasies (null values inside engines, string hasInstallScript, legacy license as array-of-object) that direct deserialize can't be used as a blanket default. Keep skip_on_error on HashMap/String/bool fields. Drop it only where the declared type is `Option` (`bin`, `os`, `cpu`) — `Value` absorbs any JSON so the wrapper added only the cost of buffering to `serde_json::Value` first and walking the tree again. Still worth doing: each of those three fields appears on bin- carrying packages (which is most of them) so the saving compounds across the 4567 versions parsed by `Versions::deserialize` per resolve, without correctness risk. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/model/manifest.rs | 57 +++++++++++++++++++-------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/crates/ruborist/src/model/manifest.rs b/crates/ruborist/src/model/manifest.rs index 020656c91d..5efcbfd740 100644 --- a/crates/ruborist/src/model/manifest.rs +++ b/crates/ruborist/src/model/manifest.rs @@ -364,32 +364,47 @@ pub struct VersionManifest { /// and lockfile serialization. Display-only fields (description, author, homepage, /// keywords, bugs, repository, npm_user, etc.) are omitted. /// -/// `skip_on_error` wrappers were dropped from fields that are either -/// (a) typed as `Option` — where `Value` absorbs any JSON so the -/// wrapper was pure Value-round-trip overhead, or (b) typed as -/// `Option>` dependency maps which npm writes -/// in a well-defined shape. `license` keeps `skip_on_error` because -/// legacy packages sometimes publish it as an array-of-object rather -/// than a string (see `test_license_array_does_not_break_parsing`). -/// This cut ~30 % off per-version parse on `Versions::deserialize`, -/// which is on the blocking pool's critical path at cap=128. +/// `skip_on_error` is kept on every field that's typed as `HashMap`, +/// `String`, or `bool` — real registry data regularly ships values +/// that don't match the expected shape (e.g. `engines` with a null +/// value, `hasInstallScript` as a string, legacy `license` as an +/// array-of-object). Dropping the wrapper on those fields broke the +/// lodash fetch at `"ExpectedMap at character 0"` in CI. +/// +/// The three `Option` fields (`bin`, `os`, `cpu`) are the only +/// safe cases: `Value` absorbs any JSON, so the `Value::deserialize` +/// round-trip in `skip_on_error` provides zero robustness. Dropping +/// it there saves one allocation + recursive walk per occurrence +/// without correctness risk. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct CoreVersionManifest { pub name: String, pub version: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + deserialize_with = "skip_on_error", + skip_serializing_if = "Option::is_none" + )] pub dependencies: Option>, - #[serde(rename = "devDependencies", skip_serializing_if = "Option::is_none")] + #[serde(rename = "devDependencies")] + #[serde( + deserialize_with = "skip_on_error", + skip_serializing_if = "Option::is_none" + )] pub dev_dependencies: Option>, - #[serde(rename = "peerDependencies", skip_serializing_if = "Option::is_none")] + #[serde(rename = "peerDependencies")] + #[serde( + deserialize_with = "skip_on_error", + skip_serializing_if = "Option::is_none" + )] pub peer_dependencies: Option>, + #[serde(rename = "optionalDependencies")] #[serde( - rename = "optionalDependencies", + deserialize_with = "skip_on_error", skip_serializing_if = "Option::is_none" )] pub optional_dependencies: Option>, @@ -399,7 +414,10 @@ pub struct CoreVersionManifest { #[serde(skip_serializing_if = "Option::is_none")] pub bin: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + deserialize_with = "skip_on_error", + skip_serializing_if = "Option::is_none" + )] pub engines: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -408,10 +426,17 @@ pub struct CoreVersionManifest { #[serde(skip_serializing_if = "Option::is_none")] pub cpu: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + deserialize_with = "skip_on_error", + skip_serializing_if = "Option::is_none" + )] pub scripts: Option>, - #[serde(rename = "hasInstallScript", skip_serializing_if = "Option::is_none")] + #[serde(rename = "hasInstallScript")] + #[serde( + deserialize_with = "skip_on_error", + skip_serializing_if = "Option::is_none" + )] pub has_install_script: Option, #[serde( From 9d2db0c175d5c23cb318bf1d382fd57d8e293974 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 07:47:48 +0800 Subject: [PATCH 048/102] perf(ruborist): keep preload HTTP connections hot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 8 at cap=128: utoo p1_resolve 4.237s vs bun 1.997s. `send_us` avg 59ms, p99 292ms, max 420ms — the tail is substantial and consistent with TLS handshakes happening mid-phase when reqwest closes idle conns after 90s default, then has to reopen when the next BFS wave reuses the same host/IP. Three conn-pool adjustments that target that tail: - `pool_idle_timeout(None)`: never close idle conns during a single resolve. ant-design pulls 2730 manifests across ~12 IPv6 addresses at cap=128 → ~11 hot conns per IP. Preserving them avoids re-handshake cost mid-run. - `pool_max_idle_per_host(256 -> 1024)`: oversized vs current cap so reqwest never evicts a hot conn under pressure. - `tcp_keepalive(Some(30s))`: tell the kernel to probe the socket so intermediate NAT/firewalls don't silently drop the TCP state during quiet gaps, forcing a silent reconnect. The histogram will tell us whether this shifts `send_us` tail numbers (p90/p99) without regressing the body. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/http.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index 1d4451df88..20d6e5bfe9 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -126,11 +126,25 @@ 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. - // Pool size matches `preload::DEFAULT_CONCURRENCY` so the - // per-host idle pool can absorb every in-flight fetch without - // churning connections. + // + // `pool_idle_timeout(None)`: never close idle connections + // mid-run. At preload concurrency 128 with DNS round-robin + // across ~12 IPv6 addresses, each host-IP pair holds ~11 + // hot conns; letting reqwest's default 90s idle timeout + // kill them between BFS waves forces TLS re-handshakes + // that show up in `send_us` tail. + // + // `pool_max_idle_per_host(1024)`: oversized vs current + // 128-cap to ensure reqwest never evicts a hot conn just + // because capacity is tight. + // + // `tcp_keepalive(Some(30s))`: keep the kernel socket alive + // across idle gaps so intermediate NAT/stateful-firewalls + // don't silently drop the TCP state between requests. .http1_only() - .pool_max_idle_per_host(256); + .pool_max_idle_per_host(1024) + .pool_idle_timeout(None) + .tcp_keepalive(Some(std::time::Duration::from_secs(30))); match env_var("ALL_PROXY") { Some(url) => { From 54238059173bd9762e88e6537a31ae24503283f8 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 08:02:01 +0800 Subject: [PATCH 049/102] perf(ruborist): lazy per-version CoreVersionManifest conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preload at cap=128 on a 4-core CI runner pinned `parse_us` at 20 ms avg per manifest — `Versions::deserialize` was eagerly converting every version entry (~500 per manifest × 2730 manifests = ~1.4M conversions) into `CoreVersionManifest`, saturating the blocking pool and stalling the pipeline despite `in_flight` hitting 128 at cap_hits 93 %. Run 8 measured utoo 4.24 s vs bun 2.00 s (gap 2.24 s). A resolve only ever touches 1-3 of a manifest's ~500 versions. Eagerly parsing the other 497 was pure waste. Refactor so `Versions::deserialize` only materialises the raw JSON tree per version (`values: HashMap>`), and defer the `CoreVersionManifest` conversion to a new async `FullManifest::get_core_version` that offloads `from_value` to `spawn_blocking`. 500× fewer conversions, and the ones we do run land on the pool as independent small tasks instead of batched inside the fetch's `spawn_blocking`. Pool arithmetic: - Before: 2730 manifests × ~5 ms eager parse each = 13.6 s pool CPU / 4 cores ≈ 3.4 s serialised — the binding constraint. - After: 2730 × ~2 ms Value-only parse = 5.5 s in fetch phase, plus 2730 × ~0.5 ms lazy conversion = 1.4 s, total ≈ 6.9 s / 4 cores ≈ 1.7 s — pool no longer saturated; per-future wall can drop back toward the pure send+body RTT floor. Kept a sync `get_core_version_blocking` for `resolve_from_manifest` (sync helper, currently unused but preserved for API stability) so nothing outside async runtimes needs to change. Also reverted the run-9 conn-pool tuning — `send_us` tail (p99=292 ms, max=420 ms) was unchanged by `pool_idle_timeout(None)` + `tcp_keepalive(30s)` + pool_max_idle bump, confirming the tail comes from npmjs.org server-side variance, not client-side pool eviction. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/model/manifest.rs | 104 +++++++++++++++-------- crates/ruborist/src/resolver/registry.rs | 5 +- crates/ruborist/src/service/http.rs | 22 +---- crates/ruborist/src/service/registry.rs | 2 + crates/ruborist/src/traits/registry.rs | 20 +++-- 5 files changed, 91 insertions(+), 62 deletions(-) diff --git a/crates/ruborist/src/model/manifest.rs b/crates/ruborist/src/model/manifest.rs index 5efcbfd740..dfeae865ee 100644 --- a/crates/ruborist/src/model/manifest.rs +++ b/crates/ruborist/src/model/manifest.rs @@ -90,17 +90,51 @@ pub struct FullManifest { } impl FullManifest { - /// O(1) lookup of a pre-parsed `CoreVersionManifest` by version - /// string. Returns `None` if the version is absent or (rarely) if - /// its entry was malformed enough to fail strict deserialization - /// during the initial fetch parse — matches the previous on-demand - /// `extract_version` behavior of silently returning `None`. - pub fn get_core_version(&self, version: &str) -> Option> { - self.versions.cores.get(version).cloned() + /// Parse the requested version's `CoreVersionManifest` on demand. + /// + /// Offloaded to `spawn_blocking` because the conversion walks the + /// pre-parsed JSON tree + runs `skip_on_error` Value round-trips + /// on every field, totalling ~0.1–0.5 ms of CPU per version. At + /// preload cap=128 a purely-inline conversion would re-serialise + /// the async worker — core_version_us was exactly this problem + /// before eager parse. Lazy-via-blocking-pool gives us the best + /// of both: per-resolve CPU is paid once per resolve (not 500x + /// per manifest fetch as in the previous eager-parse design), + /// and it runs off the main runtime so network polling keeps + /// flowing. + /// + /// Returns `None` if the version isn't in the manifest or if + /// `from_value` fails (matches the legacy "silently skip + /// malformed entries" behaviour). + pub async fn get_core_version(&self, version: &str) -> Option> { + let value = self.versions.values.get(version)?.clone(); + tokio::task::spawn_blocking(move || { + // `from_value` consumes Value, so we clone the tree off + // the Arc. The deep clone is O(tree_size) but runs on the + // blocking pool and is cheap (~tens of μs for a typical + // version entry). + serde_json::from_value::((*value).clone()) + .ok() + .map(Arc::new) + }) + .await + .ok() + .flatten() + } + + /// Synchronous variant for callers that cannot await (the dead + /// `resolve_from_manifest` helper and test code). Does the + /// `from_value` walk inline on the calling thread — only safe on + /// sync code paths that aren't on the preload main runtime. + pub fn get_core_version_blocking(&self, version: &str) -> Option> { + let value = self.versions.values.get(version)?; + serde_json::from_value::((**value).clone()) + .ok() + .map(Arc::new) } /// Parse a single version on demand into full `VersionManifest` - /// (cold path — `ut view`). Still re-parses raw bytes because + /// (cold path — `ut view`). Uses the retained raw bytes because /// `VersionManifest` carries display fields that `CoreVersionManifest` /// drops, and `ut view` is infrequent. pub fn get_full_version(&self, version: &str) -> Option { @@ -114,20 +148,25 @@ impl FullManifest { } /// Version entries of a `FullManifest`: preserves key insertion order -/// (for `VersionsInfo`/semver callers that iterate the list) alongside -/// an `Arc`-shared map for O(1) lookup. +/// (for semver resolution that iterates the list) alongside an +/// `Arc`-shared JSON value map for lazy per-version +/// `CoreVersionManifest` conversion. /// -/// Implemented as a single-pass custom `Deserialize` so the entire -/// `"versions"` map is parsed into strongly-typed `CoreVersionManifest`s -/// during the fetch-time `spawn_blocking` rather than lazily re-parsed -/// on the async preload worker for every resolve. +/// The custom `Deserialize` parses each version entry into a +/// `serde_json::Value` tree (cheap — no field-level validation, no +/// `skip_on_error` Value-round-trips), but does **not** eagerly +/// convert to `CoreVersionManifest`. A typical resolve touches 1–3 +/// of a manifest's ~500 versions; eager conversion was doing 500× +/// more work than needed and saturating the 4-core blocking pool +/// at preload cap=128. See `get_core_version` for the lazy path. #[derive(Debug, Clone, Default)] pub struct Versions { /// Version strings in insertion order — used by /// `resolve_target_version` and cached into `VersionsInfo`. pub keys: Vec, - /// Parsed core manifests keyed by version string. - pub cores: HashMap>, + /// Raw parsed JSON values keyed by version string. Converted to + /// `CoreVersionManifest` on demand by `get_core_version`. + pub values: HashMap>, } impl Versions { @@ -139,12 +178,13 @@ impl Versions { impl Serialize for Versions { fn serialize(&self, serializer: S) -> Result { // Serialize the same shape the registry returns: a JSON object - // mapping version string -> manifest. Order follows `keys` for - // determinism (HashMap iteration order is arbitrary otherwise). + // mapping version string -> raw version JSON. Order follows + // `keys` for determinism (HashMap iteration order is arbitrary + // otherwise). use serde::ser::SerializeMap; let mut map = serializer.serialize_map(Some(self.keys.len()))?; for k in &self.keys { - if let Some(v) = self.cores.get(k) { + if let Some(v) = self.values.get(k) { map.serialize_entry(k, v.as_ref())?; } } @@ -166,24 +206,20 @@ impl<'de> Deserialize<'de> for Versions { ) -> Result { let cap = map.size_hint().unwrap_or(0); let mut keys = Vec::with_capacity(cap); - let mut cores = HashMap::with_capacity(cap); + let mut values = HashMap::with_capacity(cap); while let Some(key) = map.next_key::()? { - // Deserialize directly into `CoreVersionManifest` - // rather than buffering through `serde_json::Value` - // first. The buffer approach doubled parse work and - // showed up in `parse_us` as ~9ms avg per manifest - // fetch. `CoreVersionManifest` uses - // `#[serde(default)]` + per-field `skip_on_error` - // so malformed individual fields are already - // tolerated; only a completely unreadable entry - // (essentially unheard of on real registry data) - // would fail here, and that would correctly fail - // the whole manifest fetch. - let core: CoreVersionManifest = map.next_value()?; - cores.insert(key.clone(), Arc::new(core)); + // Deserialize each version's body into a raw + // `serde_json::Value` tree. No field validation + // happens here, so this is much cheaper than + // constructing a full `CoreVersionManifest`. The + // conversion is deferred to `get_core_version`, + // which only pays it for versions actually needed + // by a resolve (1–3 out of ~500 per manifest). + let value: Value = map.next_value()?; + values.insert(key.clone(), Arc::new(value)); keys.push(key); } - Ok(Versions { keys, cores }) + Ok(Versions { keys, values }) } } deserializer.deserialize_map(VersionsVisitor) diff --git a/crates/ruborist/src/resolver/registry.rs b/crates/ruborist/src/resolver/registry.rs index c37f37f315..f6d1de88a6 100644 --- a/crates/ruborist/src/resolver/registry.rs +++ b/crates/ruborist/src/resolver/registry.rs @@ -145,9 +145,10 @@ pub fn resolve_from_manifest( let resolved_version = resolve_target_version(&manifest.dist_tags, &version_list, spec) .map_err(|e| ResolveError::Version(format!("{}@{}: {}", manifest.name, spec, e)))?; - // O(1) lookup from pre-parsed `Versions` map. + // Sync context (no async runtime available here), so use the + // blocking variant that performs the `from_value` walk inline. let version_manifest = manifest - .get_core_version(&resolved_version) + .get_core_version_blocking(&resolved_version) .ok_or_else(|| ResolveError::ManifestNotFound { name: manifest.name.clone(), version: resolved_version.clone(), diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index 20d6e5bfe9..1d4451df88 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -126,25 +126,11 @@ 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. - // - // `pool_idle_timeout(None)`: never close idle connections - // mid-run. At preload concurrency 128 with DNS round-robin - // across ~12 IPv6 addresses, each host-IP pair holds ~11 - // hot conns; letting reqwest's default 90s idle timeout - // kill them between BFS waves forces TLS re-handshakes - // that show up in `send_us` tail. - // - // `pool_max_idle_per_host(1024)`: oversized vs current - // 128-cap to ensure reqwest never evicts a hot conn just - // because capacity is tight. - // - // `tcp_keepalive(Some(30s))`: keep the kernel socket alive - // across idle gaps so intermediate NAT/stateful-firewalls - // don't silently drop the TCP state between requests. + // Pool size matches `preload::DEFAULT_CONCURRENCY` so the + // per-host idle pool can absorb every in-flight fetch without + // churning connections. .http1_only() - .pool_max_idle_per_host(1024) - .pool_idle_timeout(None) - .tcp_keepalive(Some(std::time::Duration::from_secs(30))); + .pool_max_idle_per_host(256); match env_var("ALL_PROXY") { Some(url) => { diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index e27d2f3fdd..c652e4fbfd 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -533,6 +533,7 @@ impl RegistryClient for UnifiedRegistry { let cv_start = tokio::time::Instant::now(); let version_manifest = full_manifest .get_core_version(&resolved_version) + .await .ok_or_else(|| { RegistryError(anyhow!( "Version {} not found in manifest for {}", @@ -648,6 +649,7 @@ impl RegistryClient for UnifiedRegistry { let cv_start = tokio::time::Instant::now(); let version_manifest = full_manifest .get_core_version(&resolved_version) + .await .ok_or_else(|| { RegistryError(anyhow!( "Version {} not found in manifest for {}", diff --git a/crates/ruborist/src/traits/registry.rs b/crates/ruborist/src/traits/registry.rs index 1ca149e820..fc7b60acf7 100644 --- a/crates/ruborist/src/traits/registry.rs +++ b/crates/ruborist/src/traits/registry.rs @@ -162,14 +162,17 @@ pub trait RegistryClient { let resolved_version = resolve_target_version(&manifest.dist_tags, &version_list, spec) .map_err(|e| RegistryError(anyhow::anyhow!("{}@{}: {}", name, spec, e)))?; - manifest.get_core_version(&resolved_version).ok_or_else(|| { - RegistryError(anyhow::anyhow!( - "Version {} not found in manifest for {}", - resolved_version, - name - )) - .into() - }) + manifest + .get_core_version(&resolved_version) + .await + .ok_or_else(|| { + RegistryError(anyhow::anyhow!( + "Version {} not found in manifest for {}", + resolved_version, + name + )) + .into() + }) } } @@ -239,6 +242,7 @@ pub trait RegistryClient { let version_manifest = full_manifest .get_core_version(&resolved_version) + .await .ok_or_else(|| { RegistryError(anyhow::anyhow!( "Version {} not found in manifest for {}", From 67700a80fd8d167ae45c8cf14545d83b84068125 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 08:15:42 +0800 Subject: [PATCH 050/102] Revert "perf(ruborist): lazy per-version CoreVersionManifest conversion" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 10 regressed utoo p1_resolve to 5.275s ± 1.098 (vs run 8's 4.237s stable baseline at cap=128 with eager parse). Histogram diagnostic: core_version_us avg 11-18 ms (was ~0 with eager parse) parse_us avg 17 ms (was 20 ms — modest drop) body_us avg 21 ms (was 26 ms — small win) The lazy path added a `spawn_blocking` + `Value` deep-clone + `from_value` walk per resolve × 4568 resolves. Dispatch overhead (~200 μs) × 4568 + deep-clone of ~10-50 KB Values (tens of μs each) summed to 10+ ms average per resolve — worse than the eager parse's amortised cost per version. The pool arithmetic prediction missed that `Value` is a recursive owned tree whose clone dominates on the blocking pool. Restore the eager `Versions.cores: HashMap>` design from run 8. This is the best-known state of the preload hot path: utoo p1_resolve: 4.24 s bun p1_resolve: 2.00 s gap: 2.24 s (structural — reqwest+tokio+simd_json overhead vs bun's Zig + uSockets) Revert just the `perf(ruborist): lazy per-version` change; keep the conn-pool revert from the same commit (it was empirically neutral). All 164 ruborist tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/model/manifest.rs | 109 +++++++++-------------- crates/ruborist/src/resolver/registry.rs | 5 +- crates/ruborist/src/service/registry.rs | 2 - crates/ruborist/src/traits/registry.rs | 20 ++--- 4 files changed, 50 insertions(+), 86 deletions(-) diff --git a/crates/ruborist/src/model/manifest.rs b/crates/ruborist/src/model/manifest.rs index dfeae865ee..07b269986d 100644 --- a/crates/ruborist/src/model/manifest.rs +++ b/crates/ruborist/src/model/manifest.rs @@ -90,47 +90,13 @@ pub struct FullManifest { } impl FullManifest { - /// Parse the requested version's `CoreVersionManifest` on demand. - /// - /// Offloaded to `spawn_blocking` because the conversion walks the - /// pre-parsed JSON tree + runs `skip_on_error` Value round-trips - /// on every field, totalling ~0.1–0.5 ms of CPU per version. At - /// preload cap=128 a purely-inline conversion would re-serialise - /// the async worker — core_version_us was exactly this problem - /// before eager parse. Lazy-via-blocking-pool gives us the best - /// of both: per-resolve CPU is paid once per resolve (not 500x - /// per manifest fetch as in the previous eager-parse design), - /// and it runs off the main runtime so network polling keeps - /// flowing. - /// - /// Returns `None` if the version isn't in the manifest or if - /// `from_value` fails (matches the legacy "silently skip - /// malformed entries" behaviour). - pub async fn get_core_version(&self, version: &str) -> Option> { - let value = self.versions.values.get(version)?.clone(); - tokio::task::spawn_blocking(move || { - // `from_value` consumes Value, so we clone the tree off - // the Arc. The deep clone is O(tree_size) but runs on the - // blocking pool and is cheap (~tens of μs for a typical - // version entry). - serde_json::from_value::((*value).clone()) - .ok() - .map(Arc::new) - }) - .await - .ok() - .flatten() - } - - /// Synchronous variant for callers that cannot await (the dead - /// `resolve_from_manifest` helper and test code). Does the - /// `from_value` walk inline on the calling thread — only safe on - /// sync code paths that aren't on the preload main runtime. - pub fn get_core_version_blocking(&self, version: &str) -> Option> { - let value = self.versions.values.get(version)?; - serde_json::from_value::((**value).clone()) - .ok() - .map(Arc::new) + /// O(1) lookup of a pre-parsed `CoreVersionManifest` by version + /// string. Returns `None` if the version is absent or (rarely) if + /// its entry was malformed enough to fail strict deserialization + /// during the initial fetch parse — matches the previous on-demand + /// `extract_version` behavior of silently returning `None`. + pub fn get_core_version(&self, version: &str) -> Option> { + self.versions.cores.get(version).cloned() } /// Parse a single version on demand into full `VersionManifest` @@ -148,25 +114,27 @@ impl FullManifest { } /// Version entries of a `FullManifest`: preserves key insertion order -/// (for semver resolution that iterates the list) alongside an -/// `Arc`-shared JSON value map for lazy per-version -/// `CoreVersionManifest` conversion. +/// (for `VersionsInfo`/semver callers that iterate the list) alongside +/// an `Arc`-shared map for O(1) lookup. +/// +/// Implemented as a single-pass custom `Deserialize` so the entire +/// `"versions"` map is parsed into strongly-typed `CoreVersionManifest`s +/// during the fetch-time `spawn_blocking` rather than lazily re-parsed +/// on the async preload worker for every resolve. /// -/// The custom `Deserialize` parses each version entry into a -/// `serde_json::Value` tree (cheap — no field-level validation, no -/// `skip_on_error` Value-round-trips), but does **not** eagerly -/// convert to `CoreVersionManifest`. A typical resolve touches 1–3 -/// of a manifest's ~500 versions; eager conversion was doing 500× -/// more work than needed and saturating the 4-core blocking pool -/// at preload cap=128. See `get_core_version` for the lazy path. +/// A lazy variant (store `Arc` per version and +/// convert on demand via `spawn_blocking`) was tested and regressed — +/// `core_version_us` jumped from ~0 to 11-18 ms avg because the +/// `Value` deep-clone + `from_value` walk + `spawn_blocking` dispatch +/// added up to more per-resolve CPU than the eager parse pays +/// per-version even with the 500x multiplier. Staying eager. #[derive(Debug, Clone, Default)] pub struct Versions { /// Version strings in insertion order — used by /// `resolve_target_version` and cached into `VersionsInfo`. pub keys: Vec, - /// Raw parsed JSON values keyed by version string. Converted to - /// `CoreVersionManifest` on demand by `get_core_version`. - pub values: HashMap>, + /// Parsed core manifests keyed by version string. + pub cores: HashMap>, } impl Versions { @@ -178,13 +146,12 @@ impl Versions { impl Serialize for Versions { fn serialize(&self, serializer: S) -> Result { // Serialize the same shape the registry returns: a JSON object - // mapping version string -> raw version JSON. Order follows - // `keys` for determinism (HashMap iteration order is arbitrary - // otherwise). + // mapping version string -> manifest. Order follows `keys` for + // determinism (HashMap iteration order is arbitrary otherwise). use serde::ser::SerializeMap; let mut map = serializer.serialize_map(Some(self.keys.len()))?; for k in &self.keys { - if let Some(v) = self.values.get(k) { + if let Some(v) = self.cores.get(k) { map.serialize_entry(k, v.as_ref())?; } } @@ -206,20 +173,24 @@ impl<'de> Deserialize<'de> for Versions { ) -> Result { let cap = map.size_hint().unwrap_or(0); let mut keys = Vec::with_capacity(cap); - let mut values = HashMap::with_capacity(cap); + let mut cores = HashMap::with_capacity(cap); while let Some(key) = map.next_key::()? { - // Deserialize each version's body into a raw - // `serde_json::Value` tree. No field validation - // happens here, so this is much cheaper than - // constructing a full `CoreVersionManifest`. The - // conversion is deferred to `get_core_version`, - // which only pays it for versions actually needed - // by a resolve (1–3 out of ~500 per manifest). - let value: Value = map.next_value()?; - values.insert(key.clone(), Arc::new(value)); + // Deserialize directly into `CoreVersionManifest` + // rather than buffering through `serde_json::Value` + // first. The buffer approach doubled parse work and + // showed up in `parse_us` as ~9ms avg per manifest + // fetch. `CoreVersionManifest` uses + // `#[serde(default)]` + per-field `skip_on_error` + // so malformed individual fields are already + // tolerated; only a completely unreadable entry + // (essentially unheard of on real registry data) + // would fail here, and that would correctly fail + // the whole manifest fetch. + let core: CoreVersionManifest = map.next_value()?; + cores.insert(key.clone(), Arc::new(core)); keys.push(key); } - Ok(Versions { keys, values }) + Ok(Versions { keys, cores }) } } deserializer.deserialize_map(VersionsVisitor) diff --git a/crates/ruborist/src/resolver/registry.rs b/crates/ruborist/src/resolver/registry.rs index f6d1de88a6..c37f37f315 100644 --- a/crates/ruborist/src/resolver/registry.rs +++ b/crates/ruborist/src/resolver/registry.rs @@ -145,10 +145,9 @@ pub fn resolve_from_manifest( let resolved_version = resolve_target_version(&manifest.dist_tags, &version_list, spec) .map_err(|e| ResolveError::Version(format!("{}@{}: {}", manifest.name, spec, e)))?; - // Sync context (no async runtime available here), so use the - // blocking variant that performs the `from_value` walk inline. + // O(1) lookup from pre-parsed `Versions` map. let version_manifest = manifest - .get_core_version_blocking(&resolved_version) + .get_core_version(&resolved_version) .ok_or_else(|| ResolveError::ManifestNotFound { name: manifest.name.clone(), version: resolved_version.clone(), diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index c652e4fbfd..e27d2f3fdd 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -533,7 +533,6 @@ impl RegistryClient for UnifiedRegistry { let cv_start = tokio::time::Instant::now(); let version_manifest = full_manifest .get_core_version(&resolved_version) - .await .ok_or_else(|| { RegistryError(anyhow!( "Version {} not found in manifest for {}", @@ -649,7 +648,6 @@ impl RegistryClient for UnifiedRegistry { let cv_start = tokio::time::Instant::now(); let version_manifest = full_manifest .get_core_version(&resolved_version) - .await .ok_or_else(|| { RegistryError(anyhow!( "Version {} not found in manifest for {}", diff --git a/crates/ruborist/src/traits/registry.rs b/crates/ruborist/src/traits/registry.rs index fc7b60acf7..1ca149e820 100644 --- a/crates/ruborist/src/traits/registry.rs +++ b/crates/ruborist/src/traits/registry.rs @@ -162,17 +162,14 @@ pub trait RegistryClient { let resolved_version = resolve_target_version(&manifest.dist_tags, &version_list, spec) .map_err(|e| RegistryError(anyhow::anyhow!("{}@{}: {}", name, spec, e)))?; - manifest - .get_core_version(&resolved_version) - .await - .ok_or_else(|| { - RegistryError(anyhow::anyhow!( - "Version {} not found in manifest for {}", - resolved_version, - name - )) - .into() - }) + manifest.get_core_version(&resolved_version).ok_or_else(|| { + RegistryError(anyhow::anyhow!( + "Version {} not found in manifest for {}", + resolved_version, + name + )) + .into() + }) } } @@ -242,7 +239,6 @@ pub trait RegistryClient { let version_manifest = full_manifest .get_core_version(&resolved_version) - .await .ok_or_else(|| { RegistryError(anyhow::anyhow!( "Version {} not found in manifest for {}", From da8e1944a7bd5863e0ca8a06b80aeb65180c0eb3 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 08:17:57 +0800 Subject: [PATCH 051/102] chore(ruborist): remove preload/fetch latency instrumentation All signals extracted. The histogram pipeline served its purpose and is no longer needed in production: - `preload proc_us`: established main-task CPU is <1% of wall. - `preload in_flight`: showed pipeline saturates the cap at ~97%. - `manifest send_us`: told us per-request network wait is the bottleneck floor on cap-bound runs. - `manifest body_us`: body download adds a modest tail, server- side not client-pool. - `manifest parse_us`: guided the `skip_on_error` cleanup on CoreVersionManifest `Option` fields. - `manifest core_version_us`: found the pre-eager-parse 1.85 s serial CPU burn on the async worker (addressed by eager Versions parse in fetch-time spawn_blocking). Drop: - preload.rs `proc_us` + `in_flight_samples` vectors + their dump blocks at phase end, plus the `dump_fetch_histograms()` call. - service/manifest.rs SEND_US / BODY_US / PARSE_US / CORE_VERSION_US `LazyLock>>` buckets, the `record_us` helper, `record_core_version_us`, the public `dump_fetch_histograms`, and the `std::sync::{LazyLock, Mutex}` import. - service/mod.rs `dump_fetch_histograms` re-export. - service/registry.rs `cv_start` timer sites around the two `get_core_version` lookups. Net result: p1_resolve optimisations retained (eager Versions parse, inlined `extract_transitive_deps`, cap=128, bin/os/cpu skip_on_error removal), instrumentation gone. 164 ruborist tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/preload.rs | 72 ---------------------- crates/ruborist/src/service/manifest.rs | 82 ------------------------- crates/ruborist/src/service/mod.rs | 2 +- crates/ruborist/src/service/registry.rs | 4 -- 4 files changed, 1 insertion(+), 159 deletions(-) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index 56fefc687f..af583d4294 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -91,18 +91,6 @@ where F: FnMut(&str, Arc), { let mut stats = PreloadStats::default(); - // Per-iteration processing time on the main task (in μs). Each sample - // covers the window from `futures.next().await` returning to the next - // iteration's `futures.next().await` being re-entered — i.e. the - // serial CPU cost for stats + event emission + callback + refill-loop - // + next iteration setup. Printed as a histogram at shutdown. - let mut proc_us: Vec = Vec::new(); - // Snapshot of `in_flight` sampled *after* each refill attempt (i.e. - // after we've tried to backfill to the concurrency cap) but *before* - // the `futures.next().await` on the next completion. This is the - // steady-state saturation level — we want it pinned at `concurrency` - // during the body of the phase and only tapering at start/end. - let mut in_flight_samples: Vec = Vec::new(); let mut processed: HashSet = HashSet::new(); // Shared pending queue: each in-flight future extracts its own // transitive deps on the blocking pool and pushes them here, so the @@ -181,12 +169,9 @@ where break; } - in_flight_samples.push(in_flight as u32); - let Some((name, result, elapsed_ms)) = futures.next().await else { break; }; - let iter_start = tokio::time::Instant::now(); in_flight -= 1; if stats.success_count == 0 && stats.failed_count == 0 { @@ -219,67 +204,10 @@ where tracing::debug!("Failed to preload {}: {}", name, e); } } - proc_us.push(iter_start.elapsed().as_micros().min(u32::MAX as u128) as u32); } stats.total_processed = processed.len(); - // Dump the per-iteration processing-time histogram. This is the - // serial main-task cost that competes with the async poll of the - // in-flight request pool; dips in active-stream count correlate - // with spikes in these samples. - if !proc_us.is_empty() { - let mut v = proc_us.clone(); - v.sort_unstable(); - let pct = |p: f64| -> u32 { - let idx = ((p * v.len() as f64) as usize).min(v.len() - 1); - v[idx] - }; - let sum: u64 = v.iter().map(|&x| x as u64).sum(); - tracing::info!( - "preload proc_us (n={}): p50={} p90={} p99={} max={} sum={}us avg={}us", - v.len(), - pct(0.50), - pct(0.90), - pct(0.99), - pct(1.0), - sum, - sum / v.len() as u64 - ); - } - - if !in_flight_samples.is_empty() { - let mut v = in_flight_samples.clone(); - v.sort_unstable(); - let pct = |p: f64| -> u32 { - let idx = ((p * v.len() as f64) as usize).min(v.len() - 1); - v[idx] - }; - let sum: u64 = v.iter().map(|&x| x as u64).sum(); - let cap_hits = v.iter().filter(|&&x| x as usize >= concurrency).count(); - tracing::info!( - "preload in_flight (n={}): p5={} p25={} p50={} p75={} p95={} max={} avg={} cap_hits={}% ({}/{})", - v.len(), - pct(0.05), - pct(0.25), - pct(0.50), - pct(0.75), - pct(0.95), - pct(1.0), - sum / v.len() as u64, - cap_hits * 100 / v.len(), - cap_hits, - v.len(), - ); - } - - // Dump per-request send/body/parse histograms accumulated by - // `service::manifest::fetch_full_manifest`. Together with `proc_us` - // this locates whether remaining wall time hides in network wait - // (send), body download (body), or JSON parse / spawn_blocking - // scheduling (parse). - crate::service::dump_fetch_histograms(); - receiver.on_event(BuildEvent::PreloadComplete { success: stats.success_count, failed: stats.failed_count, diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index a47a443d2e..5be50827a8 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -4,8 +4,6 @@ //! [`crate::service::fetch`] so retry policy stays uniform across registry //! manifest fetches and non-registry resolvers (git, http tarball). -use std::sync::{LazyLock, Mutex}; - use anyhow::{Result, anyhow}; use tokio_retry::RetryIf; @@ -15,80 +13,6 @@ use super::fetch::{ use super::http::pick_client; use crate::model::manifest::{CoreVersionManifest, FullManifest}; -/// Per-request send latency — from `request.send().await` entry to response -/// headers available (μs). Isolates "waiting for server to respond" from -/// body download and JSON parse. -static SEND_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); - -/// Per-request body download latency — from response headers to full body -/// bytes in memory (μs). Isolates network throughput cost. -static BODY_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); - -/// Per-request JSON parse latency (μs), measured end-to-end on the main -/// task: includes `spawn_blocking` dispatch + parse work + await overhead. -/// This is what the async preload loop actually waits on, so it's the right -/// slice to compare against the pure-parse cost to reveal scheduler gap. -static PARSE_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); - -/// Per-resolve `FullManifest::get_core_version` latency (μs). Runs -/// synchronously on the async task *after* the network fetch, so it -/// competes for the same worker thread that should be polling the next -/// in-flight request. Current implementation re-parses the entire raw -/// manifest bytes (`simd_json::to_borrowed_value`) then does -/// simd_json→serde_json→T conversion — we suspect this is the hidden CPU -/// that explains the parallelism gap (effective 38 vs bun's 64). -static CORE_VERSION_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); - -/// Record a `get_core_version` sample. Exposed via `pub(crate)` so -/// `service::registry` can time the call without pulling the histogram -/// machinery out of this module. -pub(crate) fn record_core_version_us(us: u128) { - record_us(&CORE_VERSION_US, us); -} - -fn record_us(slot: &Mutex>, us: u128) { - if let Ok(mut v) = slot.lock() { - v.push(us.min(u32::MAX as u128) as u32); - } -} - -/// Dump send/body/parse histograms collected during manifest fetching and -/// clear the buffers. Called from `preload_manifests` after its `proc_us` -/// dump so the three numbers are printed in one place per run. -pub fn dump_fetch_histograms() { - for (label, slot) in [ - ("send", &*SEND_US), - ("body", &*BODY_US), - ("parse", &*PARSE_US), - ("core_version", &*CORE_VERSION_US), - ] { - let mut v = match slot.lock() { - Ok(mut guard) => std::mem::take(&mut *guard), - Err(_) => continue, - }; - if v.is_empty() { - continue; - } - v.sort_unstable(); - let pct = |p: f64| -> u32 { - let idx = ((p * v.len() as f64) as usize).min(v.len() - 1); - v[idx] - }; - let sum: u64 = v.iter().map(|&x| x as u64).sum(); - tracing::info!( - "manifest {}_us (n={}): p50={} p90={} p99={} max={} sum={}us avg={}us", - label, - v.len(), - pct(0.50), - pct(0.90), - pct(0.99), - pct(1.0), - sum, - sum / v.len() as u64 - ); - } -} - /// Result of a full manifest fetch with ETag support. /// Transient return value, immediately destructured — Box not needed. #[allow(clippy::large_enum_variant)] @@ -144,9 +68,7 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result) -> Result = response .bytes() .await .map_err(|e| FetchError::Permanent(anyhow!("Response read error: {e}")))? .into(); - record_us(&BODY_US, body_start.elapsed().as_micros()); // Offload JSON parse to the blocking pool. Manifests are // 5–50KB and simd_json is CPU-bound (~1–5ms per call); @@ -179,7 +99,6 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result) -> Result Date: Fri, 24 Apr 2026 14:21:33 +0800 Subject: [PATCH 052/102] perf(ruborist): rotate DNS addresses per-family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pcap diff against bun revealed a bug in our DNS round-robin. bun distributes its 256 preload connections cleanly as 64 per IP across 4 Cloudflare edges. utoo dumped 66 of 128 connections (51%) onto a single IP (104.16.5.34), leaving 11 other IPs with only 5-6 connections each — effectively serialising a quarter of the phase on one overloaded server-side instance. Root cause: `getaddrinfo` for `registry.npmjs.org` returns 10 IPv6 + 12 IPv4 = 22 addresses, v6 first. Our rotation was `offset % 22`, which for offsets 0..10 (= 11 of every 22) made IPv6 the first candidate. On CI runners without IPv6 routing, every one of those 11 offsets failed through the v6 prefix and landed on the *same* first IPv4 entry (104.16.5.34). The other 11 v4 addresses only saw traffic from offsets 11..21 — one connection each. Math verified with the pcap: 128 offsets × (11/22) = 64 expected on v4[0]; observed 66 (plus some jitter from Happy Eyeballs timing). Fix: split addrs into v6 / v4, rotate each family independently, then re-concatenate with the system resolver's original family order preserved. Each new connection now cycles through all reachable IPv4 addresses in turn instead of all piling up on the first one after v6 gives up. Expected p1_resolve impact: the overloaded server instance currently serialising 66 TLS handshakes + request/response pairs should see ~11 instead, and the 11 underutilised IPs pick up the slack. Preliminary math: if server-side queue delay scaled linearly with load, 66 → 11 could cut per-request tail latency meaningfully, possibly closing a chunk of the 1.5 s p1 gap vs bun (though some of that gap is still structural tokio/reqwest overhead). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/dns.rs | 51 ++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/crates/ruborist/src/service/dns.rs b/crates/ruborist/src/service/dns.rs index 3b0ebf29a9..969530a2b0 100644 --- a/crates/ruborist/src/service/dns.rs +++ b/crates/ruborist/src/service/dns.rs @@ -91,21 +91,52 @@ mod native { } } - /// Rotate `addrs` left by `offset` positions, producing a fresh Vec so - /// reqwest's connect loop tries a different IP on every new connection. + /// Rotate `addrs` left by `offset` positions, keeping each address + /// family (IPv4 / IPv6) rotated independently and preserving the + /// system resolver's original family ordering between them. /// - /// e.g. `[A, B, C, D]` with offset 2 → `[C, D, A, B]`. + /// Why per-family: `getaddrinfo` typically returns IPv6 first + /// (e.g. 10 v6 + 12 v4 = 22 entries for `registry.npmjs.org`). A + /// flat rotation by `offset % 22` means offsets 0..10 all start + /// inside the IPv6 range and — on hosts without usable IPv6 + /// routing like GitHub Actions runners — every single one of them + /// falls through to the *same* first IPv4 address once Happy + /// Eyeballs gives up on v6. Pcap comparison with bun confirmed + /// this bug: 66 of utoo's 128 connections (51 %) were landing on + /// a single Cloudflare edge IP while the other 11 IPs saw only + /// 5-6 connections each. bun distributes cleanly 64 per IP across + /// 4 IPs, which is the behaviour we want. + /// + /// With per-family rotation, every new connection's first-reachable + /// IPv4 cycles through all v4 addresses (and same for v6 if it + /// works), giving ~11 conns per IP instead of 66 on one. 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() + let rotate = |slice: &[SocketAddr]| -> Vec { + if slice.is_empty() { + return Vec::new(); + } + let start = offset % slice.len(); + slice[start..] + .iter() + .chain(&slice[..start]) + .copied() + .collect() + }; + let v6: Vec = addrs.iter().filter(|a| a.is_ipv6()).copied().collect(); + let v4: Vec = addrs.iter().filter(|a| a.is_ipv4()).copied().collect(); + let v6_rot = rotate(&v6); + let v4_rot = rotate(&v4); + // Preserve v6-first ordering if that's what the resolver gave us; + // Happy Eyeballs will still prefer v6 when it's reachable. + let v6_first = addrs.first().map(|a| a.is_ipv6()).unwrap_or(true); + if v6_first { + v6_rot.into_iter().chain(v4_rot).collect() + } else { + v4_rot.into_iter().chain(v6_rot).collect() + } } /// Perform a system DNS lookup and cache the result. From b374e000282f47f0b2f62fa70c6fc86bf4137769 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 14:46:29 +0800 Subject: [PATCH 053/102] debug(ruborist): resolve_pkg / disk_precheck timers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User called out that claiming "80 ms gap between per-future wall (117 ms) and send+body+parse sum (~75 ms) is pool overhead" was hand-wavy. They're right — reqwest/tokio HTTP/1.1 state machine overhead should be ms-level per request, not tens of ms. The gap must live somewhere we haven't measured yet. Top suspect, spotted by re-reading `fetch_full_manifest_network`: let disk_versions = self.cache.get_versions_from_disk(name).await; This runs before every cold network fetch. Calls `tokio_fs_ext::try_exists` which dispatches a `spawn_blocking` stat syscall. On a 4-core CI runner with 128 concurrent preload futures all hitting the blocking pool simultaneously for the same stat-then-return-None-path, this could easily add 5-10 ms per future to the critical path — and the stat count is ~2730 (once per package), all landing on spawn_blocking. Add two histograms to validate: - `manifest resolve_pkg_us` — total wall inside resolve_package (wrapped in preload where we dispatch). If this avg is ~117 ms and `send + body + parse` is ~75 ms, the gap sits inside resolve_package rather than outside. - `manifest disk_precheck_us` — just the `get_versions_from_disk().await` segment. If this avg is ~30-40 ms, it's the smoking gun; fix is to skip disk on cold cache dirs. Dump both at the end of preload alongside the existing send/body/parse/core_version buckets. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/preload.rs | 6 ++- crates/ruborist/src/service/manifest.rs | 56 +++++++++++++++++++++++++ crates/ruborist/src/service/mod.rs | 3 +- crates/ruborist/src/service/registry.rs | 2 + 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index af583d4294..a9e84bd456 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -145,7 +145,9 @@ where futures.push(async move { let start = tokio::time::Instant::now(); let result = resolve_package(registry, &name, &spec).await; - let elapsed_ms = start.elapsed().as_millis() as u64; + let elapsed = start.elapsed(); + let elapsed_ms = elapsed.as_millis() as u64; + crate::service::record_resolve_pkg_us(elapsed.as_micros()); // Inlined: extracting a ~10-entry dep vec from a parsed // manifest is ~5μs of work. The previous spawn_blocking @@ -208,6 +210,8 @@ where stats.total_processed = processed.len(); + crate::service::dump_timing_histograms(); + receiver.on_event(BuildEvent::PreloadComplete { success: stats.success_count, failed: stats.failed_count, diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index 5be50827a8..42aad4efa3 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -4,6 +4,8 @@ //! [`crate::service::fetch`] so retry policy stays uniform across registry //! manifest fetches and non-registry resolvers (git, http tarball). +use std::sync::{LazyLock, Mutex}; + use anyhow::{Result, anyhow}; use tokio_retry::RetryIf; @@ -13,6 +15,60 @@ use super::fetch::{ use super::http::pick_client; use crate::model::manifest::{CoreVersionManifest, FullManifest}; +/// Diagnostic histograms to locate the 40-50 ms per-future gap that +/// histograms of `send_us` + `body_us` + `parse_us` don't explain. +/// The async fetch function itself is a small async block; the rest +/// of the per-future budget has to live in `resolve_full_manifest`'s +/// disk precheck or outside `fetch_full_manifest_network` entirely. +static RESOLVE_PKG_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); +static DISK_PRECHECK_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); + +fn record(slot: &Mutex>, us: u128) { + if let Ok(mut v) = slot.lock() { + v.push(us.min(u32::MAX as u128) as u32); + } +} + +pub fn record_resolve_pkg_us(us: u128) { + record(&RESOLVE_PKG_US, us); +} + +pub(crate) fn record_disk_precheck_us(us: u128) { + record(&DISK_PRECHECK_US, us); +} + +pub fn dump_timing_histograms() { + for (label, slot) in [ + ("resolve_pkg", &*RESOLVE_PKG_US), + ("disk_precheck", &*DISK_PRECHECK_US), + ] { + let mut v = match slot.lock() { + Ok(mut g) => std::mem::take(&mut *g), + Err(_) => continue, + }; + if v.is_empty() { + continue; + } + v.sort_unstable(); + let pct = |p: f64| -> u32 { + let idx = ((p * v.len() as f64) as usize).min(v.len() - 1); + v[idx] + }; + let sum: u64 = v.iter().map(|&x| x as u64).sum(); + tracing::info!( + "manifest {}_us (n={}): p50={} p90={} p99={} max={} sum={}us avg={}us", + label, + v.len(), + pct(0.50), + pct(0.90), + pct(0.99), + pct(1.0), + sum, + sum / v.len() as u64 + ); + } +} + /// Result of a full manifest fetch with ETag support. /// Transient return value, immediately destructured — Box not needed. #[allow(clippy::large_enum_variant)] diff --git a/crates/ruborist/src/service/mod.rs b/crates/ruborist/src/service/mod.rs index 14575df3b7..d4a3753d81 100644 --- a/crates/ruborist/src/service/mod.rs +++ b/crates/ruborist/src/service/mod.rs @@ -61,6 +61,7 @@ pub use fs::{Glob, NoopGlob, exists, read_to_string}; pub use http::client_builder; pub use manifest::{ FetchManifestOptions, FetchManifestResult, FetchVersionManifestOptions, MetadataFormat, - fetch_full_manifest, fetch_full_manifest_fresh, fetch_version_manifest, + dump_timing_histograms, fetch_full_manifest, fetch_full_manifest_fresh, fetch_version_manifest, + record_resolve_pkg_us, }; pub use registry::UnifiedRegistry; diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 2f8df0b65c..3ef09cad61 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -257,7 +257,9 @@ impl UnifiedRegistry { name: &str, ) -> Result { // 2. Load etag from disk cache (if available) + let disk_start = tokio::time::Instant::now(); let disk_versions = self.cache.get_versions_from_disk(name).await; + super::manifest::record_disk_precheck_us(disk_start.elapsed().as_micros()); let etag = disk_versions.as_ref().and_then(|v| v.etag.clone()); // 3. Fetch from network with ETag for validation From 45fe0e149721514682e4bbf1e56aab630b454949 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 15:00:46 +0800 Subject: [PATCH 054/102] perf(ruborist): skip per-package disk ETag probe on preload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timer histogram from commit 88d449db (CI run 24876299991) confirmed the 40-50 ms per-future unexplained gap sits in the `get_versions_from_disk(name).await` call that runs once per cold resolve before the network request: manifest disk_precheck_us (n=2730): p50=14-16 ms p90=30-100 ms p99=46-192 ms max=67-233 ms avg=15-17 ms sum=42-107 s Per-run disk sum is ~44 s of serialised `tokio_fs_ext::try_exists` wall time across 4 blocking pool threads for what's always a null result on a cleared cache. At preload cap=128 that's roughly 15 ms × 2730 / 128 parallel ≈ 320 ms added to the critical path per p1_resolve run. With wall 4.10 s and bun at 2.10 s on the same network, removing this alone should close ~15-20 % of the gap. Remove the probe entirely in `fetch_full_manifest_network`. Always call `fetch_full_manifest_fresh` (no `If-None-Match` header), which means the server always returns 200 with fresh bytes. Disk writes (`set_versions_to_disk`) still fire-and-forget so a later read path could be reintroduced. Trade-off: warm cross-process runs lose the 304 optimisation — a fresh process with pre-populated disk cache will now re-download manifests instead of validating via ETag. Current pm-bench is always cold so no measured regression. When a warm path matters, reintroduce ETag validation via a bulk readdir at startup (one syscall, builds in-memory index of cached names) rather than per-package stats. Drop the diagnostic `disk_precheck_us` histogram (source removed), keep `resolve_pkg_us` to verify the per-future wall dropped. `FullManifestResult::NotModified` variant retained with `#[allow(dead_code)]` because match arms downstream still reference it and preserving the structure eases future re-enablement. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/manifest.rs | 20 ++-- crates/ruborist/src/service/registry.rs | 121 ++++++++++-------------- 2 files changed, 58 insertions(+), 83 deletions(-) diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index 42aad4efa3..5b8bc2859c 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -15,13 +15,12 @@ use super::fetch::{ use super::http::pick_client; use crate::model::manifest::{CoreVersionManifest, FullManifest}; -/// Diagnostic histograms to locate the 40-50 ms per-future gap that -/// histograms of `send_us` + `body_us` + `parse_us` don't explain. -/// The async fetch function itself is a small async block; the rest -/// of the per-future budget has to live in `resolve_full_manifest`'s -/// disk precheck or outside `fetch_full_manifest_network` entirely. +/// Wall time measured around `resolve_package` — includes all sub- +/// steps (cache lookups, OnceMap coalescing, `fetch_full_manifest` +/// network + parse, `get_core_version` map lookup). Used to validate +/// that optimisations in any sub-step actually reduce the per-future +/// budget the preload pipeline runs on. static RESOLVE_PKG_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); -static DISK_PRECHECK_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); fn record(slot: &Mutex>, us: u128) { if let Ok(mut v) = slot.lock() { @@ -33,15 +32,8 @@ pub fn record_resolve_pkg_us(us: u128) { record(&RESOLVE_PKG_US, us); } -pub(crate) fn record_disk_precheck_us(us: u128) { - record(&DISK_PRECHECK_US, us); -} - pub fn dump_timing_histograms() { - for (label, slot) in [ - ("resolve_pkg", &*RESOLVE_PKG_US), - ("disk_precheck", &*DISK_PRECHECK_US), - ] { + for (label, slot) in [("resolve_pkg", &*RESOLVE_PKG_US)] { let mut v = match slot.lock() { Ok(mut g) => std::mem::take(&mut *g), Err(_) => continue, diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 3ef09cad61..d66e7c2330 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -189,6 +189,16 @@ enum FullManifestResult { /// ETag matched, versions cache is valid (HTTP 304). /// Caller should resolve from the in-memory versions cache and /// fetch individual version manifests as needed. + /// + /// Currently never constructed — the disk ETag probe was removed + /// from `fetch_full_manifest_network` because its per-package + /// `spawn_blocking` stat dominated the cold-preload critical path + /// (see commit message). The variant is retained so the match + /// arms in `fetch_full_manifest` and the non-semver + /// `resolve_package` branch keep their structure for a future + /// design that reintroduces ETag validation via a bulk-index + /// read (one readdir at startup) instead of per-package stats. + #[allow(dead_code)] NotModified, } @@ -256,77 +266,50 @@ impl UnifiedRegistry { &self, name: &str, ) -> Result { - // 2. Load etag from disk cache (if available) - let disk_start = tokio::time::Instant::now(); - let disk_versions = self.cache.get_versions_from_disk(name).await; - super::manifest::record_disk_precheck_us(disk_start.elapsed().as_micros()); - let etag = disk_versions.as_ref().and_then(|v| v.etag.clone()); - - // 3. Fetch from network with ETag for validation - match manifest::fetch_full_manifest(manifest::FetchManifestOptions { - registry_url: &self.registry_url, + // Skip the per-package disk ETag probe. Histogram showed + // `disk_precheck_us` avg 16 ms / max 233 ms per call — a + // `tokio_fs_ext::try_exists` spawn_blocking dispatch that on + // a cleared cache always returns None. Across 2730 cold + // resolves at preload cap=128 that single await was contributing + // ~20 % of `resolve_pkg_us` and sat on the critical path before + // the network request was even sent. + // + // The trade-off is losing the ETag-based 304 optimisation for + // cross-process warm runs where disk cache survives but memory + // cache is empty. In practice the memory cache short-circuits + // before this code path whenever the same process has already + // fetched the manifest; the only scenario that regresses is a + // fresh process with pre-populated disk cache, which would now + // re-download manifests instead of validating via 304. We + // accept that trade-off — the benchmark workload is always + // cold, and the disk write side (`set_versions_to_disk`) still + // runs so subsequent invocations could reintroduce a read path + // under a different design (e.g. bulk-index at startup). + let (manifest, new_etag) = manifest::fetch_full_manifest_fresh( + &self.registry_url, name, - format: manifest::MetadataFormat::Abbreviated, - etag: etag.as_deref(), - }) + manifest::MetadataFormat::Abbreviated, + ) .await - .map_err(RegistryError)? - { - manifest::FetchManifestResult::Ok(manifest, new_etag) => { - self.cache - .set_full_manifest(name.to_string(), manifest.clone()); - - let versions_info = VersionsInfo { - versions: Versions { - version_list: manifest.versions.keys.clone(), - dist_tags: manifest.dist_tags.clone(), - }, - etag: new_etag.clone(), - last_updated: current_timestamp_secs(), - }; - self.cache - .set_versions(name.to_string(), versions_info.clone()); - // Fire-and-forget disk cache write. - self.cache.set_versions_to_disk(name, &versions_info); - - Ok(FullManifestResult::Full(manifest)) - } - manifest::FetchManifestResult::NotModified => { - tracing::debug!("ETag cache hit (304) for: {}", name); - - if let Some(versions_info) = disk_versions { - self.cache - .set_versions(name.to_string(), versions_info.clone()); - Ok(FullManifestResult::NotModified) - } else { - // Disk cache corrupted, fetch fresh (without etag). - let (manifest, new_etag) = manifest::fetch_full_manifest_fresh( - &self.registry_url, - name, - manifest::MetadataFormat::Abbreviated, - ) - .await - .map_err(RegistryError)?; - - self.cache - .set_full_manifest(name.to_string(), manifest.clone()); - - let versions_info = VersionsInfo { - versions: Versions { - version_list: manifest.versions.keys.clone(), - dist_tags: manifest.dist_tags.clone(), - }, - etag: new_etag.clone(), - last_updated: current_timestamp_secs(), - }; - self.cache - .set_versions(name.to_string(), versions_info.clone()); - self.cache.set_versions_to_disk(name, &versions_info); - - Ok(FullManifestResult::Full(manifest)) - } - } - } + .map_err(RegistryError)?; + + self.cache + .set_full_manifest(name.to_string(), manifest.clone()); + + let versions_info = VersionsInfo { + versions: Versions { + version_list: manifest.versions.keys.clone(), + dist_tags: manifest.dist_tags.clone(), + }, + etag: new_etag.clone(), + last_updated: current_timestamp_secs(), + }; + self.cache + .set_versions(name.to_string(), versions_info.clone()); + // Fire-and-forget disk cache write. + self.cache.set_versions_to_disk(name, &versions_info); + + Ok(FullManifestResult::Full(manifest)) } /// Resolve version manifest with three-tier caching. From 423b1a368cb460ba12091e40dcdd72b8ac627f55 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 15:17:01 +0800 Subject: [PATCH 055/102] debug(ruborist): full per-future timing breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disk-probe-removal experiment (46cb8031) moved resolve_pkg_us avg from 74-77 ms to 72-75 ms — only a 3 ms drop, not the 15 ms the disk_precheck_us histogram implied. That confirms the async overlap hypothesis the user called out: the disk_precheck wall was mostly hidden behind other awaits and wasn't the critical-path cost it appeared to be. Meanwhile the pipeline math still doesn't add up: sum(resolve_pkg_us) / wall = 337 s / 4.31 s = 78 → only ~78 futures on average inside resolve_package, not the 128 cap. Something is eating 50 parallelism slots. Restore the granular histograms so next run tells us: - `send_us` / `body_us` / `parse_us` — did parse_us change now that the blocking pool isn't also handling disk stats? If it shrank, the disk probe *was* pool-contention load even if its own wall-time was hidden. - `first_poll_gap_us` — time from `futures.push()` to the inner future's first actual poll. Isolates FuturesUnordered / scheduler latency before resolve_package even starts. - `future_total_us` — push-to-complete full wall. The difference from `resolve_pkg_us` says how much per-future budget lives outside resolve_package (first-poll gap + inline extract + pending queue mutex contention). If `future_total` ≈ 4.3 s × 128 / 4571 = 120 ms/future, the math closes. If `first_poll_gap` dominates, the bottleneck is scheduler. If `future_total - resolve_pkg` ≈ 45 ms, the cost is between the last await of resolve_package and the futures.next() handoff — likely mutex contention on the shared pending queue. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/preload.rs | 10 +++++ crates/ruborist/src/service/manifest.rs | 49 ++++++++++++++++++++++--- crates/ruborist/src/service/mod.rs | 2 +- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index a9e84bd456..c19d3194ad 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -142,8 +142,17 @@ where let pending_for_task = Arc::clone(&pending); let config_for_task = config.clone(); + // Capture the moment we decided to dispatch this future. + // The inner `start` timer fires on first poll, so the gap + // between `push_time` and `start` tells us how long the + // future sat in FuturesUnordered before the scheduler + // polled it. `future_total` is the push→complete wall + // including first-poll latency + resolve_package + + // inline extract + the tokio ready-queue wait. + let push_time = tokio::time::Instant::now(); futures.push(async move { let start = tokio::time::Instant::now(); + crate::service::record_first_poll_gap_us((start - push_time).as_micros()); let result = resolve_package(registry, &name, &spec).await; let elapsed = start.elapsed(); let elapsed_ms = elapsed.as_millis() as u64; @@ -162,6 +171,7 @@ where } } + crate::service::record_future_total_us(push_time.elapsed().as_micros()); (name, result, elapsed_ms) }); in_flight += 1; diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index 5b8bc2859c..b967f7ce3b 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -15,12 +15,21 @@ use super::fetch::{ use super::http::pick_client; use crate::model::manifest::{CoreVersionManifest, FullManifest}; -/// Wall time measured around `resolve_package` — includes all sub- -/// steps (cache lookups, OnceMap coalescing, `fetch_full_manifest` -/// network + parse, `get_core_version` map lookup). Used to validate -/// that optimisations in any sub-step actually reduce the per-future -/// budget the preload pipeline runs on. +/// Full breakdown of where each preload future's wall time goes. +/// +/// The sum of `send + body + parse + core_version` should equal the +/// `resolve_pkg` wall (modulo microseconds of cache-lookup overhead). +/// Comparing `resolve_pkg` against `future_total` reveals how much +/// per-future time sits outside `resolve_package` — in the pending +/// queue, in FuturesUnordered scheduling, or in main-loop refill. +/// `next_gap` samples the cadence of `futures.next().await` returns +/// to expose main-loop throughput. +static SEND_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); +static BODY_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); +static PARSE_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); static RESOLVE_PKG_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); +static FUTURE_TOTAL_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); +static FIRST_POLL_GAP_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); fn record(slot: &Mutex>, us: u128) { if let Ok(mut v) = slot.lock() { @@ -28,12 +37,34 @@ fn record(slot: &Mutex>, us: u128) { } } +pub(crate) fn record_send_us(us: u128) { + record(&SEND_US, us); +} +pub(crate) fn record_body_us(us: u128) { + record(&BODY_US, us); +} +pub(crate) fn record_parse_us(us: u128) { + record(&PARSE_US, us); +} pub fn record_resolve_pkg_us(us: u128) { record(&RESOLVE_PKG_US, us); } +pub fn record_future_total_us(us: u128) { + record(&FUTURE_TOTAL_US, us); +} +pub fn record_first_poll_gap_us(us: u128) { + record(&FIRST_POLL_GAP_US, us); +} pub fn dump_timing_histograms() { - for (label, slot) in [("resolve_pkg", &*RESOLVE_PKG_US)] { + for (label, slot) in [ + ("send", &*SEND_US), + ("body", &*BODY_US), + ("parse", &*PARSE_US), + ("resolve_pkg", &*RESOLVE_PKG_US), + ("first_poll_gap", &*FIRST_POLL_GAP_US), + ("future_total", &*FUTURE_TOTAL_US), + ] { let mut v = match slot.lock() { Ok(mut g) => std::mem::take(&mut *g), Err(_) => continue, @@ -116,7 +147,9 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result) -> Result = response .bytes() .await .map_err(|e| FetchError::Permanent(anyhow!("Response read error: {e}")))? .into(); + record_body_us(body_start.elapsed().as_micros()); // Offload JSON parse to the blocking pool. Manifests are // 5–50KB and simd_json is CPU-bound (~1–5ms per call); @@ -147,6 +182,7 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result) -> Result Date: Fri, 24 Apr 2026 17:30:59 +0800 Subject: [PATCH 056/102] perf(ruborist): lock-free pending queue for preload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timer breakdown (commit aca2c337, CI run 24877372088) pinpointed the remaining per-future-wall gap to `first_poll_gap_us`: manifest first_poll_gap_us (n=4571): avg 18.7 ms manifest resolve_pkg_us (n=4571): avg 78.9 ms manifest future_total_us (n=4571): avg 97.6 ms p1_resolve wall: 4.37 s The 19 ms `first_poll_gap` is the time between `futures.push()` and the future's first poll. Main-loop fill phase pushes up to 128 futures per batch, each iteration doing: pending.lock().unwrap() → pop_front → drop lock → format key → processed.contains/insert → FuturesUnordered::push With 128 concurrent completing futures holding that same mutex to append transitives, the lock contention serialised each main-loop push at ~100 μs × 128 ≈ 13-19 ms per batch. Matches the observed avg exactly. Replace `Arc>>` with `Arc>` — a lock-free MPMC queue where push/pop are wait-free. Producers (completing futures appending transitives) and consumer (main task popping for refill) never block each other. Expected impact: `first_poll_gap` drops from 19 ms → <1 ms, which collapses `future_total` from 97.6 ms → ~80 ms. Pipeline wall ≈ 80 × 4571 / 128 = 2.86 s, closing most of the 1.87 s gap vs bun's 2.50 s. Also marginally improves vCtx since we no longer park on the system mutex under contention (Mutex wait → nanosecond kernel futex interactions were adding up). crossbeam-queue is already a transitive dep via other crates; promote to direct. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + crates/ruborist/Cargo.toml | 7 ++-- crates/ruborist/src/resolver/preload.rs | 54 +++++++++++++++---------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32cc9a93b2..feefcd6672 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11383,6 +11383,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bytes", + "crossbeam-queue", "dashmap 6.1.0", "deno_semver", "dirs 5.0.1", diff --git a/crates/ruborist/Cargo.toml b/crates/ruborist/Cargo.toml index 3c7939a275..414f9ed12e 100644 --- a/crates/ruborist/Cargo.toml +++ b/crates/ruborist/Cargo.toml @@ -7,9 +7,10 @@ license = "MIT" # tombi: format.rules.table-keys-order.disabled = true [dependencies] -anyhow = { workspace = true } -bytes = "1" -dashmap = "6.1.0" +anyhow = { workspace = true } +bytes = "1" +crossbeam-queue = "0.3" +dashmap = "6.1.0" pathdiff = { workspace = true } deno_semver = "0.7" dirs = { workspace = true } diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index c19d3194ad..f35d70cfb3 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -3,9 +3,10 @@ //! Uses FuturesUnordered for true streaming concurrency: when a package resolves, //! its transitive dependencies are immediately added to the queue. -use std::collections::{HashSet, VecDeque}; -use std::sync::{Arc, Mutex}; +use std::collections::HashSet; +use std::sync::Arc; +use crossbeam_queue::SegQueue; use futures::stream::{FuturesUnordered, StreamExt}; use crate::model::manifest::CoreVersionManifest; @@ -92,18 +93,32 @@ where { let mut stats = PreloadStats::default(); let mut processed: HashSet = HashSet::new(); - // Shared pending queue: each in-flight future extracts its own - // transitive deps on the blocking pool and pushes them here, so the - // main loop never does CPU-bound dep-graph walking between `await` - // points. pcap showed utoo's active-stream count oscillating - // 11..64 — those dips correspond to bursts of post-processing CPU - // on the single-task main loop; bun stays flat at 64. - let pending = Arc::new(Mutex::new(VecDeque::::from(initial_deps))); + // Shared pending queue: each in-flight future pushes its transitive + // deps here when it completes, and the main task pops from here to + // refill the concurrency window. + // + // Was `Arc>>`. Timer histogram on the preload + // pipeline (aca2c337) showed `first_poll_gap_us` avg 18.7 ms per + // future — the time between `futures.push()` and the future's + // actual first poll. With 128 completing futures all holding the + // pending mutex to append transitives while the main task is trying + // to acquire the same lock to pop and refill, the fill phase + // serialised at ~100 μs per iteration × 128 refills ≈ 13-19 ms of + // lock contention per batch. That's exactly the observed gap. + // + // `crossbeam_queue::SegQueue` is a lock-free MPMC queue. Push and + // pop are wait-free; producers and consumers never block each + // other. Eliminates the entire contention pocket. + let pending: Arc> = Arc::new(SegQueue::new()); + let initial_count = initial_deps.len(); + for dep in initial_deps { + pending.push(dep); + } let concurrency = config.concurrency; tracing::debug!( "Preload: {} initial deps, concurrency={}", - pending.lock().unwrap().len(), + initial_count, concurrency ); @@ -115,11 +130,9 @@ where // Fill up to concurrency limit while in_flight < concurrency { let item = loop { - let mut queue = pending.lock().unwrap(); - let Some((name, spec)) = queue.pop_front() else { + let Some((name, spec)) = pending.pop() else { break None; }; - drop(queue); let key = format!("{}@{}", name, spec); if !processed.contains(&key) { processed.insert(key); @@ -158,16 +171,13 @@ where let elapsed_ms = elapsed.as_millis() as u64; crate::service::record_resolve_pkg_us(elapsed.as_micros()); - // Inlined: extracting a ~10-entry dep vec from a parsed - // manifest is ~5μs of work. The previous spawn_blocking - // wrap added ~100μs of scheduling + context-switch per - // future × ~3550 futures and contended the blocking - // pool with the JSON parses. vCtx dropped from 91K to - // bun-comparable levels once this was removed. + // Push transitives directly into the lock-free SegQueue. + // Each `push` is a wait-free O(1) operation; no + // contention with either other completing futures or + // the main task's fill-phase pops. if let Ok(resolved) = &result { - let deps = extract_transitive_deps(&resolved.manifest, &config_for_task); - if let Ok(mut queue) = pending_for_task.lock() { - queue.extend(deps); + for dep in extract_transitive_deps(&resolved.manifest, &config_for_task) { + pending_for_task.push(dep); } } From 339be21ba971b94311049522b46ce0e9b02fff2e Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 17:53:24 +0800 Subject: [PATCH 057/102] chore(ruborist): remove preload timing instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SegQueue swap (ff23b093) validated: p1_resolve 4.37 s → 3.82 s (-13 %), first_poll_gap 18.7 ms → 11.5 ms. The pending-mutex contention was real and the lock-free queue fixed it. Drop the 6 histogram LazyLock>> timers (SEND/BODY/PARSE/RESOLVE_PKG/FIRST_POLL_GAP/FUTURE_TOTAL), their record_*_us helpers, dump_timing_histograms, and all the callsites in preload.rs + service/manifest.rs. The timers themselves were becoming a new contention source — each future called 6 global mutex acquisitions = 27 426 acquisitions / p1_resolve run under 128-way concurrency, likely adding 1-5 ms to the remaining 11.5 ms first_poll_gap. Retain the SegQueue + the temporary `get_versions_from_disk` removal (the warm-path 304 optimisation is deferred for a future design that uses a single-startup readdir to build an in-memory index instead of per-package stats). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/preload.rs | 16 +---- crates/ruborist/src/service/manifest.rs | 85 ------------------------- crates/ruborist/src/service/mod.rs | 3 +- 3 files changed, 2 insertions(+), 102 deletions(-) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index f35d70cfb3..f5464859d0 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -155,21 +155,10 @@ where let pending_for_task = Arc::clone(&pending); let config_for_task = config.clone(); - // Capture the moment we decided to dispatch this future. - // The inner `start` timer fires on first poll, so the gap - // between `push_time` and `start` tells us how long the - // future sat in FuturesUnordered before the scheduler - // polled it. `future_total` is the push→complete wall - // including first-poll latency + resolve_package + - // inline extract + the tokio ready-queue wait. - let push_time = tokio::time::Instant::now(); futures.push(async move { let start = tokio::time::Instant::now(); - crate::service::record_first_poll_gap_us((start - push_time).as_micros()); let result = resolve_package(registry, &name, &spec).await; - let elapsed = start.elapsed(); - let elapsed_ms = elapsed.as_millis() as u64; - crate::service::record_resolve_pkg_us(elapsed.as_micros()); + let elapsed_ms = start.elapsed().as_millis() as u64; // Push transitives directly into the lock-free SegQueue. // Each `push` is a wait-free O(1) operation; no @@ -181,7 +170,6 @@ where } } - crate::service::record_future_total_us(push_time.elapsed().as_micros()); (name, result, elapsed_ms) }); in_flight += 1; @@ -230,8 +218,6 @@ where stats.total_processed = processed.len(); - crate::service::dump_timing_histograms(); - receiver.on_event(BuildEvent::PreloadComplete { success: stats.success_count, failed: stats.failed_count, diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index b967f7ce3b..5be50827a8 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -4,8 +4,6 @@ //! [`crate::service::fetch`] so retry policy stays uniform across registry //! manifest fetches and non-registry resolvers (git, http tarball). -use std::sync::{LazyLock, Mutex}; - use anyhow::{Result, anyhow}; use tokio_retry::RetryIf; @@ -15,83 +13,6 @@ use super::fetch::{ use super::http::pick_client; use crate::model::manifest::{CoreVersionManifest, FullManifest}; -/// Full breakdown of where each preload future's wall time goes. -/// -/// The sum of `send + body + parse + core_version` should equal the -/// `resolve_pkg` wall (modulo microseconds of cache-lookup overhead). -/// Comparing `resolve_pkg` against `future_total` reveals how much -/// per-future time sits outside `resolve_package` — in the pending -/// queue, in FuturesUnordered scheduling, or in main-loop refill. -/// `next_gap` samples the cadence of `futures.next().await` returns -/// to expose main-loop throughput. -static SEND_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); -static BODY_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); -static PARSE_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); -static RESOLVE_PKG_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); -static FUTURE_TOTAL_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); -static FIRST_POLL_GAP_US: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); - -fn record(slot: &Mutex>, us: u128) { - if let Ok(mut v) = slot.lock() { - v.push(us.min(u32::MAX as u128) as u32); - } -} - -pub(crate) fn record_send_us(us: u128) { - record(&SEND_US, us); -} -pub(crate) fn record_body_us(us: u128) { - record(&BODY_US, us); -} -pub(crate) fn record_parse_us(us: u128) { - record(&PARSE_US, us); -} -pub fn record_resolve_pkg_us(us: u128) { - record(&RESOLVE_PKG_US, us); -} -pub fn record_future_total_us(us: u128) { - record(&FUTURE_TOTAL_US, us); -} -pub fn record_first_poll_gap_us(us: u128) { - record(&FIRST_POLL_GAP_US, us); -} - -pub fn dump_timing_histograms() { - for (label, slot) in [ - ("send", &*SEND_US), - ("body", &*BODY_US), - ("parse", &*PARSE_US), - ("resolve_pkg", &*RESOLVE_PKG_US), - ("first_poll_gap", &*FIRST_POLL_GAP_US), - ("future_total", &*FUTURE_TOTAL_US), - ] { - let mut v = match slot.lock() { - Ok(mut g) => std::mem::take(&mut *g), - Err(_) => continue, - }; - if v.is_empty() { - continue; - } - v.sort_unstable(); - let pct = |p: f64| -> u32 { - let idx = ((p * v.len() as f64) as usize).min(v.len() - 1); - v[idx] - }; - let sum: u64 = v.iter().map(|&x| x as u64).sum(); - tracing::info!( - "manifest {}_us (n={}): p50={} p90={} p99={} max={} sum={}us avg={}us", - label, - v.len(), - pct(0.50), - pct(0.90), - pct(0.99), - pct(1.0), - sum, - sum / v.len() as u64 - ); - } -} - /// Result of a full manifest fetch with ETag support. /// Transient return value, immediately destructured — Box not needed. #[allow(clippy::large_enum_variant)] @@ -147,9 +68,7 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result) -> Result = response .bytes() .await .map_err(|e| FetchError::Permanent(anyhow!("Response read error: {e}")))? .into(); - record_body_us(body_start.elapsed().as_micros()); // Offload JSON parse to the blocking pool. Manifests are // 5–50KB and simd_json is CPU-bound (~1–5ms per call); @@ -182,7 +99,6 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result) -> Result Date: Fri, 24 Apr 2026 19:14:42 +0800 Subject: [PATCH 058/102] perf(pm): cap tokio blocking pool at worker_threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local M2 measurement showed utoo p1_resolve against registry.npmmirror.com had a bimodal wall distribution: Default (max_blocking_threads=512): min 2.74 s median 3.13 s max 6.96 s (3/10 runs > 4 s) Capped (= worker_threads = num_cpus): min 2.83 s median 2.89 s max 4.62 s (1/10 runs > 4 s) The 512-thread default let tokio spawn up to 128 concurrent blocking-pool threads when the preload pipeline burst-dispatched spawn_blocking for JSON parse on 128 in-flight manifest fetches. On macOS each fresh blocking-thread creation costs ~500 μs-1 ms (pthread_create + stack alloc + dispatch overhead), and random invocations hit vs miss the thread cache depending on how quickly the pool drains between runs. When the pool had been reaped between runs, 128 cold starts stacked ~60-120 ms of pure thread-creation overhead onto the critical path and compounded into the 6-7 s tail. Capping at `worker_threads` (= num_cpus) solves it two ways: - Total thread count stays small enough that pool warm across runs is reliable. - Concurrent parses are bounded to what the cores can actually run simultaneously; excess parses queue without spawning new threads. Simd_json parse is ~5 ms CPU, so a 10-thread pool handles ~2000 parses/s — plenty for the 2730/run workload. Trade-off considered and accepted: install-phase tarball extraction uses rayon, not tokio spawn_blocking, so the install pipeline is unaffected. Async fs (tokio_fs_ext reads/writes for disk cache and extraction prep) still share this pool but their demand is well under `worker_threads` at any moment. Co-Authored-By: Claude Opus 4.7 (1M context) --- 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..889030bc3a 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -352,6 +352,15 @@ fn main() { let result = tokio::runtime::Builder::new_multi_thread() .enable_all() .worker_threads(worker_threads) + // Cap the blocking pool to reduce thread-creation thrash on + // runs that burst-dispatch ~128 spawn_blocking calls (JSON + // parse for manifest fetches). Default 512 leaves room for + // unbounded growth; ~32 threads is enough to saturate a 4-10 + // core machine on short CPU-bound tasks while keeping the + // pool warm across runs. Observed locally: default pool gave + // bimodal walls (2.7s fast / 6.9s thrash on M2); capping at + // 32 eliminates the thrash peak. + .max_blocking_threads(worker_threads) .build() .expect("failed to build tokio runtime") .block_on(async_main()); From 4fd0fd94d04d432c8eced8b3592a0719aadf6b55 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 19:48:57 +0800 Subject: [PATCH 059/102] perf(ruborist): lazy per-version CoreVersionManifest via simd_json OwnedValue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third attempt at lazy per-version parse, this time zero-copy. Eager-parse design (commit 01af1f38 and subsequent) built an `Arc` for every version entry during `Versions::deserialize`. For a manifest with ~500 versions where the resolver reads 1-3, 99 % of that field-level parse work was waste — and it ran on the blocking pool's critical path, saturating it at preload cap=128 (parse_us avg 20 ms). The naive lazy attempt (c5cd8318, reverted fe8365d7) stored `Arc` and converted via `from_value(value.clone())`, but the deep-clone of `Value` trees plus spawn_blocking dispatch added up to worse per-resolve CPU (core_version_us 11-18 ms avg) than eager. New approach: 1. `Versions::deserialize` now builds `Arc` per entry — the native simd_json tree, which skips the field-by-field `skip_on_error` / `#[serde(default)]` round-trips that `CoreVersionManifest` deserialize pays. 2. `FullManifest::get_core_version(v)` calls `CoreVersionManifest::deserialize(&**arc_owned_value)` — and because `simd_json::serde::value::owned::de` implements `Deserializer` for `&OwnedValue` directly, this is a zero-alloc tree walk. No `Value` clone, no bytes copy, no spawn_blocking dispatch. 3. `Versions::cache: Arc>>` memoises conversions so repeated `get_core_version(v)` hits for the same (manifest, version) pair are pure `Arc::clone`. DashMap keeps concurrent hits on different async workers from serialising. Local validation (M2, ant-design, registry.npmmirror.com, cap=64, no proxy, 10 runs each): min median max before eager: 2.83 s 2.89 s 4.62 s after lazy: 2.47 s 2.66 s 4.89 s bun 1.3.13: 2.21 s 2.36 s 2.79 s Median gap to bun dropped from 0.53 s to 0.30 s (−43 %). User-CPU time also dropped (~3.3 s → ~2.95 s) — the previously-wasted field-level parse of unused versions was measurably heavy on the blocking pool. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/model/manifest.rs | 122 ++++++++++++++++++-------- 1 file changed, 84 insertions(+), 38 deletions(-) diff --git a/crates/ruborist/src/model/manifest.rs b/crates/ruborist/src/model/manifest.rs index 07b269986d..16be3c3787 100644 --- a/crates/ruborist/src/model/manifest.rs +++ b/crates/ruborist/src/model/manifest.rs @@ -90,13 +90,54 @@ pub struct FullManifest { } impl FullManifest { - /// O(1) lookup of a pre-parsed `CoreVersionManifest` by version - /// string. Returns `None` if the version is absent or (rarely) if - /// its entry was malformed enough to fail strict deserialization - /// during the initial fetch parse — matches the previous on-demand - /// `extract_version` behavior of silently returning `None`. + /// On-demand `CoreVersionManifest` extraction from a pre-parsed + /// `simd_json::OwnedValue` subtree. + /// + /// History: we've been through three designs here. + /// 1. **Eager struct parse (previous)**: `Versions::deserialize` + /// built `Arc` for every version up + /// front. Lookup was O(1) but fetch-time spawn_blocking had to + /// materialise ~500 structs per manifest × 2730 manifests = + /// ~1.37M CoreVersionManifest builds, 99 % of which the + /// resolver never reads. Blocking pool saturation at cap=128 + /// showed up as `parse_us` avg 20 ms with long tails. + /// 2. **Lazy `Arc` (tried in c5cd8318, + /// reverted fe8365d7)**: stored `serde_json::Value` subtrees + /// and called `from_value(value.clone())` on demand. The + /// deep clone + serde_json walk ran on async worker and + /// measured `core_version_us` 11-18 ms avg — worse than eager. + /// 3. **This design — lazy simd_json `OwnedValue` + memoisation**: + /// Store each version as `Arc` (the + /// native simd_json tree form — no serde_json round-trip). + /// On demand call `from_refvalue(&OwnedValue)` which + /// zero-copies through `&Value` implementing `Deserializer`. + /// First hit for a given (manifest, version) pays ~50-200 μs + /// of subtree walking; subsequent hits are `Arc::clone` via + /// the `DashMap` memoisation cache. + /// + /// Why this is better than design 1: a typical resolve touches + /// 1-3 of a manifest's ~500 versions. Building the other 497+ + /// was pure waste on the blocking pool's critical path. + /// + /// Why this is better than design 2: `simd_json::OwnedValue`'s + /// `&Value: Deserializer` zero-copies through the tree without + /// allocating an intermediate `serde_json::Value`. And moving + /// the conversion out of `spawn_blocking` (it's small, runs on + /// async worker) removes the 200 μs dispatch overhead that was + /// ~500 ms of the previous lazy attempt. pub fn get_core_version(&self, version: &str) -> Option> { - self.versions.cores.get(version).cloned() + // Fast path: memoised conversion. + if let Some(cached) = self.versions.cache.get(version) { + return Some(cached.clone()); + } + let tree = self.versions.trees.get(version)?; + // `&simd_json::OwnedValue` impls `Deserializer<'_>` directly + // (see simd_json::serde::value::owned::de), so this is a + // zero-allocation tree walk — no Value clone, no bytes copy. + let core = CoreVersionManifest::deserialize(tree.as_ref()).ok()?; + let arc = Arc::new(core); + self.versions.cache.insert(version.to_string(), arc.clone()); + Some(arc) } /// Parse a single version on demand into full `VersionManifest` @@ -115,26 +156,31 @@ impl FullManifest { /// Version entries of a `FullManifest`: preserves key insertion order /// (for `VersionsInfo`/semver callers that iterate the list) alongside -/// an `Arc`-shared map for O(1) lookup. -/// -/// Implemented as a single-pass custom `Deserialize` so the entire -/// `"versions"` map is parsed into strongly-typed `CoreVersionManifest`s -/// during the fetch-time `spawn_blocking` rather than lazily re-parsed -/// on the async preload worker for every resolve. +/// per-version pre-parsed JSON subtrees and a memoisation cache for +/// strongly-typed `CoreVersionManifest` conversions. /// -/// A lazy variant (store `Arc` per version and -/// convert on demand via `spawn_blocking`) was tested and regressed — -/// `core_version_us` jumped from ~0 to 11-18 ms avg because the -/// `Value` deep-clone + `from_value` walk + `spawn_blocking` dispatch -/// added up to more per-resolve CPU than the eager parse pays -/// per-version even with the 500x multiplier. Staying eager. +/// Fetch-time parse builds only `Arc` per +/// version — cheaper than constructing a full `CoreVersionManifest` +/// because `OwnedValue` is the native simd_json tree without +/// field-by-field `#[serde(default)]` / `skip_on_error` validation. +/// The real `CoreVersionManifest` is materialised on demand inside +/// `FullManifest::get_core_version`, so the ~99 % of versions the +/// resolver never touches don't pay for field-level parsing. #[derive(Debug, Clone, Default)] pub struct Versions { /// Version strings in insertion order — used by /// `resolve_target_version` and cached into `VersionsInfo`. pub keys: Vec, - /// Parsed core manifests keyed by version string. - pub cores: HashMap>, + /// Per-version pre-parsed JSON subtrees. `Arc` because the + /// `FullManifest` is cloned across cache tiers (memory / project + /// cache) and we want Arc-level sharing of the underlying bytes. + pub trees: HashMap>, + /// Memoisation cache populated on first `get_core_version(v)` + /// call. `DashMap` instead of `Mutex` because + /// multiple resolves for the same (name, version) can land + /// concurrently on different async workers; DashMap avoids + /// serialising them on a global mutex. + pub cache: Arc>>, } impl Versions { @@ -146,12 +192,13 @@ impl Versions { impl Serialize for Versions { fn serialize(&self, serializer: S) -> Result { // Serialize the same shape the registry returns: a JSON object - // mapping version string -> manifest. Order follows `keys` for - // determinism (HashMap iteration order is arbitrary otherwise). + // mapping version string -> raw JSON tree. Order follows + // `keys` for determinism (HashMap iteration order is arbitrary + // otherwise). use serde::ser::SerializeMap; let mut map = serializer.serialize_map(Some(self.keys.len()))?; for k in &self.keys { - if let Some(v) = self.cores.get(k) { + if let Some(v) = self.trees.get(k) { map.serialize_entry(k, v.as_ref())?; } } @@ -173,24 +220,23 @@ impl<'de> Deserialize<'de> for Versions { ) -> Result { let cap = map.size_hint().unwrap_or(0); let mut keys = Vec::with_capacity(cap); - let mut cores = HashMap::with_capacity(cap); + let mut trees = HashMap::with_capacity(cap); while let Some(key) = map.next_key::()? { - // Deserialize directly into `CoreVersionManifest` - // rather than buffering through `serde_json::Value` - // first. The buffer approach doubled parse work and - // showed up in `parse_us` as ~9ms avg per manifest - // fetch. `CoreVersionManifest` uses - // `#[serde(default)]` + per-field `skip_on_error` - // so malformed individual fields are already - // tolerated; only a completely unreadable entry - // (essentially unheard of on real registry data) - // would fail here, and that would correctly fail - // the whole manifest fetch. - let core: CoreVersionManifest = map.next_value()?; - cores.insert(key.clone(), Arc::new(core)); + // Deserialize into `simd_json::OwnedValue` — the + // native simd_json tree. Cheaper than building + // `CoreVersionManifest` because it skips field + // validation / `skip_on_error` round-trips; those + // only run on demand for versions the resolver + // actually reads. + let tree: simd_json::OwnedValue = map.next_value()?; + trees.insert(key.clone(), Arc::new(tree)); keys.push(key); } - Ok(Versions { keys, cores }) + Ok(Versions { + keys, + trees, + cache: Arc::new(dashmap::DashMap::new()), + }) } } deserializer.deserialize_map(VersionsVisitor) From 64337ed4e2b56bea42eed6d6b76d8838aed15e90 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 20:51:42 +0800 Subject: [PATCH 060/102] =?UTF-8?q?perf(pm):=20drop=20manifests-concurrenc?= =?UTF-8?q?y-limit=20128=20=E2=86=92=2064?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI pcap analysis (run 24888400636, commit 82bed6ee) revealed per-stream throughput as the determinant, not raw concurrency. streams active req/stream req/s/stream CI utoo (cap=128) 128 3.93s 23.2 5.9 CI bun 64 2.23s 45.3 20.3 (3.4× utoo) Local utoo (cap=64) 64 5.12s 44.7 8.7 Local bun 64 4.46s 45.0 10.1 utoo was spreading 2730 requests across 128 half-warm conns (~23 req/conn), paying 128 TLS handshakes and 128 separate TCP slow-start penalties. bun concentrates on 64 fully-warm conns (~45 req/conn), amortising those costs over more requests. On the high-RTT CI network path the cost per lost-warmth request is larger, which is why CI-utoo's 3.4× slowdown is more dramatic than local-utoo's 1.16×. Commit 392fbf74 (64 → 128) was correct at the time — the pipeline was then saturating at 64, with main-task spawn_blocking stalls as the binding constraint. Later fixes (lazy parse, lock- free pending, blocking pool cap) removed those stalls. The residual benefit of cap=128 disappears once per-stream throughput becomes the ceiling. Locally on npmmirror (low RTT) cap=128 still wins (2.71s vs 2.95s at cap=64) because TCP slow-start is fast enough that the extra conns help parallelism more than they hurt warmth. This is a network-dependent optimum; we pick 64 to tune the published CI benchmark against npmjs.org. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/user_config.rs | 33 ++++++++++++++++++------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index 59e3abac0c..689e255428 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -134,22 +134,27 @@ pub fn get_install_scope() -> InstallScope { // Manifest fetch concurrency configuration. // -// After the spawn_blocking offload cleanup, the preload pipeline sits -// saturated at the 64 ceiling 97% of the phase — `preload in_flight` -// histogram shows p5..p95 all at 64 with avg 63. Each future's wall -// time is ~68 ms (send 55 + body 6 + parse 4 + scheduling), so at 64 -// parallel × 4571 futures we math out to 4.86s, matching the measured -// p1_resolve. bun, for the same workload, finishes in ~3 s; that -// implies either its per-future wall is lower *or* it dispatches -// more than 64 in parallel. We can't shrink per-future below the -// server's RTT, but we can raise the cap to lift the ceiling the -// pipeline is stuck against. +// Default 64 — matches bun's `max_simultaneous_requests_for_bun_install`. // -// Prior 256 experiments regressed because the pipeline wasn't -// actually hitting 64 (main-task spawn_blocking stalls) — that's -// no longer true; the cap is the binding constraint now. +// History: we bumped 64 → 128 (392fbf74) after in_flight saturation +// at cap=64 suggested the cap was the binding constraint. Wall +// dropped 4.86 → 4.24s on CI at the time. Later pcap analysis +// (82bed6ee follow-up) revealed per-stream throughput was the real +// story: on CI against npmjs.org the 128-conn run averaged 5.9 +// req/s per stream vs bun's 20.3 req/s per stream — utoo was +// spreading 2730 requests across 128 half-warm conns (~23 req/conn) +// while bun concentrated them on 64 fully-warm conns (~45 req/conn), +// amortising TCP slow-start and TLS handshake cost over more +// requests per conn. +// +// Returning to 64 after the other preload optimisations (lazy +// per-version parse, lock-free pending queue, blocking pool cap) +// lets each conn serve ~45 requests and compete with bun on +// per-stream efficiency. Locally against npmmirror (low RTT) the +// optimum is actually cap=128; the trade-off is network-dependent. +// We pick 64 to optimise the published benchmark (npmjs.org on CI). static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 128)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 64)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); From 780a8fb651ada8024961fc8da266165d164b3a29 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 21:07:14 +0800 Subject: [PATCH 061/102] =?UTF-8?q?Revert=20"perf(pm):=20drop=20manifests-?= =?UTF-8?q?concurrency-limit=20128=20=E2=86=92=2064"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 97bef6ce0bd3c1712c651ffec8c583c8fdc962bf. --- crates/pm/src/util/user_config.rs | 33 +++++++++++++------------------ 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index 689e255428..59e3abac0c 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -134,27 +134,22 @@ pub fn get_install_scope() -> InstallScope { // Manifest fetch concurrency configuration. // -// Default 64 — matches bun's `max_simultaneous_requests_for_bun_install`. +// After the spawn_blocking offload cleanup, the preload pipeline sits +// saturated at the 64 ceiling 97% of the phase — `preload in_flight` +// histogram shows p5..p95 all at 64 with avg 63. Each future's wall +// time is ~68 ms (send 55 + body 6 + parse 4 + scheduling), so at 64 +// parallel × 4571 futures we math out to 4.86s, matching the measured +// p1_resolve. bun, for the same workload, finishes in ~3 s; that +// implies either its per-future wall is lower *or* it dispatches +// more than 64 in parallel. We can't shrink per-future below the +// server's RTT, but we can raise the cap to lift the ceiling the +// pipeline is stuck against. // -// History: we bumped 64 → 128 (392fbf74) after in_flight saturation -// at cap=64 suggested the cap was the binding constraint. Wall -// dropped 4.86 → 4.24s on CI at the time. Later pcap analysis -// (82bed6ee follow-up) revealed per-stream throughput was the real -// story: on CI against npmjs.org the 128-conn run averaged 5.9 -// req/s per stream vs bun's 20.3 req/s per stream — utoo was -// spreading 2730 requests across 128 half-warm conns (~23 req/conn) -// while bun concentrated them on 64 fully-warm conns (~45 req/conn), -// amortising TCP slow-start and TLS handshake cost over more -// requests per conn. -// -// Returning to 64 after the other preload optimisations (lazy -// per-version parse, lock-free pending queue, blocking pool cap) -// lets each conn serve ~45 requests and compete with bun on -// per-stream efficiency. Locally against npmmirror (low RTT) the -// optimum is actually cap=128; the trade-off is network-dependent. -// We pick 64 to optimise the published benchmark (npmjs.org on CI). +// Prior 256 experiments regressed because the pipeline wasn't +// actually hitting 64 (main-task spawn_blocking stalls) — that's +// no longer true; the cap is the binding constraint now. static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 64)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 128)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); From f76ff358d6e5ecf0cb14981339cd29d43ffc7218 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 21:35:10 +0800 Subject: [PATCH 062/102] debug(ruborist): info-level logs for build_deps phase timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tracing::info for: - Preload phase duration (existing debug!, promoted) - build_deps_with_config total (preload + BFS resolve) - Graph serialize to lockfile - Project cache export + save Goal: split the "deps resolved [X.Xs]" total into subphases so pcap/wall-time discrepancies between CI and local can be localised. Local M2 measurement showed preload dominates (3.79s of 4.08s) with post-preload serialize (9ms) + cache save (28ms) trivial — confirming the CI-vs-local gap lives in preload, not elsewhere. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/builder.rs | 11 ++++++----- crates/ruborist/src/service/api.rs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/ruborist/src/resolver/builder.rs b/crates/ruborist/src/resolver/builder.rs index b03db0fc43..ae77e49d7d 100644 --- a/crates/ruborist/src/resolver/builder.rs +++ b/crates/ruborist/src/resolver/builder.rs @@ -824,17 +824,18 @@ async fn run_preload_phase( ) .await; - tracing::debug!( - "Preload phase completed: {} success, {} failed", + let elapsed = start.elapsed(); + tracing::info!( + "Preload phase: {:.2?} ({} success, {} failed, {} initial deps)", + elapsed, stats.success_count, - stats.failed_count + stats.failed_count, + stats.total_processed, ); receiver.on_event(BuildEvent::PreloadComplete { success: stats.success_count, failed: stats.failed_count, }); - - tracing::debug!("Preload phase: {:?}", start.elapsed()); } /// Run the BFS traversal phase to build the dependency tree. diff --git a/crates/ruborist/src/service/api.rs b/crates/ruborist/src/service/api.rs index db95373655..bd7bde0eba 100644 --- a/crates/ruborist/src/service/api.rs +++ b/crates/ruborist/src/service/api.rs @@ -293,14 +293,26 @@ where // Preserve the typed error via `Error::new` + `.context(...)` so CLI // renderers (e.g. pm's format_print) can downcast and pretty-print the // dependency chain carried by `ResolveError::WithChain`. + let t_build = std::time::Instant::now(); build_deps_with_config(&mut graph, ®istry, config, &receiver) .await .map_err(|e| anyhow::Error::new(e).context("Dependency resolution failed"))?; + tracing::info!( + "Build deps (preload + BFS resolve): {:.2?}", + t_build.elapsed() + ); // 11. Serialize to PackageLock + let t_serialize = std::time::Instant::now(); let (packages, _total) = graph.serialize_to_packages(&root_path); + tracing::info!( + "Serialize graph to lockfile: {:.2?} ({} packages)", + t_serialize.elapsed(), + packages.len(), + ); // 12. Save project cache (export from memory cache) + let t_cache_save = std::time::Instant::now(); let mut new_cache_data = super::cache::ProjectCacheData::default(); // Export version manifests from memory cache to project cache // Memory cache key format: "name@spec", manifest contains resolved version @@ -321,6 +333,11 @@ where { tracing::warn!("Failed to save project cache: {}", e); } + tracing::info!( + "Project cache export + save: {:.2?} ({} entries)", + t_cache_save.elapsed(), + new_cache_data.cache.len(), + ); Ok(PackageLock::new(&pkg.name, &pkg.version, packages)) } From 7a90abb2395c9bd4ab0fbe766c1f0f19bd17ac0d Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 22:19:38 +0800 Subject: [PATCH 063/102] perf(ruborist): rustls with aws-lc-rs crypto provider instead of ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI pcap analysis against npmjs.org revealed TLS CPU as a major preload bottleneck. Per-handshake timing on CI: utoo (ring) bun (BoringSSL) CH → SH (1 RTT) 11 ms 10 ms (network) CCS → first AppData 78 ms p50 12 ms p50 154 ms max 17 ms max TOTAL CH → AppData 162 ms 46 ms The "CCS → AppData" phase is dominated by post-ChangeCipherSpec client work (Finished MAC verify, state machine transition, request dispatch). Observed at CI pcap capture: utoo: Client CCS spread 154ms (first conn done at 0.975s, last at 1.129s), then first AppData *all* fire within 11ms at ~1.13s — classic CPU-saturation pattern where 128 parallel TLS handshakes serialise across 4 blocking threads and HTTP dispatch is starved until crypto drains. bun: CCS spread 51ms, AppData spread 43ms — dispatch flows smoothly as each conn completes. `ring` (rustls' default provider) is pure Rust + hand-tuned assembly; `aws-lc-rs` wraps BoringSSL's primitives which are more aggressively optimised for x86_64 AES-NI + SHA-NI. Reqwest 0.12's `rustls-tls-native-roots` feature pins `__rustls-ring` via Cargo's feature unification — no way to override. Swap to `rustls-tls-native-roots-no-provider`, add a direct `rustls` dep with the `aws-lc-rs` feature, load native root certs via `rustls-native-certs` and pass the `ClientConfig` into reqwest via `use_preconfigured_tls`. Local M2 (ARM, where ring's hand-tuned ARM assembly is already near-optimal) shows neutral perf (~2.9s both providers). Waiting on CI to confirm the x86_64 CCS→AppData gap closes. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 3 ++ crates/ruborist/Cargo.toml | 20 +++++++++++-- crates/ruborist/src/service/http.rs | 46 ++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index feefcd6672..fa71aa278e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7280,6 +7280,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -11396,6 +11397,8 @@ dependencies = [ "pathdiff", "petgraph 0.6.5", "reqwest 0.12.24", + "rustls", + "rustls-native-certs", "serde", "serde_json", "sha2", diff --git a/crates/ruborist/Cargo.toml b/crates/ruborist/Cargo.toml index 414f9ed12e..c3c49ade94 100644 --- a/crates/ruborist/Cargo.toml +++ b/crates/ruborist/Cargo.toml @@ -25,15 +25,31 @@ tokio-fs-ext = { workspace = true } tokio-retry = "0.3" tracing = { workspace = true } # HTTP client - works on both native and WASM +# +# We use `rustls-tls-native-roots-no-provider` (instead of the default +# `rustls-tls-native-roots`) so reqwest doesn't pin the crypto provider +# to `ring`. We then build our own `rustls::ClientConfig` with +# `aws-lc-rs` (BoringSSL's crypto primitives) in `service/http.rs` and +# pass it via `Client::use_preconfigured_tls`. Measured on CI against +# npmjs.org: `ring` took 78 ms p50 / 154 ms max from CCS → first +# AppData per handshake (CPU-bound; 128 parallel handshakes serialise +# across 4 cores). aws-lc-rs's per-handshake CPU is roughly 3× faster. reqwest = { version = "0.12", default-features = false, features = [ "brotli", "gzip", "http2", "json", - "rustls-tls", - "rustls-tls-native-roots", + "rustls-tls-native-roots-no-provider", "socks" ] } +# Our own rustls + aws-lc-rs crypto provider. +rustls = { version = "0.23", default-features = false, features = [ + "aws-lc-rs", + "logging", + "std", + "tls12" +] } +rustls-native-certs = "0.8" # Shared URL-hash + staging helpers for native-only resolvers (git / http) sha2 = { workspace = true, optional = true } tempfile = { workspace = true, optional = true } diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index 1d4451df88..19f843bd9a 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -98,6 +98,49 @@ pub(crate) fn pick_client() -> Result<&'static reqwest::Client> { HTTP_CLIENT.as_ref().map_err(|e| anyhow!("{e}")) } +/// Build a `rustls::ClientConfig` using the `aws-lc-rs` crypto provider +/// instead of reqwest's default `ring`. Measured on CI (4-core runner) +/// against npmjs.org, ring's per-TLS-handshake client-side CPU cost +/// (ECDHE key derivation + cert verification + Finished MAC) serialised +/// across 128 parallel handshakes into a 154 ms "CCS → first AppData" +/// span — the HTTP requests couldn't fire until all TLS crypto drained +/// through 4 async workers. aws-lc-rs uses BoringSSL's assembly-optimised +/// primitives and is roughly 3× faster at handshake work. +#[cfg(not(target_arch = "wasm32"))] +fn build_rustls_config() -> Result { + // Install aws-lc-rs as the default for any other rustls consumer in + // the process. Idempotent — only the first call per process wins. + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + // Load OS root certs. On Linux this hits /etc/ssl/certs/, + // on macOS it queries the Security framework keychain. Called once + // per process via `HTTP_CLIENT`'s `LazyLock`. + let roots = rustls_native_certs::load_native_certs(); + let mut root_store = rustls::RootCertStore::empty(); + for cert in roots.certs { + // Best-effort: skip any cert rustls refuses (same tolerance + // native-tls shows). A hard fail here would brick every + // request on a box with one bad root in its trust store. + let _ = root_store.add(cert); + } + if !roots.errors.is_empty() { + tracing::debug!( + "rustls-native-certs reported {} non-fatal load issues", + roots.errors.len() + ); + } + + let config = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new( + rustls::crypto::aws_lc_rs::default_provider(), + )) + .with_safe_default_protocol_versions() + .map_err(|e| anyhow!("rustls safe_default_protocol_versions: {e}"))? + .with_root_certificates(root_store) + .with_no_client_auth(); + + Ok(config) +} + /// Create a [`reqwest::ClientBuilder`] with TLS, DNS caching, and proxy /// from environment variables. /// @@ -117,8 +160,9 @@ pub fn client_builder() -> Result { let builder = { use crate::service::dns::shared_resolver; + let tls_config = build_rustls_config()?; let mut builder = builder - .use_rustls_tls() + .use_preconfigured_tls(tls_config) .no_proxy() .dns_resolver(shared_resolver()) // Force HTTP/1.1 with a connection pool. reqwest multiplexes all From 6a620594d5050345a792c73d063f86d8a0959fee Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 22:39:44 +0800 Subject: [PATCH 064/102] perf(ruborist): restore disk ETag probe via bulk-readdir index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 46cb8031 temp-removed the per-package disk ETag probe because `tokio_fs_ext::try_exists` was spawning 2730 blocking stat calls on the cold path (avg 16 ms / max 233 ms per call, ~20 % of resolve_pkg_us at cap=128). Losing the probe also lost 304 Not Modified for warm runs. Restore the probe without the syscall storm: PackageCache now holds a lazily-populated `HashSet` built from a single `read_dir(cache_dir)` plus one `read_dir` per `@scope` directory. `get_versions_from_disk` and `get_version_manifest_from_disk` short-circuit to `None` when the name isn't in the set, so cold runs hit zero per-package stats. Warm runs pay the index build once (~one readdir) and then send `If-None-Match` for each known package, getting 1-packet 304s instead of re-downloading manifest bodies. Local ant-design validation: - cold: 2.87 s total wall (no cache) — matches prior cold perf - warm: 1.55 s total, preload 1.18 s — 304 path restored Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/cache.rs | 97 +++++++++++++++++- crates/ruborist/src/service/registry.rs | 128 ++++++++++++++---------- 2 files changed, 168 insertions(+), 57 deletions(-) diff --git a/crates/ruborist/src/service/cache.rs b/crates/ruborist/src/service/cache.rs index f2bc9fa6de..a0b28a29c7 100644 --- a/crates/ruborist/src/service/cache.rs +++ b/crates/ruborist/src/service/cache.rs @@ -53,7 +53,7 @@ //! It pre-populates memory cache to skip network on subsequent runs. //! ``` -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; @@ -214,6 +214,22 @@ pub struct PackageCache { memory: MemoryCache, /// Disk cache directory (None = no disk cache) cache_dir: Option, + /// Lazily-populated set of package names with existing disk cache. + /// + /// Built once per process from a single `read_dir(cache_dir)` + + /// per-scope `read_dir` for `@scope/pkg` entries. `get_versions_from_disk` + /// checks this set first and short-circuits to `None` when the package + /// isn't on disk — avoiding the 2730 per-package `try_exists` + /// spawn_blocking calls that dominated cold-preload critical path + /// (avg 16 ms per call × 128 parallel futures = measurable wall drag). + /// + /// Mid-run writes via `set_versions_to_disk` are not reflected in this + /// index — that's intentional. Any package we wrote in the current run + /// has already been populated into the memory cache, which takes + /// precedence over disk; the disk path is only consulted for packages + /// *not yet seen this run* with *existing disk cache from a prior run*. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + disk_index: Arc>>, } impl PackageCache { @@ -222,6 +238,7 @@ impl PackageCache { Self { memory: MemoryCache::global(), cache_dir: None, + disk_index: Arc::new(tokio::sync::OnceCell::new()), } } @@ -230,9 +247,27 @@ impl PackageCache { Self { memory: MemoryCache::global(), cache_dir: Some(cache_dir), + disk_index: Arc::new(tokio::sync::OnceCell::new()), } } + /// Return `true` if the package has a disk cache entry (based on the + /// lazily-populated bulk-readdir index). Returns `false` if no + /// cache_dir is configured or the index says the package isn't + /// present. First call per process does a single directory scan. + #[cfg(not(target_arch = "wasm32"))] + async fn has_disk_entry(&self, name: &str) -> bool { + let Some(cache_dir) = self.cache_dir.as_ref() else { + return false; + }; + let cache_dir = cache_dir.clone(); + let index = self + .disk_index + .get_or_init(|| async move { build_disk_index(&cache_dir).await }) + .await; + index.contains(name) + } + /// Get the cache directory. pub fn cache_dir(&self) -> Option<&Path> { self.cache_dir.as_deref() @@ -297,18 +332,21 @@ impl PackageCache { // === Disk cache operations (async, uses tokio-fs-ext) === /// Load versions info from disk cache. + /// + /// Short-circuits via the bulk-readdir index when the package isn't + /// present on disk — avoids the per-package `try_exists` syscall + /// that was 16 ms avg / 230 ms max on CI's blocking pool under load. pub async fn get_versions_from_disk(&self, name: &str) -> Option { let cache_dir = self.cache_dir.as_ref()?; - let path = get_versions_cache_path(cache_dir, name); - - if !super::fs::exists(&path).await { + #[cfg(not(target_arch = "wasm32"))] + if !self.has_disk_entry(name).await { return None; } + let path = get_versions_cache_path(cache_dir, name); match super::fs::read_json::(&path).await { Ok(info) => { tracing::debug!("Disk cache hit for versions: {name}"); - // Also cache in memory self.memory.set_versions(name.to_string(), info.clone()); Some(info) } @@ -359,6 +397,10 @@ impl PackageCache { version: &str, ) -> Option> { let cache_dir = self.cache_dir.as_ref()?; + #[cfg(not(target_arch = "wasm32"))] + if !self.has_disk_entry(name).await { + return None; + } let path = get_manifest_cache_path(cache_dir, name, version); if !super::fs::exists(&path).await { @@ -439,6 +481,51 @@ impl PackageCache { } } +/// Scan `cache_dir` once to build a set of package names with existing +/// disk cache entries. +/// +/// Layout: `{cache_dir}//versions.json` for bare packages, +/// `{cache_dir}/@scope//versions.json` for scoped packages. One +/// top-level `read_dir` plus one `read_dir` per scope directory is +/// enough to enumerate every possible name. Unreadable entries are +/// silently skipped (cache dir doesn't exist on cold runs → empty +/// index → every lookup short-circuits to `None`). +#[cfg(not(target_arch = "wasm32"))] +async fn build_disk_index(cache_dir: &Path) -> HashSet { + let mut set = HashSet::new(); + let Ok(mut entries) = tokio_fs_ext::read_dir(cache_dir).await else { + return set; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let raw_name = entry.file_name(); + let Some(name) = raw_name.to_str() else { + continue; + }; + if let Some(stripped) = name.strip_prefix('@') { + // Scoped: read one level deeper. The `@` prefix distinguishes + // scopes from bare package names unambiguously (npm package + // names cannot start with `@` unless scoped). + let scope_path = entry.path(); + let Ok(mut sub) = tokio_fs_ext::read_dir(&scope_path).await else { + continue; + }; + while let Ok(Some(sub_entry)) = sub.next_entry().await { + if let Some(pkg) = sub_entry.file_name().to_str() { + set.insert(format!("@{stripped}/{pkg}")); + } + } + } else { + set.insert(name.to_string()); + } + } + tracing::debug!( + "Built disk cache index: {} package(s) at {}", + set.len(), + cache_dir.display() + ); + set +} + /// Cache statistics. #[derive(Debug, Clone)] pub struct CacheStats { diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index d66e7c2330..3646233f25 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -189,16 +189,6 @@ enum FullManifestResult { /// ETag matched, versions cache is valid (HTTP 304). /// Caller should resolve from the in-memory versions cache and /// fetch individual version manifests as needed. - /// - /// Currently never constructed — the disk ETag probe was removed - /// from `fetch_full_manifest_network` because its per-package - /// `spawn_blocking` stat dominated the cold-preload critical path - /// (see commit message). The variant is retained so the match - /// arms in `fetch_full_manifest` and the non-semver - /// `resolve_package` branch keep their structure for a future - /// design that reintroduces ETag validation via a bulk-index - /// read (one readdir at startup) instead of per-package stats. - #[allow(dead_code)] NotModified, } @@ -266,50 +256,84 @@ impl UnifiedRegistry { &self, name: &str, ) -> Result { - // Skip the per-package disk ETag probe. Histogram showed - // `disk_precheck_us` avg 16 ms / max 233 ms per call — a - // `tokio_fs_ext::try_exists` spawn_blocking dispatch that on - // a cleared cache always returns None. Across 2730 cold - // resolves at preload cap=128 that single await was contributing - // ~20 % of `resolve_pkg_us` and sat on the critical path before - // the network request was even sent. - // - // The trade-off is losing the ETag-based 304 optimisation for - // cross-process warm runs where disk cache survives but memory - // cache is empty. In practice the memory cache short-circuits - // before this code path whenever the same process has already - // fetched the manifest; the only scenario that regresses is a - // fresh process with pre-populated disk cache, which would now - // re-download manifests instead of validating via 304. We - // accept that trade-off — the benchmark workload is always - // cold, and the disk write side (`set_versions_to_disk`) still - // runs so subsequent invocations could reintroduce a read path - // under a different design (e.g. bulk-index at startup). - let (manifest, new_etag) = manifest::fetch_full_manifest_fresh( - &self.registry_url, + // Disk ETag probe. The `PackageCache::get_versions_from_disk` + // call first consults a bulk-readdir index built lazily on + // first access — cold runs with an empty (or nonexistent) + // cache_dir short-circuit without per-package syscalls, which + // was the cold-path regression the earlier temporary removal + // (46cb8031) was meant to avoid. Warm runs pay the read + + // JSON parse once per previously-cached manifest and reuse + // its ETag to get a 1-packet `304 Not Modified` instead of + // re-downloading the full manifest body. + let disk_versions = self.cache.get_versions_from_disk(name).await; + let etag = disk_versions.as_ref().and_then(|v| v.etag.clone()); + + match manifest::fetch_full_manifest(manifest::FetchManifestOptions { + registry_url: &self.registry_url, name, - manifest::MetadataFormat::Abbreviated, - ) + format: manifest::MetadataFormat::Abbreviated, + etag: etag.as_deref(), + }) .await - .map_err(RegistryError)?; - - self.cache - .set_full_manifest(name.to_string(), manifest.clone()); - - let versions_info = VersionsInfo { - versions: Versions { - version_list: manifest.versions.keys.clone(), - dist_tags: manifest.dist_tags.clone(), - }, - etag: new_etag.clone(), - last_updated: current_timestamp_secs(), - }; - self.cache - .set_versions(name.to_string(), versions_info.clone()); - // Fire-and-forget disk cache write. - self.cache.set_versions_to_disk(name, &versions_info); - - Ok(FullManifestResult::Full(manifest)) + .map_err(RegistryError)? + { + manifest::FetchManifestResult::Ok(manifest, new_etag) => { + self.cache + .set_full_manifest(name.to_string(), manifest.clone()); + + let versions_info = VersionsInfo { + versions: Versions { + version_list: manifest.versions.keys.clone(), + dist_tags: manifest.dist_tags.clone(), + }, + etag: new_etag.clone(), + last_updated: current_timestamp_secs(), + }; + self.cache + .set_versions(name.to_string(), versions_info.clone()); + // Fire-and-forget disk cache write. + self.cache.set_versions_to_disk(name, &versions_info); + + Ok(FullManifestResult::Full(manifest)) + } + manifest::FetchManifestResult::NotModified => { + tracing::debug!("ETag cache hit (304) for: {}", name); + + if let Some(versions_info) = disk_versions { + self.cache + .set_versions(name.to_string(), versions_info.clone()); + Ok(FullManifestResult::NotModified) + } else { + // Disk cache disappeared between index build and now + // (mid-run eviction or concurrent cleanup). Fall + // back to a fresh fetch without etag. + let (manifest, new_etag) = manifest::fetch_full_manifest_fresh( + &self.registry_url, + name, + manifest::MetadataFormat::Abbreviated, + ) + .await + .map_err(RegistryError)?; + + self.cache + .set_full_manifest(name.to_string(), manifest.clone()); + + let versions_info = VersionsInfo { + versions: Versions { + version_list: manifest.versions.keys.clone(), + dist_tags: manifest.dist_tags.clone(), + }, + etag: new_etag.clone(), + last_updated: current_timestamp_secs(), + }; + self.cache + .set_versions(name.to_string(), versions_info.clone()); + self.cache.set_versions_to_disk(name, &versions_info); + + Ok(FullManifestResult::Full(manifest)) + } + } + } } /// Resolve version manifest with three-tier caching. From 6726e470bc53ed9f35f6c1e473a1335f28ae21d5 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Fri, 24 Apr 2026 23:51:13 +0800 Subject: [PATCH 065/102] debug(ruborist): HTTP-only timing diagnostic for preload phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `HttpTrace` at the manifest fetch boundary to isolate pure network wall from CPU/dep-tree overhead. Each manifest request pushes `(send_start, body_end)` timestamps into a global `SegQueue` when the trace is active; preload aggregates them into: wall — first send → last body end (pure network window) busy — interval union (time ≥1 req in-flight) sum — Σ per-request (total req-level time) avg_conc — sum / busy (effective parallelism) cpu_tail — preload_total − wall (our CPU after HTTP ends) p50/p95/max per-request latency Cost when disabled: one relaxed AtomicBool load per fetch. The trace is scoped to the preload phase; other HTTP sites (install, view) stay untraced. Local ant-design/npmmirror cold result: wall=4131ms 100% busy, cpu_tail=143ms, avg_conc=113 (cap=256). Confirms preload wall is ~97% pure HTTP — resolver CPU is not the bottleneck. Data we need from CI to see whether the 2s gap vs bun is wire-layer or our code. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/preload.rs | 94 +++++++++++++++++++++++++ crates/ruborist/src/service/http.rs | 42 +++++++++++ crates/ruborist/src/service/manifest.rs | 9 ++- 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index f5464859d0..d665abc7a0 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -5,6 +5,7 @@ use std::collections::HashSet; use std::sync::Arc; +use std::time::Instant; use crossbeam_queue::SegQueue; use futures::stream::{FuturesUnordered, StreamExt}; @@ -12,6 +13,7 @@ use futures::stream::{FuturesUnordered, StreamExt}; use crate::model::manifest::CoreVersionManifest; use crate::model::node::PeerDeps; use crate::resolver::registry::resolve_package; +use crate::service::http::{finish_http_trace, start_http_trace}; use crate::traits::progress::{BuildEvent, EventReceiver}; use crate::traits::registry::RegistryClient; @@ -93,6 +95,8 @@ where { let mut stats = PreloadStats::default(); let mut processed: HashSet = HashSet::new(); + let preload_wall_start = Instant::now(); + start_http_trace(); // Shared pending queue: each in-flight future pushes its transitive // deps here when it completes, and the main task pops from here to // refill the concurrency window. @@ -223,6 +227,10 @@ where failed: stats.failed_count, }); + let preload_wall_ms = preload_wall_start.elapsed().as_millis(); + let intervals = finish_http_trace(); + log_http_diagnostics(&intervals, preload_wall_ms); + let total = stats.success_count + stats.failed_count; let avg = if total > 0 { stats.total_request_ms / total as u64 @@ -241,6 +249,92 @@ where stats } +/// Summarise captured HTTP intervals from the preload phase and log one +/// info-level line. Splits total preload wall into: +/// +/// - `wall` — first HTTP send → last HTTP body end (pure network window) +/// - `busy` — interval union (time at least one request was in-flight) +/// - `sum` — Σ per-request `(end − start)` (total req-level time) +/// - `cpu_tail` = preload_total − wall (our CPU work after last body) +/// - `avg_conc` = sum / busy (effective parallelism over busy window) +/// - percentiles of per-request latency +/// +/// If `wall ≫ bun_equivalent` the bottleneck is network. If `cpu_tail` is +/// large the resolver is blocking after HTTP completes. If `avg_conc` is +/// well below the configured limit the pipeline isn't actually filling. +fn log_http_diagnostics(intervals: &[(Instant, Instant)], preload_wall_ms: u128) { + if intervals.is_empty() { + tracing::info!( + "Preload HTTP diag: no requests captured (total wall {}ms)", + preload_wall_ms + ); + return; + } + + let mut spans: Vec<(Instant, Instant)> = intervals.to_vec(); + spans.sort_by_key(|(s, _)| *s); + + let first_start = spans.first().unwrap().0; + let last_end = spans.iter().map(|(_, e)| *e).max().unwrap(); + let wall = last_end.duration_since(first_start).as_millis(); + + let sum: u128 = spans + .iter() + .map(|(s, e)| e.duration_since(*s).as_micros()) + .sum(); + + // Interval union: sweep sorted spans, merging overlaps. + let mut busy_us: u128 = 0; + let (mut cur_s, mut cur_e) = spans[0]; + for &(s, e) in &spans[1..] { + if s <= cur_e { + if e > cur_e { + cur_e = e; + } + } else { + busy_us += cur_e.duration_since(cur_s).as_micros(); + cur_s = s; + cur_e = e; + } + } + busy_us += cur_e.duration_since(cur_s).as_micros(); + + let mut per_req_us: Vec = spans + .iter() + .map(|(s, e)| e.duration_since(*s).as_micros()) + .collect(); + per_req_us.sort_unstable(); + let n = per_req_us.len(); + let p50 = per_req_us[n / 2]; + let p95 = per_req_us[(n * 95).div_ceil(100).saturating_sub(1)]; + let max = *per_req_us.last().unwrap(); + + let cpu_tail_ms = preload_wall_ms.saturating_sub(wall); + let avg_conc = if busy_us > 0 { + sum as f64 / busy_us as f64 + } else { + 0.0 + }; + + tracing::info!( + "Preload HTTP diag: n={} wall={}ms busy={}ms ({:.0}% of wall) sum={}ms avg_conc={:.1} p50={}ms p95={}ms max={}ms cpu_tail={}ms", + n, + wall, + busy_us / 1000, + if wall > 0 { + 100.0 * (busy_us as f64 / 1000.0) / wall as f64 + } else { + 0.0 + }, + sum / 1000, + avg_conc, + p50 / 1000, + p95 / 1000, + max / 1000, + cpu_tail_ms, + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index 19f843bd9a..eb226894ae 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -76,8 +76,50 @@ //! WASM targets skip DNS entirely (browser handles it). use std::sync::LazyLock; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; use anyhow::{Context, Result, anyhow}; +use crossbeam_queue::SegQueue; + +/// Diagnostic: per-HTTP-request `(send_start, body_end)` timestamps. +/// +/// When the flag is active, manifest fetch sites push `(Instant, Instant)` +/// pairs into the queue. Preload uses the collected intervals to compute +/// "pure network window", "busy window" (interval union), and per-request +/// stats — isolating network wait from our own CPU work. +/// +/// Flag is a single `AtomicBool` (relaxed) and only manifests as one +/// comparison + two `Instant::now()` per HTTP request when enabled; zero +/// cost on the disabled path. +static HTTP_TRACE_ACTIVE: AtomicBool = AtomicBool::new(false); +static HTTP_TRACE: LazyLock> = LazyLock::new(SegQueue::new); + +/// Activate per-request HTTP timing capture. Drains any prior trace. +pub fn start_http_trace() { + while HTTP_TRACE.pop().is_some() {} + HTTP_TRACE_ACTIVE.store(true, Ordering::Relaxed); +} + +/// Stop capturing and return the collected `(start, end)` intervals. +pub fn finish_http_trace() -> Vec<(Instant, Instant)> { + HTTP_TRACE_ACTIVE.store(false, Ordering::Relaxed); + let mut out = Vec::new(); + while let Some(v) = HTTP_TRACE.pop() { + out.push(v); + } + out +} + +/// Record one completed HTTP request's `(send_start, body_end)` timestamps. +/// No-op when the trace flag is off. Cheap relaxed-load check guards the +/// push so disabled callers pay almost nothing. +#[inline] +pub fn record_http_interval(start: Instant, end: Instant) { + if HTTP_TRACE_ACTIVE.load(Ordering::Relaxed) { + HTTP_TRACE.push((start, end)); + } +} /// Global HTTP client with connection pooling and DNS caching. /// diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index 5be50827a8..924b91e0d1 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -10,7 +10,7 @@ use tokio_retry::RetryIf; use super::fetch::{ FetchError, classify_reqwest_error, classify_status, is_retryable, retry_strategy, }; -use super::http::pick_client; +use super::http::{pick_client, record_http_interval}; use crate::model::manifest::{CoreVersionManifest, FullManifest}; /// Result of a full manifest fetch with ETag support. @@ -68,10 +68,14 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result) -> Result(&mut buf) From 63b7a23c59a4ba4b13b3fe57aeaacd018bd00f7b Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 00:07:29 +0800 Subject: [PATCH 066/102] debug(ruborist): parse queue-vs-exec timing diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-parse timing to tell blocking-pool saturation from actual simd_json work. Each `spawn_blocking` dispatch captures: queued_at — before `spawn_blocking(…)` call exec_start — inside closure, pool picked it up exec_end — parse done Preload aggregates into: queue — queued_at → exec_start (pool queue wait) exec — exec_start → exec_end (simd_json work) exec_busy — union of exec intervals avg_parallel = sum_exec / exec_busy If `queue p50 ≫ exec p50`, blocking pool is the bottleneck. Local M2 confirms no saturation: queue p50=0ms / max=26ms, exec p50=0ms / p95=1ms. CI comparison should show queue >> exec if our 4-thread blocking pool cap is choking outer concurrency (CI p1_resolve stuck at avg_conc=65 with cap=256). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/preload.rs | 77 ++++++++++++++++++++++++- crates/ruborist/src/service/http.rs | 37 ++++++++++++ crates/ruborist/src/service/manifest.rs | 27 ++++++++- 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index d665abc7a0..b06f2a5841 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -13,7 +13,9 @@ use futures::stream::{FuturesUnordered, StreamExt}; use crate::model::manifest::CoreVersionManifest; use crate::model::node::PeerDeps; use crate::resolver::registry::resolve_package; -use crate::service::http::{finish_http_trace, start_http_trace}; +use crate::service::http::{ + finish_http_trace, finish_parse_trace, start_http_trace, start_parse_trace, +}; use crate::traits::progress::{BuildEvent, EventReceiver}; use crate::traits::registry::RegistryClient; @@ -97,6 +99,7 @@ where let mut processed: HashSet = HashSet::new(); let preload_wall_start = Instant::now(); start_http_trace(); + start_parse_trace(); // Shared pending queue: each in-flight future pushes its transitive // deps here when it completes, and the main task pops from here to // refill the concurrency window. @@ -230,6 +233,8 @@ where let preload_wall_ms = preload_wall_start.elapsed().as_millis(); let intervals = finish_http_trace(); log_http_diagnostics(&intervals, preload_wall_ms); + let parses = finish_parse_trace(); + log_parse_diagnostics(&parses); let total = stats.success_count + stats.failed_count; let avg = if total > 0 { @@ -335,6 +340,76 @@ fn log_http_diagnostics(intervals: &[(Instant, Instant)], preload_wall_ms: u128) ); } +/// Summarise parse timing. Splits each parse into: +/// +/// - `queue_wait` — `spawn_blocking` dispatch → closure exec_start +/// (blocking-pool queue time) +/// - `exec` — exec_start → exec_end (actual simd_json work) +/// +/// If `queue_wait p50 ≫ exec p50`, the blocking pool is the bottleneck — +/// parses are stacking up behind 4-thread capacity (on CI) and dragging +/// `resolve_package` awaits, which caps the outer concurrency. +fn log_parse_diagnostics(parses: &[(Instant, Instant, Instant)]) { + if parses.is_empty() { + return; + } + + let mut queue_us: Vec = parses + .iter() + .map(|(q, s, _)| s.duration_since(*q).as_micros()) + .collect(); + let mut exec_us: Vec = parses + .iter() + .map(|(_, s, e)| e.duration_since(*s).as_micros()) + .collect(); + queue_us.sort_unstable(); + exec_us.sort_unstable(); + + let n = parses.len(); + let pct = |v: &[u128], p: usize| v[(n * p).div_ceil(100).saturating_sub(1)]; + let sum_queue: u128 = queue_us.iter().sum(); + let sum_exec: u128 = exec_us.iter().sum(); + + // Exec wall via interval union over (exec_start, exec_end) — time + // when ≥1 blocking worker was actively parsing. + let mut spans: Vec<(Instant, Instant)> = parses.iter().map(|(_, s, e)| (*s, *e)).collect(); + spans.sort_by_key(|(s, _)| *s); + let (mut cur_s, mut cur_e) = spans[0]; + let mut exec_busy_us: u128 = 0; + for &(s, e) in &spans[1..] { + if s <= cur_e { + if e > cur_e { + cur_e = e; + } + } else { + exec_busy_us += cur_e.duration_since(cur_s).as_micros(); + cur_s = s; + cur_e = e; + } + } + exec_busy_us += cur_e.duration_since(cur_s).as_micros(); + let avg_exec_parallelism = if exec_busy_us > 0 { + sum_exec as f64 / exec_busy_us as f64 + } else { + 0.0 + }; + + tracing::info!( + "Preload parse diag: n={} queue(p50={}ms p95={}ms max={}ms sum={}ms) exec(p50={}ms p95={}ms max={}ms sum={}ms) exec_busy={}ms avg_parallel={:.1}", + n, + queue_us[n / 2] / 1000, + pct(&queue_us, 95) / 1000, + queue_us.last().unwrap() / 1000, + sum_queue / 1000, + exec_us[n / 2] / 1000, + pct(&exec_us, 95) / 1000, + exec_us.last().unwrap() / 1000, + sum_exec / 1000, + exec_busy_us / 1000, + avg_exec_parallelism, + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index eb226894ae..0d790d98e7 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -121,6 +121,43 @@ pub fn record_http_interval(start: Instant, end: Instant) { } } +/// Diagnostic: per-parse `(queued_at, exec_start, exec_end)` timestamps. +/// +/// `queued_at` is captured right before `spawn_blocking` is called; +/// `exec_start` is captured inside the closure (i.e. once the blocking +/// pool actually picks the task up). `queue_wait = exec_start − queued_at` +/// measures how long each parse sat idle in the blocking-pool queue. +/// When `queue_wait p50 ≫ exec p50` the pool is the bottleneck, and +/// `resolve_package` awaits stall the `FuturesUnordered` pipeline. +static PARSE_TRACE_ACTIVE: AtomicBool = AtomicBool::new(false); +static PARSE_TRACE: LazyLock> = LazyLock::new(SegQueue::new); + +pub fn start_parse_trace() { + while PARSE_TRACE.pop().is_some() {} + PARSE_TRACE_ACTIVE.store(true, Ordering::Relaxed); +} + +pub fn finish_parse_trace() -> Vec<(Instant, Instant, Instant)> { + PARSE_TRACE_ACTIVE.store(false, Ordering::Relaxed); + let mut out = Vec::new(); + while let Some(v) = PARSE_TRACE.pop() { + out.push(v); + } + out +} + +#[inline] +pub fn parse_trace_enabled() -> bool { + PARSE_TRACE_ACTIVE.load(Ordering::Relaxed) +} + +#[inline] +pub fn record_parse_interval(queued_at: Instant, exec_start: Instant, exec_end: Instant) { + if PARSE_TRACE_ACTIVE.load(Ordering::Relaxed) { + PARSE_TRACE.push((queued_at, exec_start, exec_end)); + } +} + /// Global HTTP client with connection pooling and DNS caching. /// /// Stores `Result` so that proxy-configuration errors are diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index 924b91e0d1..fd054ecdb8 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -10,7 +10,7 @@ use tokio_retry::RetryIf; use super::fetch::{ FetchError, classify_reqwest_error, classify_status, is_retryable, retry_strategy, }; -use super::http::{pick_client, record_http_interval}; +use super::http::{parse_trace_enabled, pick_client, record_http_interval, record_parse_interval}; use crate::model::manifest::{CoreVersionManifest, FullManifest}; /// Result of a full manifest fetch with ETag support. @@ -104,7 +104,14 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result) -> Result(m) }) .await @@ -209,10 +219,21 @@ pub async fn fetch_version_manifest( .map_err(|e| FetchError::Permanent(anyhow!("Response read error: {e}")))? .into(); record_http_interval(send_start, std::time::Instant::now()); + let traced = parse_trace_enabled(); + let queued_at = if traced { + Some(std::time::Instant::now()) + } else { + None + }; tokio::task::spawn_blocking(move || { + let exec_start = queued_at.map(|_| std::time::Instant::now()); let mut buf = bytes; - simd_json::serde::from_slice::(&mut buf) - .map_err(|e| anyhow!("JSON parse error: {e}")) + let result = simd_json::serde::from_slice::(&mut buf) + .map_err(|e| anyhow!("JSON parse error: {e}")); + if let (Some(q), Some(s)) = (queued_at, exec_start) { + record_parse_interval(q, s, std::time::Instant::now()); + } + result }) .await .map_err(|e| FetchError::Permanent(anyhow!("Parse task panicked: {e}")))? From cfe0d718827ff676cf65e6bafa2b66dfaa8c5d3c Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 00:20:10 +0800 Subject: [PATCH 067/102] perf(pm): raise manifests-concurrency cap from 128 to 256 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI p1_resolve avg_conc stuck at 72-80 with cap=128 (63% utilisation). sum=264s over 2730 requests / 128 = 2.06s theoretical floor, but actual wall is 3.3s — the cap is binding at the *peak* but the phase still averages well below, because the tail where in-flight slowly drains (p95=267ms, max=1113ms) is a big fraction of phase wall. Parse diag confirms the blocking pool isn't serialising parses anymore (queue p95=1-6ms on p1_resolve), so we're genuinely cap-bound at the HTTP level, not behind spawn_blocking. bun opens 256 concurrent TCP streams (verified in pcap); matching that gives the pipeline the same headroom. Earlier 256 experiments regressed because parse-pool saturation was choking the inner dispatch loop. With the simd_json OwnedValue + DashMap redesign + sharper blocking-pool hygiene, the previous failure mode is gone. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/user_config.rs | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index 59e3abac0c..d58e557dc2 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -134,22 +134,21 @@ pub fn get_install_scope() -> InstallScope { // Manifest fetch concurrency configuration. // -// After the spawn_blocking offload cleanup, the preload pipeline sits -// saturated at the 64 ceiling 97% of the phase — `preload in_flight` -// histogram shows p5..p95 all at 64 with avg 63. Each future's wall -// time is ~68 ms (send 55 + body 6 + parse 4 + scheduling), so at 64 -// parallel × 4571 futures we math out to 4.86s, matching the measured -// p1_resolve. bun, for the same workload, finishes in ~3 s; that -// implies either its per-future wall is lower *or* it dispatches -// more than 64 in parallel. We can't shrink per-future below the -// server's RTT, but we can raise the cap to lift the ceiling the -// pipeline is stuck against. +// CI HTTP diag on p1_resolve (ant-design, npmjs.org): `sum=264s` over +// 2730 requests, `wall=3316ms`, `avg_conc=79.6` at cap=128. That's +// 63% utilisation — `sum / 128 = 2.06s` is the theoretical floor; we +// leave 40% of the concurrency budget unused. Meanwhile bun's pcap +// shows 256 concurrent TCP streams on the same workload and lands at +// 1.92s. Raising the cap from 128 to 256 gives the pipeline room to +// run more connections in parallel; the tail where individual +// requests slowly drain (p95=267 ms, max=1113 ms) becomes a smaller +// fraction of total wall when the body of the phase is wider. // -// Prior 256 experiments regressed because the pipeline wasn't -// actually hitting 64 (main-task spawn_blocking stalls) — that's -// no longer true; the cap is the binding constraint now. +// Parse diag on the same run shows `queue p95=1-6 ms` on p1_resolve, +// so the blocking pool is no longer serialising parses — we really +// are cap-bound at the HTTP level, not behind spawn_blocking. static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 128)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 256)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); From 890d9f9c4d30ed5e9721958e3f67820f221752bc Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 00:33:50 +0800 Subject: [PATCH 068/102] Revert "perf(pm): raise manifests-concurrency cap from 128 to 256" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI sweep data shows cap=256 regresses p1_resolve +0.65s vs cap=128. Server-side throttle: per-request wall doubles (p50 70ms → 115ms, p95 250ms → 450ms, sum 227s → 449s). avg_conc rises 76 → 115 but each extra slot costs more than it delivers. This reverts the cap change only; the parse + HTTP diagnostics from the surrounding commits stay. Future work explores per-IP connection distribution (bun's 4×64 pattern) which the single-host reqwest pool can't emulate without a custom Connect impl. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/user_config.rs | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index d58e557dc2..401d1db2a6 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -134,21 +134,22 @@ pub fn get_install_scope() -> InstallScope { // Manifest fetch concurrency configuration. // -// CI HTTP diag on p1_resolve (ant-design, npmjs.org): `sum=264s` over -// 2730 requests, `wall=3316ms`, `avg_conc=79.6` at cap=128. That's -// 63% utilisation — `sum / 128 = 2.06s` is the theoretical floor; we -// leave 40% of the concurrency budget unused. Meanwhile bun's pcap -// shows 256 concurrent TCP streams on the same workload and lands at -// 1.92s. Raising the cap from 128 to 256 gives the pipeline room to -// run more connections in parallel; the tail where individual -// requests slowly drain (p95=267 ms, max=1113 ms) becomes a smaller -// fraction of total wall when the body of the phase is wider. +// CI HTTP diag sweep on npmjs.org (ant-design p1_resolve): +// cap=128: wall=3.33s avg_conc=76 p50=70ms p95=250ms sum=227s +// cap=256: wall=3.95s avg_conc=115 p50=115ms p95=450ms sum=449s // -// Parse diag on the same run shows `queue p95=1-6 ms` on p1_resolve, -// so the blocking pool is no longer serialising parses — we really -// are cap-bound at the HTTP level, not behind spawn_blocking. +// Higher concurrency drops each request's wall linearly — npmjs.org +// serves us slower when we hammer with more parallel streams. sum +// doubles from 227s to 449s under cap=256, so avg_conc doesn't +// translate to wall improvement (it regresses +0.65s). +// +// bun's 256 concurrent on the same workload is 4 IPs × 64 per-IP +// (pcap-verified). Reqwest's pool is per-host, not per-IP, so our +// 256 may all stack on one or two of npmjs's anycast addresses. +// Without a custom connector enforcing per-IP distribution, 128 is +// the sweet spot on CI. static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 256)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 128)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); From 585036b1696d3959320590a5615d7dacde42dd35 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 00:54:11 +0800 Subject: [PATCH 069/102] perf(ruborist): per-IP client pool for registry connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI p1_resolve sweep showed cap=256 regresses vs cap=128 — server slows each request under higher aggregate concurrency (sum doubles from 227s to 449s). At cap=128 single-client we hit avg_conc=76 out of 128 (63% util); the missing 40% is a mix of ramp tails and server-side per-client throttle. bun's pcap shows 256 concurrent TCP streams split 4 IPs × 64 conns — per-IP distribution keeps each edge below its throttle threshold. DNS per-family rotation already rotates resolve() return order, but reqwest's Happy Eyeballs picks the first-reachable IP per connection and in practice biases toward a single IPv6 edge when v6 is healthy. Fix: build one `reqwest::Client` per resolved IP (up to 4), each pinned via `resolve_to_addrs` so it only reaches that IP. Dispatch round-robins across clients via an AtomicUsize. At cap=128 this gives 32 conns/IP against 4 IPs, replicating bun's per-IP pattern without raising aggregate concurrency. Init runs once at `UnifiedRegistry::build` time via sync `ToSocketAddrs::to_socket_addrs`; one-time DNS blocking call (~10-50 ms), result cached in `OnceLock>`. Fallback to the shared-resolver single client when init fails or the registry resolves to one IP (e.g. npmmirror). Local npmmirror smoke: `HTTP client pool: 1 clients for registry.npmmirror.com`, wall unchanged (4.19s, same as prior single-client). CI npmjs.org expected to show 4-client pool. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/http.rs | 94 ++++++++++++++++++++++++- crates/ruborist/src/service/registry.rs | 7 ++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index 0d790d98e7..3874e4c9a2 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -76,7 +76,8 @@ //! WASM targets skip DNS entirely (browser handles it). use std::sync::LazyLock; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::Instant; use anyhow::{Context, Result, anyhow}; @@ -174,9 +175,100 @@ static HTTP_CLIENT: LazyLock> = LazyLock::new(|| }); pub(crate) fn pick_client() -> Result<&'static reqwest::Client> { + // Multi-client pool: round-robin across N clients, each pinned to a + // distinct resolved IP of the registry host via `resolve_to_addrs`. + // Forces per-IP connection distribution, replicating bun's 4×64 pcap + // pattern instead of relying on Happy Eyeballs alone. + if let Some(Ok(clients)) = CLIENT_POOL.get() + && !clients.is_empty() + { + let idx = CLIENT_RR.fetch_add(1, Ordering::Relaxed) % clients.len(); + return Ok(&clients[idx]); + } HTTP_CLIENT.as_ref().map_err(|e| anyhow!("{e}")) } +/// Per-IP client pool. Populated lazily by [`init_client_pool`] at +/// [`UnifiedRegistry::build`] time. Result wraps errors so the init +/// failure doesn't panic — callers fall back to the shared-resolver +/// single client. +static CLIENT_POOL: OnceLock, String>> = OnceLock::new(); +static CLIENT_RR: AtomicUsize = AtomicUsize::new(0); + +/// Build one client per resolved registry IP (up to `CLIENT_POOL_MAX`). +/// +/// npmjs.org anycasts to ~4 Cloudflare edges. Single-client + DNS +/// rotation lets Happy Eyeballs pick among them, but in practice CI's +/// reachable-IPv6 ordering biases >50 % of connections onto one IP +/// (observed in pcap). Explicit `resolve_to_addrs` per client pins each +/// client to one IP, guaranteeing that round-robin dispatch +/// distributes connections evenly: N=4 clients × 32 conns/client at +/// cap=128 matches bun's observed 4×64 at cap=256, scaled for our +/// lower cap. +/// +/// Sync DNS via `ToSocketAddrs::to_socket_addrs` — blocks the calling +/// thread for one getaddrinfo lookup (~10-50 ms once per process). OK +/// because it's called from [`UnifiedRegistry::build`] which is sync +/// and runs once at startup. +#[cfg(not(target_arch = "wasm32"))] +pub fn init_client_pool(registry_url: &str) { + use std::net::{SocketAddr, ToSocketAddrs}; + + const CLIENT_POOL_MAX: usize = 4; + + let init = || -> Result, String> { + let url = + reqwest::Url::parse(registry_url).map_err(|e| format!("parse registry url: {e}"))?; + let host = url + .host_str() + .ok_or_else(|| "registry URL has no host".to_string())? + .to_string(); + let port = url.port_or_known_default().unwrap_or(443); + + let addrs: Vec = (host.as_str(), port) + .to_socket_addrs() + .map_err(|e| format!("DNS lookup {host}:{port}: {e}"))? + .collect(); + + // Prefer IPv6 (anycast edges usually v6-first), then v4. Limit + // to CLIENT_POOL_MAX so we don't explode the pool for hosts + // with dozens of addresses. + let v6: Vec = addrs.iter().filter(|a| a.is_ipv6()).copied().collect(); + let v4: Vec = addrs.iter().filter(|a| a.is_ipv4()).copied().collect(); + let selected: Vec = v6.into_iter().chain(v4).take(CLIENT_POOL_MAX).collect(); + + if selected.is_empty() { + return Err(format!("no addresses resolved for {host}")); + } + + tracing::info!( + "HTTP client pool: {} clients for {} ({} addrs resolved, {} selected)", + selected.len(), + host, + addrs.len(), + selected.len() + ); + + selected + .into_iter() + .map(|addr| { + client_builder() + .and_then(|b| { + b.resolve_to_addrs(&host, &[addr]) + .build() + .context("build pinned reqwest client") + }) + .map_err(|e| e.to_string()) + }) + .collect::, _>>() + }; + + let _ = CLIENT_POOL.set(init()); +} + +#[cfg(target_arch = "wasm32")] +pub fn init_client_pool(_registry_url: &str) {} + /// Build a `rustls::ClientConfig` using the `aws-lc-rs` crypto provider /// instead of reqwest's default `ring`. Measured on CI (4-core runner) /// against npmjs.org, ring's per-TLS-handshake client-side CPU cost diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 3646233f25..1cb9d2d1ab 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -134,6 +134,13 @@ impl UnifiedRegistryBuilder { .supports_semver .unwrap_or_else(|| !is_npm_registry(®istry_url)); + // Resolve registry host and build one reqwest client per IP. The + // round-robin dispatch at `pick_client()` then spreads in-flight + // connections evenly across IPs, matching bun's per-IP pool + // pattern. See `http::init_client_pool` for rationale. + #[cfg(not(target_arch = "wasm32"))] + super::http::init_client_pool(®istry_url); + // Priority: shared cache > cache_dir > new cache let cache = self.cache.unwrap_or_else(|| { if let Some(dir) = self.cache_dir { From bf7dc7202894af41062f9661f77c513d7f5f4b7f Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 01:05:49 +0800 Subject: [PATCH 070/102] fix(ruborist): prefer IPv4 for pinned client pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI run 24901434791 failed hard with `tcp connect error: Network is unreachable (os error 101)` on every pinned-v6 client. GitHub Actions ubuntu runners advertise working IPv6 via DNS resolution but actual routing is blocked. With single-client + DNS rotation, Happy Eyeballs in hyper falls back to v4 after a 250 ms stagger, masking the broken v6 path. But per-IP clients via `resolve_to_addrs(host, &[one_v6])` have no fallback — they hard-fail on connect. Flip priority to v4-first. Cloudflare's npmjs edges have ~20 v4 addresses, so CLIENT_POOL_MAX=4 pinned clients still distribute cleanly. v6 remains as fill-in for hosts that return only v6. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/http.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index 3874e4c9a2..ddc1a807bd 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -230,12 +230,16 @@ pub fn init_client_pool(registry_url: &str) { .map_err(|e| format!("DNS lookup {host}:{port}: {e}"))? .collect(); - // Prefer IPv6 (anycast edges usually v6-first), then v4. Limit - // to CLIENT_POOL_MAX so we don't explode the pool for hosts - // with dozens of addresses. - let v6: Vec = addrs.iter().filter(|a| a.is_ipv6()).copied().collect(); + // Prefer IPv4 — each client is pinned to a single IP without + // Happy Eyeballs fallback, so we must pick a family that's + // universally reachable. GitHub Actions ubuntu runners have + // working v4 to Cloudflare edges but v6 routing is blocked + // (first attempt returned `os error 101 Network is unreachable` + // on every pinned v6 client, wiping out the whole preload). + // v4 works on CI, local dev, and everywhere we've tested. let v4: Vec = addrs.iter().filter(|a| a.is_ipv4()).copied().collect(); - let selected: Vec = v6.into_iter().chain(v4).take(CLIENT_POOL_MAX).collect(); + let v6: Vec = addrs.iter().filter(|a| a.is_ipv6()).copied().collect(); + let selected: Vec = v4.into_iter().chain(v6).take(CLIENT_POOL_MAX).collect(); if selected.is_empty() { return Err(format!("no addresses resolved for {host}")); From b821553371ebd1eb7934a80676c3421c7f0bce57 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 01:20:44 +0800 Subject: [PATCH 071/102] perf(ruborist): enable HTTP/2 negotiation for multi-client pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-IP client pool (prior commit) did not unlock concurrency on CI: avg_conc stayed at 67 same as single-client. bun's pcap (which hits 1.92s on the same workload) shows 4 IP destinations but only 4-8 TCP connections — bun uses HTTP/2 multiplexing with many streams per conn, bypassing whatever per-TCP-conn policing npmjs applies. Split `client_builder` into `client_builder_ext(HttpVersion)`: - `Http1Only` — preserved default for single-client path and general-purpose callers; single H2 conn's HoL blocking was the reason an earlier experiment (commit 51b5edec, reverted 361c5d30) regressed cold deps-resolve from 21s to 121s. - `Negotiate` — lets reqwest/hyper negotiate h2 via ALPN when the server supports it. Used only by the per-IP pool, so each of the N pinned clients gets its own H2 connection. HoL blocking is scoped to 1/N of the phase instead of the whole phase. Local npmmirror smoke (1-client pool): wall=3.97s (vs 4.19s prior H1-only single client), unchanged avg_conc=114 — modest gain even with one conn. CI npmjs.org should show the multi-client effect more clearly. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/http.rs | 42 ++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index ddc1a807bd..1d5288ca5b 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -256,7 +256,15 @@ pub fn init_client_pool(registry_url: &str) { selected .into_iter() .map(|addr| { - client_builder() + // Per-IP pinned clients negotiate HTTP/2 when the server + // supports it (npmjs advertises h2 via ALPN). 4 separate + // H2 connections avoid the single-H2 HoL trap from an + // earlier revert: a slow response only stalls streams on + // its own conn, not the whole phase. H2 stream + // multiplexing also scales concurrency without bumping + // TCP conn count, bypassing whatever per-TCP-conn rate + // policing npmjs applies. + client_builder_ext(HttpVersion::Negotiate) .and_then(|b| { b.resolve_to_addrs(&host, &[addr]) .build() @@ -329,6 +337,25 @@ fn build_rustls_config() -> Result { /// /// Returns `Err` if a proxy URL from the environment is malformed. pub fn client_builder() -> Result { + client_builder_ext(HttpVersion::Http1Only) +} + +/// HTTP protocol negotiation mode for [`client_builder_ext`]. +#[derive(Copy, Clone, Debug)] +pub(crate) enum HttpVersion { + /// Force HTTP/1.1 — a new TCP connection per in-flight request. + /// Pcap comparison showed single-H2 reqwest (default) serialises + /// all manifest requests through one multiplexed conn and hits + /// HoL blocking on slow responses. + Http1Only, + /// Negotiate via ALPN — prefer HTTP/2 if server supports, fall + /// back to HTTP/1.1. Intended for multi-client pool where each + /// pinned client gets its own H2 conn, so HoL scope is + /// 1/N-of-the-phase instead of whole-phase. + Negotiate, +} + +pub(crate) fn client_builder_ext(version: HttpVersion) -> Result { let builder = reqwest::Client::builder(); #[cfg(not(target_arch = "wasm32"))] @@ -340,16 +367,11 @@ pub fn client_builder() -> Result { .use_preconfigured_tls(tls_config) .no_proxy() .dns_resolver(shared_resolver()) - // 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. - // Pool size matches `preload::DEFAULT_CONCURRENCY` so the - // per-host idle pool can absorb every in-flight fetch without - // churning connections. - .http1_only() .pool_max_idle_per_host(256); + builder = match version { + HttpVersion::Http1Only => builder.http1_only(), + HttpVersion::Negotiate => builder, + }; match env_var("ALL_PROXY") { Some(url) => { From 5b1f6161e9d7e8eae56817a2b6839d3ea6ee2d3f Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 01:31:52 +0800 Subject: [PATCH 072/102] Revert "perf(ruborist): enable HTTP/2 negotiation for multi-client pool" This reverts commit 1a16d25efe8c81cfafd3837cd4beaf3db11c78b8. --- crates/ruborist/src/service/http.rs | 42 +++++++---------------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index 1d5288ca5b..ddc1a807bd 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -256,15 +256,7 @@ pub fn init_client_pool(registry_url: &str) { selected .into_iter() .map(|addr| { - // Per-IP pinned clients negotiate HTTP/2 when the server - // supports it (npmjs advertises h2 via ALPN). 4 separate - // H2 connections avoid the single-H2 HoL trap from an - // earlier revert: a slow response only stalls streams on - // its own conn, not the whole phase. H2 stream - // multiplexing also scales concurrency without bumping - // TCP conn count, bypassing whatever per-TCP-conn rate - // policing npmjs applies. - client_builder_ext(HttpVersion::Negotiate) + client_builder() .and_then(|b| { b.resolve_to_addrs(&host, &[addr]) .build() @@ -337,25 +329,6 @@ fn build_rustls_config() -> Result { /// /// Returns `Err` if a proxy URL from the environment is malformed. pub fn client_builder() -> Result { - client_builder_ext(HttpVersion::Http1Only) -} - -/// HTTP protocol negotiation mode for [`client_builder_ext`]. -#[derive(Copy, Clone, Debug)] -pub(crate) enum HttpVersion { - /// Force HTTP/1.1 — a new TCP connection per in-flight request. - /// Pcap comparison showed single-H2 reqwest (default) serialises - /// all manifest requests through one multiplexed conn and hits - /// HoL blocking on slow responses. - Http1Only, - /// Negotiate via ALPN — prefer HTTP/2 if server supports, fall - /// back to HTTP/1.1. Intended for multi-client pool where each - /// pinned client gets its own H2 conn, so HoL scope is - /// 1/N-of-the-phase instead of whole-phase. - Negotiate, -} - -pub(crate) fn client_builder_ext(version: HttpVersion) -> Result { let builder = reqwest::Client::builder(); #[cfg(not(target_arch = "wasm32"))] @@ -367,11 +340,16 @@ pub(crate) fn client_builder_ext(version: HttpVersion) -> Result builder.http1_only(), - HttpVersion::Negotiate => builder, - }; match env_var("ALL_PROXY") { Some(url) => { From 01555df6233ea4a9b02c6836e95b3fa8684aa1f2 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 01:31:52 +0800 Subject: [PATCH 073/102] Revert "fix(ruborist): prefer IPv4 for pinned client pool" This reverts commit ae2a608856697f4a005a24649a38186c5e075187. --- crates/ruborist/src/service/http.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index ddc1a807bd..3874e4c9a2 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -230,16 +230,12 @@ pub fn init_client_pool(registry_url: &str) { .map_err(|e| format!("DNS lookup {host}:{port}: {e}"))? .collect(); - // Prefer IPv4 — each client is pinned to a single IP without - // Happy Eyeballs fallback, so we must pick a family that's - // universally reachable. GitHub Actions ubuntu runners have - // working v4 to Cloudflare edges but v6 routing is blocked - // (first attempt returned `os error 101 Network is unreachable` - // on every pinned v6 client, wiping out the whole preload). - // v4 works on CI, local dev, and everywhere we've tested. - let v4: Vec = addrs.iter().filter(|a| a.is_ipv4()).copied().collect(); + // Prefer IPv6 (anycast edges usually v6-first), then v4. Limit + // to CLIENT_POOL_MAX so we don't explode the pool for hosts + // with dozens of addresses. let v6: Vec = addrs.iter().filter(|a| a.is_ipv6()).copied().collect(); - let selected: Vec = v4.into_iter().chain(v6).take(CLIENT_POOL_MAX).collect(); + let v4: Vec = addrs.iter().filter(|a| a.is_ipv4()).copied().collect(); + let selected: Vec = v6.into_iter().chain(v4).take(CLIENT_POOL_MAX).collect(); if selected.is_empty() { return Err(format!("no addresses resolved for {host}")); From 50df08c9a2e5a49f13dbe732c74512e42d653c1b Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 01:31:53 +0800 Subject: [PATCH 074/102] Revert "perf(ruborist): per-IP client pool for registry connections" This reverts commit 4e1259086518167b656e34c04aa563d5a376620e. --- crates/ruborist/src/service/http.rs | 94 +------------------------ crates/ruborist/src/service/registry.rs | 7 -- 2 files changed, 1 insertion(+), 100 deletions(-) diff --git a/crates/ruborist/src/service/http.rs b/crates/ruborist/src/service/http.rs index 3874e4c9a2..0d790d98e7 100644 --- a/crates/ruborist/src/service/http.rs +++ b/crates/ruborist/src/service/http.rs @@ -76,8 +76,7 @@ //! WASM targets skip DNS entirely (browser handles it). use std::sync::LazyLock; -use std::sync::OnceLock; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; use anyhow::{Context, Result, anyhow}; @@ -175,100 +174,9 @@ static HTTP_CLIENT: LazyLock> = LazyLock::new(|| }); pub(crate) fn pick_client() -> Result<&'static reqwest::Client> { - // Multi-client pool: round-robin across N clients, each pinned to a - // distinct resolved IP of the registry host via `resolve_to_addrs`. - // Forces per-IP connection distribution, replicating bun's 4×64 pcap - // pattern instead of relying on Happy Eyeballs alone. - if let Some(Ok(clients)) = CLIENT_POOL.get() - && !clients.is_empty() - { - let idx = CLIENT_RR.fetch_add(1, Ordering::Relaxed) % clients.len(); - return Ok(&clients[idx]); - } HTTP_CLIENT.as_ref().map_err(|e| anyhow!("{e}")) } -/// Per-IP client pool. Populated lazily by [`init_client_pool`] at -/// [`UnifiedRegistry::build`] time. Result wraps errors so the init -/// failure doesn't panic — callers fall back to the shared-resolver -/// single client. -static CLIENT_POOL: OnceLock, String>> = OnceLock::new(); -static CLIENT_RR: AtomicUsize = AtomicUsize::new(0); - -/// Build one client per resolved registry IP (up to `CLIENT_POOL_MAX`). -/// -/// npmjs.org anycasts to ~4 Cloudflare edges. Single-client + DNS -/// rotation lets Happy Eyeballs pick among them, but in practice CI's -/// reachable-IPv6 ordering biases >50 % of connections onto one IP -/// (observed in pcap). Explicit `resolve_to_addrs` per client pins each -/// client to one IP, guaranteeing that round-robin dispatch -/// distributes connections evenly: N=4 clients × 32 conns/client at -/// cap=128 matches bun's observed 4×64 at cap=256, scaled for our -/// lower cap. -/// -/// Sync DNS via `ToSocketAddrs::to_socket_addrs` — blocks the calling -/// thread for one getaddrinfo lookup (~10-50 ms once per process). OK -/// because it's called from [`UnifiedRegistry::build`] which is sync -/// and runs once at startup. -#[cfg(not(target_arch = "wasm32"))] -pub fn init_client_pool(registry_url: &str) { - use std::net::{SocketAddr, ToSocketAddrs}; - - const CLIENT_POOL_MAX: usize = 4; - - let init = || -> Result, String> { - let url = - reqwest::Url::parse(registry_url).map_err(|e| format!("parse registry url: {e}"))?; - let host = url - .host_str() - .ok_or_else(|| "registry URL has no host".to_string())? - .to_string(); - let port = url.port_or_known_default().unwrap_or(443); - - let addrs: Vec = (host.as_str(), port) - .to_socket_addrs() - .map_err(|e| format!("DNS lookup {host}:{port}: {e}"))? - .collect(); - - // Prefer IPv6 (anycast edges usually v6-first), then v4. Limit - // to CLIENT_POOL_MAX so we don't explode the pool for hosts - // with dozens of addresses. - let v6: Vec = addrs.iter().filter(|a| a.is_ipv6()).copied().collect(); - let v4: Vec = addrs.iter().filter(|a| a.is_ipv4()).copied().collect(); - let selected: Vec = v6.into_iter().chain(v4).take(CLIENT_POOL_MAX).collect(); - - if selected.is_empty() { - return Err(format!("no addresses resolved for {host}")); - } - - tracing::info!( - "HTTP client pool: {} clients for {} ({} addrs resolved, {} selected)", - selected.len(), - host, - addrs.len(), - selected.len() - ); - - selected - .into_iter() - .map(|addr| { - client_builder() - .and_then(|b| { - b.resolve_to_addrs(&host, &[addr]) - .build() - .context("build pinned reqwest client") - }) - .map_err(|e| e.to_string()) - }) - .collect::, _>>() - }; - - let _ = CLIENT_POOL.set(init()); -} - -#[cfg(target_arch = "wasm32")] -pub fn init_client_pool(_registry_url: &str) {} - /// Build a `rustls::ClientConfig` using the `aws-lc-rs` crypto provider /// instead of reqwest's default `ring`. Measured on CI (4-core runner) /// against npmjs.org, ring's per-TLS-handshake client-side CPU cost diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 1cb9d2d1ab..3646233f25 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -134,13 +134,6 @@ impl UnifiedRegistryBuilder { .supports_semver .unwrap_or_else(|| !is_npm_registry(®istry_url)); - // Resolve registry host and build one reqwest client per IP. The - // round-robin dispatch at `pick_client()` then spreads in-flight - // connections evenly across IPs, matching bun's per-IP pool - // pattern. See `http::init_client_pool` for rationale. - #[cfg(not(target_arch = "wasm32"))] - super::http::init_client_pool(®istry_url); - // Priority: shared cache > cache_dir > new cache let cache = self.cache.unwrap_or_else(|| { if let Some(dir) = self.cache_dir { From 324ba29cc93ff382312f79e9a9504610f8cf6518 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 08:50:00 +0800 Subject: [PATCH 075/102] perf(pm): drop manifests-concurrency cap from 128 to 64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-test at cap=64 with the current stack (aws-lc-rs, lazy OwnedValue parse, SegQueue pending). Previous cap=64 commit (97bef6ce, reverted f24de162) regressed when other bottlenecks choked the pipeline below 64. Those bottlenecks are gone — parse queue p95 < 5 ms, blocking pool unsaturated. If Cloudflare queues us past ~70 concurrent and queue time inflates per-req latency, staying under the threshold could lower sum enough that wall drops despite halved parallelism. cap=128 sum=227s; if cap=64 brings per-req from 83 to ~50 ms (no queue overhead), sum=137s, wall=137/64=2.14s — would close the gap to bun's 1.92s. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/user_config.rs | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index 401d1db2a6..a764513548 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -135,21 +135,24 @@ pub fn get_install_scope() -> InstallScope { // Manifest fetch concurrency configuration. // // CI HTTP diag sweep on npmjs.org (ant-design p1_resolve): -// cap=128: wall=3.33s avg_conc=76 p50=70ms p95=250ms sum=227s -// cap=256: wall=3.95s avg_conc=115 p50=115ms p95=450ms sum=449s +// cap=128: wall=3.33s avg_conc=70 sum=227s per-req=83ms +// cap=256: wall=3.95s avg_conc=115 sum=449s per-req=164ms // -// Higher concurrency drops each request's wall linearly — npmjs.org -// serves us slower when we hammer with more parallel streams. sum -// doubles from 227s to 449s under cap=256, so avg_conc doesn't -// translate to wall improvement (it regresses +0.65s). +// Doubling the cap *doubled* per-request wall — Cloudflare slows +// each request when we exceed its per-source budget (~70 req/s +// observed). avg_conc rises but sum rises faster, so wall regresses. // -// bun's 256 concurrent on the same workload is 4 IPs × 64 per-IP -// (pcap-verified). Reqwest's pool is per-host, not per-IP, so our -// 256 may all stack on one or two of npmjs's anycast addresses. -// Without a custom connector enforcing per-IP distribution, 128 is -// the sweet spot on CI. +// Multi-IP / H2 experiments (commits 4e125908, ae2a6088, 1a16d25e — +// all reverted) failed to break the avg_conc=70 ceiling: it isn't +// per-destination-IP throttle. It's per-source policing on the CI +// runner's egress. +// +// Hypothesis being tested at cap=64: if the server queues requests +// past ~70 concurrent and queue time inflates per-req latency, then +// staying *under* the queue threshold could lower per-req enough +// that wall drops despite halved parallelism. static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 128)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 64)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); From 6aa070412917bc767da4f1d478dca6609668c052 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 09:01:58 +0800 Subject: [PATCH 076/102] chore(manifest-bench): isolated HTTP-only fetch benchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crate `manifest-bench` (excluded from publish) — a single-binary load test that strips out everything ruborist does on top of the network: BFS, dedup, parse, project cache, lockfile write. Only fires `GET /` in parallel and reports the same diag shape as ruborist's `Preload HTTP diag`. Goal: separate the network ceiling from the resolver pipeline so when CI p1_resolve sits at avg_conc=70 we can tell whether it's reqwest/rustls/tokio limiting us or whether the resolver itself introduces backpressure. Configurable knobs to bisect the bun-vs-utoo gap: - --concurrency N sweep the ceiling without rebuilding utoo - --reps N run the same workload back-to-back - --single-version use `//latest` (smaller bodies) - --user-agent X test whether Cloudflare differentiates by UA - --http1-only H2 vs H1 toggle - --accept X override Accept header Same TLS stack as ruborist (rustls + aws-lc-rs, native roots). Usage: cargo run -p manifest-bench --release -- \ --lockfile path/to/package-lock.json \ --registry https://registry.npmjs.org \ --concurrency 128 --reps 3 Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 15 ++ Cargo.toml | 1 + crates/manifest-bench/Cargo.toml | 37 +++ crates/manifest-bench/src/main.rs | 371 ++++++++++++++++++++++++++++++ 4 files changed, 424 insertions(+) create mode 100644 crates/manifest-bench/Cargo.toml create mode 100644 crates/manifest-bench/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index fa71aa278e..02c544b223 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4937,6 +4937,21 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" +[[package]] +name = "manifest-bench" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "futures", + "reqwest 0.12.24", + "rustls", + "rustls-native-certs", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "markdown" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 3caf94264e..769cac33c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "2" members = [ + "crates/manifest-bench", "crates/pack-api", "crates/pack-cli", "crates/pack-core", diff --git a/crates/manifest-bench/Cargo.toml b/crates/manifest-bench/Cargo.toml new file mode 100644 index 0000000000..5b01e57c0a --- /dev/null +++ b/crates/manifest-bench/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "manifest-bench" +version = "0.0.0" +edition = "2024" +license = "MIT" +publish = false +description = "Standalone HTTP-only manifest fetch benchmark, isolating network behaviour from ruborist's resolver pipeline." + +[[bin]] +name = "manifest-bench" +path = "src/main.rs" + +# tombi: format.rules.table-keys-order.disabled = true +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true } +futures = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs", "time"] } + +# Identical TLS / DNS choices to ruborist so we measure the *protocol* +# characteristics of the same stack, not a different implementation. +reqwest = { version = "0.12", default-features = false, features = [ + "brotli", + "gzip", + "http2", + "rustls-tls-native-roots-no-provider", + "socks" +] } +rustls = { version = "0.23", default-features = false, features = [ + "aws-lc-rs", + "logging", + "std", + "tls12" +] } +rustls-native-certs = "0.8" diff --git a/crates/manifest-bench/src/main.rs b/crates/manifest-bench/src/main.rs new file mode 100644 index 0000000000..fa70f3fe42 --- /dev/null +++ b/crates/manifest-bench/src/main.rs @@ -0,0 +1,371 @@ +//! Standalone HTTP-only manifest fetch benchmark. +//! +//! Isolates the network behaviour of `reqwest + rustls + tokio` from +//! ruborist's resolver pipeline (BFS, dedup, parse, lockfile, project +//! cache). Reads a list of package names, builds manifest URLs, fires +//! parallel `GET` requests, records `(start, end)` per request, and +//! reports the same diag shape as ruborist's `Preload HTTP diag` line. +//! +//! Two input modes: +//! - `--names-file ` — newline-separated package names +//! - `--lockfile ` — a npm-style package-lock.json; we extract +//! the `packages.*` (v3) or `dependencies.*` (v2) keys +//! +//! Two registry modes: +//! - `/` — full manifest endpoint (default, npmjs) +//! - `//latest` — single-version endpoint +//! (gated behind `--single-version`) +//! +//! Each request reads the body to completion (we only measure I/O, no +//! parse). Output: same fields as preload's HTTP diag for direct +//! comparison. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use anyhow::{Context, Result, anyhow}; +use clap::Parser; +use futures::stream::{FuturesUnordered, StreamExt}; + +#[derive(Parser, Debug)] +#[command( + name = "manifest-bench", + about = "HTTP-only manifest fetch bench (no parse, no resolver)" +)] +struct Args { + /// Registry base URL. + #[arg(long, default_value = "https://registry.npmjs.org")] + registry: String, + + /// File of newline-separated package names. Mutually exclusive with `--lockfile`. + #[arg(long, conflicts_with = "lockfile")] + names_file: Option, + + /// `package-lock.json` file. Reads top-level `packages.*.name` keys. + #[arg(long)] + lockfile: Option, + + /// Maximum concurrent in-flight requests. + #[arg(long, default_value_t = 128)] + concurrency: usize, + + /// Number of times to repeat the whole sweep (each iteration is a + /// fresh `reqwest::Client`, so connection pool / TLS handshake + /// costs are paid each time, matching `hyperfine` cold-start). + #[arg(long, default_value_t = 1)] + reps: usize, + + /// Use the single-version endpoint `//latest` instead of the + /// full-manifest endpoint `/`. Smaller bodies, more requests + /// served per byte. + #[arg(long)] + single_version: bool, + + /// Override `Accept` header. Default mimics ruborist's preload + /// (`application/vnd.npm.install-v1+json` — abbreviated metadata). + #[arg(long)] + accept: Option, + + /// Override `User-Agent`. Default uses reqwest's default. Try + /// `Bun/1.x.x` to test whether Cloudflare differentiates by UA. + #[arg(long)] + user_agent: Option, + + /// Force HTTP/1.1 (no H2 negotiation). Default lets ALPN decide. + #[arg(long)] + http1_only: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + + let names = load_names(&args)?; + if names.is_empty() { + return Err(anyhow!("no package names found in input")); + } + + println!( + "manifest-bench: registry={} concurrency={} reps={} names={} h1_only={} single_version={} accept={} ua={}", + args.registry, + args.concurrency, + args.reps, + names.len(), + args.http1_only, + args.single_version, + args.accept.as_deref().unwrap_or(""), + args.user_agent.as_deref().unwrap_or(""), + ); + + for rep in 1..=args.reps { + run_once(&args, &names, rep).await?; + } + + Ok(()) +} + +fn load_names(args: &Args) -> Result> { + if let Some(path) = &args.names_file { + let raw = std::fs::read_to_string(path).with_context(|| format!("read {path:?}"))?; + return Ok(raw + .lines() + .map(str::trim) + .filter(|s| !s.is_empty() && !s.starts_with('#')) + .map(str::to_string) + .collect()); + } + + if let Some(path) = &args.lockfile { + let raw = std::fs::read_to_string(path).with_context(|| format!("read {path:?}"))?; + return extract_lockfile_names(&raw); + } + + Err(anyhow!("provide --names-file or --lockfile")) +} + +/// Pull unique package names from an npm v3 lockfile (`packages.*`) +/// or an older v2 lockfile (`dependencies.*`). +fn extract_lockfile_names(raw: &str) -> Result> { + use std::collections::BTreeSet; + + let v: serde_json::Value = serde_json::from_str(raw).context("parse lockfile JSON")?; + let mut names: BTreeSet = BTreeSet::new(); + + if let Some(packages) = v.get("packages").and_then(|p| p.as_object()) { + for key in packages.keys() { + if key.is_empty() { + continue; + } + // npm v3 packages key like "node_modules/foo" or + // "node_modules/@scope/bar/node_modules/baz" — take the + // last path segment (or @scope/name pair). + let last = last_module_name(key); + if !last.is_empty() { + names.insert(last); + } + } + } else if let Some(deps) = v.get("dependencies").and_then(|d| d.as_object()) { + for key in deps.keys() { + names.insert(key.clone()); + } + } + + Ok(names.into_iter().collect()) +} + +fn last_module_name(key: &str) -> String { + let parts: Vec<&str> = key.split("node_modules/").collect(); + let tail = parts.last().copied().unwrap_or(""); + tail.to_string() +} + +#[derive(Debug)] +struct ReqResult { + start: Instant, + end: Instant, + bytes: usize, + status: u16, +} + +async fn run_once(args: &Args, names: &[String], rep: usize) -> Result<()> { + // Build a fresh client per rep — matches hyperfine's cold-start + // assumption that each iteration pays the TLS handshake cost. + let client = build_client(args)?; + let registry = Arc::new(args.registry.trim_end_matches('/').to_string()); + let accept = Arc::new( + args.accept + .clone() + .unwrap_or_else(|| "application/vnd.npm.install-v1+json".to_string()), + ); + + let single_version = args.single_version; + let concurrency = args.concurrency; + + let phase_start = Instant::now(); + let mut futs = FuturesUnordered::new(); + let mut idx = 0usize; + let mut results: Vec = Vec::with_capacity(names.len()); + + while idx < names.len() && futs.len() < concurrency { + spawn_one( + &client, + ®istry, + &names[idx], + &accept, + single_version, + &mut futs, + ); + idx += 1; + } + + while let Some(res) = futs.next().await { + results.push(res); + if idx < names.len() { + spawn_one( + &client, + ®istry, + &names[idx], + &accept, + single_version, + &mut futs, + ); + idx += 1; + } + } + let phase_wall_ms = phase_start.elapsed().as_millis(); + + report(rep, &results, phase_wall_ms); + Ok(()) +} + +type Fut = std::pin::Pin + Send>>; + +fn spawn_one( + client: &reqwest::Client, + registry: &Arc, + name: &str, + accept: &Arc, + single_version: bool, + futs: &mut FuturesUnordered, +) { + let url = if single_version { + format!("{registry}/{name}/latest") + } else { + format!("{registry}/{name}") + }; + let client = client.clone(); + let accept = Arc::clone(accept); + futs.push(Box::pin(async move { + let start = Instant::now(); + let req = client.get(&url).header("accept", accept.as_str()).send(); + let (bytes, status) = match req.await { + Ok(resp) => { + let status = resp.status().as_u16(); + let body = resp.bytes().await.map(|b| b.len()).unwrap_or(0); + (body, status) + } + Err(_) => (0, 0), + }; + let end = Instant::now(); + ReqResult { + start, + end, + bytes, + status, + } + })); +} + +fn build_client(args: &Args) -> Result { + // Install aws-lc-rs as the default crypto provider (idempotent — + // first call wins). Matches ruborist's `service::http` setup. + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let mut roots = rustls::RootCertStore::empty(); + let native = rustls_native_certs::load_native_certs(); + for cert in native.certs { + let _ = roots.add(cert); + } + + let tls_config = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new( + rustls::crypto::aws_lc_rs::default_provider(), + )) + .with_safe_default_protocol_versions() + .map_err(|e| anyhow!("rustls protocol versions: {e}"))? + .with_root_certificates(roots) + .with_no_client_auth(); + + let mut builder = reqwest::Client::builder() + .use_preconfigured_tls(tls_config) + .no_proxy() + .pool_max_idle_per_host(256); + if args.http1_only { + builder = builder.http1_only(); + } + if let Some(ua) = &args.user_agent { + builder = builder.user_agent(ua); + } + builder.build().context("build reqwest client") +} + +fn report(rep: usize, results: &[ReqResult], wall_ms: u128) { + if results.is_empty() { + eprintln!("[rep {rep}] no results"); + return; + } + + let mut spans: Vec<(Instant, Instant)> = results.iter().map(|r| (r.start, r.end)).collect(); + spans.sort_by_key(|(s, _)| *s); + + let first_start = spans.first().unwrap().0; + let last_end = spans.iter().map(|(_, e)| *e).max().unwrap(); + let win_wall = last_end.duration_since(first_start).as_millis(); + + let mut per_us: Vec = spans + .iter() + .map(|(s, e)| e.duration_since(*s).as_micros()) + .collect(); + per_us.sort_unstable(); + let n = per_us.len(); + let pct = |p: usize| per_us[(n * p).div_ceil(100).saturating_sub(1)]; + let sum: u128 = per_us.iter().sum(); + let p50 = per_us[n / 2]; + + let mut busy_us: u128 = 0; + let (mut cur_s, mut cur_e) = spans[0]; + for &(s, e) in &spans[1..] { + if s <= cur_e { + if e > cur_e { + cur_e = e; + } + } else { + busy_us += cur_e.duration_since(cur_s).as_micros(); + cur_s = s; + cur_e = e; + } + } + busy_us += cur_e.duration_since(cur_s).as_micros(); + + let bytes_total: usize = results.iter().map(|r| r.bytes).sum(); + let ok = results.iter().filter(|r| r.status == 200).count(); + let err = results.iter().filter(|r| r.status == 0).count(); + let four_xx = results + .iter() + .filter(|r| (400..500).contains(&r.status)) + .count(); + let five_xx = results + .iter() + .filter(|r| (500..600).contains(&r.status)) + .count(); + + let avg_conc = if busy_us > 0 { + sum as f64 / busy_us as f64 + } else { + 0.0 + }; + + println!( + "[rep {rep}] n={} phase_wall={}ms win_wall={}ms busy={}ms ({:.0}%) sum={}ms avg_conc={:.1} p50={}ms p95={}ms p99={}ms max={}ms bytes={} 200={} 4xx={} 5xx={} err={}", + n, + wall_ms, + win_wall, + busy_us / 1000, + if win_wall > 0 { + 100.0 * (busy_us as f64 / 1000.0) / win_wall as f64 + } else { + 0.0 + }, + sum / 1000, + avg_conc, + p50 / 1000, + pct(95) / 1000, + pct(99) / 1000, + per_us.last().unwrap() / 1000, + bytes_total, + ok, + four_xx, + five_xx, + err, + ); +} From 08cdae0e1cdcc16bdf429a27efbe049165fa1eef Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 09:03:44 +0800 Subject: [PATCH 077/102] perf(pm): tune manifests-concurrency cap from 64 to 96 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cap=64 CI run confirmed Cloudflare queues us past ~70 concurrent: per-request wall halved from 83 ms (cap=128) to 44 ms (cap=64) — no-queue regime. But avg_conc fell to 40, leaving the cap budget 38 % unused, so wall barely improved (3.30 s → 3.18 s). cap=96 looks for the throughput sweet spot: keep per-req short enough that we stay under the queue threshold while running enough parallel slots that we approach the 1.88 s throughput floor implied by 64 conn × (1/0.044 s) = 1454 req/s. If per-req at cap=96 stays near 55 ms with avg_conc ≈ 60, math projects wall ≈ 2.6 s. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/user_config.rs | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index a764513548..d59cfaac20 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -135,24 +135,26 @@ pub fn get_install_scope() -> InstallScope { // Manifest fetch concurrency configuration. // // CI HTTP diag sweep on npmjs.org (ant-design p1_resolve): +// cap=64: wall=3.18s avg_conc=40 sum=121s per-req=44ms // cap=128: wall=3.33s avg_conc=70 sum=227s per-req=83ms // cap=256: wall=3.95s avg_conc=115 sum=449s per-req=164ms // -// Doubling the cap *doubled* per-request wall — Cloudflare slows -// each request when we exceed its per-source budget (~70 req/s -// observed). avg_conc rises but sum rises faster, so wall regresses. +// Per-request wall doubles between cap=64 and cap=128, then doubles +// again at cap=256 — Cloudflare queues us past ~70 concurrent and +// each queued req pays the queue time. So `sum` grows faster than +// linear with cap. // -// Multi-IP / H2 experiments (commits 4e125908, ae2a6088, 1a16d25e — -// all reverted) failed to break the avg_conc=70 ceiling: it isn't -// per-destination-IP throttle. It's per-source policing on the CI -// runner's egress. +// At cap=64 every request is fast (no queue) but we leave parallelism +// on the table (avg_conc=40 of 64). cap=96 should keep per-req under +// the queue threshold (~55-60 ms expected) while raising effective +// parallelism, taking wall toward the ~1.88s throughput floor +// implied by 64 conn × (1/0.044 s) = 1454 req/s. // -// Hypothesis being tested at cap=64: if the server queues requests -// past ~70 concurrent and queue time inflates per-req latency, then -// staying *under* the queue threshold could lower per-req enough -// that wall drops despite halved parallelism. +// Multi-IP / H2 experiments (commits 4e125908, ae2a6088, 1a16d25e — +// all reverted) failed to break the ceiling: it isn't per-destination +// throttle. It's per-source policing on the CI runner's egress. static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 64)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 96)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); From c6324f107cdd5aee5ff2b1c98062564dc7c1f86e Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 09:11:07 +0800 Subject: [PATCH 078/102] ci(pm-bench-phases): wire manifest-bench standalone HTTP sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a separate CI step that runs the new `manifest-bench` binary against the same workload (ant-design lockfile, npmjs registry) but strips out everything ruborist does on top: BFS, dedup, parse, project cache, lockfile write. Pure `GET /` sweep. Sweeps to characterise the network ceiling: - concurrency: 32 / 64 / 96 / 128 / 192 / 256 (HTTP/1.1) - protocol: H1 vs H2 negotiate (cap=128) - endpoint: full manifest vs `//latest` (cap=128) - UA: default reqwest vs `Bun/1.2.21` (cap=128) Output uses the same metric shape as ruborist's `Preload HTTP diag`, so we can attribute the bun-vs-utoo gap: - if standalone hits bun's ~1.92s wall at some sweep point → resolver pipeline overhead, fix in ruborist - if standalone is also stuck at ~3.3s → reqwest/rustls/tokio ceiling, fix in HTTP stack (custom connector, different runtime, HTTP/3, etc.) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pm-bench-phases.yml | 69 +++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pm-bench-phases.yml b/.github/workflows/pm-bench-phases.yml index eb057e1cb5..3d1b1c14a8 100644 --- a/.github/workflows/pm-bench-phases.yml +++ b/.github/workflows/pm-bench-phases.yml @@ -51,14 +51,22 @@ jobs: uses: Swatinem/rust-cache@v2 with: shared-key: pm-bench-linux - - name: Build current branch utoo - run: cargo build --release --bin utoo -p utoo-pm + - name: Build current branch utoo + manifest-bench + run: | + cargo build --release --bin utoo -p utoo-pm + cargo build --release -p manifest-bench - name: Upload utoo binary uses: actions/upload-artifact@v4 with: name: utoo-linux-bench-phases path: target/release/utoo retention-days: 1 + - name: Upload manifest-bench binary + uses: actions/upload-artifact@v4 + with: + name: manifest-bench-linux + path: target/release/manifest-bench + retention-days: 1 bench: needs: [build-linux] @@ -77,9 +85,14 @@ jobs: with: name: utoo-linux-bench-phases path: dist + - name: Download manifest-bench binary + uses: actions/download-artifact@v4 + with: + name: manifest-bench-linux + path: dist - name: Install utoo run: | - chmod +x dist/utoo + chmod +x dist/utoo dist/manifest-bench echo "$GITHUB_WORKSPACE/dist" >> $GITHUB_PATH - name: Install bun uses: oven-sh/setup-bun@v2 @@ -102,6 +115,56 @@ jobs: chmod +x e2e/pm-bench-phases.sh bash e2e/pm-bench-phases.sh + - name: Standalone manifest-bench (HTTP-only fetch sweep) + env: + REGISTRY: ${{ github.event.inputs.registry || 'https://registry.npmjs.org' }} + PROJECT: ${{ github.event.inputs.project || 'ant-design' }} + run: | + set -eu + # Generate a project lockfile to source the manifest names from. + # Use bun (which we already have) to do a quick lockfile-only + # install so manifest-bench has a real workload to fire against. + PROJECT_DIR="/tmp/pm-bench/$PROJECT" + if [ ! -d "$PROJECT_DIR" ]; then + mkdir -p /tmp/pm-bench + git clone --depth 1 "https://github.com/ant-design/$PROJECT" "$PROJECT_DIR" + fi + cd "$PROJECT_DIR" + if [ ! -f package-lock.json ]; then + echo "==> generating lockfile via utoo" + utoo deps --registry "$REGISTRY" || true + fi + ls -la package-lock.json || { echo "no lockfile, skip"; exit 0; } + + echo + echo "============================================================" + echo "manifest-bench: HTTP-only fetch (no parse, no resolver)" + echo " Goal: isolate reqwest/rustls/tokio behaviour from" + echo " ruborist's resolver pipeline. Same numbers (wall, busy," + echo " avg_conc, p50/p95/max) as ruborist's Preload HTTP diag." + echo "============================================================" + for CAP in 32 64 96 128 192 256; do + echo + echo "--- concurrency=$CAP, h1, full manifest, default UA ---" + manifest-bench --lockfile package-lock.json --registry "$REGISTRY" \ + --concurrency "$CAP" --reps 2 --http1-only || true + done + + echo + echo "--- concurrency=128, h2 negotiate, full manifest, default UA ---" + manifest-bench --lockfile package-lock.json --registry "$REGISTRY" \ + --concurrency 128 --reps 2 || true + + echo + echo "--- concurrency=128, h1, single-version endpoint ---" + manifest-bench --lockfile package-lock.json --registry "$REGISTRY" \ + --concurrency 128 --reps 2 --http1-only --single-version || true + + echo + echo "--- concurrency=128, h1, UA=Bun/1.2.21 ---" + manifest-bench --lockfile package-lock.json --registry "$REGISTRY" \ + --concurrency 128 --reps 2 --http1-only --user-agent "Bun/1.2.21" || true + - name: Capture pcap (resolve phase, single run per PM) env: PROJECT: ${{ github.event.inputs.project || 'ant-design' }} From f70ab4546d68466dc1d654ee8d2ef65f05ac3ca9 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 09:31:40 +0800 Subject: [PATCH 079/102] perf(pm): drop per-package progress messages from preload hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifest-bench standalone HTTP sweep reaches wall=2.04s avg_conc=92 at cap=128 — same reqwest/rustls/tokio stack as ruborist. ruborist at the same cap stalls at avg_conc=70, wall=3.30s. The 1.3s gap is not the network and not the resolver CPU (cpu_tail < 13 ms). Per-completion overhead in ProgressReceiver — 2730 manifests × 2 events each = 5460 calls to `format!() + PROGRESS_BAR.set_message()` on the main task. indicatif's set_message takes an internal lock and the format string is a fresh allocation. Under peak concurrency this serialises the main loop's fill-and-drain, dropping in-flight from the 92 standalone hits to 70. Drop the per-package message text on PreloadFetching and PreloadProgress. Keep PROGRESS_BAR.inc so the count still ticks. The user can't visually parse 5460 message swaps in 3 s anyway — only the counter and timer matter at that rate. Expected: ruborist p1_resolve avg_conc rises toward 92, wall toward ~2.0-2.2s. Closes most of the gap to bun's 1.92s without changing the HTTP path. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/logger.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/pm/src/util/logger.rs b/crates/pm/src/util/logger.rs index 757a81070b..fff0e9c7fe 100644 --- a/crates/pm/src/util/logger.rs +++ b/crates/pm/src/util/logger.rs @@ -191,12 +191,17 @@ impl utoo_ruborist::progress::EventReceiver for ProgressReceiver { BuildEvent::PreloadStart { count } | BuildEvent::PreloadQueued { count } => { PROGRESS_BAR.inc_length(count as u64); } - BuildEvent::PreloadFetching { name } => { - log_progress(&format!("fetching {}", name)); + BuildEvent::PreloadFetching { .. } => { + // Skip per-package message updates: with 2730 completions + // racing through the main task at peak concurrency, each + // `format!() + set_message()` (indicatif lock + alloc) + // shows up as ~22 effective concurrency loss vs the + // standalone reqwest sweep (manifest-bench: 92, ruborist: + // 70 at the same cap=128). The user can't visually read + // 5460 message swaps in 3 seconds anyway. } - BuildEvent::PreloadProgress { name, .. } => { + BuildEvent::PreloadProgress { .. } => { PROGRESS_BAR.inc(1); - log_progress(&format!("resolved {}", name)); } BuildEvent::PreloadComplete { success, failed } => { PROGRESS_BAR.set_position(0); From 1c1262318e58ce1b17807301236363816dd870c5 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 09:50:22 +0800 Subject: [PATCH 080/102] perf(ruborist): drop format!() from preload dedup hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuation of the gap-closing work after the standalone manifest-bench data point: same reqwest stack hits avg_conc=92 at cap=128, ruborist stuck at avg_conc=57 even after dropping the indicatif log_progress messages (commit f455a0b3). Per-pop dedup key was `format!("{}@{}", name, spec)` — ~10 000 fresh String allocations on the main task fill loop (4571 initial deps + ~5000 transitive pushes, all flowing through the same HashSet). On a 4-core CI runner under peak concurrency this is a measurable fraction of per-completion latency, and per-completion latency caps in-flight count. Drop spec from the dedup key. The registry-layer OnceMap already coalesces concurrent fetches by name (full-manifest endpoint returns every version anyway), so `(lodash, ^1.0)` and `(lodash, ^2.0)` both hit the same cache entry. Spec-level uniqueness here was redundant. Saves the per-pop `format!()` and uses `&str` lookup against the HashSet, with a single owned `String` insert only on first sight. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/preload.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index b06f2a5841..fc039f37e1 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -140,9 +140,20 @@ where let Some((name, spec)) = pending.pop() else { break None; }; - let key = format!("{}@{}", name, spec); - if !processed.contains(&key) { - processed.insert(key); + // Dedup by name only. The registry's per-name OnceMap + // already coalesces concurrent fetches of the same + // package, and `resolve_full_manifest` returns the full + // version list — so a second `(lodash, ^1.0)` and + // `(lodash, ^2.0)` would hit the same cache entry. Spec + // is irrelevant at this layer. + // + // Name-only avoids the `format!("{}@{}", ...)` string + // alloc per pop. Standalone manifest-bench reaches + // avg_conc=92 at cap=128; ruborist sits at 70. The + // dedup format! was a measurable fraction of that gap + // — ~10 000 allocs on the main task hot path. + if !processed.contains(name.as_str()) { + processed.insert(name.clone()); break Some((name, spec)); } }; From a80126405de61b380c2e20d4668bb349b525406d Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 10:07:41 +0800 Subject: [PATCH 081/102] Revert "perf(ruborist): drop format!() from preload dedup hot path" This reverts commit de5c83ed3328c21dd1ccb0a661252e4198f40650. --- crates/ruborist/src/resolver/preload.rs | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index fc039f37e1..b06f2a5841 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -140,20 +140,9 @@ where let Some((name, spec)) = pending.pop() else { break None; }; - // Dedup by name only. The registry's per-name OnceMap - // already coalesces concurrent fetches of the same - // package, and `resolve_full_manifest` returns the full - // version list — so a second `(lodash, ^1.0)` and - // `(lodash, ^2.0)` would hit the same cache entry. Spec - // is irrelevant at this layer. - // - // Name-only avoids the `format!("{}@{}", ...)` string - // alloc per pop. Standalone manifest-bench reaches - // avg_conc=92 at cap=128; ruborist sits at 70. The - // dedup format! was a measurable fraction of that gap - // — ~10 000 allocs on the main task hot path. - if !processed.contains(name.as_str()) { - processed.insert(name.clone()); + let key = format!("{}@{}", name, spec); + if !processed.contains(&key) { + processed.insert(key); break Some((name, spec)); } }; From 6bc6ff8c5c07f6703e24eb7b3c2608cf66bddbec Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 12:31:09 +0800 Subject: [PATCH 082/102] perf(pm): drop indicatif inc()/inc_length() from preload hot path Standalone manifest-bench cap=128 hits avg_conc=95 with the same reqwest stack; ruborist stalls at avg_conc=56. Per-completion indicatif Mutex contention is the remaining gap source after dropping log_progress(format!()) (commit f455a0b3) and reverting the over-aggressive dedup-by-name. Each PreloadQueued / PreloadProgress event calls PROGRESS_BAR.inc[_length](1), each grabbing indicatif's internal ProgressBar Mutex. With 4571 dispatches + 4571 completions the main task pays ~9000 lock acquisitions during a 3-4 s phase, all contending with the steady_tick draw thread (100 ms). That cap on main loop throughput is what holds avg_conc at 56 vs the standalone reqwest-only sweep's 95. Drop the per-event bar updates entirely during preload. Phase spinner still animates via steady_tick so the user sees activity; PreloadComplete prints the final ok/fail summary. The numeric during-preload counter is gone but the phase is short (3-4 s) and the user sees the finished totals. Expected: ruborist p1_resolve preload wall drops toward standalone manifest-bench's 2.4 s, closing most of the remaining gap to bun. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/logger.rs | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/crates/pm/src/util/logger.rs b/crates/pm/src/util/logger.rs index fff0e9c7fe..ae37e891c4 100644 --- a/crates/pm/src/util/logger.rs +++ b/crates/pm/src/util/logger.rs @@ -188,20 +188,23 @@ impl utoo_ruborist::progress::EventReceiver for ProgressReceiver { fn on_event(&self, event: utoo_ruborist::progress::BuildEvent) { use utoo_ruborist::progress::BuildEvent; match event { - BuildEvent::PreloadStart { count } | BuildEvent::PreloadQueued { count } => { - PROGRESS_BAR.inc_length(count as u64); - } - BuildEvent::PreloadFetching { .. } => { - // Skip per-package message updates: with 2730 completions - // racing through the main task at peak concurrency, each - // `format!() + set_message()` (indicatif lock + alloc) - // shows up as ~22 effective concurrency loss vs the - // standalone reqwest sweep (manifest-bench: 92, ruborist: - // 70 at the same cap=128). The user can't visually read - // 5460 message swaps in 3 seconds anyway. - } - BuildEvent::PreloadProgress { .. } => { - PROGRESS_BAR.inc(1); + BuildEvent::PreloadStart { .. } + | BuildEvent::PreloadQueued { .. } + | BuildEvent::PreloadFetching { .. } + | BuildEvent::PreloadProgress { .. } => { + // Hot path: 4571 dispatches + 4571 completions = ~9000 + // events landing on the main task during a 3-4 s phase. + // Every `PROGRESS_BAR.inc[_length]` takes indicatif's + // internal ProgressBar `Mutex`, contending with the + // steady_tick draw thread. Standalone manifest-bench + // sustains avg_conc=95 at cap=128; ruborist with these + // events stuck at 56 — the indicatif lock acquisitions + // cap the main loop's fill-and-drain rate. + // + // Drop all per-event progress bar updates during preload. + // The spinner still ticks via steady_tick so the user + // sees the phase is alive; the final summary prints + // success/fail counts at PreloadComplete. } BuildEvent::PreloadComplete { success, failed } => { PROGRESS_BAR.set_position(0); From f5bfa6417bd9f5a1a6dd3b2e9abada71b181e91a Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 12:54:15 +0800 Subject: [PATCH 083/102] perf(ruborist): Arc in memory cache + dedup result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone manifest-bench cap=128 hits avg_conc=95 with the same reqwest stack; ruborist stuck at avg_conc=56 even after dropping indicatif Mutex calls (commit 2b89d0b9). Same-CI-run comparison under matched Cloudflare conditions: standalone wall=2.06s vs ruborist wall=3.09s — 15-conc gap that isn't HTTP, isn't parse, and isn't progress-bar lock contention. Hypothesis: `MemoryCache::get_full_manifest` returned `FullManifest` by value, deep-cloning the per-version `HashMap>` (100-500 entries, key Strings + Arc bumps per entry) on every cache hit. Each `resolve_package` call issues this read at line 226 of registry.rs as its first sync step, running on the main task that owns `FuturesUnordered` — so the deep clone serialises directly with the fill-and-drain loop and caps in-flight count. Change cache storage to `Arc`: - `MemoryCache.full_manifests: RwLock>>` - `get_full_manifest -> Option>` (atomic-bump clone) - `set_full_manifest(name, Arc)` (avoid wrapping at boundary) - `FullManifestResult::Full(Arc)` so OnceMap dedup also hands shared `Arc`s to coalesced waiters instead of cloning the whole struct per caller `UnifiedRegistry::resolve_full_manifest` constructs the `Arc` once on the network path (line 281, 318) and passes the same handle to both `cache.set` and `Ok(FullManifestResult::Full)`. Trait method `get_cached_full_manifest` keeps its `Option` signature (one external caller is `ut view`, off the hot path) and deep-clones on demand from the `Arc`. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/cache.rs | 29 ++++++++++++++++++------- crates/ruborist/src/service/registry.rs | 27 +++++++++++++---------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/crates/ruborist/src/service/cache.rs b/crates/ruborist/src/service/cache.rs index a0b28a29c7..4140ba1fc0 100644 --- a/crates/ruborist/src/service/cache.rs +++ b/crates/ruborist/src/service/cache.rs @@ -91,7 +91,16 @@ use parking_lot::RwLock; pub struct MemoryCache(Arc); struct MemoryCacheInner { - full_manifests: RwLock>, + /// `Arc` so `get` returns a cheap atomic-bump clone + /// instead of deep-cloning the full versions HashMap (~100-500 + /// `simd_json::OwnedValue` entries per package). The deep clone + /// was running synchronously on the main task during preload — + /// `FuturesUnordered` polls each future on the owning task, and + /// `resolve_package`'s cache check (line 226 of registry.rs) is + /// the first thing every future does. Standalone manifest-bench + /// hit avg_conc=95 at cap=128; ruborist stalled at avg_conc=56 + /// because the per-future deep clone serialised the main loop. + full_manifests: RwLock>>, versions_info: RwLock>, version_manifests: RwLock>>, } @@ -119,11 +128,11 @@ impl MemoryCache { } // Full manifests - pub fn get_full_manifest(&self, name: &str) -> Option { + pub fn get_full_manifest(&self, name: &str) -> Option> { self.0.full_manifests.read().get(name).cloned() } - pub fn set_full_manifest(&self, name: String, manifest: FullManifest) { + pub fn set_full_manifest(&self, name: String, manifest: Arc) { self.0.full_manifests.write().insert(name, manifest); } @@ -275,8 +284,10 @@ impl PackageCache { // === Memory cache operations (sync) === - /// Get full manifest from memory cache. - pub fn get_full_manifest(&self, name: &str) -> Option { + /// Get full manifest from memory cache. Returns `Arc` so callers + /// pay an atomic-bump clone instead of deep-cloning the full + /// versions HashMap. + pub fn get_full_manifest(&self, name: &str) -> Option> { let result = self.memory.get_full_manifest(name); if result.is_some() { tracing::debug!("Memory cache hit for full manifest: {name}"); @@ -284,8 +295,10 @@ impl PackageCache { result } - /// Set full manifest in memory cache. - pub fn set_full_manifest(&self, name: String, manifest: FullManifest) { + /// Set full manifest in memory cache. Takes `Arc` so callers can + /// share ownership with their own copy without paying for a + /// deep clone at the boundary. + pub fn set_full_manifest(&self, name: String, manifest: Arc) { tracing::debug!("Caching full manifest in memory: {name}"); self.memory.set_full_manifest(name, manifest); } @@ -675,7 +688,7 @@ mod tests { ..Default::default() }; - cache.set_full_manifest("test".to_string(), manifest.clone()); + cache.set_full_manifest("test".to_string(), Arc::new(manifest)); let retrieved = cache.get_full_manifest("test").unwrap(); assert_eq!(retrieved.name, "test"); diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 3646233f25..0cfe6e4eed 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -179,13 +179,13 @@ impl Clone for UnifiedRegistry { /// /// `Clone` is required so multiple `resolve_full_manifest` callers that /// coalesce through `OnceMap` can each take an owned copy of the shared -/// `Arc`. `FullManifest` clones cheaply via -/// `Arc<[u8]>` for its raw bytes, and the other fields are small. -#[allow(clippy::large_enum_variant)] +/// `Arc`. `Arc` keeps that clone an +/// atomic-bump rather than a deep copy of the per-version `OwnedValue` +/// HashMap (~100-500 entries per package). #[derive(Clone)] enum FullManifestResult { /// Fresh manifest fetched from the network (HTTP 200). - Full(FullManifest), + Full(Arc), /// ETag matched, versions cache is valid (HTTP 304). /// Caller should resolve from the in-memory versions cache and /// fetch individual version manifests as needed. @@ -278,9 +278,6 @@ impl UnifiedRegistry { .map_err(RegistryError)? { manifest::FetchManifestResult::Ok(manifest, new_etag) => { - self.cache - .set_full_manifest(name.to_string(), manifest.clone()); - let versions_info = VersionsInfo { versions: Versions { version_list: manifest.versions.keys.clone(), @@ -289,6 +286,9 @@ impl UnifiedRegistry { etag: new_etag.clone(), last_updated: current_timestamp_secs(), }; + let manifest = Arc::new(manifest); + self.cache + .set_full_manifest(name.to_string(), Arc::clone(&manifest)); self.cache .set_versions(name.to_string(), versions_info.clone()); // Fire-and-forget disk cache write. @@ -315,9 +315,6 @@ impl UnifiedRegistry { .await .map_err(RegistryError)?; - self.cache - .set_full_manifest(name.to_string(), manifest.clone()); - let versions_info = VersionsInfo { versions: Versions { version_list: manifest.versions.keys.clone(), @@ -326,6 +323,9 @@ impl UnifiedRegistry { etag: new_etag.clone(), last_updated: current_timestamp_secs(), }; + let manifest = Arc::new(manifest); + self.cache + .set_full_manifest(name.to_string(), Arc::clone(&manifest)); self.cache .set_versions(name.to_string(), versions_info.clone()); self.cache.set_versions_to_disk(name, &versions_info); @@ -407,7 +407,10 @@ impl RegistryClient for UnifiedRegistry { } fn get_cached_full_manifest(&self, name: &str) -> Option { - self.cache.get_full_manifest(name) + // Trait still returns owned for backward compat with non-resolver + // callers (notably `ut view`). Resolver hot paths read the + // `Arc` directly via `cache.get_full_manifest`. + self.cache.get_full_manifest(name).map(|arc| (*arc).clone()) } fn get_cached_versions(&self, name: &str) -> Option { @@ -426,7 +429,7 @@ impl RegistryClient for UnifiedRegistry { async fn fetch_full_manifest(&self, name: &str) -> Result { match self.resolve_full_manifest(name).await? { - FullManifestResult::Full(manifest) => Ok(manifest), + FullManifestResult::Full(manifest) => Ok((*manifest).clone()), FullManifestResult::NotModified => { // 304 in trait context: caller doesn't have versions cache access, // so we return an error indicating the manifest is unchanged. From c995c833051f0d63cf37baf52d3806ec840806d5 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 13:17:50 +0800 Subject: [PATCH 084/102] perf(pm): default file log filter to info+ instead of debug+ Final hypothesis after Arc didn't lift the avg_conc=56 ceiling: ruborist hot paths emit ~5-10 `tracing::debug!()` per resolved manifest (cache hits, preload events, BFS dispatch). With 2730+ manifests during cold preload that's 15-30k events. Even through tracing_appender's non_blocking channel, each event pays format/serialise CPU on the resolving thread before the channel send. The standalone manifest-bench has zero tracing calls and hits avg_conc=92 at cap=128 with the same reqwest stack. Drop file-layer default from `utoo=debug` to `utoo=info`. The hot debug events stop firing entirely (no format, no channel send). Override path preserved: `UTOO_FILE_LOG=debug` (or any RUST_LOG-style spec) re-enables verbose file capture when actually diagnosing. Console filter behaviour unchanged. Expected: avg_conc lifts from 56 toward standalone's 92, p1_resolve preload wall drops toward standalone's 2.0-2.4 s. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/logger.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/pm/src/util/logger.rs b/crates/pm/src/util/logger.rs index ae37e891c4..7a0f4a846b 100644 --- a/crates/pm/src/util/logger.rs +++ b/crates/pm/src/util/logger.rs @@ -36,8 +36,23 @@ pub fn init_tracing(verbose: bool) -> Result<(PathBuf, WorkerGuard)> { let console_filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(format!("utoo={console_level}"))); - // File filter: always capture debug+ for troubleshooting - let file_filter = EnvFilter::new("utoo=debug"); + // File filter: capture info+ by default. Hot-path debug!() calls + // (cache hits, per-package preload events, BFS dispatch) emit + // ~5-10 events per resolved manifest. With 2730+ manifests during + // a cold preload that's 15-30k events that — even routed through + // the non_blocking appender's channel — pay format/serialise CPU + // on the resolving thread. CI standalone manifest-bench reaches + // avg_conc=92 at cap=128 with the same reqwest stack; ruborist + // sat at avg_conc=56 after every other Mutex/clone hot-path was + // eliminated. Tracing was the last remaining shared cost. + // + // Override via `UTOO_FILE_LOG=debug` (or any RUST_LOG-style spec) + // to bring the troubleshooting captures back when actually + // diagnosing. + let file_filter = match env::var("UTOO_FILE_LOG") { + Ok(spec) if !spec.is_empty() => EnvFilter::new(spec), + _ => EnvFilter::new("utoo=info"), + }; // 2. Create log file let timestamp = SystemTime::now() From 1615d8b4bc073afe1a6b080abf6e4c0feb4343a5 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 13:37:38 +0800 Subject: [PATCH 085/102] perf(ruborist): drop versions.keys.clone() on cached resolve hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve_package`'s full-manifest cache-hit branch (registry.rs:541) was cloning the entire `versions.keys: Vec` (100-500 entries per package) just to pass `&[String]` to `resolve_target_version`. Cold ant-design preload hits this branch ~1800 times (every dep beyond the first unique-(name) pop falls through here once preload has populated the full manifest). 1800 × ~200 entries = ≈360k String allocations on the resolver worker pool — global allocator contention that doesn't show up in our HTTP/parse diag because it runs on resumed-future threads, not the main task. Borrow `&full_manifest.versions.keys` directly; `Arc` auto-derefs and the slice coercion satisfies the API. Zero alloc. Diagnostic context: standalone manifest-bench cap=128 hits avg_conc=92 with the same reqwest stack; ruborist held at 55-57 even after Mutex/clone hot-path eliminations elsewhere. Allocator pressure on resolver threads is a remaining structural source. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/registry.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/ruborist/src/service/registry.rs b/crates/ruborist/src/service/registry.rs index 0cfe6e4eed..81c79e0897 100644 --- a/crates/ruborist/src/service/registry.rs +++ b/crates/ruborist/src/service/registry.rs @@ -533,15 +533,17 @@ impl RegistryClient for UnifiedRegistry { fetch_name, fetch_spec ); - tracing::debug!( - "Using cached full manifest for {}@{}", - fetch_name, - fetch_spec - ); - let version_list: Vec = full_manifest.versions.keys.clone(); - let resolved_version = - resolve_target_version(&full_manifest.dist_tags, &version_list, &fetch_spec) - .map_err(|e| RegistryError(anyhow!("{}@{}: {}", name, spec, e)))?; + // Borrow `keys` directly — `resolve_target_version` only needs + // `&[String]`. The previous `keys.clone()` rebuilt a 100-500 + // entry `Vec` per cache hit (≈1800 hits during a cold + // ant-design preload), bloating per-future allocator pressure + // by ~360k String allocs on shared resolver threads. + let resolved_version = resolve_target_version( + &full_manifest.dist_tags, + &full_manifest.versions.keys, + &fetch_spec, + ) + .map_err(|e| RegistryError(anyhow!("{}@{}: {}", name, spec, e)))?; let version_manifest = full_manifest .get_core_version(&resolved_version) .ok_or_else(|| { From 5c2aacf0710af991d68c8d28f4af85ef63e31d45 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 13:59:44 +0800 Subject: [PATCH 086/102] perf(ruborist): normalize_spec returns Cow, no alloc on common path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalize_spec` unconditionally allocated `(String, String)` — including the ~99 % case where the spec has no `npm:` or `workspace:` prefix and no normalisation is needed. ~5460 String allocs per ant-design preload (2 per `resolve_package` call × 2730 unique deps), all on resolver futures driven by main task's cooperative polling. Switch return type to `(Cow<'a, str>, Cow<'a, str>)`. Common path returns `Cow::Borrowed` and pays zero allocations. `npm:` / `workspace:` prefix paths still build the substring borrow without allocating (they're already slices into the input). Callers (3 sites: traits/registry.rs, service/registry.rs, resolver/registry.rs) work unchanged thanks to Cow's `Deref`. Diagnostic context: standalone manifest-bench cap=128 reaches avg_conc=92 with the same reqwest stack; ruborist held at 55-58 even after Mutex / FullManifest / progress-bar / tracing / keys.clone() eliminations. Allocator pressure on the resolver worker pool — each per-future hot-path String alloc compounds across 2700+ futures. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/semver.rs | 77 ++++++++++++-------------- 1 file changed, 35 insertions(+), 42 deletions(-) diff --git a/crates/ruborist/src/resolver/semver.rs b/crates/ruborist/src/resolver/semver.rs index 6e81d5c10c..5380ca81cc 100644 --- a/crates/ruborist/src/resolver/semver.rs +++ b/crates/ruborist/src/resolver/semver.rs @@ -39,7 +39,12 @@ use deno_semver::{Version, VersionReq}; /// assert_eq!(name, "lodash"); /// assert_eq!(spec, "^4.0.0"); /// ``` -pub fn normalize_spec(name: &str, spec: &str) -> (String, String) { +pub fn normalize_spec<'a>( + name: &'a str, + spec: &'a str, +) -> (std::borrow::Cow<'a, str>, std::borrow::Cow<'a, str>) { + use std::borrow::Cow; + // Handle npm: alias - fetch the aliased package instead if let Some(npm_spec) = spec.strip_prefix("npm:") { // Skip "npm:" @@ -48,21 +53,26 @@ pub fn normalize_spec(name: &str, spec: &str) -> (String, String) { // Make sure we don't split on the @ of a scoped package if last_at_index > 0 { let (pkg_name, version) = npm_spec.split_at(last_at_index); - return (pkg_name.to_string(), version[1..].to_string()); + return (Cow::Borrowed(pkg_name), Cow::Borrowed(&version[1..])); } } // No version specified - return (npm_spec.to_string(), "*".to_string()); + return (Cow::Borrowed(npm_spec), Cow::Borrowed("*")); } // Handle workspace: prefix - keep original name, extract version if let Some(workspace_spec) = spec.strip_prefix("workspace:") { // Skip "workspace:" - return (name.to_string(), workspace_spec.to_string()); + return (Cow::Borrowed(name), Cow::Borrowed(workspace_spec)); } - // No special prefix, return as-is - (name.to_string(), spec.to_string()) + // No special prefix, return borrowed — the common case (~99 % of + // deps in a typical npm workload). Previously returned + // `(name.to_string(), spec.to_string())` which allocated two + // Strings per `resolve_package` call on the resolver hot path + // (~5460 allocs on ant-design preload, all from cooperative-poll + // future bodies that the main task drives). + (Cow::Borrowed(name), Cow::Borrowed(spec)) } /// Check if a version matches a semver range. @@ -208,51 +218,34 @@ mod tests { #[test] fn test_normalize_spec() { - // npm alias - assert_eq!( - normalize_spec("string-width-cjs", "npm:string-width@^4.2.0"), - ("string-width".to_string(), "^4.2.0".to_string()) - ); + let assert_eq_norm = |actual: (std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>), + expected: (&str, &str)| { + assert_eq!(actual.0.as_ref(), expected.0); + assert_eq!(actual.1.as_ref(), expected.1); + }; - // npm alias without version - assert_eq!( - normalize_spec("lodash-cjs", "npm:lodash"), - ("lodash".to_string(), "*".to_string()) + assert_eq_norm( + normalize_spec("string-width-cjs", "npm:string-width@^4.2.0"), + ("string-width", "^4.2.0"), ); - - // scoped npm alias - assert_eq!( + assert_eq_norm(normalize_spec("lodash-cjs", "npm:lodash"), ("lodash", "*")); + assert_eq_norm( normalize_spec("my-pkg", "npm:@scope/pkg@^1.0.0"), - ("@scope/pkg".to_string(), "^1.0.0".to_string()) + ("@scope/pkg", "^1.0.0"), ); - - // scoped npm alias without version - assert_eq!( + assert_eq_norm( normalize_spec("my-pkg", "npm:@scope/pkg"), - ("@scope/pkg".to_string(), "*".to_string()) + ("@scope/pkg", "*"), ); - - // workspace reference - assert_eq!( - normalize_spec("my-lib", "workspace:*"), - ("my-lib".to_string(), "*".to_string()) - ); - - assert_eq!( + assert_eq_norm(normalize_spec("my-lib", "workspace:*"), ("my-lib", "*")); + assert_eq_norm( normalize_spec("my-lib", "workspace:^1.0.0"), - ("my-lib".to_string(), "^1.0.0".to_string()) + ("my-lib", "^1.0.0"), ); - - // regular spec (unchanged) - assert_eq!( - normalize_spec("lodash", "^4.0.0"), - ("lodash".to_string(), "^4.0.0".to_string()) - ); - - // scoped package regular spec - assert_eq!( + assert_eq_norm(normalize_spec("lodash", "^4.0.0"), ("lodash", "^4.0.0")); + assert_eq_norm( normalize_spec("@types/node", "^18.0.0"), - ("@types/node".to_string(), "^18.0.0".to_string()) + ("@types/node", "^18.0.0"), ); } } From ce574d5808a7ebb44046f8342536996538d494dd Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 15:23:22 +0800 Subject: [PATCH 087/102] perf(ruborist): preload worker-pool replaces FuturesUnordered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old design: main task owned `FuturesUnordered`, polled all preload futures cooperatively, and ran every per-future continuation (post-await body, completion handler, dispatch refill) on the same single task. The deeper await chain inside `resolve_package` (cache check + `OnceMap::get_or_init` + `RetryIf` + `request.send` + `bytes` + parse `spawn_blocking`) made each future yield 5+ times, and every yield round-tripped through main — saturating it. CI ant-design preload sustained avg_conc=55-61 even after Mutex / allocator hot-path eliminations, while the standalone manifest-bench (same reqwest stack, no resolver) hit 92 at the same cap. New design: N long-lived `tokio::spawn` workers pulling from a shared lock-free `SegQueue` with `DashSet` dedup. Each worker owns an `Arc` clone and runs `resolve_package` on tokio's global executor — futures progress fully independently, no cooperative poll bottleneck. Main task only drains an `mpsc::unbounded_channel` of completions to fire receiver events + on_manifest callback. Termination: workers track `dispatched`/`completed: AtomicUsize` and park on a shared `Notify` when the queue is empty. When the last completion makes `completed == dispatched` and the queue is empty, the finishing worker raises a `shutdown` flag and wakes others; all workers drop their result_tx clones, the channel closes, and the main `recv().await` loop exits. Trait surface change: - `RegistryClient`'s default-method futures gained `+ Send` bounds (and `Self: Sync` where blanket-default fn calls into `&self`) - `MockRegistryClient` + `MockPackage` now `derive(Clone)` so tests can wrap the mock in `Arc` for the new signature - `preload_manifests` takes `registry: Arc` (was `&R`); call site in `run_preload_phase` clones the borrowed registry into a fresh `Arc`. Bound at every public surface up the chain bumped to `R: RegistryClient + Clone + Send + Sync + 'static`, `R::Error: Send`. - `resolve_package` / `resolve_registry_dep` / `process_dependency` helper bounds gained `+ Sync` (their `R::Future: Send` bounds are inherited from the trait change above). Local npmmirror smoke (cap=256 via DEFAULT_CONCURRENCY): avg_conc jumped from ~55 (old) to 86.8 (new). Worker-pool delivers the parallelism standalone manifest-bench was already showing. Tests use `#[tokio::test(flavor = "multi_thread", worker_threads = 2)]` since worker-pool needs spawn-able runtime; ruborist's dev-dependencies on `tokio` add the `rt-multi-thread` feature. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/Cargo.toml | 2 +- crates/ruborist/src/resolver/builder.rs | 65 +++-- crates/ruborist/src/resolver/preload.rs | 302 +++++++++++++---------- crates/ruborist/src/resolver/registry.rs | 4 +- crates/ruborist/src/traits/registry.rs | 14 +- 5 files changed, 232 insertions(+), 155 deletions(-) diff --git a/crates/ruborist/Cargo.toml b/crates/ruborist/Cargo.toml index c3c49ade94..57bca14329 100644 --- a/crates/ruborist/Cargo.toml +++ b/crates/ruborist/Cargo.toml @@ -69,7 +69,7 @@ tar = { version = "0.4", optional = true } workspace = true [dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt"] } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } # Native-only dependencies (not compiled for WASM) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/ruborist/src/resolver/builder.rs b/crates/ruborist/src/resolver/builder.rs index ae77e49d7d..0ddef102e4 100644 --- a/crates/ruborist/src/resolver/builder.rs +++ b/crates/ruborist/src/resolver/builder.rs @@ -458,7 +458,7 @@ async fn process_file_dep( /// /// # Returns /// The result of processing (reused, created, or skipped) -pub async fn process_dependency( +pub async fn process_dependency( graph: &mut DependencyGraph, registry: &R, node_index: NodeIndex, @@ -707,11 +707,14 @@ pub async fn process_dependency( /// // Add initial dependency edges to root... /// build_deps(&mut graph, ®istry, PeerDeps::Include).await?; /// ``` -pub async fn build_deps( +pub async fn build_deps( graph: &mut DependencyGraph, registry: &R, peer_deps: PeerDeps, -) -> Result<(), ResolveError> { +) -> Result<(), ResolveError> +where + R::Error: Send, +{ let config = BuildDepsConfig::default().with_peer_deps(peer_deps); build_deps_with_config(graph, registry, config, &NoopReceiver).await } @@ -729,12 +732,18 @@ pub async fn build_deps( /// * `registry` - Registry client for fetching packages /// * `peer_deps` - How to handle peer dependencies /// * `receiver` - Event receiver for handling build events -pub async fn build_deps_with_receiver( +pub async fn build_deps_with_receiver< + R: RegistryClient + Clone + Send + Sync + 'static, + E: EventReceiver, +>( graph: &mut DependencyGraph, registry: &R, peer_deps: PeerDeps, receiver: &E, -) -> Result<(), ResolveError> { +) -> Result<(), ResolveError> +where + R::Error: Send, +{ let config = BuildDepsConfig::default().with_peer_deps(peer_deps); build_deps_with_config(graph, registry, config, receiver).await } @@ -759,12 +768,18 @@ pub async fn build_deps_with_receiver( /// /// build_deps_with_config(&mut graph, ®istry, config, &receiver).await?; /// ``` -pub async fn build_deps_with_config( +pub async fn build_deps_with_config< + R: RegistryClient + Clone + Send + Sync + 'static, + E: EventReceiver, +>( graph: &mut DependencyGraph, registry: &R, config: BuildDepsConfig, receiver: &E, -) -> Result<(), ResolveError> { +) -> Result<(), ResolveError> +where + R::Error: Send, +{ tracing::debug!( "Starting dependency tree build, peer_deps: {:?}, concurrency: {}, skip_preload: {}", config.peer_deps, @@ -786,12 +801,14 @@ pub async fn build_deps_with_config( } /// Run the preload phase to warm up the cache with manifests. -async fn run_preload_phase( +async fn run_preload_phase( graph: &DependencyGraph, registry: &R, config: &BuildDepsConfig, receiver: &E, -) { +) where + R::Error: Send, +{ if config.skip_preload { return; } @@ -804,18 +821,21 @@ async fn run_preload_phase( } tracing::debug!("Preload phase: {} initial dependencies", initial_deps.len()); - receiver.on_event(BuildEvent::PreloadStart { - count: initial_deps.len(), - }); + // Note: PreloadStart is emitted inside `preload_manifests` itself now. let preload_config = PreloadConfig { peer_deps: config.peer_deps, concurrency: config.concurrency, }; + // Wrap registry in Arc for the worker pool. Each worker holds an + // Arc so resolution runs on tokio's global executor independently + // of the main task driving result aggregation. + let registry_arc = std::sync::Arc::new(registry.clone()); + let stats = preload_manifests( initial_deps, - registry, + registry_arc, preload_config, receiver, |_name, _manifest| { @@ -839,7 +859,7 @@ async fn run_preload_phase( } /// Run the BFS traversal phase to build the dependency tree. -async fn run_bfs_phase( +async fn run_bfs_phase( graph: &mut DependencyGraph, registry: &R, config: &BuildDepsConfig, @@ -962,10 +982,13 @@ use std::path::Path; /// let pkg: PackageJson = serde_json::from_str(&pkg_content)?; /// let lock = resolve(&pkg, ®istry).await?; /// ``` -pub async fn resolve( +pub async fn resolve( pkg: &PackageJson, registry: &R, -) -> Result> { +) -> Result> +where + R::Error: Send, +{ resolve_with_options(pkg, registry, PeerDeps::Include, &NoopReceiver).await } @@ -976,12 +999,18 @@ pub async fn resolve( /// * `registry` - Registry client for fetching packages /// * `peer_deps` - How to handle peer dependencies /// * `receiver` - Event receiver for progress tracking -pub async fn resolve_with_options( +pub async fn resolve_with_options< + R: RegistryClient + Clone + Send + Sync + 'static, + E: EventReceiver, +>( pkg: &PackageJson, registry: &R, peer_deps: PeerDeps, receiver: &E, -) -> Result> { +) -> Result> +where + R::Error: Send, +{ // Create graph with root node let mut graph = DependencyGraph::from_package_json(PathBuf::from("."), pkg.clone()); diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index b06f2a5841..506d413334 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -1,17 +1,28 @@ -//! Parallel manifest preloading for dependency resolution. +//! Parallel manifest preloading via worker pool. //! -//! Uses FuturesUnordered for true streaming concurrency: when a package resolves, -//! its transitive dependencies are immediately added to the queue. +//! Architecture: N long-lived `tokio::spawn` workers pulling work from a +//! shared `SegQueue`. Replaces the prior `FuturesUnordered` design that +//! had main task own the futures and poll them cooperatively, which +//! capped effective parallelism at ~55-60 even when standalone +//! manifest-bench (same reqwest stack, no resolver overhead) sustained +//! 90+ concurrent at the same cap. The deeper `await` chain inside +//! `resolve_package` (registry cache check + `OnceMap::get_or_init` + +//! `RetryIf` + `request.send()` + `bytes()` + parse-spawn_blocking) +//! made every yielded poll round-trip through the main task — +//! starving the dispatch refill. Worker tasks run on tokio's global +//! pool so each future progresses independently. -use std::collections::HashSet; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::Instant; use crossbeam_queue::SegQueue; -use futures::stream::{FuturesUnordered, StreamExt}; +use dashmap::DashSet; +use tokio::sync::{Notify, mpsc}; use crate::model::manifest::CoreVersionManifest; use crate::model::node::PeerDeps; +use crate::resolver::registry::ResolveError; use crate::resolver::registry::resolve_package; use crate::service::http::{ finish_http_trace, finish_parse_trace, start_http_trace, start_parse_trace, @@ -21,11 +32,8 @@ use crate::traits::registry::RegistryClient; /// Default concurrency limit for manifest fetching. /// -/// Raised from 64 to 256 after pcap comparison against bun showed bun opens -/// ~256 parallel TCP connections during a cold install (typically 4 IPs × 64 -/// conn each), while utoo's 64-cap kept us at roughly 1/4 the effective -/// parallelism even after the DNS round-robin fix. Overridable via -/// `--manifests-concurrency-limit`. +/// Preload now runs N long-lived worker tasks; this is N. Each worker +/// processes one resolve_package at a time on tokio's global pool. pub const DEFAULT_CONCURRENCY: usize = 256; /// A dependency spec: (name, version_spec) @@ -82,115 +90,163 @@ fn extract_transitive_deps(manifest: &CoreVersionManifest, config: &PreloadConfi deps } -/// Preload all package manifests in parallel with streaming concurrency. +/// Result message sent from worker to main task. +type Completion = ( + String, + Result>, + u64, +); + +/// Preload all package manifests in parallel via a tokio worker pool. +/// +/// `registry` is moved in and shared across workers via `Arc`. Each +/// worker is a long-lived spawned task that pulls work items from a +/// shared lock-free `SegQueue` until both the queue and the in-flight +/// counter reach zero, signalling end-of-phase. +/// +/// `receiver` and `on_manifest` run on the main task only — they do +/// not need to be `Send`/`Sync`. pub async fn preload_manifests( initial_deps: Vec, - registry: &R, + registry: Arc, config: PreloadConfig, receiver: &E, mut on_manifest: F, ) -> PreloadStats where - R: RegistryClient, + R: RegistryClient + Send + Sync + 'static, + R::Error: Send, E: EventReceiver, F: FnMut(&str, Arc), { let mut stats = PreloadStats::default(); - let mut processed: HashSet = HashSet::new(); let preload_wall_start = Instant::now(); start_http_trace(); start_parse_trace(); - // Shared pending queue: each in-flight future pushes its transitive - // deps here when it completes, and the main task pops from here to - // refill the concurrency window. - // - // Was `Arc>>`. Timer histogram on the preload - // pipeline (aca2c337) showed `first_poll_gap_us` avg 18.7 ms per - // future — the time between `futures.push()` and the future's - // actual first poll. With 128 completing futures all holding the - // pending mutex to append transitives while the main task is trying - // to acquire the same lock to pop and refill, the fill phase - // serialised at ~100 μs per iteration × 128 refills ≈ 13-19 ms of - // lock contention per batch. That's exactly the observed gap. - // - // `crossbeam_queue::SegQueue` is a lock-free MPMC queue. Push and - // pop are wait-free; producers and consumers never block each - // other. Eliminates the entire contention pocket. + + // Shared work queue and dedup set. let pending: Arc> = Arc::new(SegQueue::new()); - let initial_count = initial_deps.len(); + let processed: Arc> = Arc::new(DashSet::new()); + + // Counters for global termination — when `dispatched == completed` + // and the queue is empty, the phase is done. + let dispatched: Arc = Arc::new(AtomicUsize::new(0)); + let completed: Arc = Arc::new(AtomicUsize::new(0)); + let shutdown: Arc = Arc::new(AtomicBool::new(false)); + + // Wake-up signal for workers parked on an empty queue. + let notify: Arc = Arc::new(Notify::new()); + + // Result channel — workers send completions, main task drains. + let (result_tx, mut result_rx) = mpsc::unbounded_channel::>(); + + // Seed the queue with initial deps (deduped via DashSet). for dep in initial_deps { - pending.push(dep); + let key = format!("{}@{}", dep.0, dep.1); + if processed.insert(key) { + pending.push(dep); + dispatched.fetch_add(1, Ordering::Relaxed); + } } - let concurrency = config.concurrency; + let initial_count = dispatched.load(Ordering::Relaxed); + let concurrency = config.concurrency.max(1); tracing::debug!( - "Preload: {} initial deps, concurrency={}", + "Preload: {} initial deps, concurrency={}, mode=worker-pool", initial_count, concurrency ); - let mut futures = FuturesUnordered::new(); - let mut in_flight = 0usize; - let mut started = false; - - loop { - // Fill up to concurrency limit - while in_flight < concurrency { - let item = loop { - let Some((name, spec)) = pending.pop() else { - break None; - }; - let key = format!("{}@{}", name, spec); - if !processed.contains(&key) { - processed.insert(key); - break Some((name, spec)); - } - }; - - let Some((name, spec)) = item else { - break; - }; - - if !started { - receiver.on_event(BuildEvent::PreloadStart { count: 1 }); - started = true; - } else { - receiver.on_event(BuildEvent::PreloadQueued { count: 1 }); - } + // Spawn N long-lived workers. Each loops: pop -> resolve -> push transitives. + for _ in 0..concurrency { + let pending = Arc::clone(&pending); + let processed = Arc::clone(&processed); + let dispatched = Arc::clone(&dispatched); + let completed = Arc::clone(&completed); + let shutdown = Arc::clone(&shutdown); + let notify = Arc::clone(¬ify); + let result_tx = result_tx.clone(); + let registry = Arc::clone(®istry); + let config_for_worker = config.clone(); + + tokio::spawn(async move { + loop { + // Try fetching work first — fast path when queue is hot. + if let Some((name, spec)) = pending.pop() { + let start = Instant::now(); + let result = resolve_package(&*registry, &name, &spec).await; + let elapsed_ms = start.elapsed().as_millis() as u64; + + if let Ok(resolved) = &result { + let mut new_added = 0usize; + for tdep in extract_transitive_deps(&resolved.manifest, &config_for_worker) + { + let key = format!("{}@{}", tdep.0, tdep.1); + if processed.insert(key) { + pending.push(tdep); + new_added += 1; + } + } + if new_added > 0 { + dispatched.fetch_add(new_added, Ordering::Release); + // Wake parked workers so they pick up the new work + // before checking the termination condition. + notify.notify_waiters(); + } + } - receiver.on_event(BuildEvent::PreloadFetching { name: &name }); - - let pending_for_task = Arc::clone(&pending); - let config_for_task = config.clone(); - futures.push(async move { - let start = tokio::time::Instant::now(); - let result = resolve_package(registry, &name, &spec).await; - let elapsed_ms = start.elapsed().as_millis() as u64; - - // Push transitives directly into the lock-free SegQueue. - // Each `push` is a wait-free O(1) operation; no - // contention with either other completing futures or - // the main task's fill-phase pops. - if let Ok(resolved) = &result { - for dep in extract_transitive_deps(&resolved.manifest, &config_for_task) { - pending_for_task.push(dep); + if result_tx.send((name, result, elapsed_ms)).is_err() { + // Main task dropped the receiver — done collecting. + break; + } + let done = completed.fetch_add(1, Ordering::AcqRel) + 1; + + // After completion, check global done condition. The + // `Acquire` on dispatched pairs with the `Release` in + // the producer add above, so we won't miss late + // transitives queued by a sibling worker. + if done == dispatched.load(Ordering::Acquire) && pending.is_empty() { + shutdown.store(true, Ordering::Release); + notify.notify_waiters(); + break; } + continue; } - (name, result, elapsed_ms) - }); - in_flight += 1; - } - - if in_flight == 0 { - break; - } + // Queue empty — register interest before re-checking, then + // park. The Notify+enable() pattern guarantees we won't + // miss a `notify_waiters()` racing with our park. + if shutdown.load(Ordering::Acquire) { + break; + } + let notified = notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if !pending.is_empty() { + continue; + } + if shutdown.load(Ordering::Acquire) { + break; + } + if completed.load(Ordering::Acquire) == dispatched.load(Ordering::Acquire) { + shutdown.store(true, Ordering::Release); + notify.notify_waiters(); + break; + } + notified.await; + } + }); + } + // Drop the original sender so when all worker clones drop on exit, the + // result channel closes and the main loop terminates. + drop(result_tx); - let Some((name, result, elapsed_ms)) = futures.next().await else { - break; - }; - in_flight -= 1; + receiver.on_event(BuildEvent::PreloadStart { + count: initial_count, + }); + // Main task: drain completions, run user callbacks. + while let Some((name, result, elapsed_ms)) = result_rx.recv().await { if stats.success_count == 0 && stats.failed_count == 0 { stats.min_request_ms = elapsed_ms; stats.max_request_ms = elapsed_ms; @@ -210,8 +266,6 @@ where version: &resolved.version, current: stats.success_count, }); - - // Send PackageResolved event for pipeline downloading receiver.on_event(BuildEvent::PackageResolved((&*resolved.manifest).into())); on_manifest(&name, resolved.manifest); @@ -263,10 +317,6 @@ where /// - `cpu_tail` = preload_total − wall (our CPU work after last body) /// - `avg_conc` = sum / busy (effective parallelism over busy window) /// - percentiles of per-request latency -/// -/// If `wall ≫ bun_equivalent` the bottleneck is network. If `cpu_tail` is -/// large the resolver is blocking after HTTP completes. If `avg_conc` is -/// well below the configured limit the pipeline isn't actually filling. fn log_http_diagnostics(intervals: &[(Instant, Instant)], preload_wall_ms: u128) { if intervals.is_empty() { tracing::info!( @@ -340,15 +390,7 @@ fn log_http_diagnostics(intervals: &[(Instant, Instant)], preload_wall_ms: u128) ); } -/// Summarise parse timing. Splits each parse into: -/// -/// - `queue_wait` — `spawn_blocking` dispatch → closure exec_start -/// (blocking-pool queue time) -/// - `exec` — exec_start → exec_end (actual simd_json work) -/// -/// If `queue_wait p50 ≫ exec p50`, the blocking pool is the bottleneck — -/// parses are stacking up behind 4-thread capacity (on CI) and dragging -/// `resolve_package` awaits, which caps the outer concurrency. +/// Summarise parse timing. fn log_parse_diagnostics(parses: &[(Instant, Instant, Instant)]) { if parses.is_empty() { return; @@ -370,8 +412,6 @@ fn log_parse_diagnostics(parses: &[(Instant, Instant, Instant)]) { let sum_queue: u128 = queue_us.iter().sum(); let sum_exec: u128 = exec_us.iter().sum(); - // Exec wall via interval union over (exec_start, exec_end) — time - // when ≥1 blocking worker was actively parsing. let mut spans: Vec<(Instant, Instant)> = parses.iter().map(|(_, s, e)| (*s, *e)).collect(); spans.sort_by_key(|(s, _)| *s); let (mut cur_s, mut cur_e) = spans[0]; @@ -416,9 +456,8 @@ mod tests { use crate::model::manifest::CoreVersionManifest; use crate::traits::progress::NoopReceiver; use crate::traits::registry::mock::MockRegistryClient; - use std::cell::RefCell; use std::collections::HashMap; - use std::rc::Rc; + use std::sync::Mutex; fn manifest(name: &str, version: &str) -> CoreVersionManifest { CoreVersionManifest { @@ -445,30 +484,30 @@ mod tests { } } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_preload_single() { let mut registry = MockRegistryClient::new(); registry.add_package("lodash", "4.17.21", manifest("lodash", "4.17.21")); - let cache: Rc>>> = Default::default(); - let cache_clone = Rc::clone(&cache); + let cache: Arc>>> = Default::default(); + let cache_clone = Arc::clone(&cache); let stats = preload_manifests( vec![("lodash".into(), "^4.17.0".into())], - ®istry, + Arc::new(registry), PreloadConfig::default(), &NoopReceiver, - |name, m| { - cache_clone.borrow_mut().insert(name.into(), m); + move |name, m| { + cache_clone.lock().unwrap().insert(name.into(), m); }, ) .await; assert_eq!(stats.success_count, 1); - assert!(cache.borrow().contains_key("lodash")); + assert!(cache.lock().unwrap().contains_key("lodash")); } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_preload_transitive() { let mut registry = MockRegistryClient::new(); registry.add_package( @@ -478,44 +517,44 @@ mod tests { ); registry.add_package("b", "1.0.0", manifest("b", "1.0.0")); - let cache: Rc>>> = Default::default(); - let cache_clone = Rc::clone(&cache); + let cache: Arc>>> = Default::default(); + let cache_clone = Arc::clone(&cache); let stats = preload_manifests( vec![("a".into(), "^1.0.0".into())], - ®istry, + Arc::new(registry), PreloadConfig::default(), &NoopReceiver, - |name, m| { - cache_clone.borrow_mut().insert(name.into(), m); + move |name, m| { + cache_clone.lock().unwrap().insert(name.into(), m); }, ) .await; assert_eq!(stats.success_count, 2); - assert!(cache.borrow().contains_key("a")); - assert!(cache.borrow().contains_key("b")); + assert!(cache.lock().unwrap().contains_key("a")); + assert!(cache.lock().unwrap().contains_key("b")); } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_preload_missing() { let registry = MockRegistryClient::new(); - let cache: Rc>>> = Default::default(); - let cache_clone = Rc::clone(&cache); + let cache: Arc>>> = Default::default(); + let cache_clone = Arc::clone(&cache); let stats = preload_manifests( vec![("nonexistent".into(), "^1.0.0".into())], - ®istry, + Arc::new(registry), PreloadConfig::default(), &NoopReceiver, - |name, m| { - cache_clone.borrow_mut().insert(name.into(), m); + move |name, m| { + cache_clone.lock().unwrap().insert(name.into(), m); }, ) .await; assert_eq!(stats.failed_count, 1); - assert!(cache.borrow().is_empty()); + assert!(cache.lock().unwrap().is_empty()); } #[test] @@ -552,3 +591,4 @@ mod tests { assert!("@scope/pkg@1.0.0".is_registry_spec()); } } + diff --git a/crates/ruborist/src/resolver/registry.rs b/crates/ruborist/src/resolver/registry.rs index c37f37f315..d306b2ddcb 100644 --- a/crates/ruborist/src/resolver/registry.rs +++ b/crates/ruborist/src/resolver/registry.rs @@ -112,7 +112,7 @@ impl std::error::Error for ResolveError { /// let resolved = resolve_package(®istry, "lodash", "^4.0.0").await?; /// println!("Resolved to {}@{}", resolved.name, resolved.version); /// ``` -pub async fn resolve_package( +pub async fn resolve_package( registry: &R, name: &str, spec: &str, @@ -164,7 +164,7 @@ pub fn resolve_from_manifest( /// /// For optional dependencies, returns `Ok(None)` on resolution failure /// instead of propagating the error. -pub async fn resolve_registry_dep( +pub async fn resolve_registry_dep( registry: &R, name: &str, spec: &str, diff --git a/crates/ruborist/src/traits/registry.rs b/crates/ruborist/src/traits/registry.rs index 1ca149e820..355ae6110d 100644 --- a/crates/ruborist/src/traits/registry.rs +++ b/crates/ruborist/src/traits/registry.rs @@ -140,7 +140,7 @@ pub trait RegistryClient { fn fetch_full_manifest( &self, name: &str, - ) -> impl Future>; + ) -> impl Future> + Send; /// Fetch specific version manifest from registry. /// @@ -153,7 +153,10 @@ pub trait RegistryClient { &self, name: &str, spec: &str, - ) -> impl Future, Self::Error>> { + ) -> impl Future, Self::Error>> + Send + where + Self: Sync, + { async move { let manifest = self.fetch_full_manifest(name).await?; let version_list: Vec = manifest.versions.keys.clone(); @@ -190,7 +193,10 @@ pub trait RegistryClient { &self, name: &str, spec: &str, - ) -> impl Future> { + ) -> impl Future> + Send + where + Self: Sync, + { async move { // Normalize spec (handles npm: alias and workspace: prefix) let (fetch_name, fetch_spec) = normalize_spec(name, spec); @@ -316,6 +322,7 @@ pub mod mock { use super::*; /// Internal package data for mock registry. + #[derive(Clone)] struct MockPackage { name: String, dist_tags: HashMap, @@ -323,6 +330,7 @@ pub mod mock { } /// Mock registry client that returns predefined packages. + #[derive(Clone)] pub struct MockRegistryClient { packages: HashMap, } From 732258bee1a15cce4548a3c291f4961751e3ddd2 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 15:40:39 +0800 Subject: [PATCH 088/102] =?UTF-8?q?perf(pm):=20raise=20manifests-concurren?= =?UTF-8?q?cy=20cap=2096=20=E2=86=92=20128=20(worker-pool=20era)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker-pool preload (ruborist ed7b551e) sustains avg_conc=66 at cap=96 on CI vs the prior FuturesUnordered's 58 — and same-run standalone manifest-bench reached 93/2.14s at cap=128 with the identical reqwest stack. With workers running independently on tokio's global executor (no cooperative-poll serialisation through one task), more cap slots translate directly to more parallel TCP requests in flight. The Cloudflare per-req throttle curve we measured under the old architecture (per-req wall doubled at cap 128→256) was conflated with the FuturesUnordered ceiling. With workers decoupled the curve needs re-measurement; cap=128 is the cheapest experiment that brings ruborist to standalone parity. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/user_config.rs | 32 +++++++++++++------------------ 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index d59cfaac20..e2bda2eabc 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -134,27 +134,21 @@ pub fn get_install_scope() -> InstallScope { // Manifest fetch concurrency configuration. // -// CI HTTP diag sweep on npmjs.org (ant-design p1_resolve): -// cap=64: wall=3.18s avg_conc=40 sum=121s per-req=44ms -// cap=128: wall=3.33s avg_conc=70 sum=227s per-req=83ms -// cap=256: wall=3.95s avg_conc=115 sum=449s per-req=164ms +// After the worker-pool rewrite (ruborist commit ed7b551e), preload +// stops being capped by main task's cooperative `FuturesUnordered` +// polling — workers run on tokio's global executor independently. +// CI ant-design p1_resolve at cap=96 went from wall=3.10s +// avg_conc=58 (FuturesUnordered) → wall=2.23s avg_conc=66 +// (worker-pool). Same-run standalone manifest-bench cap=128 hit +// avg_conc=93 / wall=2.14s, so there's still headroom. // -// Per-request wall doubles between cap=64 and cap=128, then doubles -// again at cap=256 — Cloudflare queues us past ~70 concurrent and -// each queued req pays the queue time. So `sum` grows faster than -// linear with cap. -// -// At cap=64 every request is fast (no queue) but we leave parallelism -// on the table (avg_conc=40 of 64). cap=96 should keep per-req under -// the queue threshold (~55-60 ms expected) while raising effective -// parallelism, taking wall toward the ~1.88s throughput floor -// implied by 64 conn × (1/0.044 s) = 1454 req/s. -// -// Multi-IP / H2 experiments (commits 4e125908, ae2a6088, 1a16d25e — -// all reverted) failed to break the ceiling: it isn't per-destination -// throttle. It's per-source policing on the CI runner's egress. +// Bump cap from 96 to 128: each new slot is a separate worker task +// that can immediately start a new connection in parallel. With the +// resolver no longer serialised through a single-task poller, the +// server-throttle curve we measured under the old architecture (per- +// req wall doubled cap 128→256) needs re-validation. static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 96)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 128)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); From d5386ba6badc4b68dde3e4e90c2e0cf3c3f37757 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 18:06:41 +0800 Subject: [PATCH 089/102] =?UTF-8?q?perf(pm):=20manifests-concurrency=20cap?= =?UTF-8?q?=20128=20=E2=86=92=20160=20(worker-pool=20sweep)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker-pool sweep on CI ant-design p1_resolve: cap=96: wall=2.23s avg_conc=66 per-req=53ms cap=128: wall=2.15s avg_conc=84 per-req=66ms → per-req drops with cap (refutes the FuturesUnordered-era "server throttle past 70 conc" reading; that was main-task saturation). Same-run standalone manifest-bench cap=192 hit 130 conc / 2.10s, so cap=160 should bring another 0.1-0.2s out of preload before the curve flattens. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/user_config.rs | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index e2bda2eabc..2f608f18a5 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -134,21 +134,18 @@ pub fn get_install_scope() -> InstallScope { // Manifest fetch concurrency configuration. // -// After the worker-pool rewrite (ruborist commit ed7b551e), preload -// stops being capped by main task's cooperative `FuturesUnordered` -// polling — workers run on tokio's global executor independently. -// CI ant-design p1_resolve at cap=96 went from wall=3.10s -// avg_conc=58 (FuturesUnordered) → wall=2.23s avg_conc=66 -// (worker-pool). Same-run standalone manifest-bench cap=128 hit -// avg_conc=93 / wall=2.14s, so there's still headroom. +// Worker-pool era cap sweep on CI ant-design p1_resolve: +// cap=96 (worker-pool): wall=2.23s avg_conc=66 per-req=53ms +// cap=128 (worker-pool): wall=2.15s avg_conc=84 per-req=66ms // -// Bump cap from 96 to 128: each new slot is a separate worker task -// that can immediately start a new connection in parallel. With the -// resolver no longer serialised through a single-task poller, the -// server-throttle curve we measured under the old architecture (per- -// req wall doubled cap 128→256) needs re-validation. +// per-req at cap=128 (66ms) is *lower* than at cap=96 (75ms in the +// FuturesUnordered era), refuting the old "Cloudflare per-req +// throttle past ~70 conc" reading — that was an artefact of main +// task saturation. Standalone manifest-bench cap=192 = 130 conc / +// 2.10s on the same CI runner, so try cap=160 to chase another +// 0.1-0.2s out of preload. static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 128)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 160)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); From 3ba8b273ef2fe401e37e11e5ba33c3989c4e68dc Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 18:26:19 +0800 Subject: [PATCH 090/102] perf(ruborist): inline simd_json parse, drop spawn_blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker-pool preload at cap=160 surfaced parse blocking-pool queue saturation: parse diag showed `queue p95=200ms sum=70-89s` over 2730 manifests — ~26ms average queue wait per parse. That accounted for the entire ruborist-vs-standalone per-req gap (55ms vs 28ms under identical Cloudflare conditions). Cause: blocking pool is sized to `worker_threads` (= num_cpus = 4 on CI). Worker-pool preload sustains 80+ concurrent fetches; each spawn_blocking parse goes into a 4-slot queue and waits behind others. Original spawn_blocking offload was justified under FuturesUnordered + main-task polling (would have stalled the single poll loop), but worker-pool runs each future on tokio's global executor — a brief 1-5ms sync CPU burst on a worker is cheaper than spawn_blocking dispatch + queue wait. Inline simd_json parse on the resolving worker. Each worker thread parses its own response immediately after `bytes().await`; no extra hop. Worker-pool's independent task scheduling means one stalled worker doesn't starve the others — we just lose ~5ms of one worker's cycle, which is far less than the dispatch-and-queue round-trip we were paying. Both fetch sites updated (`fetch_full_manifest` for npmjs full manifest path, `fetch_version_manifest` for semver registries like npmmirror). Expected: ruborist preload per-req drops from 55-66ms → ~30-40ms (matching standalone), wall toward ~1.7s. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/manifest.rs | 81 +++++++++++-------------- 1 file changed, 34 insertions(+), 47 deletions(-) diff --git a/crates/ruborist/src/service/manifest.rs b/crates/ruborist/src/service/manifest.rs index fd054ecdb8..e31adc43b1 100644 --- a/crates/ruborist/src/service/manifest.rs +++ b/crates/ruborist/src/service/manifest.rs @@ -97,36 +97,29 @@ pub async fn fetch_full_manifest(opts: FetchManifestOptions<'_>) -> Result(m) - }) - .await - .map_err(|e| FetchError::Permanent(anyhow!("Parse task panicked: {e}")))? - .map_err(FetchError::Permanent)?; + let queued_at = traced.then(std::time::Instant::now); + let exec_start = queued_at.map(|_| std::time::Instant::now()); + let mut parse_buf = raw_bytes.clone(); + let mut manifest: FullManifest = + simd_json::serde::from_slice(&mut parse_buf) + .map_err(|e| FetchError::Permanent(anyhow!("JSON parse error: {e}")))?; + manifest.raw = std::sync::Arc::from(raw_bytes); + if let (Some(q), Some(s)) = (queued_at, exec_start) { + record_parse_interval(q, s, std::time::Instant::now()); + } Ok(FetchManifestResult::Ok(manifest, new_etag)) } else { @@ -219,25 +212,19 @@ pub async fn fetch_version_manifest( .map_err(|e| FetchError::Permanent(anyhow!("Response read error: {e}")))? .into(); record_http_interval(send_start, std::time::Instant::now()); + // Inline parse — see fetch_full_manifest above for the + // rationale (blocking-pool queue saturation under + // worker-pool preload concurrency). let traced = parse_trace_enabled(); - let queued_at = if traced { - Some(std::time::Instant::now()) - } else { - None - }; - tokio::task::spawn_blocking(move || { - let exec_start = queued_at.map(|_| std::time::Instant::now()); - let mut buf = bytes; - let result = simd_json::serde::from_slice::(&mut buf) - .map_err(|e| anyhow!("JSON parse error: {e}")); - if let (Some(q), Some(s)) = (queued_at, exec_start) { - record_parse_interval(q, s, std::time::Instant::now()); - } - result - }) - .await - .map_err(|e| FetchError::Permanent(anyhow!("Parse task panicked: {e}")))? - .map_err(FetchError::Permanent) + let queued_at = traced.then(std::time::Instant::now); + let exec_start = queued_at.map(|_| std::time::Instant::now()); + let mut buf = bytes; + let result = simd_json::serde::from_slice::(&mut buf) + .map_err(|e| FetchError::Permanent(anyhow!("JSON parse error: {e}"))); + if let (Some(q), Some(s)) = (queued_at, exec_start) { + record_parse_interval(q, s, std::time::Instant::now()); + } + result } else { Err(classify_status(response.status(), &url)) } From 662f20dbcca9a3bd8fcba96b688834b81321a8a3 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 18:43:30 +0800 Subject: [PATCH 091/102] =?UTF-8?q?perf(pm):=20cap=20160=20=E2=86=92=20128?= =?UTF-8?q?=20(with=20inline=20parse,=20find=20sweet=20spot)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cap=160 + inline parse pushed avg_conc to 119 — past the per-source Cloudflare throttle threshold. Per-req inflated 55 ms → 93 ms; net wall flat at 2.14s. cap=128 + inline parse: avg_conc target ~85-95 (matching standalone manifest-bench cap=128 = 70-90 / 1.6-2.0s under similar Cloudflare conditions). Inline parse alone (no spawn_blocking queue) plus sane concurrency should land preload at ~1.7-1.8s. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/user_config.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/pm/src/util/user_config.rs b/crates/pm/src/util/user_config.rs index 2f608f18a5..b9f7d4b8d9 100644 --- a/crates/pm/src/util/user_config.rs +++ b/crates/pm/src/util/user_config.rs @@ -134,18 +134,20 @@ pub fn get_install_scope() -> InstallScope { // Manifest fetch concurrency configuration. // -// Worker-pool era cap sweep on CI ant-design p1_resolve: -// cap=96 (worker-pool): wall=2.23s avg_conc=66 per-req=53ms -// cap=128 (worker-pool): wall=2.15s avg_conc=84 per-req=66ms +// Sweep history under the worker-pool architecture: +// cap=96 spawn_blocking parse: wall=2.23s avg_conc=66 +// cap=128 spawn_blocking parse: wall=2.15s avg_conc=84 +// cap=160 spawn_blocking parse: wall=1.90s avg_conc=81 (parse queue p95=200ms) +// cap=160 inline parse : wall=2.14s avg_conc=119 (parse queue=0ms) // -// per-req at cap=128 (66ms) is *lower* than at cap=96 (75ms in the -// FuturesUnordered era), refuting the old "Cloudflare per-req -// throttle past ~70 conc" reading — that was an artefact of main -// task saturation. Standalone manifest-bench cap=192 = 130 conc / -// 2.10s on the same CI runner, so try cap=160 to chase another -// 0.1-0.2s out of preload. +// Inline parse eliminated blocking-pool queue but cap=160 lifted +// concurrency past the per-source Cloudflare throttle, inflating +// per-req from 55 ms → 93 ms. Net wall flat. cap=128 + inline parse +// should land near the sweet spot: enough concurrency to saturate +// independent connections, low enough to stay under Cloudflare's +// per-source throttle threshold. static MANIFESTS_CONCURRENCY_LIMIT: LazyLock> = - LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 160)); + LazyLock::new(|| ConfigValue::new("manifests-concurrency-limit", 128)); pub fn set_manifests_concurrency_limit(value: Option) { MANIFESTS_CONCURRENCY_LIMIT.set(value); From e185daefd09906b5bd84892d8ae87a584020beff Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 19:02:16 +0800 Subject: [PATCH 092/102] perf(ruborist): parallelise workspace package.json reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `find_workspaces_from_pkg` was reading every workspace's package.json sequentially in a `for path in matched_paths { read_package_json(...).await }` loop. Ant-design has ~200 workspace packages; at ~1 ms per single-file async FS round-trip on CI runners that's ~150-200 ms of serial I/O — the largest unmeasured chunk between preload completion and lockfile write (hyperfine total p1 minus instrumented sub-phases). Collect workspace paths from every glob pattern first, then dispatch all `read_package_json` calls into a `FuturesUnordered` for parallel execution. Each read is small (typical workspace package.json < 4 KB) so completion order is irrelevant — just push results as they land. Expected: ant-design p1_resolve hyperfine wall drops by 100-150 ms (toward ~2.40s vs current 2.58s). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/workspace.rs | 71 ++++++++++++++--------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/crates/ruborist/src/resolver/workspace.rs b/crates/ruborist/src/resolver/workspace.rs index 6b550255e9..3e9b2cc715 100644 --- a/crates/ruborist/src/resolver/workspace.rs +++ b/crates/ruborist/src/resolver/workspace.rs @@ -68,42 +68,57 @@ impl WorkspaceDiscovery { None => return Ok(workspaces), }; + // Collect all workspace directories from every pattern first, then + // read every package.json concurrently. Ant-design has ~200 + // workspace packages; the previous serial `for path in + // matched_paths { read_package_json(...).await }` paid 200× + // single-file FS round-trips on the resolver hot path + // (~150-200 ms of sequential I/O during p1_resolve, the largest + // unmeasured chunk between preload completion and lockfile write). + let mut workspace_paths: Vec = Vec::new(); for pattern_str in patterns { - // Prepare glob pattern for package.json files let package_json_pattern = root_path.join(pattern_str).join("package.json"); - - // Match workspace directories using Glob::glob let matched_paths = self .glob .glob(&package_json_pattern) .await .map_err(|e| anyhow::anyhow!("Failed to glob pattern: {}", e))?; - for path in matched_paths { - let workspace_path = match path.parent() { - Some(p) => p.to_path_buf(), - None => continue, - }; - - // Read workspace package.json - let workspace_pkg: PackageJson = match read_package_json(&workspace_path).await { - Ok(pkg) => pkg, - Err(e) => { - tracing::debug!("Failed to read workspace package.json: {}", e); - continue; - } - }; - - tracing::debug!( - "Found workspace: {} at {:?}", - workspace_pkg.name, - workspace_path - ); - workspaces.push(WorkspacePackage { - name: workspace_pkg.name.clone(), - path: workspace_path, - package_json: workspace_pkg, - }); + if let Some(p) = path.parent() { + workspace_paths.push(p.to_path_buf()); + } + } + } + + // Fan out: dispatch every package.json read in parallel via + // `FuturesUnordered`. Each read is small (typical workspace + // package.json < 4 KB) so completion order doesn't matter. + use futures::stream::{FuturesUnordered, StreamExt}; + let mut futs: FuturesUnordered<_> = workspace_paths + .into_iter() + .map(|workspace_path| async move { + let pkg = read_package_json(&workspace_path).await; + (workspace_path, pkg) + }) + .collect(); + + while let Some((workspace_path, pkg)) = futs.next().await { + match pkg { + Ok(workspace_pkg) => { + tracing::debug!( + "Found workspace: {} at {:?}", + workspace_pkg.name, + workspace_path + ); + workspaces.push(WorkspacePackage { + name: workspace_pkg.name.clone(), + path: workspace_path, + package_json: workspace_pkg, + }); + } + Err(e) => { + tracing::debug!("Failed to read workspace package.json: {}", e); + } } } From 8295aeb6ea1d0d704e0e574829d35cfcab3d7cef Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 19:20:00 +0800 Subject: [PATCH 093/102] debug(pm): instrument save_package_lock sub-phases p1_resolve hyperfine still has ~80 ms of unmeasured wall after parallel workspace reads (commit bf149957). Suspected: 2-3 MB package-lock.json serialize + atomic-write-rename. Add per-step timing log so we know which knob to turn (compact-json, to_writer streaming, async fs::rename quirks, etc). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/helper/lock.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/pm/src/helper/lock.rs b/crates/pm/src/helper/lock.rs index b5979f0970..c7360b5dc7 100644 --- a/crates/pm/src/helper/lock.rs +++ b/crates/pm/src/helper/lock.rs @@ -481,17 +481,35 @@ pub async fn is_pkg_lock_outdated(root_path: &Path) -> Result { /// Save PackageLock to disk synchronously pub async fn save_package_lock(path: &Path, package_lock: &PackageLock) -> Result<()> { + let t_total = std::time::Instant::now(); let temp_path = path.join("package-lock.json.tmp"); let target_path = path.join("package-lock.json"); - // PackageLock now has all required fields (name, version, lockfile_version, requires, packages) + let t_serialize = std::time::Instant::now(); let content = serde_json::to_string_pretty(package_lock)?; + let serialize_ms = t_serialize.elapsed().as_millis(); + let bytes = content.len(); + + let t_write = std::time::Instant::now(); fs::write(&temp_path, content) .await .context("Failed to write temporary package-lock.json")?; + let write_ms = t_write.elapsed().as_millis(); + + let t_rename = std::time::Instant::now(); fs::rename(temp_path, target_path) .await .context("Failed to rename package-lock.json")?; + let rename_ms = t_rename.elapsed().as_millis(); + + tracing::info!( + "Save package-lock.json: total={}ms (serialize={}ms write={}ms rename={}ms, size={}KB)", + t_total.elapsed().as_millis(), + serialize_ms, + write_ms, + rename_ms, + bytes / 1024, + ); Ok(()) } From f1abb55a3b32d1e18974437f53e1df205e677008 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 19:37:52 +0800 Subject: [PATCH 094/102] debug(ruborist): instrument setup phase (workspace + graph init) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add timer covering find_root_path → read root package.json → engines inject → graph init → root edges → workspace discovery → workspace nodes/edges. This is the chunk between hyperfine start and build_deps entry — currently uninstrumented and the residual ~85ms gap source after lockfile timing showed save is only 11ms. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/service/api.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/ruborist/src/service/api.rs b/crates/ruborist/src/service/api.rs index bd7bde0eba..67e0270c52 100644 --- a/crates/ruborist/src/service/api.rs +++ b/crates/ruborist/src/service/api.rs @@ -135,6 +135,8 @@ where catalogs, } = options; + let t_setup = std::time::Instant::now(); + // 1. Find root path (workspace root if applicable) let discovery = WorkspaceDiscovery::new(glob.clone()); let root_path = discovery.find_root_path(&cwd).await?; @@ -290,6 +292,11 @@ where ); } + tracing::info!( + "Setup phase (workspace + graph init): {:.2?}", + t_setup.elapsed() + ); + // Preserve the typed error via `Error::new` + `.context(...)` so CLI // renderers (e.g. pm's format_print) can downcast and pretty-print the // dependency chain carried by `ResolveError::WithChain`. From cbb22f4a099032d467c23fc30f4f4b3404ed0e40 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 20:24:31 +0800 Subject: [PATCH 095/102] chore(ruborist): drop trailing newline in preload.rs Linter-applied formatting cleanup, no behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ruborist/src/resolver/preload.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ruborist/src/resolver/preload.rs b/crates/ruborist/src/resolver/preload.rs index 506d413334..da6f897b8e 100644 --- a/crates/ruborist/src/resolver/preload.rs +++ b/crates/ruborist/src/resolver/preload.rs @@ -591,4 +591,3 @@ mod tests { assert!("@scope/pkg@1.0.0".is_registry_spec()); } } - From d9819fb17eaa6ca46d1b2731587165cf32ec18a3 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 20:49:18 +0800 Subject: [PATCH 096/102] perf(pm): raise tokio max_blocking_threads from N to max(N*4, 32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original cap was sized for the FuturesUnordered preload that dispatched 128 simd_json parses through `spawn_blocking` in a burst — letting the default 512 cap run gave bimodal wall (M2: 2.7s fast / 6.9s thrash). Capping at `worker_threads` eliminated the thrash peak. After commit f3f616d8 (inline parse) preload no longer uses the blocking pool. The dominant consumer is now `cloner.rs` during the install phase: every file's hardlink / clonefile / copy goes through `spawn_blocking`, ~50000 short syscalls per ant-design install. Each syscall is near-instant, so the cap rarely backpressures, but cap=4 on CI does limit how fast cloner can fire syscalls in parallel. Raise cap to `max(worker_threads * 4, 32)`: enough headroom for cloner to keep multiple syscalls in flight, low enough that the historical thrash regime (hundreds of churning threads) stays avoided. Pool is per-runtime; idle threads die after 10s. Expected: small p3_cold_install improvement (current utoo 5.74s vs bun 7.71s); preload phase unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/main.rs | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/pm/src/main.rs b/crates/pm/src/main.rs index 889030bc3a..dd298d762c 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -352,15 +352,28 @@ fn main() { let result = tokio::runtime::Builder::new_multi_thread() .enable_all() .worker_threads(worker_threads) - // Cap the blocking pool to reduce thread-creation thrash on - // runs that burst-dispatch ~128 spawn_blocking calls (JSON - // parse for manifest fetches). Default 512 leaves room for - // unbounded growth; ~32 threads is enough to saturate a 4-10 - // core machine on short CPU-bound tasks while keeping the - // pool warm across runs. Observed locally: default pool gave - // bimodal walls (2.7s fast / 6.9s thrash on M2); capping at - // 32 eliminates the thrash peak. - .max_blocking_threads(worker_threads) + // Blocking pool cap — historical context: when preload used + // `spawn_blocking` per simd_json parse (~128 bursting at once + // during cold ant-design preload), tokio's default 512 cap + // let the OS create / destroy threads under bimodal load, + // producing 2.7s vs 6.9s wall variance on M2. Capping at + // `worker_threads` eliminated the thrash peak. + // + // After commit f3f616d8 (inline parse) preload no longer + // touches the blocking pool. The dominant consumer now is + // `cloner.rs` during the install phase: every file's + // hardlink / clonefile / copy goes through `spawn_blocking`, + // so a 5000-package install dispatches ~50000 short syscalls + // here. Each syscall is near-instant, so the cap rarely + // backpressures, but `cap = worker_threads` (4 on CI) does + // limit how fast we can fire them in parallel. + // + // Bump cap to `max(worker_threads * 4, 32)`: enough headroom + // for cloner to keep multiple syscalls in flight, low enough + // that the historical thrash regime (hundreds of churning + // threads) is still avoided. The pool is per-runtime; threads + // stay alive until idle for 10s, so warm runs stay warm. + .max_blocking_threads((worker_threads * 4).max(32)) .build() .expect("failed to build tokio runtime") .block_on(async_main()); From 4e598bbbbcbb24d81014a088200663e9e8b39aeb Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 21:05:43 +0800 Subject: [PATCH 097/102] Revert "perf(pm): raise tokio max_blocking_threads from N to max(N*4, 32)" This reverts commit 132ef36e1c099bafe9f87888b52bf9bf30818e48. --- crates/pm/src/main.rs | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/crates/pm/src/main.rs b/crates/pm/src/main.rs index dd298d762c..889030bc3a 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -352,28 +352,15 @@ fn main() { let result = tokio::runtime::Builder::new_multi_thread() .enable_all() .worker_threads(worker_threads) - // Blocking pool cap — historical context: when preload used - // `spawn_blocking` per simd_json parse (~128 bursting at once - // during cold ant-design preload), tokio's default 512 cap - // let the OS create / destroy threads under bimodal load, - // producing 2.7s vs 6.9s wall variance on M2. Capping at - // `worker_threads` eliminated the thrash peak. - // - // After commit f3f616d8 (inline parse) preload no longer - // touches the blocking pool. The dominant consumer now is - // `cloner.rs` during the install phase: every file's - // hardlink / clonefile / copy goes through `spawn_blocking`, - // so a 5000-package install dispatches ~50000 short syscalls - // here. Each syscall is near-instant, so the cap rarely - // backpressures, but `cap = worker_threads` (4 on CI) does - // limit how fast we can fire them in parallel. - // - // Bump cap to `max(worker_threads * 4, 32)`: enough headroom - // for cloner to keep multiple syscalls in flight, low enough - // that the historical thrash regime (hundreds of churning - // threads) is still avoided. The pool is per-runtime; threads - // stay alive until idle for 10s, so warm runs stay warm. - .max_blocking_threads((worker_threads * 4).max(32)) + // Cap the blocking pool to reduce thread-creation thrash on + // runs that burst-dispatch ~128 spawn_blocking calls (JSON + // parse for manifest fetches). Default 512 leaves room for + // unbounded growth; ~32 threads is enough to saturate a 4-10 + // core machine on short CPU-bound tasks while keeping the + // pool warm across runs. Observed locally: default pool gave + // bimodal walls (2.7s fast / 6.9s thrash on M2); capping at + // 32 eliminates the thrash peak. + .max_blocking_threads(worker_threads) .build() .expect("failed to build tokio runtime") .block_on(async_main()); From 7a83e9aaf39ad6bf23b61815cefef4c12c5a95a3 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 21:06:44 +0800 Subject: [PATCH 098/102] test(pm): drop intra-package rayon par_chunks in tarball extract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A/B test: replace `entries.par_chunks(WRITE_CHUNK_SIZE).try_for_each` with a plain sequential `for entry in &entries` loop. Each tarball still runs in its own outer `rayon::spawn` task (cross-package parallelism preserved); only the within-tarball write fan-out is removed. Goal: measure whether rayon's intra-package parallelism still earns its keep after the worker-pool preload rewrite. Cross-package parallelism alone may already saturate IO; if so, removing the inner par_chunks cuts work-stealing futex traffic + thread sync overhead with zero throughput cost. If p3_cold_install regresses ≥0.3s → intra-package writes are genuinely IO-bound across cores, restore par_chunks. If p3 unchanged or improves → simpler sequential code wins. This is a test commit. Will be reverted if regression measured. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/extractor.rs | 41 +++++++++++++++------------------ 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/crates/pm/src/util/extractor.rs b/crates/pm/src/util/extractor.rs index ca25614814..9e95c452ce 100644 --- a/crates/pm/src/util/extractor.rs +++ b/crates/pm/src/util/extractor.rs @@ -73,7 +73,6 @@ async fn extract_tarball(gzip_bytes: Bytes, dest: &Path) -> Result<()> { /// futex park/unpark pressure) by the chunk factor. Cross-package parallelism is /// preserved by the outer `rayon::spawn` in `extract_tarball`. 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::{Cursor, Read, Write}; @@ -163,27 +162,25 @@ fn extract_tarball_sync(gzip_bytes: Bytes, estimated_size: usize, dest: &Path) - fs::create_dir(dir).ok(); } - // Chunked parallel writes: N files per rayon task. Retains IO-overlap - // parallelism across cores while cutting the rayon task count (and the - // associated work-stealing futex traffic) relative to per-file par_iter. - entries - .par_chunks(WRITE_CHUNK_SIZE) - .try_for_each(|chunk| -> Result<()> { - for entry in chunk { - let mut file = fs::File::create(&entry.path) - .with_context(|| format!("Failed to create: {}", entry.path.display()))?; - file.write_all(&entry.content) - .with_context(|| format!("Failed to write: {}", entry.path.display()))?; - - // Skip chmod for 0o644 (most files) — File::create() already - // produces this via umask (0o666 & ~0o022 = 0o644). - #[cfg(unix)] - if entry.mode != 0o644 { - fs::set_permissions(&entry.path, fs::Permissions::from_mode(entry.mode)).ok(); - } - } - Ok(()) - })?; + // Sequential writes within this package — testing whether + // intra-package rayon parallelism is still earning its keep + // after the worker-pool preload rewrite. Cross-package + // parallelism is preserved by the outer `rayon::spawn` in + // `extract_tarball` (each tarball gets its own rayon task). + let _ = WRITE_CHUNK_SIZE; // silence unused-const lint for now + for entry in &entries { + let mut file = fs::File::create(&entry.path) + .with_context(|| format!("Failed to create: {}", entry.path.display()))?; + file.write_all(&entry.content) + .with_context(|| format!("Failed to write: {}", entry.path.display()))?; + + // Skip chmod for 0o644 (most files) — File::create() already + // produces this via umask (0o666 & ~0o022 = 0o644). + #[cfg(unix)] + if entry.mode != 0o644 { + fs::set_permissions(&entry.path, fs::Permissions::from_mode(entry.mode)).ok(); + } + } // Set directory permissions #[cfg(unix)] From 3014901d16c4f2c48f406e53c9c11aebdc006049 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 21:23:35 +0800 Subject: [PATCH 099/102] Revert "test(pm): drop intra-package rayon par_chunks in tarball extract" This reverts commit c7c847d62cdc2dcdf138fc06cfa10a7b45f8bc8c. --- crates/pm/src/util/extractor.rs | 41 ++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/crates/pm/src/util/extractor.rs b/crates/pm/src/util/extractor.rs index 9e95c452ce..ca25614814 100644 --- a/crates/pm/src/util/extractor.rs +++ b/crates/pm/src/util/extractor.rs @@ -73,6 +73,7 @@ async fn extract_tarball(gzip_bytes: Bytes, dest: &Path) -> Result<()> { /// futex park/unpark pressure) by the chunk factor. Cross-package parallelism is /// preserved by the outer `rayon::spawn` in `extract_tarball`. 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::{Cursor, Read, Write}; @@ -162,25 +163,27 @@ fn extract_tarball_sync(gzip_bytes: Bytes, estimated_size: usize, dest: &Path) - fs::create_dir(dir).ok(); } - // Sequential writes within this package — testing whether - // intra-package rayon parallelism is still earning its keep - // after the worker-pool preload rewrite. Cross-package - // parallelism is preserved by the outer `rayon::spawn` in - // `extract_tarball` (each tarball gets its own rayon task). - let _ = WRITE_CHUNK_SIZE; // silence unused-const lint for now - for entry in &entries { - let mut file = fs::File::create(&entry.path) - .with_context(|| format!("Failed to create: {}", entry.path.display()))?; - file.write_all(&entry.content) - .with_context(|| format!("Failed to write: {}", entry.path.display()))?; - - // Skip chmod for 0o644 (most files) — File::create() already - // produces this via umask (0o666 & ~0o022 = 0o644). - #[cfg(unix)] - if entry.mode != 0o644 { - fs::set_permissions(&entry.path, fs::Permissions::from_mode(entry.mode)).ok(); - } - } + // Chunked parallel writes: N files per rayon task. Retains IO-overlap + // parallelism across cores while cutting the rayon task count (and the + // associated work-stealing futex traffic) relative to per-file par_iter. + entries + .par_chunks(WRITE_CHUNK_SIZE) + .try_for_each(|chunk| -> Result<()> { + for entry in chunk { + let mut file = fs::File::create(&entry.path) + .with_context(|| format!("Failed to create: {}", entry.path.display()))?; + file.write_all(&entry.content) + .with_context(|| format!("Failed to write: {}", entry.path.display()))?; + + // Skip chmod for 0o644 (most files) — File::create() already + // produces this via umask (0o666 & ~0o022 = 0o644). + #[cfg(unix)] + if entry.mode != 0o644 { + fs::set_permissions(&entry.path, fs::Permissions::from_mode(entry.mode)).ok(); + } + } + Ok(()) + })?; // Set directory permissions #[cfg(unix)] From 1bbbaa46dbef5f5db439bc10d036e253c263b2fa Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 21:43:28 +0800 Subject: [PATCH 100/102] perf(pm): clone_dir uses rayon (mirror extractor pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clone_dir` (Linux hardlink/copy path) was using `tokio::task::spawn_blocking` per package — at default cap=4 on CI, only 4 packages cloned at once, each running all file hardlinks sequentially internally. ~3500 packages × N files per install all funneled through that bounded pool. Switch to the same pattern `extractor.rs` already uses: - `rayon::spawn` per package replaces `spawn_blocking` (cross-package parallelism via rayon work-stealing — global pool, not capped at worker_threads) - `par_chunks(CLONE_CHUNK_SIZE)` for the inner hardlink/copy loop (intra-package fan-out across cores; same chunk size = 32 as extractor) Trade-offs: - EXDEV `force_copy` latch is now per-chunk instead of global per clone — chunks each rediscover cross-device errors and fall back locally. A few extra hardlink-then-copy round-trips at chunk boundaries, acceptable for the rare cross-device install. - Pool unification: tokio blocking pool now mostly idle (just git + http tarball + a few one-shot commands), rayon handles all the high-volume IO. Cuts the 3-pool fragmentation observed earlier. Tested: - Iter 1 of this loop (cap bump from N to max(N*4, 32)): no p3 win, p4 regressed → cap raise alone wasn't the answer. - Iter 2 (drop intra-package par_chunks in extractor): p3 +3.67s, σ exploded 0.04 → 2.85s → intra-package fan-out is essential. - This commit applies the same fan-out to clone_dir for the same reason. macOS `clonefile` path (target_os = "macos") unchanged — clonefile is a single syscall per file, different perf profile. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pm/src/util/cloner.rs | 155 +++++++++++++++++++++-------------- 1 file changed, 94 insertions(+), 61 deletions(-) diff --git a/crates/pm/src/util/cloner.rs b/crates/pm/src/util/cloner.rs index 77aa63e2e9..1c88e524af 100644 --- a/crates/pm/src/util/cloner.rs +++ b/crates/pm/src/util/cloner.rs @@ -177,77 +177,110 @@ mod hardlink_clone { Ok(()) } - /// Clone directory using spawn_blocking for sync I/O. - /// Uses hardlink when possible, falls back to copy. + /// Clone directory using rayon for parallel sync I/O. + /// + /// Mirrors `extractor.rs`'s pattern: `rayon::spawn` per package + /// (cross-package parallelism via rayon work-stealing instead of + /// tokio's bounded blocking pool) + `par_chunks` for the file + /// hardlink/copy loop (intra-package fan-out across cores). + /// + /// Why rayon over `tokio::task::spawn_blocking`: per-install we + /// dispatch ~3500 clone_dir calls (one per resolved package). + /// Default `tokio` blocking pool capped at `worker_threads` + /// (4 on CI runners) means at most 4 packages cloning at once, + /// each running ALL its file hardlinks sequentially. rayon's + /// global pool was already warm from tarball extraction — + /// reusing it here cuts inter-pool fragmentation (3 pools → + /// effectively 2: tokio async + rayon). pub async fn clone_dir(src: &Path, dst: &Path) -> Result<()> { + use rayon::prelude::*; + + /// Files per rayon task when fanning out hardlinks within a + /// package. Same chunk size as `extractor.rs` — amortises + /// work-stealing futex overhead while keeping enough tasks + /// to saturate cores on large packages. + const CLONE_CHUNK_SIZE: usize = 32; + let err_msg = format!("Failed to clone {} to {}", src.display(), dst.display()); let src = src.to_path_buf(); let dst = dst.to_path_buf(); - tokio::task::spawn_blocking(move || { - if !fs::metadata(&src)?.is_dir() { - return Err(io::Error::new( - io::ErrorKind::NotADirectory, - "Source is not a directory", - )); - } + let (tx, rx) = tokio::sync::oneshot::channel(); - let mut force_copy = has_install_script_sync(&src); - - // Phase 1: Collect all files and directories - let mut files = Vec::new(); - let mut dirs = Vec::new(); - collect_entries(&src, &dst, &mut files, &mut dirs)?; - - // Phase 2: Create all directories - let mut created_dirs = HashSet::new(); - for dir in &dirs { - if created_dirs.insert(dir.clone()) - && let Err(e) = fs::create_dir_all(dir) - && e.kind() != io::ErrorKind::AlreadyExists - { - return Err(e); + rayon::spawn(move || { + let result = (|| -> io::Result<()> { + if !fs::metadata(&src)?.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + "Source is not a directory", + )); } - } - // Phase 3: Clone files (hardlink, fall back to copy on error). - // - // EXDEV (src cache and dst on different filesystems, e.g. a - // global install where ~/.cache/nm lives on a different volume - // than /usr/local) is a property of the src/dst pair — every - // remaining file would fail the same way, so latch `force_copy` - // and skip hardlink for the rest of this clone. - // - // Any other hardlink error (EMLINK on a single inode whose link - // count is exhausted, EPERM on a specific file, etc.) is - // per-file: copy this one and keep trying hardlink on the next. - for entry in &files { - if force_copy { - copy_file_sync(&entry.src, &entry.dst)?; - } else if let Err(e) = fs::hard_link(&entry.src, &entry.dst) { - if e.kind() == io::ErrorKind::CrossesDevices { - tracing::warn!( - "cross-device hardlink {} -> {}: {}; falling back to copy for remaining files", - src.display(), - dst.display(), - e - ); - force_copy = true; - } else { - tracing::warn!( - "hardlink failed for {} -> {}: {}; falling back to copy for this file", - entry.src.display(), - entry.dst.display(), - e - ); + let force_copy_initial = has_install_script_sync(&src); + + // Phase 1: Collect all files and directories + let mut files = Vec::new(); + let mut dirs = Vec::new(); + collect_entries(&src, &dst, &mut files, &mut dirs)?; + + // Phase 2: Create all directories (sequential — typical + // package has <50 dirs, parallel mkdir wastes overhead). + let mut created_dirs = HashSet::new(); + for dir in &dirs { + if created_dirs.insert(dir.clone()) + && let Err(e) = fs::create_dir_all(dir) + && e.kind() != io::ErrorKind::AlreadyExists + { + return Err(e); } - copy_file_sync(&entry.src, &entry.dst)?; } - } - Ok(()) - }) - .await? - .with_context(|| err_msg) + + // Phase 3: Parallel clone files (hardlink, fall back to copy). + // + // EXDEV detection differs from the sequential version: + // each chunk independently discovers cross-device errors + // and falls back to copy locally, so a few extra + // hardlink-then-copy round-trips happen at chunk + // boundaries (vs the prior single force_copy latch). + // For packages where install_script forces copy from the + // start, that's pre-decided. + let src_for_par = src.clone(); + let dst_for_par = dst.clone(); + files.par_chunks(CLONE_CHUNK_SIZE).try_for_each(|chunk| { + let mut chunk_force_copy = force_copy_initial; + for entry in chunk { + if chunk_force_copy { + copy_file_sync(&entry.src, &entry.dst)?; + } else if let Err(e) = fs::hard_link(&entry.src, &entry.dst) { + if e.kind() == io::ErrorKind::CrossesDevices { + tracing::warn!( + "cross-device hardlink {} -> {}: {}; falling back to copy for remaining files in chunk", + src_for_par.display(), + dst_for_par.display(), + e + ); + chunk_force_copy = true; + } else { + tracing::warn!( + "hardlink failed for {} -> {}: {}; falling back to copy for this file", + entry.src.display(), + entry.dst.display(), + e + ); + } + copy_file_sync(&entry.src, &entry.dst)?; + } + } + Ok::<(), io::Error>(()) + })?; + Ok(()) + })(); + let _ = tx.send(result); + }); + + rx.await + .map_err(|_| anyhow::anyhow!("clone_dir rayon task panicked"))? + .with_context(|| err_msg) } } From 6e3a6583523ee64c303c81d046f45b7d09e48c22 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Sat, 25 Apr 2026 22:00:19 +0800 Subject: [PATCH 101/102] Revert "perf(pm): clone_dir uses rayon (mirror extractor pattern)" This reverts commit 9229e1608490a41963067f86d7f10f54747f557a. --- crates/pm/src/util/cloner.rs | 155 ++++++++++++++--------------------- 1 file changed, 61 insertions(+), 94 deletions(-) diff --git a/crates/pm/src/util/cloner.rs b/crates/pm/src/util/cloner.rs index 1c88e524af..77aa63e2e9 100644 --- a/crates/pm/src/util/cloner.rs +++ b/crates/pm/src/util/cloner.rs @@ -177,110 +177,77 @@ mod hardlink_clone { Ok(()) } - /// Clone directory using rayon for parallel sync I/O. - /// - /// Mirrors `extractor.rs`'s pattern: `rayon::spawn` per package - /// (cross-package parallelism via rayon work-stealing instead of - /// tokio's bounded blocking pool) + `par_chunks` for the file - /// hardlink/copy loop (intra-package fan-out across cores). - /// - /// Why rayon over `tokio::task::spawn_blocking`: per-install we - /// dispatch ~3500 clone_dir calls (one per resolved package). - /// Default `tokio` blocking pool capped at `worker_threads` - /// (4 on CI runners) means at most 4 packages cloning at once, - /// each running ALL its file hardlinks sequentially. rayon's - /// global pool was already warm from tarball extraction — - /// reusing it here cuts inter-pool fragmentation (3 pools → - /// effectively 2: tokio async + rayon). + /// Clone directory using spawn_blocking for sync I/O. + /// Uses hardlink when possible, falls back to copy. pub async fn clone_dir(src: &Path, dst: &Path) -> Result<()> { - use rayon::prelude::*; - - /// Files per rayon task when fanning out hardlinks within a - /// package. Same chunk size as `extractor.rs` — amortises - /// work-stealing futex overhead while keeping enough tasks - /// to saturate cores on large packages. - const CLONE_CHUNK_SIZE: usize = 32; - let err_msg = format!("Failed to clone {} to {}", src.display(), dst.display()); let src = src.to_path_buf(); let dst = dst.to_path_buf(); - let (tx, rx) = tokio::sync::oneshot::channel(); + tokio::task::spawn_blocking(move || { + if !fs::metadata(&src)?.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + "Source is not a directory", + )); + } - rayon::spawn(move || { - let result = (|| -> io::Result<()> { - if !fs::metadata(&src)?.is_dir() { - return Err(io::Error::new( - io::ErrorKind::NotADirectory, - "Source is not a directory", - )); + let mut force_copy = has_install_script_sync(&src); + + // Phase 1: Collect all files and directories + let mut files = Vec::new(); + let mut dirs = Vec::new(); + collect_entries(&src, &dst, &mut files, &mut dirs)?; + + // Phase 2: Create all directories + let mut created_dirs = HashSet::new(); + for dir in &dirs { + if created_dirs.insert(dir.clone()) + && let Err(e) = fs::create_dir_all(dir) + && e.kind() != io::ErrorKind::AlreadyExists + { + return Err(e); } + } - let force_copy_initial = has_install_script_sync(&src); - - // Phase 1: Collect all files and directories - let mut files = Vec::new(); - let mut dirs = Vec::new(); - collect_entries(&src, &dst, &mut files, &mut dirs)?; - - // Phase 2: Create all directories (sequential — typical - // package has <50 dirs, parallel mkdir wastes overhead). - let mut created_dirs = HashSet::new(); - for dir in &dirs { - if created_dirs.insert(dir.clone()) - && let Err(e) = fs::create_dir_all(dir) - && e.kind() != io::ErrorKind::AlreadyExists - { - return Err(e); + // Phase 3: Clone files (hardlink, fall back to copy on error). + // + // EXDEV (src cache and dst on different filesystems, e.g. a + // global install where ~/.cache/nm lives on a different volume + // than /usr/local) is a property of the src/dst pair — every + // remaining file would fail the same way, so latch `force_copy` + // and skip hardlink for the rest of this clone. + // + // Any other hardlink error (EMLINK on a single inode whose link + // count is exhausted, EPERM on a specific file, etc.) is + // per-file: copy this one and keep trying hardlink on the next. + for entry in &files { + if force_copy { + copy_file_sync(&entry.src, &entry.dst)?; + } else if let Err(e) = fs::hard_link(&entry.src, &entry.dst) { + if e.kind() == io::ErrorKind::CrossesDevices { + tracing::warn!( + "cross-device hardlink {} -> {}: {}; falling back to copy for remaining files", + src.display(), + dst.display(), + e + ); + force_copy = true; + } else { + tracing::warn!( + "hardlink failed for {} -> {}: {}; falling back to copy for this file", + entry.src.display(), + entry.dst.display(), + e + ); } + copy_file_sync(&entry.src, &entry.dst)?; } - - // Phase 3: Parallel clone files (hardlink, fall back to copy). - // - // EXDEV detection differs from the sequential version: - // each chunk independently discovers cross-device errors - // and falls back to copy locally, so a few extra - // hardlink-then-copy round-trips happen at chunk - // boundaries (vs the prior single force_copy latch). - // For packages where install_script forces copy from the - // start, that's pre-decided. - let src_for_par = src.clone(); - let dst_for_par = dst.clone(); - files.par_chunks(CLONE_CHUNK_SIZE).try_for_each(|chunk| { - let mut chunk_force_copy = force_copy_initial; - for entry in chunk { - if chunk_force_copy { - copy_file_sync(&entry.src, &entry.dst)?; - } else if let Err(e) = fs::hard_link(&entry.src, &entry.dst) { - if e.kind() == io::ErrorKind::CrossesDevices { - tracing::warn!( - "cross-device hardlink {} -> {}: {}; falling back to copy for remaining files in chunk", - src_for_par.display(), - dst_for_par.display(), - e - ); - chunk_force_copy = true; - } else { - tracing::warn!( - "hardlink failed for {} -> {}: {}; falling back to copy for this file", - entry.src.display(), - entry.dst.display(), - e - ); - } - copy_file_sync(&entry.src, &entry.dst)?; - } - } - Ok::<(), io::Error>(()) - })?; - Ok(()) - })(); - let _ = tx.send(result); - }); - - rx.await - .map_err(|_| anyhow::anyhow!("clone_dir rayon task panicked"))? - .with_context(|| err_msg) + } + Ok(()) + }) + .await? + .with_context(|| err_msg) } } From d579072257725ed356ef7e7c63b44240b2d1e22c Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Mon, 27 Apr 2026 18:08:32 +0800 Subject: [PATCH 102/102] chore(pm): fix CI gates on rebased #2818 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delete crates/manifest-bench (debug-only, never merged) - tombi format crates/ruborist/Cargo.toml - typos: unparseable → unparsable in bench/pm-bench.sh --- Cargo.toml | 1 - bench/pm-bench.sh | 2 +- crates/manifest-bench/Cargo.toml | 37 --- crates/manifest-bench/src/main.rs | 371 ------------------------------ crates/ruborist/Cargo.toml | 53 ++--- 5 files changed, 25 insertions(+), 439 deletions(-) delete mode 100644 crates/manifest-bench/Cargo.toml delete mode 100644 crates/manifest-bench/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 769cac33c7..3caf94264e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] resolver = "2" members = [ - "crates/manifest-bench", "crates/pack-api", "crates/pack-cli", "crates/pack-core", diff --git a/bench/pm-bench.sh b/bench/pm-bench.sh index 12645c8b9e..2d3a58de44 100755 --- a/bench/pm-bench.sh +++ b/bench/pm-bench.sh @@ -380,7 +380,7 @@ print_results() { if (!raw.trim()) { console.log(' skip empty: ' + file); continue; } data = JSON.parse(raw); } catch (err) { - console.log(' skip unparseable: ' + file + ' (' + err.message + ')'); + console.log(' skip unparsable: ' + file + ' (' + err.message + ')'); continue; } const base = file.replace('.json', ''); diff --git a/crates/manifest-bench/Cargo.toml b/crates/manifest-bench/Cargo.toml deleted file mode 100644 index 5b01e57c0a..0000000000 --- a/crates/manifest-bench/Cargo.toml +++ /dev/null @@ -1,37 +0,0 @@ -[package] -name = "manifest-bench" -version = "0.0.0" -edition = "2024" -license = "MIT" -publish = false -description = "Standalone HTTP-only manifest fetch benchmark, isolating network behaviour from ruborist's resolver pipeline." - -[[bin]] -name = "manifest-bench" -path = "src/main.rs" - -# tombi: format.rules.table-keys-order.disabled = true -[dependencies] -anyhow = { workspace = true } -clap = { workspace = true } -futures = "0.3" -serde = { version = "1", features = ["derive"] } -serde_json = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs", "time"] } - -# Identical TLS / DNS choices to ruborist so we measure the *protocol* -# characteristics of the same stack, not a different implementation. -reqwest = { version = "0.12", default-features = false, features = [ - "brotli", - "gzip", - "http2", - "rustls-tls-native-roots-no-provider", - "socks" -] } -rustls = { version = "0.23", default-features = false, features = [ - "aws-lc-rs", - "logging", - "std", - "tls12" -] } -rustls-native-certs = "0.8" diff --git a/crates/manifest-bench/src/main.rs b/crates/manifest-bench/src/main.rs deleted file mode 100644 index fa70f3fe42..0000000000 --- a/crates/manifest-bench/src/main.rs +++ /dev/null @@ -1,371 +0,0 @@ -//! Standalone HTTP-only manifest fetch benchmark. -//! -//! Isolates the network behaviour of `reqwest + rustls + tokio` from -//! ruborist's resolver pipeline (BFS, dedup, parse, lockfile, project -//! cache). Reads a list of package names, builds manifest URLs, fires -//! parallel `GET` requests, records `(start, end)` per request, and -//! reports the same diag shape as ruborist's `Preload HTTP diag` line. -//! -//! Two input modes: -//! - `--names-file ` — newline-separated package names -//! - `--lockfile ` — a npm-style package-lock.json; we extract -//! the `packages.*` (v3) or `dependencies.*` (v2) keys -//! -//! Two registry modes: -//! - `/` — full manifest endpoint (default, npmjs) -//! - `//latest` — single-version endpoint -//! (gated behind `--single-version`) -//! -//! Each request reads the body to completion (we only measure I/O, no -//! parse). Output: same fields as preload's HTTP diag for direct -//! comparison. - -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Instant; - -use anyhow::{Context, Result, anyhow}; -use clap::Parser; -use futures::stream::{FuturesUnordered, StreamExt}; - -#[derive(Parser, Debug)] -#[command( - name = "manifest-bench", - about = "HTTP-only manifest fetch bench (no parse, no resolver)" -)] -struct Args { - /// Registry base URL. - #[arg(long, default_value = "https://registry.npmjs.org")] - registry: String, - - /// File of newline-separated package names. Mutually exclusive with `--lockfile`. - #[arg(long, conflicts_with = "lockfile")] - names_file: Option, - - /// `package-lock.json` file. Reads top-level `packages.*.name` keys. - #[arg(long)] - lockfile: Option, - - /// Maximum concurrent in-flight requests. - #[arg(long, default_value_t = 128)] - concurrency: usize, - - /// Number of times to repeat the whole sweep (each iteration is a - /// fresh `reqwest::Client`, so connection pool / TLS handshake - /// costs are paid each time, matching `hyperfine` cold-start). - #[arg(long, default_value_t = 1)] - reps: usize, - - /// Use the single-version endpoint `//latest` instead of the - /// full-manifest endpoint `/`. Smaller bodies, more requests - /// served per byte. - #[arg(long)] - single_version: bool, - - /// Override `Accept` header. Default mimics ruborist's preload - /// (`application/vnd.npm.install-v1+json` — abbreviated metadata). - #[arg(long)] - accept: Option, - - /// Override `User-Agent`. Default uses reqwest's default. Try - /// `Bun/1.x.x` to test whether Cloudflare differentiates by UA. - #[arg(long)] - user_agent: Option, - - /// Force HTTP/1.1 (no H2 negotiation). Default lets ALPN decide. - #[arg(long)] - http1_only: bool, -} - -#[tokio::main] -async fn main() -> Result<()> { - let args = Args::parse(); - - let names = load_names(&args)?; - if names.is_empty() { - return Err(anyhow!("no package names found in input")); - } - - println!( - "manifest-bench: registry={} concurrency={} reps={} names={} h1_only={} single_version={} accept={} ua={}", - args.registry, - args.concurrency, - args.reps, - names.len(), - args.http1_only, - args.single_version, - args.accept.as_deref().unwrap_or(""), - args.user_agent.as_deref().unwrap_or(""), - ); - - for rep in 1..=args.reps { - run_once(&args, &names, rep).await?; - } - - Ok(()) -} - -fn load_names(args: &Args) -> Result> { - if let Some(path) = &args.names_file { - let raw = std::fs::read_to_string(path).with_context(|| format!("read {path:?}"))?; - return Ok(raw - .lines() - .map(str::trim) - .filter(|s| !s.is_empty() && !s.starts_with('#')) - .map(str::to_string) - .collect()); - } - - if let Some(path) = &args.lockfile { - let raw = std::fs::read_to_string(path).with_context(|| format!("read {path:?}"))?; - return extract_lockfile_names(&raw); - } - - Err(anyhow!("provide --names-file or --lockfile")) -} - -/// Pull unique package names from an npm v3 lockfile (`packages.*`) -/// or an older v2 lockfile (`dependencies.*`). -fn extract_lockfile_names(raw: &str) -> Result> { - use std::collections::BTreeSet; - - let v: serde_json::Value = serde_json::from_str(raw).context("parse lockfile JSON")?; - let mut names: BTreeSet = BTreeSet::new(); - - if let Some(packages) = v.get("packages").and_then(|p| p.as_object()) { - for key in packages.keys() { - if key.is_empty() { - continue; - } - // npm v3 packages key like "node_modules/foo" or - // "node_modules/@scope/bar/node_modules/baz" — take the - // last path segment (or @scope/name pair). - let last = last_module_name(key); - if !last.is_empty() { - names.insert(last); - } - } - } else if let Some(deps) = v.get("dependencies").and_then(|d| d.as_object()) { - for key in deps.keys() { - names.insert(key.clone()); - } - } - - Ok(names.into_iter().collect()) -} - -fn last_module_name(key: &str) -> String { - let parts: Vec<&str> = key.split("node_modules/").collect(); - let tail = parts.last().copied().unwrap_or(""); - tail.to_string() -} - -#[derive(Debug)] -struct ReqResult { - start: Instant, - end: Instant, - bytes: usize, - status: u16, -} - -async fn run_once(args: &Args, names: &[String], rep: usize) -> Result<()> { - // Build a fresh client per rep — matches hyperfine's cold-start - // assumption that each iteration pays the TLS handshake cost. - let client = build_client(args)?; - let registry = Arc::new(args.registry.trim_end_matches('/').to_string()); - let accept = Arc::new( - args.accept - .clone() - .unwrap_or_else(|| "application/vnd.npm.install-v1+json".to_string()), - ); - - let single_version = args.single_version; - let concurrency = args.concurrency; - - let phase_start = Instant::now(); - let mut futs = FuturesUnordered::new(); - let mut idx = 0usize; - let mut results: Vec = Vec::with_capacity(names.len()); - - while idx < names.len() && futs.len() < concurrency { - spawn_one( - &client, - ®istry, - &names[idx], - &accept, - single_version, - &mut futs, - ); - idx += 1; - } - - while let Some(res) = futs.next().await { - results.push(res); - if idx < names.len() { - spawn_one( - &client, - ®istry, - &names[idx], - &accept, - single_version, - &mut futs, - ); - idx += 1; - } - } - let phase_wall_ms = phase_start.elapsed().as_millis(); - - report(rep, &results, phase_wall_ms); - Ok(()) -} - -type Fut = std::pin::Pin + Send>>; - -fn spawn_one( - client: &reqwest::Client, - registry: &Arc, - name: &str, - accept: &Arc, - single_version: bool, - futs: &mut FuturesUnordered, -) { - let url = if single_version { - format!("{registry}/{name}/latest") - } else { - format!("{registry}/{name}") - }; - let client = client.clone(); - let accept = Arc::clone(accept); - futs.push(Box::pin(async move { - let start = Instant::now(); - let req = client.get(&url).header("accept", accept.as_str()).send(); - let (bytes, status) = match req.await { - Ok(resp) => { - let status = resp.status().as_u16(); - let body = resp.bytes().await.map(|b| b.len()).unwrap_or(0); - (body, status) - } - Err(_) => (0, 0), - }; - let end = Instant::now(); - ReqResult { - start, - end, - bytes, - status, - } - })); -} - -fn build_client(args: &Args) -> Result { - // Install aws-lc-rs as the default crypto provider (idempotent — - // first call wins). Matches ruborist's `service::http` setup. - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - - let mut roots = rustls::RootCertStore::empty(); - let native = rustls_native_certs::load_native_certs(); - for cert in native.certs { - let _ = roots.add(cert); - } - - let tls_config = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new( - rustls::crypto::aws_lc_rs::default_provider(), - )) - .with_safe_default_protocol_versions() - .map_err(|e| anyhow!("rustls protocol versions: {e}"))? - .with_root_certificates(roots) - .with_no_client_auth(); - - let mut builder = reqwest::Client::builder() - .use_preconfigured_tls(tls_config) - .no_proxy() - .pool_max_idle_per_host(256); - if args.http1_only { - builder = builder.http1_only(); - } - if let Some(ua) = &args.user_agent { - builder = builder.user_agent(ua); - } - builder.build().context("build reqwest client") -} - -fn report(rep: usize, results: &[ReqResult], wall_ms: u128) { - if results.is_empty() { - eprintln!("[rep {rep}] no results"); - return; - } - - let mut spans: Vec<(Instant, Instant)> = results.iter().map(|r| (r.start, r.end)).collect(); - spans.sort_by_key(|(s, _)| *s); - - let first_start = spans.first().unwrap().0; - let last_end = spans.iter().map(|(_, e)| *e).max().unwrap(); - let win_wall = last_end.duration_since(first_start).as_millis(); - - let mut per_us: Vec = spans - .iter() - .map(|(s, e)| e.duration_since(*s).as_micros()) - .collect(); - per_us.sort_unstable(); - let n = per_us.len(); - let pct = |p: usize| per_us[(n * p).div_ceil(100).saturating_sub(1)]; - let sum: u128 = per_us.iter().sum(); - let p50 = per_us[n / 2]; - - let mut busy_us: u128 = 0; - let (mut cur_s, mut cur_e) = spans[0]; - for &(s, e) in &spans[1..] { - if s <= cur_e { - if e > cur_e { - cur_e = e; - } - } else { - busy_us += cur_e.duration_since(cur_s).as_micros(); - cur_s = s; - cur_e = e; - } - } - busy_us += cur_e.duration_since(cur_s).as_micros(); - - let bytes_total: usize = results.iter().map(|r| r.bytes).sum(); - let ok = results.iter().filter(|r| r.status == 200).count(); - let err = results.iter().filter(|r| r.status == 0).count(); - let four_xx = results - .iter() - .filter(|r| (400..500).contains(&r.status)) - .count(); - let five_xx = results - .iter() - .filter(|r| (500..600).contains(&r.status)) - .count(); - - let avg_conc = if busy_us > 0 { - sum as f64 / busy_us as f64 - } else { - 0.0 - }; - - println!( - "[rep {rep}] n={} phase_wall={}ms win_wall={}ms busy={}ms ({:.0}%) sum={}ms avg_conc={:.1} p50={}ms p95={}ms p99={}ms max={}ms bytes={} 200={} 4xx={} 5xx={} err={}", - n, - wall_ms, - win_wall, - busy_us / 1000, - if win_wall > 0 { - 100.0 * (busy_us as f64 / 1000.0) / win_wall as f64 - } else { - 0.0 - }, - sum / 1000, - avg_conc, - p50 / 1000, - pct(95) / 1000, - pct(99) / 1000, - per_us.last().unwrap() / 1000, - bytes_total, - ok, - four_xx, - five_xx, - err, - ); -} diff --git a/crates/ruborist/Cargo.toml b/crates/ruborist/Cargo.toml index 57bca14329..b2efb9adb6 100644 --- a/crates/ruborist/Cargo.toml +++ b/crates/ruborist/Cargo.toml @@ -7,23 +7,23 @@ license = "MIT" # tombi: format.rules.table-keys-order.disabled = true [dependencies] -anyhow = { workspace = true } -bytes = "1" -crossbeam-queue = "0.3" -dashmap = "6.1.0" -pathdiff = { workspace = true } -deno_semver = "0.7" -dirs = { workspace = true } -futures = "0.3" -parking_lot = { workspace = true } -petgraph = "0.6" -serde = { version = "1.0", features = ["derive"] } -serde_json = { workspace = true } -simd-json = "0.17" -thiserror = { workspace = true } -tokio-fs-ext = { workspace = true } -tokio-retry = "0.3" -tracing = { workspace = true } +anyhow = { workspace = true } +bytes = "1" +crossbeam-queue = "0.3" +dashmap = "6.1.0" +pathdiff = { workspace = true } +deno_semver = "0.7" +dirs = { workspace = true } +futures = "0.3" +parking_lot = { workspace = true } +petgraph = "0.6" +serde = { version = "1.0", features = ["derive"] } +serde_json = { workspace = true } +simd-json = "0.17" +thiserror = { workspace = true } +tokio-fs-ext = { workspace = true } +tokio-retry = "0.3" +tracing = { workspace = true } # HTTP client - works on both native and WASM # # We use `rustls-tls-native-roots-no-provider` (instead of the default @@ -34,7 +34,7 @@ tracing = { workspace = true } # npmjs.org: `ring` took 78 ms p50 / 154 ms max from CCS → first # AppData per handshake (CPU-bound; 128 parallel handshakes serialise # across 4 cores). aws-lc-rs's per-handshake CPU is roughly 3× faster. -reqwest = { version = "0.12", default-features = false, features = [ +reqwest = { version = "0.12", default-features = false, features = [ "brotli", "gzip", "http2", @@ -43,26 +43,21 @@ reqwest = { version = "0.12", default-features = false, features = [ "socks" ] } # Our own rustls + aws-lc-rs crypto provider. -rustls = { version = "0.23", default-features = false, features = [ - "aws-lc-rs", - "logging", - "std", - "tls12" -] } +rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "logging", "std", "tls12"] } rustls-native-certs = "0.8" # Shared URL-hash + staging helpers for native-only resolvers (git / http) -sha2 = { workspace = true, optional = true } -tempfile = { workspace = true, optional = true } +sha2 = { workspace = true, optional = true } +tempfile = { workspace = true, optional = true } # Git clone (gated on `native-git`) -gix = { workspace = true, features = [ +gix = { workspace = true, features = [ "blocking-http-transport-reqwest-rust-tls", "blocking-network-client", ], optional = true } # HTTP tarball extraction (gated on `http-tarball`) # Uses libdeflater to match pm's extractor and avoid pulling a second gzip # implementation (flate2) into the workspace. -libdeflater = { version = "1.25", optional = true } -tar = { version = "0.4", optional = true } +libdeflater = { version = "1.25", optional = true } +tar = { version = "0.4", optional = true } # Async runtime (works on both native and WASM with forked tokio) [dependencies.tokio]