Skip to content

perf(cache): make the result cache pay only where it helps#4

Open
naamanhirschfeld-armis wants to merge 3 commits into
Goldziher:mainfrom
naamanhirschfeld-armis:perf/harden-large-corpus
Open

perf(cache): make the result cache pay only where it helps#4
naamanhirschfeld-armis wants to merge 3 commits into
Goldziher:mainfrom
naamanhirschfeld-armis:perf/harden-large-corpus

Conversation

@naamanhirschfeld-armis

Copy link
Copy Markdown

Hardening + profiling pass on whole-repository runs. Profiling a large mixed corpus (~70k files) showed the run was spending most of its wall time in filesystem syscalls for the result cache, not in the wrapped tools — a cold run was 2–3× slower than --no-cache, and a warm run barely beat it.

Cache: cost-gate writes + presence index (perf(cache))

The cache wrote one tiny file per (file, engine). For the majority of files the wrapped tools (oxc, ruff, …) are fast, so the open+write+rename to store — and the failed open() to miss — cost more than recomputing.

  • Duration-gated puts: persist a result only when its engine ran ≥ MIN_CACHE_DURATION. Cheap results recompute each run (fast by construction); expensive files are still cached. The gate is on cost, not on emptiness, so an expensive file that reformats to unchanged is still cached.
  • In-memory presence index: scan on-disk keys once at open and serve misses from a hash set (a miss is a lookup, not a failed syscall). A flag-guarded put layer keeps get-after-put correct while staying lock-free until the first write.
  • Skip digest/key/serialize entirely when the cache is disabled.
  • Surface slow single-file engine runs at info/warn (file, size, backend, and an upstream-report hint for wrapped tools).

Result on the corpus: cold ≈ --no-cache; warm full-corpus run 227s → ~15s; a single pathological file that took ~150s/run is served from cache in <0.1s on re-runs. Lint/format output is byte-for-byte identical across no-cache/cold/warm.

Build: sccache (chore(build))

.cargo/config.toml routes rustc through sccache; CI (ci.yaml, publish.yaml) provisions it, and the Alpine/musl release container neutralizes the wrapper (no sccache there). Requires sccache on PATH locally.

Benches: flamegraphs + cache_get (test(bench))

A pprof-based criterion profiler emits flamegraph.svg per bench under --profile-time=N (dev-only dep). New cache_get bench guards the miss path (~14 ns index miss vs ~36 µs disk hit).

Note

The ~150s pathological case is a super-linear blowup in an upstream backend on a large generated input; it is tracked upstream and not addressed here.

On a whole-repository run the result cache wrote one tiny file per
(file, engine). For the great majority of files the wrapped tools are fast,
so the open + write + rename to store a result — and the failed open() to
miss it — cost more than simply recomputing. The effect was a cold run
several times slower than `--no-cache`, while a warm run barely beat it.

Two changes make the cache pay only where it helps:

- Duration-gated puts: persist a result only when its engine ran at least
  MIN_CACHE_DURATION. Cheap results are recomputed each run (fast by
  construction); genuinely expensive files (large/slow inputs) are still
  cached and skip the work next time. The gate is on cost, not on emptiness,
  so an expensive file that produces no diagnostics / no reformat is still
  cached.

- In-memory presence index: ResultCache scans the on-disk keys once at open
  and serves misses from a hash set, so a miss costs a lookup instead of a
  failed syscall. A small put-updated layer (guarded by a flag, so it stays
  lock-free until the first write) keeps get-after-put correct on one handle.

Also skip digest/key/serialize entirely when the cache is disabled, and
surface slow single-file engine runs at info/warn — noting the file, its
size, the backend, and (for a wrapped upstream tool) a hint to report it
upstream, since that cost is in the tool, not in polylint.

Result on a large mixed corpus: cold ~= --no-cache, and a previously
150s-per-run file is served from cache in well under a second on re-runs.
Add .cargo/config.toml setting rustc-wrapper = "sccache" so clean builds and
branch switches reuse cached artifacts (the pinned oxc/ruff git dependencies
dominate build time and cache well). This requires sccache on PATH, so every
compiling CI job is updated to install it:

- ci.yaml: add mozilla-actions/sccache-action to fmt/clippy/test, with
  SCCACHE_GHA_ENABLED and CARGO_INCREMENTAL=0 (sccache can't cache incremental).
- publish.yaml: add sccache to the native release builds; the Alpine/musl
  container build sets RUSTC_WRAPPER="" to neutralize the wrapper, since
  rust:alpine has no sccache (the env var overrides the committed config).
…get bench

Wire a pprof-based criterion profiler (benches/support/profiler.rs) into all
three benches, so `cargo bench -- --profile-time=N` writes a flamegraph.svg
per benchmark. It wraps pprof::ProfilerGuard directly rather than enabling
pprof's own `criterion` feature, staying decoupled from whichever criterion
version that feature pins. pprof is a dev-only dependency (never in the
shipped binaries).

Add a cache_get bench measuring a miss (served from the in-memory presence
index) vs a hit (disk read): the miss is ~14 ns vs ~36 us, so a regression
that reintroduces a per-miss syscall shows up here.
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.

1 participant