Skip to content

perf(pm): sequential writes within tarball extraction - #2818

Closed
elrrrrrrr wants to merge 122 commits into
nextfrom
perf/extract-sequential-writes
Closed

elrrrrrrr wants to merge 122 commits into
nextfrom
perf/extract-sequential-writes

Conversation

@elrrrrrrr

@elrrrrrrr elrrrrrrr commented Apr 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Started as "intra-package sequential tarball writes" (single-line perf tweak) and evolved into a multi-week, data-driven preload-resolver overhaul plus a complete bench infrastructure. This PR is now too large to review safely and is being split — see "PR split plan" at the bottom.

This description captures the full exploration journey so subsequent split-PRs can reference it as context.

End-to-end results (ant-design / npmjs.org / GitHub Actions ubuntu)

Phase bun utoo (start) utoo (now) utoo Δ vs bun
p0_full_cold 9.07-9.32s ~14s 8.89-9.06s −5s utoo wins / matches
p1_resolve 1.98-2.34s 5.78s 2.65s −54% gap +0.31-0.67s
p3_cold_install 7.71-7.96s ~9s 5.74s −36% utoo +2s ahead
p4_warm_link 3.42-3.51s ~3s 1.98s −34% utoo +1.5s ahead

utoo now matches or beats bun on 3 of 4 phases. The remaining p1 gap (+0.4s) is fully accounted for and at the architectural floor.

Journey timeline — p1_resolve preload wall

5.78s baseline
  → 4.47s  eager Versions parse → kept the parse, refactored architecture
  → 4.09s  inline extract_transitive_deps + direct deserialize
  → 3.82s  lock-free SegQueue pending queue (was Mutex<VecDeque>)
  → 3.30s  rustls + aws-lc-rs (replaces ring; CCS→AppData 154ms→17ms)
  → 3.10s  tracing file_filter info+ (drop debug! eval cost)
  → 2.96s  Arc<FullManifest> in cache (no more deep clone)
  → 2.65s  drop indicatif Mutex from per-completion path
  → 2.15s  worker-pool replaces FuturesUnordered ⭐ biggest single win
  → 2.65s  current (Cloudflare variance: best 2.04s, typical 2.65s incl BFS+lockfile)

Key architectural moves

1. Worker-pool preload (commit ed7b551e) — the core win

Problem: FuturesUnordered polled all preload futures from a single main task. Each resolve_package call had 5+ awaits (cache check + OnceMap::get_or_init + RetryIf + request.send + bytes + parse). Every yield round-tripped through main, saturating it. Even after killing every Mutex/clone hot-path, avg_conc held at 55-60 while standalone manifest-bench (same reqwest stack, no resolver) sustained 92.

Fix: N long-lived tokio::spawn workers pulling work from Arc<SegQueue<Dep>> with DashSet dedup. Workers run on tokio's global executor independently; main task only drains an mpsc::unbounded_channel for receiver events + on_manifest callback. Termination via dispatched/completed: AtomicUsize + Notify.

Trait surface: RegistryClient futures gained + Send bounds, MockRegistryClient derives Clone. preload_manifests takes Arc<R> instead of &R. Bound R: Clone + Send + Sync + 'static, R::Error: Send propagated up the API chain.

Result: avg_conc 55 → 84 (CI), wall 3.10s → 2.15s (-31%).

2. HTTP stack ceiling — empirically verified (crates/manifest-bench)

Built a standalone HTTP-only fetch tool that strips out everything ruborist does on top of network: BFS, dedup, parse, project cache, lockfile. Dispatches identical workload through identical reqwest+rustls+tokio stack.

Key data point (CI ant-design npmjs.org cap=128, controlled for same Cloudflare conditions):

  • standalone manifest-bench wall ≈ 2.10-2.30s, avg_conc 89-95
  • ruborist preload wall ≈ 2.04-2.15s, avg_conc 84

ruborist now matches the HTTP stack ceiling. Further preload speedup requires a non-reqwest stack (HTTP/3, custom hyper Connector, etc.). This empirically refuted ~10 candidate optimizations as not the bottleneck.

3. Allocator hot-path cleanup

Each resolve_package call allocated several Strings on the resolver hot path. Cumulatively ~10k allocs per ant-design preload, all on workers competing for shared allocator. Eliminated via:

  • normalize_spec returns Cow<'_, str> instead of (String, String) (commit 12d34dd4)
  • Arc<FullManifest> in MemoryCache — clone is atomic bump not deep HashMap clone (commit 337d4f26)
  • versions.keys.clone() removed from cache-hit path — pass borrow directly (commit bb256ecd)
  • format!() dedup key kept (testing removal broke transitive prefetch)

4. Inline parse (drop spawn_blocking)

Worker-pool surfaced parse blocking-pool queue saturation: queue p95=200ms sum=70-89s over 2730 manifests. Cap=4 on CI was funneling all parses through a 4-slot queue.

Fix: inline simd_json parse on the worker (commit f3f616d8). 1-5ms CPU per manifest is acceptable on async worker; eliminates dispatch + queue overhead.

5. Workspace discovery parallelization

find_workspaces_from_pkg was reading 200+ workspace package.json files sequentially in a for loop. Replaced with FuturesUnordered (commit bf149957). Saved 150ms (200×1ms serial → ~10ms parallel).

6. tarball extraction + cloner — install path

  • extractor.rs: rayon::spawn per package + par_chunks(32) intra-package writes. Verified essential by testing removal — sequential write regressed p3 +3.67s, σ exploded 0.04→2.85.
  • cloner.rs: kept on tokio::task::spawn_blocking. Verified essential by testing rayon migration — that regressed p3 +2.65s due to oversubscription with extractor's rayon pool. The current tokio::blocking + cap=worker_threads is the local optimum for hardlink (single short syscall, no fan-out benefit).

