Skip to content

perf: cut full-run wall time ~8x (65s → 8s) - #38

Merged
iamr8 merged 4 commits into
developfrom
chore/improve-and-enhance
Aug 7, 2026
Merged

perf: cut full-run wall time ~8x (65s → 8s)#38
iamr8 merged 4 commits into
developfrom
chore/improve-and-enhance

Conversation

@iamr8

@iamr8 iamr8 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

Profiled a full apps -a run (239 apps) and removed the bottlenecks. Wall time dropped from ~65s to ~8s (≈8×). Output is byte-for-byte the same (239 discovered · 244 checked · 9 updates) and all 286 tests pass.

The run was dominated by one blocking subprocess; the rest were serial paths, an O(N²) set recompute, an unbounded-latency subprocess cap, and a few avoidable allocations / repeated I/O.

Where the time went (measured, before)

Stage Time Owner
softwareupdate --list --all 48.3s Mac scan (blocked whole scan phase)
Check phase ~14s Mac per-cask Homebrew lookups
brew info --json 0.7s Mac scan (already batched)
everything else (dotnet/node/go/vscode/chrome) <1s each fine

Enhancements

# Area Change Type Impact
1 MacApplicationsScanner softwareupdate --list --all--list --no-scan (read macOS's cached scan instead of blocking on Apple) Speed ~48s → <1s; unblocks the entire scan phase
2 CheckOrchestrator Scanner check groups now fan out concurrently via the bounded-channel WhenAll helper (were strictly serial despite docs) Speed / concurrency removes cross-scanner head-of-line blocking; matches documented behaviour
3 MacApplicationsScanner O(N²) Where(!resolved.Any(…)) + ExceptHashSet lookups Complexity O(N²) → O(N) on the unresolved-app reconciliation
4 MacOsRegistration homebrew-api client concurrency 4 → 12 Speed ~3× throughput on the per-cask 404-heavy fallback
5 ProcessRunner Global subprocess cap 6 → max(6, ProcessorCount) Speed / utilization I/O-bound child work no longer throttled on multi-core Macs
6 ScannerHelper.FindExecutable Memoized results (ConcurrentDictionary) Speed / I/O was re-walking PATH + 9 File.Exists probes on every call
7 GoScanner go version -m per binary now runs concurrently (was serial await in a loop) Speed N × spawn latency → bounded-parallel
8 PlistReader Removed dead new MemoryStream(Encoding.UTF8.GetBytes(xml)) (allocated, never used) Alloc drops one full-plist-sized byte[] + stream per app bundle
9 PlistReader.HasAppStoreReceipt Fast-path the fixed Contents/_MASReceipt/receipt location before falling back to a recursive walk I/O avoids a full recursive AllDirectories bundle walk per app (common case)
10 ProjectManifestFinder Parse each .gitignore once and cache; lookups via HashSet I/O / alloc was re-reading the same parent .gitignore once per subdirectory

Verification

  • dotnet build — 0 warnings, 0 errors
  • dotnet test286 / 286 passed
  • apps -a — 239 discovered · 244 checked · 9 updates (unchanged); ~8.2s vs ~65s baseline

Notes / out of scope

  • OSV security audit is registered (AddAuditComponent) but never invoked — Orchestrator.cs:78 is still a // TODO. Left untouched here; it's dead code, not a live perf cost. Wire it or remove it in a follow-up.
  • bin/Release/net10.0 on the dev machine is root-owned (leftover from a prior sudo publish/--install) and blocks Release/AOT builds locally; verified against the Debug build. Unrelated to this change.

🤖 Generated with Claude Code

Profiling a full `apps -a` run (239 apps) showed the pipeline was dominated
by a single blocking subprocess and a handful of avoidable serial paths.

- Mac software update: `softwareupdate --list --all` blocked the entire scan
  for ~48s hitting Apple's servers; switch to `--no-scan` (reads macOS's own
  cached scan, effectively instant). Single biggest win.
- CheckOrchestrator: scanner check groups ran strictly serially despite the
  docs claiming concurrency; fan them out through the existing bounded-channel
  WhenAll helper. Counting/mutation stays on the single reader.
- Mac cask fallback: replaced O(N²) unresolved-set recomputation with HashSet
  lookups and raised the homebrew-api client concurrency 4 → 12.
- ProcessRunner: subprocess cap 6 → max(6, ProcessorCount); child work is
  I/O-bound so it under-utilised multi-core machines.
- ScannerHelper.FindExecutable: memoized (was re-walking PATH + 9 probes per call).
- GoScanner: `go version -m` now runs concurrently per binary instead of serially.
- PlistReader: dropped a dead per-plist MemoryStream allocation; added a
  fast-path for the common `_MASReceipt` location to avoid a full recursive
  bundle walk per app.
- ProjectManifestFinder: parse each `.gitignore` once and cache it instead of
  re-reading per subdirectory.

Output unchanged (239 discovered, 244 checked, 9 updates). All 286 tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@iamr8
iamr8 changed the base branch from main to develop August 7, 2026 10:53
Comment thread apps/PlistReader.cs Fixed
Comment thread apps/ProjectManifestFinder.cs Fixed
@iamr8
iamr8 merged commit 1063030 into develop Aug 7, 2026
11 checks passed
@iamr8
iamr8 deleted the chore/improve-and-enhance branch August 7, 2026 11:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants