Skip to content

perf: fix usage_graph never completing on large workspaces - #2

Draft
sontek wants to merge 10 commits into
upstream-masterfrom
fix/batch-relational-definition-lookups
Draft

perf: fix usage_graph never completing on large workspaces#2
sontek wants to merge 10 commits into
upstream-masterfrom
fix/batch-relational-definition-lookups

Conversation

@sontek

@sontek sontek commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Fixes usage_graph's whole-workspace path, which never completed on a Kubernetes-scale monorepo (killed at 53+ minutes with no result, then again past 4 hours in a later diagnostic run). Nine commits, in order:

  1. Parallelize whole-workspace declaration enumeration.
  2. Batch relational-store lookups per language instead of one round trip per name.
  3. Wire up batched import-facts reads for Go, C++, Kotlin, Ruby, and Rust (the batch API already existed and Python already used it).
  4. Let ReferenceEngine callers pick the candidate-file provider, so a caller with no real cancellation deadline can use the cached reverse-import-index path instead of the uncached per-target importer scan.
  5. Forward the relational-batch and import-prefetch test hooks that were silently defaulting to no-ops on eleven of the twelve language wrappers.
  6. Hoist the loop-invariant catalog rebuild, batch endpoint lookups, and batch the get_definition fallback per file instead of per ambiguous edge, plus route usage_graph's own candidate discovery through the cached reverse-import index from commit 4.
  7. Parallelize the remaining ambiguous-edge target resolution (935 targets on the same reproduction) on the existing HEAVY_SCAN_POOL, since each target's resolution is independent of every other's.
  8. Cache per-file source text and parsed Go trees across ambiguous targets. A workspace with heavy per-platform build-tag duplication (golang.org/x/sys/unix's many _linux.go/_darwin.go/... variants of the same function name is the extreme case) makes hundreds of distinct ambiguous targets share a near-identical candidate file set, and each one used to re-parse the same files from scratch.
  9. Stop re-scanning and re-sorting the whole workspace per candidate declaration. find_import_graph_candidates called a helper that fanned out across every language delegate, sorted and deduped the result, then filtered it back down to one language, on every single call, once per source file of every declaration it examines. Cached that per language instead of recomputing it every time.

Measured against the original Kubernetes-scale reproduction: never completed before any of this, completed in about 27 minutes after commits 1 through 6, then about 23 minutes with commit 7 added.

That reproduction's checkout was lost partway through this work, so commits 8 and 9 are measured against a freshly rebuilt equivalent-scale Go corpus instead (also kubernetes/kubernetes, a different commit), holding that corpus fixed. Commit 8 took it from 12m49s to 11m44s. Commit 9's phase-level numbers are the best of any run this session (the parallel ambiguous-edge phase's own cost and its worst single straggler both dropped further, and a live-sampled hot path collapsed from 11,758 calls to 2), but total wall-clock time across repeated runs stayed within a roughly 45-second band with no clean trend, which looks like run-to-run variance on a shared, non-isolated benchmark machine layered on top of a real but partial fix. Recording that honestly rather than claiming a specific percentage for commit 9.

Full test suite passes throughout (cargo test -p brokk-bifrost-analysis --lib): 2412 passed, the same pre-existing environment-only failure (missing local Go 1.24 toolchain) both before and after.

Refs BrokkAi/bifrost#15

WorkspaceUsageCatalog::build_with_cancellation hydrated every analyzed
file's declarations sequentially, one file at a time. Extract the
per-file enumeration into declarations_for_file and run it across
files with rayon's par_iter, used by both the unrooted build path and
the existing rooted build_for_files path.

Confirmed with a live sample on a large monorepo: 100% CPU (single
core) before, 1000%+ CPU (fully parallel) after, for this phase.
AnalyzerDefinitionLookup resolved one name at a time against the
relational store: an exact-name attempt, then (on a miss) an
identifier-candidate fallback, each its own round trip. On a large
workspace resolving thousands of names this was thousands of
sequential round trips.

Add prefetch_fqn_in_language, which batches many names' exact-name
lookups into one relational query, then batches every miss's
identifier-candidate fallback into a second query, writing results
into the existing per-name cache that fqn_in_language already reads.
Four cross-package names went from 8 round trips (one exact attempt
plus one fallback per name) to 2 (one batched round trip per phase).
ImportAnalysisProvider::import_infos_for_files defaults to None,
which forces every caller of the batch path (candidate discovery's
importer scan, among others) back to one import_info_of call per
file. TreeSitterAnalyzer already exposes a real batched
implementation (bulk_import_infos, backed by one relational read for
every cache-miss file), and Python's provider already forwards to it,
but Go, C++, Kotlin, Ruby, and Rust never did.

Forward all five to the existing bulk_import_infos, matching the
pattern each of them already uses for
file_dependency_facts_for_files right above it.
references_to_edges hardcoded its candidate-file provider to None,
which routes candidate discovery through find_direct_importers_with_
cancellation: a per-candidate, uncached workspace-wide importer scan
that exists specifically so a caller with a real cancellation
deadline can bail out mid-scan instead of being forced through one
uninterruptible reverse-import-index build. A caller whose
cancellation token can never actually fire pays that scan's full,
uncached cost on every call for a protection it will never use.

Extract the existing body into references_to_edges_with_provider,
which forwards an explicit CandidateFileProvider through to
query_with_provider_and_source_budget instead of hardcoding None.
references_to_edges becomes a thin wrapper that keeps passing None,
so its one existing caller (inverse-edge derivation, which does carry
a real deadline) is unaffected.

Add a test-only call counter to find_direct_importers_with_cancellation
so a caller that switches providers can assert it stopped taking the
uncached path, and make the candidates module visible within the
crate so that counter is reachable from tests elsewhere.
Two batches of methods landed on IAnalyzer / AnalyzerTestHooks
(relational_definition_batch_call_count_for_test and its reset,
definition_candidates_query_count_for_test /
definition_prefetch_batch_count_for_test and their resets, and
IAnalyzer::prefetch_definitions) with default no-op implementations,
but a hand-written per-language wrapper's own impl block does not
auto-inherit a new trait method it does not explicitly list -- it
silently falls back to the trait's default rather than forwarding to
its inner TreeSitterAnalyzer. Only Rust and C# already forwarded the
query-count pair; nothing forwarded prefetch_definitions.

Add the missing forwarding to all twelve language wrappers and to
MultiAnalyzer's own delegate-summing implementations, so a counter
read through &dyn IAnalyzer reflects what actually happened underneath
regardless of which language backs it.
ambiguous-edge resolution

usage_graph's ambiguous-edge resolution had three separate O(N*M)
patterns that only show up at whole-workspace scale:

- The exact-layer catalog it builds against declarations was rebuilt
  from scratch on every iteration of the depth loop, even though
  declarations only grows within a run and the catalog only needs
  rebuilding when it actually has. Track the length it was last built
  from and skip the rebuild when nothing changed.

- Resolving each ambiguous endpoint name scanned the full declaration
  list once to check whether it existed locally, then scanned it
  again to collect the matching units. Replace both scans with one
  HashMap<(ecosystem, fq_name), Vec<CodeUnit>> built once.

- The get_definition fallback for occurrences without an exact target
  called resolve_definition_batch_with_source once per ambiguous edge,
  and each call built a fresh DefinitionBatchContext with empty tree
  and source caches -- a file referenced by many ambiguous edges got
  its AST re-parsed once per edge. Restructure the loop into three
  passes: run today's fast-path and structural/inverse scans
  unchanged, deferring any site that falls through to a per-file
  queue; resolve every file's queued sites in one batched call each;
  then run today's per-site finalization a second time using the now-
  complete evidence. On a small fixture, four fallback sites sharing
  one file collapse from four calls to one.

Also switch usage_graph's own ReferenceEngine calls to
references_to_edges_with_provider(..., Some(&ImportGraphCandidateProvider::new()), ...):
this scan never carries a real cancellation deadline, so the
interruptible importer scan the default path exists to protect
buys nothing here, while the cached reverse-import-index path (already
used elsewhere) does real work only once per file instead of once per
ambiguous target.

Measured on a live Kubernetes-scale reproduction (BIFROST_TIMING):
this run had reached ~275s of CPU time in 3 of these functions before
these commits, spread across a run that never completed in over an
hour; after this branch's fixes the same functions no longer show up
in that profile, and the same request completes in ~27 minutes end to
end for a request that previously never returned.
usage_graph()'s 935 ambiguous targets (declarations sharing an fq name)
were resolved one at a time: each target's fast-path scan plus its
exact-reference-engine scan ran sequentially, even though no target's
work can collide with another's (every site key embeds the target's
own name). On the k8s reproduction this loop was ~24.5% of total
runtime with a heavily skewed cost distribution.

Select the ambiguous targets first (a cheap sequential pass that keeps
today's exact "first edge in BTreeMap order wins" semantics for a name
shared by several edges), hoist the one-time structural scan out of
the loop entirely, then run each target's resolution in parallel on
the existing HEAVY_SCAN_POOL rayon pool and merge results back
sequentially. Also drops the now-redundant endpoints_by_edge map
(its value never depended on from_name) in favor of
endpoints_by_target, keyed only by (ecosystem, to_name).

Full test suite: 2338 passed, same 4 pre-existing environment-only
failures (missing Go 1.24 toolchain, missing javac/jar) as before.
Extended the existing ambiguous-edges test from two to three parallel
targets to catch a merge bug that would only surface with more than
two workers.
…ared default code path

The ambiguous-edges regression test asserted a global AtomicUsize count
of find_direct_importers_with_cancellation calls to prove usage_graph's
candidate discovery avoids the slow, uncached importer scan. That
function is also the workspace's shared default candidate-discovery
path (dead_code_smells and five of candidates.rs's own tests call it
directly), so the counter raced with unrelated tests running
concurrently in the same test binary and flaked under cargo test's
default parallelism -- confirmed by 15/15 clean passes in isolation
(--test-threads=1, this test only) against 2 failures in 3 full-suite
runs.

The property itself needs no runtime count: ImportGraphCandidateProvider
::find_candidates always calls find_import_graph_candidates with
scope: None, which forces cancellation: None and therefore the fast
referencing_files_of path -- there is no code path from there to the
slow scan. Documented that guarantee in the test instead of asserting
it empirically, and removed the now-unused counter plumbing.

Full suite: 2412 passed, same pre-existing environment-only Go-toolchain
failure, across 5 consecutive clean runs.
@sontek
sontek force-pushed the fix/batch-relational-definition-lookups branch from 174666b to 08d106a Compare September 3, 2026 17:00
…s targets

build_go_graph_with_edge_index re-read and, on a text match, re-parsed
every candidate file from scratch on every call. usage_graph's
ambiguous-edge resolution calls it once per distinct ambiguous target
name, and a workspace with heavy per-platform build-tag duplication
(golang.org/x/sys/unix's many _linux.go/_darwin.go/... variants of the
same function name is the extreme case) makes hundreds of distinct
targets share a near-identical candidate file set -- on the k8s
reproduction, 460 of 911 ambiguous targets share an identical 335-file
set, and many of those files reference more than one of the 460
functions, so the same file's tree was rebuilt from scratch by every
target that touched it.

Adds two KeyedPoolSafeMemo caches to GoEdgeIndex, which already lives
for the analyzer's lifetime: one for raw source text (backing the
existing identifier/owner text prefilter, so a non-matching file still
costs one cached read instead of one read per target) and one for the
resulting parsed tree (populated only for files that pass that
prefilter, preserving the original lazy-parse behavior for the common,
low-sharing case). Both are shared by every caller of
build_go_graph_with_edge_index, not only the ambiguous-edge path.