Failed experiments (kept here so future attempts don't repeat)

All reverted. Each tested with CI bench data.

Experiment Hypothesis Result Commits
cap=256 More concurrency = faster wall +0.65s, per-req doubled 25814552 reverted by c610a582
cap=64 Avoid Cloudflare throttle wall same, per-req halved (cancels) 02ef0562
Per-IP multi-client Cloudflare throttles per-destination No change at avg_conc 4e125908/ae2a6088/5a897e4a
HTTP/2 negotiate Multiplex many streams over fewer conns wall identical to H1 (Cloudflare throttle is per-source-IP) 1a16d25e reverted by f379f7a9
dedup-by-name only Skip same-name spec duplicates preload wall down BUT BFS exploded +5s (lost transitive prefetch) de5c83ed reverted by 90e421a5
max_blocking_threads = N*4 Cloner cap=4 was throttle p3 unchanged, p4 +0.35s (cold-pool spawn cost) 132ef36e reverted by 9a071f29
Drop intra-package par_chunks in extractor Cross-package parallelism enough p3 +3.67s catastrophic c7c847d6 reverted by 2f1092c3
Migrate cloner to rayon Pool unification p3 +2.65s (oversubscription with extractor rayon pool) 9229e160 reverted by e38329c8

Cloudflare per-source-IP throttle — measured

Standalone manifest-bench cap sweep (cap=32/64/96/128/192/256):

  • per-req wall grows with cap: 30ms → 38ms → 53ms → 70ms → 107ms → 146ms
  • sum more than doubles between cap=128 and cap=256
  • avg_conc plateau at cap≈128 around 90 effective concurrent

Conclusion: npmjs's Cloudflare frontend throttles on a per-source-IP basis (CI runner egress). Cap=128 is the sweet spot for our setup. Going wider triggers per-req inflation that cancels the parallelism gain.

This refuted the original "raise cap aggressively" intuition and 6 cap-sweep experiments.

Diagnostic infrastructure (kept, valuable for future work)

  • HTTP diag (5e3c12d2): per-preload wall / busy / sum / avg_conc / p50/p95/max / cpu_tail
  • parse diag (6e0e60e5): spawn_blocking queue p50/p95 + exec p50/p95
  • manifest-bench tool (b92aa81f): standalone HTTP-only A/B against ruborist
  • manifest-bench CI step (f6846d6a): every CI run produces standalone control numbers
  • Phase timing (preload, build_deps, serialize, project_cache_save, save_package_lock, setup) — full account of every ms in p1
  • bulk-readdir disk ETag index (88a4b056): warm 304 path restored without per-package syscall storm

Total wall account (p1_resolve, latest CI)

Setup phase (workspace + graph init):    0.6ms ← workspace 200x parallel reads
Preload phase (HTTP):                  2.15s ← at HTTP stack ceiling
Build deps (= preload + BFS resolve):  2.41s → BFS resolve = 0.26s
Serialize graph to lockfile:            12ms
Project cache export + save:            40ms
Save package-lock.json:                 11ms (serialize 10 / write 0 / rename 0)
─────────────────────────────────────────────
Sum of instrumented:                   2.51s
Hyperfine total p1:                    2.65s
Process startup (tokio init + native_certs + clap):  ~140ms ← Rust binary fixed cost

Remaining optimization space (out of this PR's scope)

  1. BFS layer parallelization (~0.26s → ~0.13s achievable)
  2. Process startup ~140ms (rustls-native-certs lazy load, reqwest::Client deferred build)
  3. HTTP/3 / 0-RTT TLS (~50-100ms TCP+TLS handshake savings)
  4. RSS reduction — FullManifest raw bytes + index instead of OwnedValue tree (1.46GB → ~700MB)

None are small enough to fit this PR.

PR split plan

Per discussion, this PR is being decomposed into 4-5 focused PRs:

# Theme Risk Status
1 bench-phase infra + manifest-bench tool + PR auto-comment low TBD
2 install path: extractor sequential writes + chunked parallel writes low TBD
3 manifest cache & alloc cleanup (Arc, normalize_spec Cow, aws-lc-rs, bulk-readdir, tracing filter, etc.) medium TBD
4 preload worker-pool architecture rewrite (+ wasm CI fix) high TBD
5 (optional) config tweaks low TBD

This PR will close once 1-4 land. Track in subsequent PRs.

Test plan

  • cargo fmt + cargo clippy --all-targets -- -D warnings --no-deps clean across crates
  • cargo test -p utoo-pm 245 passed
  • cargo test -p utoo-ruborist 164 + 10 doctests passed
  • pm-bench-phases CI run >50 times, results documented above
  • Standalone manifest-bench CI integrated for control measurements

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
@elrrrrrrr elrrrrrrr added the benchmark Run pm-bench on PR label Apr 21, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request modifies the tarball extraction logic in crates/pm/src/util/extractor.rs to process entries sequentially instead of in parallel. This change aims to reduce excessive context switching caused by rayon work-stealing on large dependency graphs, while maintaining cross-package parallelism. Feedback suggests consuming the entries collection during iteration to optimize memory usage by dropping file buffers immediately after they are written.

Comment thread crates/pm/src/util/extractor.rs Resolved
elrrrrrrr and others added 6 commits April 21, 2026 23:21
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…anch merge

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@elrrrrrrr elrrrrrrr added the bench-phases Run pm-bench-phases workflow label Apr 22, 2026
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) <noreply@anthropic.com>
@elrrrrrrr elrrrrrrr added bench-phases Run pm-bench-phases workflow and removed bench-phases Run pm-bench-phases workflow labels Apr 22, 2026
…che wipe

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) <noreply@anthropic.com>
@elrrrrrrr elrrrrrrr added bench-phases Run pm-bench-phases workflow and removed bench-phases Run pm-bench-phases workflow labels Apr 22, 2026
`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) <noreply@anthropic.com>
@elrrrrrrr elrrrrrrr added bench-phases Run pm-bench-phases workflow and removed bench-phases Run pm-bench-phases workflow labels Apr 22, 2026
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) <noreply@anthropic.com>
@elrrrrrrr elrrrrrrr added bench-phases Run pm-bench-phases workflow and removed bench-phases Run pm-bench-phases workflow labels Apr 22, 2026
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) <noreply@anthropic.com>
@elrrrrrrr elrrrrrrr added bench-phases Run pm-bench-phases workflow and removed bench-phases Run pm-bench-phases workflow labels Apr 22, 2026
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) <noreply@anthropic.com>
@elrrrrrrr elrrrrrrr added bench-phases Run pm-bench-phases workflow and removed bench-phases Run pm-bench-phases workflow labels Apr 22, 2026
…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) <noreply@anthropic.com>
@elrrrrrrr elrrrrrrr removed the bench-phases Run pm-bench-phases workflow label Apr 22, 2026
`crates/manifest-bench` was the standalone HTTP-only fetch tool used in
PR #2818's driver-hunt to verify the HTTP stack ceiling (manifest-bench
≈ 2.10s vs ruborist ≈ 2.15s on cap=128, proving ruborist matched the
network ceiling). Now that worker-pool / OnceMap / aws-lc-rs are split
into their own targeted PRs, the standalone bench tool no longer carries
its weight as part of #2818 — it can ship as a separate "diagnostics"
PR if useful, but doesn't belong on the perf branch's critical path.

Also reverts our local-only edits to `bench/pm-bench.sh` (66-line drift
that doesn't carry value separately) and the root `Cargo.toml` /
`Cargo.lock` workspace-member entry that was only there to wire
manifest-bench in.

402 lines (manifest-bench) + 84 lines (bench script + workspace deps) =
~486 lines removed from the diff vs next.
Two clean-ups requested as we narrow the PR's diff against `next`:

1. Drop all `record_*_interval` / `*_trace` plumbing left over from the
   driver-hunt phase. These were never meant to ship — they captured
   per-request HTTP `(send, end)` and per-parse `(queued, exec_start,
   exec_end)` timestamps for diagnosing the FuturesUnordered →
   worker-pool transition. The story is in PR #2818's commit log; the
   resulting code is now the authoritative answer, so the scaffolding
   can go.

   Removed:
   - `service/http.rs`: HTTP_TRACE / PARSE_TRACE statics + 7 fns
     (`start_http_trace` / `finish_http_trace` / `record_http_interval` /
     `start_parse_trace` / `finish_parse_trace` / `parse_trace_enabled` /
     `record_parse_interval`)
   - `service/manifest.rs`: 5 instrumentation call-sites in the two
     fetch loops and their `Instant::now` book-keeping
   - `resolver/preload.rs`: imports + `start_*_trace()` / `finish_*_trace()`
     pair + `log_http_diagnostics` (~73 lines) + `log_parse_diagnostics`
     (~58 lines) + the per-stats `tracing::debug!` summary line

2. Restore the indicatif `PreloadStart` / `Queued` / `Fetching` /
   `Progress` event-handling that PR #2818's `2b89d0b9 perf(pm): drop
   indicatif inc()/inc_length() from preload hot path` removed.
   That optimisation belongs in the alloc-cleanup driver-bundle PR
   alongside the other small Mutex/clone hot-path wins (~0.31 s on
   p1_resolve per the PR doc timeline). Bringing it back here lets us
   measure the worker-pool's contribution in isolation, and keeps the
   minimum-viable bundle's split clean.

cargo clippy -p utoo-pm -p utoo-ruborist --all-targets -- -D warnings
--no-deps clean. Ruborist / pm tests untouched and still pass.
…visit)

A/B-verified independent gain on top of worker-pool. Restores the
PR #2818 originally-shipped behaviour after we briefly reverted it in
`11107948` to measure the cost in isolation.

Round-6 (no per-event indicatif update): p1_resolve 2.50s ±0.10
Round-7 (upstream indicatif restored):   p1_resolve 2.73s ±0.07
                                                    Δ +0.23s (+9%)

Both runs same workload (ant-design / npmjs / GH Actions ubuntu),
all other code identical, σ 0.07-0.10 — clean independent signal.

`PROGRESS_BAR.inc()` / `.inc_length()` each take indicatif's internal
`Mutex<ProgressBarState>`. Worker-pool preload at concurrency=128 fires
4571 dispatches + 4571 completions = ~9000 lock acquisitions in a 3-4 s
phase, contending with the steady_tick draw thread. The result is the
main task's fill-and-drain loop gets serialised on this lock — standalone
manifest-bench (no indicatif) sustains avg_conc=95 at cap=128; ruborist
with indicatif updates was stuck at 56.

The spinner still ticks via steady_tick so the user sees the phase is
alive; the final `PreloadComplete` summary prints success/fail counts.
…/queued)

Halves indicatif lock-acquisition count by dropping per-event updates on
PreloadStart / PreloadQueued / PreloadFetching while keeping per-package
PreloadProgress ("resolved {name}") for user-visible feedback.

Round-6/7/8 A/B/A established the full cost: ~0.23s on p1_resolve when
both fetching + progress fire (4571 + 4571 = ~9000 lock-acquisitions on
the main task, contending with steady_tick draw thread).

This commit removes the 4571 fetching events but keeps the 4571 progress
events — same UX (still see per-package activity) at half the lock cost.
Predicted regression vs full-drop (round-6/8): 0.10-0.15s, measurable in
round-9 bench.

Phase-boundary events:
- PreloadStart fires once per phase
- PreloadQueued fires ~once per BFS layer (~10 times for ant-design)
- Both have low frequency but their `inc_length` calls were used to set
  the bar's max. Dropping them means the bar runs counter-mode without a
  percentage — acceptable for a spinner-style indicator at this scale.
Cleanest fix to the indicatif Mutex contention identified in PR #2818
round-6/7/8 A/B/A. Detect stderr's TTY status once at startup, cache
in `IS_TTY: Lazy<bool>`, short-circuit the entire ProgressReceiver
event path on non-TTY.

Why a TTY gate works where dropping events doesn't:
- indicatif's `inc()` / `set_message()` always acquire a write-lock on
  `Mutex<ProgressState>` and mutate `pos` / `est.buf` / `message`,
  even when the draw target is hidden or the underlying stream is not
  a TTY. Only the *rendering* short-circuits — state mutation always
  runs. Round-9 (drop fetching, keep progress) still showed +0.10-
  0.15s vs full-drop, confirming the lock + ETA buffer push + format!
  alloc are the real costs, not the byte flush.
- CI (GitHub Actions, piped output, `2>&1 | tee`) now gets zero
  indicatif touches: no `inc`, no `set_message`, no `format!()`
  allocation, no `String::clone` into `set_message`. Local dev (TTY)
  gets the full upstream UX with per-package progress.

Restored the upstream PreloadStart / PreloadQueued / PreloadFetching /
PreloadProgress event handlers (PROGRESS_BAR.inc_length /
log_progress("fetching ...") / inc(1) + log_progress("resolved ...")).
The `if !*IS_TTY { return; }` at the entry of `on_event` hoists the
gate above the `match`, so per-event check overhead is also zero.

Also gated `start_progress_bar` and `log_progress` defensively for
external callers.

Predicted bench (round-10 on CI Linux): p1_resolve back to round-6/8
range (2.50-2.58s) — same as if we had dropped all events, but local
dev experience preserved.
elrrrrrrr added a commit that referenced this pull request Apr 30, 2026
… CI)

A/B-verified perf fix isolated as its own PR. Detect stderr's TTY
status once at startup (`IS_TTY: Lazy<bool>`) and short-circuit the
entire `ProgressReceiver::on_event` path on non-TTY environments.

## Why this is needed

indicatif's `inc()` / `set_message()` always acquire the internal
`Mutex<ProgressState>` write-lock and mutate `pos` / `est.buf` /
`message`, regardless of TTY status — only the *rendering* short-
circuits, not the state mutation. On the worker-pool resolve hot path
that's 9000+ lock acquisitions per phase contending across multiple
workers, costing measurable wall time on CI.

## A/B/A/A' bench evidence (CI Linux, ant-design / npmjs)

| Round | indicatif config | Lock count | p1_resolve | σ |
|---|---|---|---|---|
| A   | drop entirely         | 0     | 2.50s | 0.10 |
| B   | full restore          | 13713 | 2.73s | 0.07 |
| A'  | drop again (verify)   | 0     | 2.58s | 0.15 |
| A'' | only "resolved" event | 9142  | 2.65s | 0.05 |
| this PR | TTY gate at startup | 0 in CI | 2.58s | 0.07 |

A→B Δ = +0.23s (+9%); B→A Δ = −0.15s. Three independent runs across
different GH Actions runners and Cloudflare egress conditions.
Amortized cost is approximately linear in lock count
(0.23 × 9142/13713 ≈ 0.15 ≈ predicted; observed 0.15).

## Why this approach

- **Drop-only solutions** (A round) lose the local-dev progress UX.
- **Partial drops** (A'' round, only-resolved) still pay 9142 locks.
- **TTY gate** is the only solution that:
  - Pays zero overhead on CI / piped (`*IS_TTY = false` → match never enters)
  - Preserves full per-package "resolved {name}" UX on local dev (TTY)
  - Is a 1-file, 3-call-site change

## Counter-intuitive finding

The first instinct ("CI has no TTY, indicatif must already short-
circuit internally") is wrong: indicatif separates *rendering*
(skipped on non-TTY / hidden draw target) from *state mutation*
(always runs, always locks). Round A'' confirmed this empirically —
9142 locks with no rendering still cost +0.15s on p1_resolve.

General rule: gate hot-path library calls at the call-site; never
assume the library will short-circuit internally.

## Test plan

- [x] `cargo build --release -p utoo-pm` clean
- [x] `cargo clippy -p utoo-pm --all-targets -- -D warnings --no-deps` clean
- [x] CI bench-phases-linux on PR #2818 round-10 (`f122478b`): p1_resolve
      2.58s ±0.07 — matches "indicatif fully dropped" baseline (round-6/8)
- [ ] Local dev TTY: `cargo run --profile release-local --bin utoo -- install`
      should show full per-package progress
- [ ] Non-TTY: `… 2>/tmp/stderr.log` should produce zero indicatif ANSI
      sequences in the log
Borrowed from PR #2874 (`e17dbc8c`) — surgical optimisation that stacks
cleanly on top of the worker-pool architecture.

`resolve_via_full_manifest` now checks the version-manifest cache by
the resolved concrete version BEFORE the `get_core_version` reparse.
Whenever multiple specs (`react@^18`, `react@^18.1`, `react@18.x`)
resolve to the same `18.2.0`, only the first call pays the parse cost
— sibling calls reuse the cached `Arc<CoreVersionManifest>`.

The dual-key cache write at the call-site (line 374-378, populated
under both `(name, spec)` and `(name, resolved_version)`) was already
in place; this commit just makes the second key into a natural
short-circuit point.

Side-effect: `resolve_via_full_manifest` return type tightens from
`(String, CoreVersionManifest)` to `(String, Arc<CoreVersionManifest>)`,
saving an `Arc::new` wrap at the call-site and enabling zero-copy
share when the cache hit fires.

Test plan: `cargo clippy --all-targets -- -D warnings` clean,
ruborist 162 tests pass.
Borrowed two alloc-cleanup commits from PR #2874 (`0d52d568` + `a89a59ea`).
Both are orthogonal to the worker-pool architecture and stack cleanly:
plain alloc reduction on the resolve hot path, no behavioural change.

## (1) Drop redundant `manifest.versions.clone()` (`0d52d568`)

`resolve_target_version` takes `&[String]`, so 4 call sites that
prefixed it with `let version_list: Vec<String> = manifest.versions.clone();`
were paying for a pointless deep copy of the entire version list per
resolve (50-200 entries on popular packages).

Pass `&manifest.versions` / `&full_manifest.versions` directly. Saves
~80 String allocs × ~600 packages = ~48k allocs per ant-design install.

Sites:
- crates/ruborist/src/traits/registry.rs (default `fetch_version_manifest`,
  default `resolve_package` non-semver branch)
- crates/ruborist/src/resolver/registry.rs (`resolve_from_manifest`)
- crates/pm/src/cmd/view.rs (utoo view command)

## (2) Move version_list / dist_tags into VersionsInfo (`a89a59ea`)

Previously the 200 path of `resolve_full_manifest` deep-cloned
`manifest.versions` (Vec<String>) and `manifest.dist_tags` (HashMap)
per package fetch to fill the `versions_info` cache slot, while the
same fields also moved into `Arc<FullManifest>` for the `full_manifests`
slot.

Use `std::mem::take` to MOVE both fields out of `manifest` directly into
the new `VersionsInfo`. The cached `Arc<FullManifest>` keeps `raw` +
scalar metadata; the canonical `version_list` / `dist_tags` now live in
the `versions_info` cache slot only. `resolve_via_full_manifest`'s Full
branch reads them via `cache.get_versions(name)` (same code path that
filled the slot, atomic w.r.t. the inflight gate).

`get_core_version(&resolved)` still works on the cached `Arc<FullManifest>`
because it parses from `raw` (preserved) — not from the moved-out
`versions` field.

Saves another ~80 String + 1 HashMap clone × ~600 packages per install,
matching the second deep-copy site eliminated by PR #2874.

## Combined effect

Worker-pool baseline already parallelises the per-package CPU cost across
N workers, so absolute wall-time savings on p1_resolve will likely be in
the same noise band as the short-circuit (round-11). The architectural
benefit — a single canonical owner for version_list / dist_tags — is
worthwhile regardless of bench delta.

Test plan:
- cargo clippy --all-targets -- -D warnings clean
- cargo test -p utoo-ruborist: 162 passed
elrrrrrrr added a commit that referenced this pull request Apr 30, 2026
… CI) (#2876)

A/B-verified perf fix isolated as its own PR. Detect stderr's TTY
status once at startup (`IS_TTY: Lazy<bool>`) and short-circuit the
entire `ProgressReceiver::on_event` path on non-TTY environments.

## Why this is needed

indicatif's `inc()` / `set_message()` always acquire the internal
`Mutex<ProgressState>` write-lock and mutate `pos` / `est.buf` /
`message`, regardless of TTY status — only the *rendering* short-
circuits, not the state mutation. On the worker-pool resolve hot path
that's 9000+ lock acquisitions per phase contending across multiple
workers, costing measurable wall time on CI.

## A/B/A/A' bench evidence (CI Linux, ant-design / npmjs)

| Round | indicatif config | Lock count | p1_resolve | σ |
|---|---|---|---|---|
| A   | drop entirely         | 0     | 2.50s | 0.10 |
| B   | full restore          | 13713 | 2.73s | 0.07 |
| A'  | drop again (verify)   | 0     | 2.58s | 0.15 |
| A'' | only "resolved" event | 9142  | 2.65s | 0.05 |
| this PR | TTY gate at startup | 0 in CI | 2.58s | 0.07 |

A→B Δ = +0.23s (+9%); B→A Δ = −0.15s. Three independent runs across
different GH Actions runners and Cloudflare egress conditions.
Amortized cost is approximately linear in lock count
(0.23 × 9142/13713 ≈ 0.15 ≈ predicted; observed 0.15).

## Why this approach

- **Drop-only solutions** (A round) lose the local-dev progress UX.
- **Partial drops** (A'' round, only-resolved) still pay 9142 locks.
- **TTY gate** is the only solution that:
  - Pays zero overhead on CI / piped (`*IS_TTY = false` → match never enters)
  - Preserves full per-package "resolved {name}" UX on local dev (TTY)
  - Is a 1-file, 3-call-site change

## Counter-intuitive finding

The first instinct ("CI has no TTY, indicatif must already short-
circuit internally") is wrong: indicatif separates *rendering*
(skipped on non-TTY / hidden draw target) from *state mutation*
(always runs, always locks). Round A'' confirmed this empirically —
9142 locks with no rendering still cost +0.15s on p1_resolve.

General rule: gate hot-path library calls at the call-site; never
assume the library will short-circuit internally.

## Test plan

- [x] `cargo build --release -p utoo-pm` clean
- [x] `cargo clippy -p utoo-pm --all-targets -- -D warnings --no-deps` clean
- [x] CI bench-phases-linux on PR #2818 round-10 (`f122478b`): p1_resolve
      2.58s ±0.07 — matches "indicatif fully dropped" baseline (round-6/8)
- [ ] Local dev TTY: `cargo run --profile release-local --bin utoo -- install`
      should show full per-package progress
- [ ] Non-TTY: `… 2>/tmp/stderr.log` should produce zero indicatif ANSI
      sequences in the log
`dd5d9583 perf(pm): gate ProgressReceiver on IsTerminal (...) (#2876)`
landed on `next` while the umbrella PR was iterating. Conflict in
`pm/util/logger.rs` was purely doc-comment wording — both branches
shipped identical IS_TTY gating logic. Take next's wording (the
canonical merged-PR text).

