Skip to content

Cut LSP semantic-enrichment cost on cold index#262

Merged
zzet merged 2 commits into
mainfrom
perf/cold-index-enrichment
Jul 7, 2026
Merged

Cut LSP semantic-enrichment cost on cold index#262
zzet merged 2 commits into
mainfrom
perf/cold-index-enrichment

Conversation

@zzet

@zzet zzet commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Cut LSP semantic-enrichment cost on cold index

Profiling a real multi-repo cold index showed the wall-time is dominated by LSP semantic enrichment, not resolve. Two gopls/pyright passes alone accounted for ~28 min:

pass duration enriched what it spent
gopls (go) 905s 11,338 52,571 textDocument/references
pyright (python) 774s 3 26,059 textDocument/references

references requests dominate every enrichment pass. They come from the reference-confirm pass, which promotes/refutes each ambiguous (confidence < 1.0) edge by asking the server for the target declaration's references and checking the edge's site is among them. Two inefficiencies made that pass issue far more round-trips than it needs.

1. Cache the reference list per target declaration

findReferences is positioned on the edge's target declaration, not its call site, so every ambiguous edge pointing at the same overloaded/hot declaration re-issued the identical query. Measured fan-in is 4.7×–9.4× for call/reference edges. The list is now fetched once per target within a confirm group and the per-edge site match filters the shared list — byte-identical confirm/refute decisions, far fewer round-trips.

Clean A/B, cold gopls pass on a small Go repo: duration 230s → 108s (2.1×), req_references 6,475 → 2,199, with identical confirmed/added/nodes_enriched.

2. Skip structural-containment edges

The confirm target set was every confidence < 1.0 edge — with no kind filter. So it also issued a reference round-trip for structural-containment edges (member_of, defines, contains, param_of, imports, captures) — a third of the target set, member_of alone being the single largest kind. Those anchor a declaration's fixed relationship to its enclosing structure; they carry no use site a reference list can adjudicate, and on a miss the definition-rebind fallback can mutate a correct edge's target.

The confirm targets are now confined to edges that anchor a use / type-position / dataflow site the server can actually resolve. Use-site (calls, references, reads, writes, instantiates), type-position (typed_as, returns) and dataflow (arg_of, value_flow, returns_to) edges stay confirmable, so their LSP disambiguation is preserved.

Verified by a full store diff (dedup vs +filter, same repo):

  • Every kept kind is byte-identical — including the arg_of rebind onto the call's actual Pool.EnqueueJob overload (confirmed against source) and the typed_as/returns binds to an interface rather than a concrete impl.
  • The only edge that changes is one member_of on a local variable that the fallback had mis-rebound onto an unrelated same-named method; skipping it restores the resolver's correct bind.

Net: 0 regressions, 1 misbind fixed, and req_references drops a further ~35% on top of the per-target cache.

Verification

  • GOWORK=off go build ./...
  • GOWORK=off go test -race ./internal/semantic/lsp/ ./internal/resolver/ ./internal/indexer/ ./internal/mcp/ — green
  • Isolated cold-index A/B on a fresh HOME (the live daemon untouched); byte-level store diff for the correctness proof above.

At-scale A/B (23k-node Go repo, warm gopls cache — isolates the confirm pass)

before after (both commits)
gopls enrichment 556s 205s (−63%, 2.7×)
req_references 53,455 11,518 (−78%)
nodes_enriched 11,272 11,272 (identical)
added 984 984 (identical)

confirmed drops 37,323 → 28,722 — that is the structural-containment kinds no longer being promoted to the lsp tier; their targets are unchanged (proven by the byte-level store diff on the smaller repo, and the mechanism is repo-independent). Cold, the same before-pass ran >17 min without completing.

zzet added 2 commits July 7, 2026 15:25
The reference-confirm pass positions textDocument/references on each
ambiguous edge's TARGET declaration, not its call site, so every edge
pointing at the same overloaded or hot declaration re-issued the
identical query. Measured fan-in is 4.7x-9.4x for call/reference edges,
so a common target drew that many redundant round-trips. Cache the
reference list per target node within the confirm group and let the
per-edge site match filter the shared list: byte-identical confirm and
refute decisions, far fewer server round-trips. A clean A/B on a small
Go repo cut the gopls enrichment pass 2.1x (230s to 108s, references
6475 to 2199) with identical confirmed/added/enriched counts.
The reference-confirm target set was every confidence<1.0 edge, so it also
issued a textDocument/references round-trip for structural-containment edges
(member_of, defines, contains, param_of, imports, captures). Those anchor a
declaration's fixed relationship to its enclosing structure — they carry no
use site a reference list can adjudicate, so the match is meaningless and, on
a miss, the definition-rebind fallback can mutate a correct edge's target. On a
four-repo cold index these kinds are a third of the target set (member_of alone
is the single largest confidence<1.0 kind).

Confine the confirm targets to edges that anchor a use, type-position, or
dataflow site the server can resolve; skip the structural-containment family.
Use-site (calls, references, reads, writes, instantiates), type-position
(typed_as, returns) and dataflow (arg_of, value_flow, returns_to) edges stay
confirmable, so their LSP disambiguation is preserved — verified by a store
diff that is byte-identical for every kept kind, including the arg_of rebind
onto the call's actual EnqueueJob overload. The one edge that changes is a
member_of on a local variable that the fallback had mis-rebound onto an
unrelated same-named method; skipping it restores the resolver's correct bind.
req_references drops ~35% on a small Go repo on top of the per-target cache.
@zzet zzet merged commit 2251241 into main Jul 7, 2026
18 checks passed
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