Measured against the same Kubernetes-scale reproduction, holding the
corpus fixed to isolate this change: go_query_graph::build and
parse_candidates collectively dropped from ~2.47M ms to ~25K ms summed
duration (98%), and total usage_graph wall time dropped from 12m49s to
11m44s (8.5%). The gap between those two numbers is real: most of
graph_find_usages's remaining cost is the per-(file, target) semantic
scan (binding resolution, owner matching, local inference), which is
not redundant the way re-parsing was -- each (file, target) pair is
scanned exactly once regardless, so it is not fixed by this change.

Full suite: 2412 passed, same pre-existing environment-only Go-toolchain
failure, both before and after.
… candidate declaration

find_import_graph_candidates calls analyzed_files_for_language once per
source file of every declaration it examines -- once per ambiguous
target's every overload, on a workspace with heavy same-name
duplication (golang.org/x/sys/unix again: 460 of 911 ambiguous targets
on the k8s reproduction). That free function's only implementation
fanned out across every language delegate, sorted and deduped the
combined result, then filtered it back down to one language on every
single call (issue #1738's shape, one call site further out): live
stack sampling during a benchmark run showed this sorting code
actively executing across many concurrent worker threads at once.

Promotes analyzed_files_for_language into a CodeUnitIndex trait
method, defaulting to the previous behavior for any analyzer that
doesn't override it, and gives MultiAnalyzer a cached override that
goes straight to the delegate owning the requested language --
skipping every other language's files rather than fanning out and
filtering them away -- and memoizes the (already sorted, per-language)
result so the same unchanging list isn't rebuilt and re-sorted on
every one of thousands of calls. The memo shares snapshot_caches'
lifetime: valid as long as the snapshot is, rebuilt fresh whenever a
new one is.

Also fixes usage_ecosystem_files's one caller, which called
analyzer.analyzed_files() directly (the same whole-workspace fan-out,
bypassing analyzed_files_for_language entirely) to then filter by
ecosystem instead of language, and the JS/TS candidate-discovery
special case, which had the identical pattern.

Full test suite: 2412 passed, same pre-existing environment-only
Go-toolchain failure, both before and after.

Measured against the same Kubernetes-scale reproduction, holding the
corpus fixed: analyzer::analyzed_files.fan_out calls dropped from
11,758 to 2, and the parallel ambiguous-edge phase's own summed cost
and worst single straggler both improved to their best measurements
across every run this session. Total wall-clock time across repeated
runs stayed within a ~45s band with no clean monotonic trend, which
looks like run-to-run variance on a shared, non-isolated machine
layered on top of a real but partial fix -- recorded here rather than
claimed as a specific percentage.
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