The umbrella branch's `b2498e91 perf(pm): drop redundant
Vec<String>/HashMap clones on resolve hot path` is now part of the
diff vs `next`; the architectural follow-up (PR #2878 VersionsRef
view + drop double-fill) is separately tracked.
…fill

Upstream `24d2e441 perf(pm): introduce VersionsRef<'a> view for
resolve_target_version (#2878)` landed (covers both 63042d7 + 0f85360
that previously diverged on this umbrella branch). Conflict resolution:

- Took next's `traits/registry.rs`, `service/registry.rs`,
  `resolver/registry.rs`, `pm/cmd/view.rs` versions — they have the
  canonical VersionsRef API.
- **Re-applied** `MaybeSend` / `MaybeSync` trait shim (worker-pool
  needs `+ Send` on `tokio::spawn`'d futures, but trait can't carry
  that bound on wasm).
- **Re-applied** sibling-spec short-circuit in
  `UnifiedRegistry::resolve_via_full_manifest` (umbrella-only feature
  borrowed from PR #2874): cache lookup before `get_core_version`
  reparse; return type tightens to `(String, Arc<CoreVersionManifest>)`
  to share the cached Arc.
- **Re-applied** `MaybeSend/MaybeSync` bounds on `fetch_full_manifest`,
  `fetch_version_manifest`, `resolve_package` trait methods.
- **Re-applied** `#[derive(Clone)]` on `MockRegistryClient` and
  `MockPackage` (worker-pool requires `R: Clone`).
- Discarded the umbrella's `mem::take` versions/dist_tags
  (`b2498e91`) — superseded by next's drop-in-memory-double-fill which
  preserves `Arc<FullManifest>` integrity.

cargo clippy --all-targets -- -D warnings clean.
245 pm + 162 ruborist tests pass (+ flaky `test_view_twice_no_304_error`
unrelated to merge).
Sweep umbrella-only `Instant::now()` / `let t_*` / `start.elapsed()`
plumbing that survived the round-6→round-14 saga but isn't needed in
the final shape. None of these affected program behaviour — they were
diagnostic scaffolding for individual rounds that should not ship.

- `pm/helper/lock.rs::save_package_lock` — drop `t_total` /
  `t_serialize` / `t_write` / `t_rename` and the
  `tracing::info!("Save package-lock.json: total=...ms ...")` summary
  line they fed.
- `pm/service/install.rs` — drop the
  `println!("  download X · extract Y · clone Z · M MB fetched")`
  trailing line. The user-facing summary above it
  (`print_install_counts`) covers what users care about; the per-phase
  cumulative-time print was diagnostic-only.
- `pm/util/downloader.rs` — drop `DOWNLOAD_MICROS` / `EXTRACT_MICROS`
  / `DOWNLOAD_BYTES` statics and the per-tarball `t0`/`t1` timing,
  plus the now-orphan `phase_totals()` / `PhaseTotals` API.
- `pm/util/cloner.rs` — drop `CLONE_MICROS` static, `clone_micros()`
  fn, and the per-clone `t0` timing. `clone_count()` is kept (consumed
  by `print_install_counts`).
- `ruborist/resolver/builder.rs::run_preload_phase` — drop the
  umbrella-only `tracing::info!("Preload phase: {:.2?} (... success ...)")`.
  Restored next's existing `let start = ...; tracing::debug!(...)`
  pair that was harmless debug-level instrumentation already upstream.
- `ruborist/resolver/preload.rs` — drop per-package `Instant::now()` +
  `elapsed_ms` plumbing (was unused in any external API). Tightens
  `Completion<E>` tuple back to `(String, Result<…, …>)` and shrinks
  `PreloadStats` to just `success_count` / `failed_count` /
  `total_processed`.

cargo clippy --all-targets clean. 245 pm + 162 ruborist + 10 doctests
pass.
Three files had umbrella-only changes that were either obsolete
documentation or cosmetic — converging to next's exact text drops
them from the diff entirely without changing behaviour.

- `pm/helper/lock.rs::save_package_lock`: restore upstream's
  inline comment + tighter blank-line layout. (We had reformatted
  it during the round-12 mem::take cleanup, but the blank-line
  rearrangement isn't necessary now that mem::take is gone.)
- `ruborist/service/{http.rs,manifest.rs}` + `resolver/http.rs`:
  rename `pick_client` back to upstream's `get_client`. Pure
  naming churn from an early umbrella round; no functional
  difference.
- `ruborist/service/manifest.rs::fetch_full_manifest` /
  `fetch_version_manifest`: drop the long umbrella-only "Inline
  parse on the async worker — simd_json is fast..." documentation
  blocks. The architectural conclusion is now implicit (worker-pool
  + inline parse is the design); the extended explanation belonged
  in the round-7 commit message, not in production code. Restore
  upstream's compact `// Save raw bytes before simd_json mutates
  the parse buffer.` for fetch_full_manifest.

cargo clippy --all-targets clean.
245 pm + 162 ruborist + 10 doctests pass (+ flaky network test).
elrrrrrrr added a commit that referenced this pull request Apr 30, 2026
elrrrrrrr added a commit that referenced this pull request Apr 30, 2026
Empirical evidence from 4-sample bench on perf/stack-2818-modules
(runs 25178030660 / 25180538290 / 25181300495 / 25182053587):

| s | bun p1 | utoo p1 | utoo σ | ratio |
|---|--------|---------|--------|-------|
| 1 | 2.99 (slow) | 3.34 | 0.08 | 1.12 (lucky) |
| 2 | 2.39 (fast) | 7.45 | 6.02 | unusable |
| 3 | 2.77       | 4.34 | 0.52 | 1.57 |
| 4 | 2.60 (fast) | 4.49 | 0.29 | 1.73 |

The lucky 1.12 in s1 was a Cloudflare slot where the throttle didn't
trigger. Median across clean samples is ~1.57. Pattern: utoo bursts
128 simultaneous connections (worker-pool design issuing all workers
at once) while bun ramps up gradually. On today's throttle-heavy
slots, the burst trips Cloudflare per-IP rate limiting more often
than not.

PR #2818's testing chose 128 over 96 by ~6% in stable conditions.
Today's bench environment is throttle-heavy enough that 128's
aggression hurts more than it helps. Lower the default to 96 to
reduce burst aggression and tighten cross-slot variance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
elrrrrrrr added a commit that referenced this pull request Apr 30, 2026
…2818)

Cherry-picked from PR #2818 commit 01f25a8 to address the Cloudflare
per-IP throttle issue identified in PR #2872 bench iteration 4-7.

pcap diff against bun in PR #2818 revealed a DNS round-robin bug.
bun distributes 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. Origin/next had no rotation at
all. PR #2818's parent had a flat `offset % 22` rotation which on
GH Actions runners (no IPv6 routing) made offsets 0..10 (= 11 of
every 22) all fall through to the *same* first IPv4 entry once
Happy Eyeballs gave up on v6.

Per-family rotation keeps IPv4 and IPv6 ranges rotated independently
and preserves the system resolver's v6-first ordering. Every new
connection's first-reachable IPv4 cycles through all v4 addresses,
giving ~11 conns per IP instead of 66 on one.

This addresses the directly-observed 4-sample evidence on
perf/stack-2818-modules where utoo's p1 ratio at cap=128 hit 1.73
on bun-fast slots (utoo 4.49s, bun 2.60s) — symptomatic of utoo's
connections piling up on a single throttled Cloudflare IP while bun
fanned out across 4 IPs.

Adapted to current state:
- origin/next had no rotation; PR #2818's parent had flat rotation
- This commit adds `rr_counter: AtomicUsize` to `CachingResolver`,
  the per-family `rotate_addrs` helper, and wires it into both the
  cache-hit and cache-miss branches of `Resolve::resolve`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
elrrrrrrr added a commit that referenced this pull request Apr 30, 2026
…ne blocking)

reqwest's default ClientBuilder negotiates HTTP/2 with the server, which
means all parallel manifest requests get multiplexed over a single TCP
connection. When ANY one response is slow (e.g. Cloudflare throttle on
one of the round-robin'd Cloudflare edge IPs), the H2 stream stalls and
ALL subsequent requests on that connection block waiting — head-of-line
blocking at the HTTP layer.

With force-H1, each `tokio::spawn`-ed worker takes its own TCP stream
out of the connection pool. A slow response on one connection only
holds up that single response; the other 95 workers continue draining
their queues unaffected.

Why this matters NOW (post DNS per-family rotation):
The 4-sample bench evidence on perf/stack-2818-modules at cap=96 with
DNS rotation showed utoo's p1 wall floating at 4.5-7.5s when bun got
2.4-2.9s. Hypothesis confirmed: the bottleneck wasn't IP distribution
(rotation didn't help measurably), it was that all our requests
serialised through one H2 connection regardless of how many IPs DNS
returned.

PR #2818 made this same change as part of its full bundle (ratio 1.14)
but we'd skipped it earlier under the "within-noise" tag from older
testing. Today's bench data shows it's load-bearing — without H1, no
amount of cap tuning or DNS rotation moves the needle past ratio 1.42.

Pool size 256 = 2x DEFAULT_CONCURRENCY (96), enough headroom that the
idle pool never evicts a connection mid-phase even if the request rate
briefly exceeds the cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
elrrrrrrr added a commit that referenced this pull request Apr 30, 2026
Two PR #2818 final-state adjustments missing from PR #2872:

## max_blocking_threads(worker_threads)

tokio's default blocking-pool size is 512. With burst-dispatch of
~128 spawn_blocking calls (JSON parse for manifest fetches), the
runtime grows the pool unbounded which causes thread-creation thrash
on cold starts. PR #2818's testing showed bimodal walls (2.7s fast
vs 6.9s thrash) on this; capping the pool to worker_threads count
keeps the pool warm and eliminates the thrash peak.

## cap 96 → 128

cap=96 was chosen empirically in commit 610ebb6 when H2
head-of-line blocking was the dominant bottleneck — gentler burst
of 96 connections tripped the per-IP Cloudflare throttle less than
128 did. With `.http1_only()` (commit a28045b) requests now use
independent TCP streams, so per-IP throttle distributes across the
DNS-rotated pool. 128 is the sweet spot historically (avg_conc=84
in PR #2818's sweep), and the extra 33% concurrency saturates more
of the available Cloudflare connection budget without triggering
the per-IP throttle that was the original concern.

Combined with prior commits (worker-pool + DNS rotation + H1),
these two adjustments complete the PR #2818 perf bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented May 1, 2026

Copy link
Copy Markdown

📊 pm-bench-phases · 127989d · linux (ubuntu-latest)

Workflow run — ant-design

PMs: utoo (this branch) · utoo-npm (latest published) · bun (latest)

npmjs.org

p0_full_cold

PM wall ±σ user sys RSS pgMinor
bun 9.19s 0.69s 9.95s 9.49s 575M 299.0K
utoo-next 10.31s 0.92s 10.58s 12.67s 1.17G 154.1K
utoo-npm 10.69s 0.74s 11.03s 12.93s 1.28G 167.9K
utoo 10.02s 1.27s 10.73s 11.70s 1.32G 165.6K
PM vCtx iCtx netRX netTX cache node_mod lock
bun 18.3K 16.3K 1.16G 6M 1.81G 1.70G 1M
utoo-next 184.7K 146.3K 1.13G 5M 1.68G 1.68G 2M
utoo-npm 181.6K 166.2K 1.14G 5M 1.68G 1.68G 2M
utoo 114.8K 58.9K 1.14G 6M 1.68G 1.68G 2M

p1_resolve

PM wall ±σ user sys RSS pgMinor
bun 2.58s 0.09s 3.57s 1.08s 471M 180.5K
utoo-next 6.16s 0.26s 4.71s 1.72s 421M 73.3K
utoo-npm 6.75s 0.33s 5.00s 1.77s 428M 74.4K
utoo 2.69s 0.19s 5.22s 1.66s 563M 85.2K
PM vCtx iCtx netRX netTX cache node_mod lock
bun 13.3K 2.8K 201M 3M 104M - 1M
utoo-next 84.2K 3.6K 197M 2M 7M 3M 2M
utoo-npm 70.9K 1.9K 208M 2M 9M 5M 2M
utoo 26.3K 16.7K 200M 3M 7M 3M 2M

p3_cold_install

PM wall ±σ user sys RSS pgMinor
bun 7.24s 1.00s 6.15s 9.36s 577M 197.1K
utoo-next 12.05s 5.38s 5.37s 11.42s 785M 114.6K
utoo-npm 9.52s 0.99s 5.52s 11.20s 825M 113.5K
utoo 7.91s 0.77s 5.32s 10.26s 859M 107.7K
PM vCtx iCtx netRX netTX cache node_mod lock
bun 6.6K 7.5K 993M 4M 1.73G 1.73G 1M
utoo-next 160.6K 85.1K 965M 4M 1.67G 1.67G 2M
utoo-npm 147.8K 107.5K 965M 4M 1.67G 1.67G 2M
utoo 100.0K 57.1K 965M 4M 1.67G 1.67G 2M

p4_warm_link

PM wall ±σ user sys RSS pgMinor
bun 3.34s 0.05s 0.18s 2.39s 136M 31.9K
utoo-next 2.29s 0.10s 0.49s 3.78s 82M 19.3K
utoo-npm 2.32s 0.10s 0.56s 3.80s 82M 18.9K
utoo 2.30s 0.35s 0.43s 3.35s 60M 13.2K
PM vCtx iCtx netRX netTX cache node_mod lock
bun 240 31 7M 25K 1.88G 1.72G 1M
utoo-next 49.5K 20.9K 22K 11K 1.67G 1.67G 2M
utoo-npm 47.1K 20.6K 23K 11K 1.67G 1.67G 2M
utoo 17.6K 8.9K 28K 18K 1.68G 1.67G 2M

npmmirror.com: no output captured.

elrrrrrrr added a commit that referenced this pull request May 8, 2026
Two changes after the GHA bench on the previous commit (PR #2916,
run 25559625024) showed the concurrency=256 hypothesis was wrong on
GHA's environment.

Revert concurrency 256 → 64
---------------------------

The new fetch-timing instrumentation shipped in the previous commit
caught the surprise: GHA's pcap-vs-local profile is the *opposite*
of what local Mac measurements suggested.

  metric          local Mac    GHA Linux
  avg_request     399ms        70ms      ← network MUCH faster on GHA
  avg_body         50ms        20ms
  avg_parse       730µs        266ms     ← parse 365× SLOWER on GHA

Mechanism: `parse_json_off_runtime` dispatches to `rayon::spawn`,
and rayon's pool size is `num_cpus` (= 2 on GHA ubuntu-latest).
Bumping concurrency 64 → 256 queued 256 manifest parses behind 2
rayon workers — head-of-line blocking. avg_parse jumped from ~10ms
to 266ms wall, dragging p1 utoo wall from 3.10s up to 3.33s.

Restore manifest-bench
----------------------

Brought back `crates/manifest-bench` (originally landed in the
post-#2818 driver hunt, dropped in af714eb once #2818 graduated).
It's a single-binary HTTP-only fetch tool that strips out the
ruborist pipeline (no BFS, no dedup, no parse, no project cache,
no lockfile write) — fires `GET <registry>/<name>` in parallel
and reports the same diag shape as the new `p1-breakdown` lines.

Goal: separate the network ceiling from the resolver pipeline so
the next round of p1 experiments (parse offload, partial parse,
dedicated parse pool, etc.) can be evaluated against a stable
"pure network" baseline.

Knobs (unchanged from the original drop):
  --concurrency N    sweep without rebuilding utoo
  --reps N           run same workload back-to-back
  --single-version   use /<name>/latest (smaller bodies)
  --user-agent X     UA-fingerprint experiments
  --http1-only       H2 vs H1 toggle
  --accept X         override Accept header

Same TLS stack as ruborist (rustls + aws-lc-rs, native roots).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
elrrrrrrr added a commit that referenced this pull request May 8, 2026
…inux

build-linux now also builds + uploads `manifest-bench` when a phases
bench is going to run (label or dispatch). bench-phases-linux
downloads the binary and runs it after the regular phase-isolated
benchmark.

Sweep mirrors the original (#2818-era) wire-in:

  concurrency: 32 / 64 / 96 / 128 / 192 / 256  (HTTP/1.1, full manifest)
  protocol:    H1 vs H2-negotiate  (cap=128)
  endpoint:    full vs `/<name>/latest`  (cap=128, smaller bodies)
  UA:          default vs `Bun/1.2.21`  (cap=128)

Output goes to /tmp/pm-bench-output/manifest-bench-npmjs.log and
ships in the existing pm-bench-logs-linux artifact — no PR comment
surface (the headline phases bench comment stays the same).

Why now: the new ruborist `p1-breakdown` instrumentation showed
sum_parse on GHA can dominate when concurrency is bumped (256:
sum_parse 728s vs sum_request 193s). To attribute the bun-vs-utoo
gap on p1_resolve we need a "pure HTTP" baseline that strips out
ruborist's parse / BFS / dedup / lockfile path. manifest-bench is
that baseline: same TLS stack as ruborist (rustls + aws-lc-rs,
native roots), no resolver pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@elrrrrrrr

Copy link
Copy Markdown
Contributor Author

Stale — no updates since May 1; superseded by #2905 (par_chunks(64)) which captured the install-write win. Archived.

@elrrrrrrr elrrrrrrr closed this May 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bench-phases Run pm-bench-phases workflow benchmark Run pm-bench on PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant