perf(cli): four walk and gate performance fixes (#1113, #1119, #1116, #1114) - #1156
Merged
Conversation
`bca check` ran the full metric suite regardless of what the resolved `ThresholdSet` actually reads, then discarded the rest. Derive the selection from the set and hand it to `MetricsOptions::with_only`. The family each threshold reads is declared per entry on `MetricExtractor` rather than derived from the name via `threshold_metric_for_name`. That helper answers a *suppression* question and returns `None` for `tokens` — a marker may never silence it — but `tokens` is a real configurable threshold, so selecting by that mapping would leave `m.tokens` at its zero default and silently disarm the gate. Measured over `tests/repositories/DeepSpeech` (12.7k files), median user CPU of five runs: a one- or two-family gate drops from 28.6-29.2s to 22.4-23.3s (1.24-1.28x). A gate naming nine families — this repo's own `bca.toml` — is unchanged at 1.00x, since it already selects nearly everything. The issue's projected 5-7x came from `bca metrics`, which also serializes a document per file; `bca check` emits nothing per file, so the ~22s parse-and-walk floor bounds the saving. `Action::Exemptions` needed no change: `dispatch_exemptions` reads suppression markers off the parse and never computed metrics. Fixes #1113
`crossbeam::channel::Sender` is `Sync`, so the five result channels on `Config` no longer need the `Mutex` that `std::sync::mpsc::Sender` required, and every worker stops locking once per file. The four poisoned-lock degradation branches go with it: nothing could poison those mutexes (no code panics while holding one) and no test covered them, so they were unreachable error handling. No measurable throughput change on a 16-core host — `check` and `report` over `tests/repositories/DeepSpeech` (12.7k files) at `--jobs 32` and `--jobs 64` land within 0.975-1.003x of the previous binary. The lock was taken once per file, not per record, so it was never hot at this core count. The change stands on removing a global serialization point that scales the wrong way and 48 lines of dead error handling, which is what the issue itself proposed. `tests/walk_channel_completeness.rs` pins the rewired send path of all four channels: each command's output must be identical at `--jobs 1` and `--jobs 16`. Verified falsifiable by dropping one file's violations, which fails the check case on the record count. Fixes #1119
`walk_metric_set` ran its walk with `output_dir` pointing at a TempDir, writing one JSON document per source file (each behind a `create_dir_all`), then immediately re-walked that tree and re-parsed every document back into a `serde_json::Value`. Both sides of every `bca diff --since` paid it, while the `FuncSpace` trees were already in the workers' hands. Stream the spaces over the existing aggregate channel instead and build the `MetricSet` with `serde_json::to_value` — the same `Serialize` impl the writer used, so the resulting document, and therefore the pairing key and the `metrics` value, are the ones that used to be written out. `load_dir_set` stays for the explicit two-directory `bca diff <old> <new>` form, whose input genuinely is a directory of documents. Output is byte-identical. Verified against the previous binary on this repo (`--since HEAD~1/~2/~3`, 14,954 lines) and on `tests/repositories/DeepSpeech` (`--since HEAD~5`, 12,469 lines), plus the 23 existing `diff_since` integration tests unchanged. Float values survive because the workspace enables serde_json's `float_roundtrip`, so the parse the old route depended on was already bit-exact. Measured on DeepSpeech (12.7k files), median of three: wall 11.73s -> 8.74s (1.34x), system time 2.75s -> 1.57s (-43%), user 37.08s -> 35.62s. On a small tree the saving is inside the noise; the filesystem round-trip only becomes visible at scale. `set_from_spaces_matches_the_file_round_trip` pins the in-memory set against a real `load_dir_set` round-trip of the same space. Verified falsifiable on both halves: storing the whole document instead of its `metrics` object, and keying off the streamed path instead of the document `name`. Fixes #1116
Three changes to the walk prologue, which ran entirely before any analysis started. The CLI's directory walk drove `ignore`'s single-threaded iterator on the main thread while every worker sat idle. It now uses `build_parallel()` with the same thread budget the pool gets — the two never run at the same time, so there is nothing to split it with. Walking `tests/repositories/DeepSpeech` (12.7k files) in isolation falls from 100.8ms to 33.6ms (3.0x, 15 runs); a full `check` over the same tree improves ~1.12x. Total CPU rises slightly (69.8ms -> 102.1ms on the walk) — the trade is threads for wall time. `ConcurrentRunner::run` spawned a producer thread and reserved a pool slot for it, so `--jobs auto` on 16 cores ran 15 consumers for work that finished almost immediately. Dispatch moves to the calling thread; consumers are spawned first, so they still start draining as the first paths land. `num_jobs` now means the consumer count. The producer also re-`stat`ed every path that the CLI walk had already classified from its `dirent`. `without_path_verification` opts out; the default is unchanged for library callers, whose `FilesData` may hold arbitrary paths. The resolved file list is now sorted. A parallel walk yields entries in thread-completion order, so without it per-file output order would vary run to run at `--jobs 1` — output that used to be reproducible. Sorting also makes that order independent of readdir order, so it no longer varies by filesystem or machine, which the single-threaded walk never guaranteed. Consumers pinning the old order see a one-time reshuffle; the file *set* is unchanged, verified byte-for-byte against the previous binary over DeepSpeech. Deliberately not done: the issue also proposed streaming paths into the pool with no intermediate `Vec`. That trades away the determinism the same issue asks to preserve — you cannot sort a stream — and `FilesData`'s materialized shape is published API. With the walk itself now 3x faster the remaining overlap is small; measured warm, the whole walk is 33.6ms of a ~2s run. `num_jobs_is_the_consumer_count_not_a_budget_shared_with_a_producer`, `run_verifies_paths_by_default`, `without_path_verification_dispatches_every_path`, `walk_directory_seed_returns_sorted_paths`, and `parallel_walk_finds_every_file_at_every_job_count` cover the four behaviours; each was verified to fail against the pre-change code. The sortedness assertion is a unit test on purpose — an end-to-end five-run comparison through `bca metrics` stayed green with the sort deleted, because the ordering bug is a race. Fixes #1114
`load_dir_set` and `set_from_spaces` derived the pairing key and pulled the `metrics` object with the same four lines. `bca diff --since` builds one side in memory and `bca diff <old> <new>` reads the other off disk, so the two agreeing is load-bearing rather than incidental — a shared `insert_document` makes it structural instead of conventional. Also drops an intermediate binding in `ConcurrentRunner::run` that only renamed the dispatch result.
`thresholds.rs` gains a `metric:` line per extractor entry plus `selected_metrics`, which puts the file at 526 loc.ploc — inside the 95-100% soft band (522.5) though still under the 550 hard limit. Net the refresh removes more than it adds: four entries drop out because deleting the unreachable poisoned-lock branches took `nexits` back under its limit in `compute_since_diff`, `dispatch_exemptions` and `dispatch.rs`, and `ConcurrentRunner::run` shed enough to clear its Halstead entry. 178 entries -> 175.
#1116 moved `diff --since` off its temp-JSON round-trip but drained the aggregate channel only after the walk had joined every worker, so the unbounded channel held a `FuncSpace` tree for every file at peak. Over `tests/repositories/DeepSpeech` (12,732 files) that took peak RSS from 437 MB to 831 MB — a regression the original commit measured for time and not for memory. Run the collector on its own thread, started before the walk, over a channel bounded per job. Each tree is reduced to its metrics value and dropped as it arrives instead of queueing, and the reduction overlaps the walk rather than following it. Median of three on that tree: route sys wall(min) peak RSS temp JSON (pre-#1116) 3.59s 9.42s 437 MB #1116 as shipped 3.36s 12.42s 831 MB this commit 2.34s 7.62s 599 MB So memory falls 28% and wall time improves rather than paying for it. The residual 599 vs 437 MB is inherent to the in-memory design: a side's `MetricSet` is now accumulated during its walk instead of in a separate pass afterwards, and that overlap is what buys the speed. It is called out in the CHANGELOG so anyone sizing a CI container knows. A tighter bound does not help — at 4x per job the peak is 600 MB and wall time rises to 6.55s, because what remains at peak is the accumulated set, not the backlog. The bound's job is to stop unbounded growth, not to tune the peak. Also from review: - `walk_metric_set`'s doc still described the deleted `json_out_dir` and claimed the pairing key came from a path relative to it. The key comes from the document's `name`, which is the invariant `set_from_spaces_matches_the_file_round_trip` exists to pin. - `tests/common/mod.rs` still added 1 to `available_parallelism` to offset the producer slot #1114 removed, so every corpus runner was oversubscribed by one. - `set_from_spaces` skipped a non-metrics aggregate item with `continue`. Silently shortening a side's set is the wrong-answer failure mode #1098 guards against, so it is refused instead. - `Metric::suppressible`'s doc asserted `tokens` has no configurable threshold. It has one; only suppression excludes it. That wrong premise is exactly the trap #1113 had to work around. - `report_hotspot_tx`, the fifth channel #1119 rewired and the only one whose call site changed shape, had no serial-vs-parallel coverage. Added, asserting every change-history row carries a joined hotspot score; verified falsifiable by dropping one file's record.
Audit of the tests this branch added, by perturbing the production line each one names. Three passed against a bug they claimed to cover. `check_mi_value_is_identical_whether_or_not_the_walk_is_narrowed` widened its comparison run with `tokens`/`abc`/`nom` — none of which are MI's dependencies. Deleting `Metric::Mi`'s dependency list drove *both* sides to `mi.original = 0`, so they still matched and the test passed, while claiming to pin exactly that closure. The comparison run now names `loc.sloc`, `cyclomatic` and `halstead.volume`, selecting those families independently of the closure under test. `report_body_is_identical_serially_and_in_parallel` and `exemptions_audit_is_identical_serially_and_in_parallel` guarded only against emptiness. Serial-vs-parallel equality cannot see a deterministic drop — it removes the same record from both sides — so skipping one file's records left both runs equal and both non-empty. They now assert the absolute counts their outputs already carry (`| Functions/methods | 120 |`, `# In-source markers (40)`), matching the guards the check and aggregate tests already had. All three now fail under the perturbation and pass without it. The remaining nine tests added on this branch were already verified the same way; `parallel_walk_finds_every_file_at_every_job_count` needed a dropped-file perturbation rather than a duplicated one, since the caller's cross-seed dedupe absorbs duplicates by design.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1156 +/- ##
==========================================
- Coverage 98.16% 98.16% -0.01%
==========================================
Files 276 276
Lines 69312 69308 -4
Branches 68882 68878 -4
==========================================
- Hits 68038 68034 -4
+ Misses 873 871 -2
- Partials 401 403 +2
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
`parallel_walk_finds_every_file_at_every_job_count` reduced each emitted `name` to a basename by splitting on '/'. The name carries the platform separator, so on Windows the split found nothing and every "basename" stayed a full `C:\…\a0-impl.rs` path, failing the set comparison. `Path::file_name` handles both separators. Windows-only, and the test was new on this branch, so nothing shipped was affected. Caught by `test (windows-latest)` on PR #1156; the other 80 checks passed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four CLI performance issues, plus the follow-up work each one turned up.
Fixes #1113
Fixes #1119
Fixes #1116
Fixes #1114
Results
All measured with release binaries over
tests/repositories/DeepSpeech(12,732 analyzable files), median of three to five runs on a 16-core host.
checkcomputes only thresholded metric familiesMutexdiff --sincebuilds its sets in memorycheck~1.12×Where the issues were wrong
Each issue's plan was checked rather than followed, and three premises did not
survive.
#1113's proposed approach would have introduced a bug. It suggested
deriving the metric selection from
threshold_metric_for_name. That helperanswers a suppression question and returns
Nonefortokens— buttokensis a real configurable threshold, so selecting by it leaves
m.tokensat zeroand
0 > limitsilently never fires. The family is instead declared perMetricExtractorentry, making the registry the single source of truth.check_tokens_threshold_fires_under_narrowed_metric_selectionis theregression test.
#1113's other two claims are also wrong.
Action::Exemptionsalreadycomputed no metrics (
dispatch_exemptionsreads suppression markers off theparse), so there was nothing to narrow. And the projected 5–7× came from
bca metrics, which serializes a document per file;checkemits nothing perfile, so its ~22 s parse-and-walk floor bounds the saving. This repo's own
bca.tomlthresholds nine of thirteen families, somake self-scangainsnothing — contrary to the issue.
#1114 is not the bottleneck it describes. Warm, the serial walk was 0.09 s
of a 3.6 s
check— 2.5%. The 3.0× speedup is real but it is 3× of a smallnumber. The cold-cache monorepo claim may well hold; it is not measurable
without root to drop the page cache.
#1119's stated rationale is stale.
std::sync::mpsc::Senderhas beenSyncsince Rust 1.72 (MSRV is 1.94), so the mutex had been unnecessary foryears and crossbeam was never required — only consistent with the library's
existing channel. Corrected in the code comments and on the issue.
Deliberately not done
The streaming half of #1114. It asks to feed the pool directly from the
walker with no intermediate
Vec. Three reasons: it contradicts the sameissue's determinism requirement (you cannot sort a stream);
FilesDataispublished API, not
#[non_exhaustive], and constructed with struct literals,so changing
run's parameter is a SemVer break needing an additive entrypoint; and with the walk itself now 3× faster the remaining overlap is 33.6 ms
of a ~2 s run. Happy to file a follow-up against the next major.
A regression this PR introduced and then fixed
Reviewing #1116 showed it had traded memory for time without saying so: peak
RSS went 437 MB → 831 MB, because the unbounded aggregate channel held a
FuncSpacefor every file. A collector thread started before the walk, over aper-job bounded channel, reduces each tree and drops it as it arrives:
Memory falls 28% and wall time improves rather than paying for it. The residual
599 vs 437 MB is inherent — a side's
MetricSetnow accumulates during itswalk instead of in a pass afterwards, and that overlap is what buys the speed.
Called out in the CHANGELOG for anyone sizing a CI container.
Public API
Additive:
ConcurrentRunner::without_path_verification.Behavioural, signature unchanged:
ConcurrentRunner::new'snum_jobsis nowthe consumer count rather than a budget shared with a producer thread, so a
caller passing
ngetsnconsumers instead ofn - 1.ConcurrentErrors::Produceris no longer constructed and is retained until3.0 so a downstream
matchstill compiles. Both recorded inSTABILITY.mdand
CHANGELOG.md.Two user-visible CLI changes, both in the CHANGELOG: per-file output order for
a directory walk is now sorted rather than readdir order (previously
unspecified and filesystem-dependent; the parallel walker would otherwise vary
it run to run), and a file that vanishes between the walk and its analysis is
now a tool error rather than a silent skip that exited 0.
Tests
Twelve new tests, each verified to fail against the exact production line it
names.
audit-testscaught three of them passing for the wrong reason andthey were fixed:
check_mi_value_is_identical…widened withtokens/abc/nom, none ofwhich are MI's dependencies, so deleting
Metric::Mi's dependency list droveboth sides to
0and they still matched.report_body_…andexemptions_audit_…guarded only against emptiness, so adeterministic drop removed the same record from both sides and still
compared equal. They now assert the absolute counts their output carries.
One test is deliberately a unit test rather than end-to-end:
walk_directory_seed_returns_sorted_paths. The end-to-end version — fivebca metricsruns compared for identical order — stayed green with the sortdeleted, because the ordering bug is a race.
Validation
make pre-commitcannot complete in a nested worktree (#1145 —cargo fmt --allandenums-checkresolve the workspace-excluded crates against the maincheckout's manifest). Every other stage was run individually and passes:
scoped fmt, clippy, test, test-doc,
cargo docwith-D warnings, udeps,manpages-check, both self-scan tiers, all lint families, and all four Pythonstages. Baseline refreshed: 178 → 175 entries, four dropping out because
removing the unreachable poisoned-lock branches took
nexitsback under itslimit.
The three
enums-*stages are unverified here and are untouched by thisbranch; worth one
make pre-commitfrom the main checkout before merge. Iadded the findings to #1145, including that its planned five-manifest fix needs
to be six —
enumsis affected too.