perf: rebuild_symbol_edges is superlinear — correlated enclosing-symbol subquery per resolved ref (0.27s -> 6.68s, 24.6x) #54
Labels
No labels
code-review
correctness
dos
performance
security
severity/high
severity/low
severity/medium
tech-debt
Kind/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
h-dv/code-index#54
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Split out of the #53 investigation, which measured it but scoped it out. Filed so it does not get rediscovered as "#53 came back".
The measurement
rebuild_symbol_edges(crates/indexer/src/index.rs:408-424) runs after every non-skipped resolve pass, inside the same IMMEDIATE transaction as resolution.9.3× the refs for 24.6× the time. Same superlinear shape as the resolver statements fixed in #53, and it survives that fix — it was ~7% of wall before, and is a larger share now that the resolver itself is 2.6–3.5× faster.
Suspected cause
SYMBOL_EDGES_SELECTderives each edge's source by finding the smallest-span symbol covering the ref's start line, as a correlated subquery evaluated once per resolved ref. On rust-analyzer that is 115 997 evaluations against files averaging ~1 396 symbols.idx_symbols_file_spanexists and is what the subquery uses, so this is not the missing-index shape that #53 turned out to be — it is a per-row correlated lookup whose cost grows with symbols-per-file. Worth confirming withEXPLAIN QUERY PLANbefore optimising, exactly as #53 did rather than profiling blind.Why it matters more than 7% suggests
It is inside the resolve transaction, so it extends the window in which the write lock is held. #52's review found the CLI's
Deferredpolicy has no busy-retry, so acode-index indexconcurrent with a daemon resolve hard-failsSQLITE_BUSYagainst a 5sbusy_timeout. Anything that lengthens that window makes an existing hazard more likely.Suggested direction
Compute enclosing symbols set-wise once per pass — e.g. a temp table of
(file_id, ref_line) -> smallest covering symbolbuilt with a single windowed/grouped query — instead of a correlated subquery per ref. That is the same set-oriented rewrite I020 applied to the resolver itself.Acceptance
SYMBOL_EDGES_SELECTat tier-1 and tier-3 scale, confirming the cause before changing anythingsymbol_edgesoutput byte-identical on all 9 corpus repos (id-independent projection:from/torendered aspath#name@line, plusweight) — the standard set by #53's reviewNot urgent
Correctness is unaffected — this is purely how long a correct answer takes. Same measured-first discipline as #53: do not assume the correlated subquery is the cost until the query plan says so.
Fixed in
4510063Cause confirmed by EQP before changing anything
The index was being used; the cost was the
ORDER BY ... LIMIT 1inside the correlated subquery — a temp b-tree built 116k times on rust-analyzer.The change
Resolve the enclosing symbol set-wise, once per distinct
(file_id, line), with a single window-function sort into an indexed temp table. On rust-analyzer that is 89 267 evaluations instead of 115 997 — but the real win is one sort instead of 89k.Output equality proven, not inferred from counts
The issue's acceptance asked for byte-identical edges on all 9 repos. Done two ways:
(from_id, to_id, weight)between old and new SQL on the same DB — zero on all nine repos. Both sides areGROUP BYresults so(from_id, to_id)is unique, meaningEXCEPTcannot mask a duplicate.path#name@line, plus weight) is md5-identical on rust-analyzer, py-django, rust-ripgrep, cs-dapper, ts-zod.Independent confirmation:
corpus_ratchetpasses unchanged, which pins exactedgescounts for all 7 tier-1 repos, and the mutation guard still reports 0 rebinds / 7 056 sites.One thing I tried that was WORSE — recorded so nobody repeats it
To keep
SYMBOL_EDGES_SELECTas a single self-contained statement I triedMATERIALIZEDCTEs. Measured 19.9s — 3.4× slower than the correlated form it was meant to replace — because an unindexed CTE join gets probed once per ref, which is the very shape that made the original slow. The temp index is load-bearing. The doc comment says so, with the number, so a future tidy-up re-measures instead of regressing.Consequences
SYMBOL_EDGES_SELECTis gone (no longer expressible as one SELECT). The daemon's test helper now calls the productionrebuild_symbol_edgesdirectly — one implementation, so the two cannot drift, same reasoning as sharingTEST_MODULE_CONTAINS_REF.Acceptance
symbol_edgesbyte-identical on all 9 corpus reposFixed and released in v0.9.0.
rebuild_symbol_edgescomputed each ref's enclosing symbol with a correlated subquery; it is now set-wise via a window function into an indexed temp table. 5.79s → 1.35s, and the resulting edge set is byte-identical on all 9 pinned corpus repos — verified rather than assumed, since a "faster" rewrite that changes the graph would be a silent correctness regression.Related index work landed alongside under #53 (
ix_symbol_buckets_name,ix_file_keys_file/key,ix_file_pkg_file/root, plusm0023_symbols_parent_name_index).