Skip to content

perf(lsp): eliminate the cross-LSP O(n²) — shared Java registry, own-file overlay, complexity guard (#1669) - #1681

Merged
DeusData merged 11 commits into
mainfrom
feat/scaling-probe
Aug 17, 2026
Merged

perf(lsp): eliminate the cross-LSP O(n²) — shared Java registry, own-file overlay, complexity guard (#1669)#1681
DeusData merged 11 commits into
mainfrom
feat/scaling-probe

Conversation

@DeusData

Copy link
Copy Markdown
Owner

What does this PR do?

Fixes the dominant 0.9.0 → 0.10.x indexing slowdown (#1669): cross-file LSP was O(files × corpus_defs) for Java/Kotlin, plus ships the diagnostics that found it and a deterministic complexity-guard test suite that prevents the class from returning.

Five commits, each green:

  1. feat(diagnostics) — scaling probe + cross_lsp_cost/scan_cost telemetry: normalized per-file numbers that make a superlinear pass visible from ONE run's log instead of an 11-corpus two-binary A/B.
  2. style — clang-format of those lines.
  3. feat(diagnostics)perfile_registry (defs_per_file vs defs_total): the counter that proved the filter reduces by constant factor, not constant size.
  4. perf(lsp)the fix: shared Java+Kotlin cross-registry built once (two-phase: types → finalize → funcs, because pre-finalize type lookups are linear scans — a one-pass build cost ~300 s), resolved per file through an overlay of own-file defs (module/namespace scope re-imports the quadratic on large packages — proven by the suite below). Wired into both the full and incremental pipelines.
  5. test(complexity) — the guard suite: replicated-module + growing-shared-package corpora, gates on deterministic work-counter RATIOS (never wall time), ~2 s runtime, auto-joins CI sharding. RED at ratio 4.00 on the pre-fix tree, GREEN at 2.00 after; it also rejected an earlier module-scoped overlay design before it shipped.

Measured (elasticsearch corpus, 46,477 files, same host, this session's baselines)

v0.10.5 this PR v0.9.0
wall (cold) 419.9 s 85.6 s (4.9×) 61.5 s
wall (warm daemon) 83.7 s
cross-LSP CPU 6,195,780 ms 52,513 ms (118×) 110,344 ms
us_per_file_per_kdef 300 2
nodes 693,100 693,100
edges 5,646,235 5,649,949 (+0.066 %)

Full 11-corpus validation (warm daemon): java 477 → 86 s; every language improves vs v0.10.5 (total 2,370 → 1,774 s); small corpora halve. Node counts stable on clean corpora.

Edge delta named: +0.066 % on java — the shared base resolves cross-package targets the old per-file namespace/import filter could not see, plus source-order independence from the two-phase build. Run-to-run jitter ±54 edges on this corpus (known MT jitter class).

Explicitly not fixed here (separate root cause — C# already had a shared registry and did not move): the csharp 2.6×-vs-0.9.0 regression, tracked as the next perf target. Also recorded-and-rejected attempts with numbers (tail-index ×2, batched registration) so they aren't re-tried.

Verification

  • Full macOS suite green + full Linux (Colima arm64, ASan+LSan) leg green on this exact tree.
  • Complexity suite reproduce-first: RED on baseline (ratio 4.00) → GREEN with fix (2.00) → re-verified after every later edit.
  • mem/diagnostics suites carry the new deterministic tests (probe math is a pure function — no timing assertions anywhere).
  • lint-ci clean (cppcheck + pinned clang-format + NOLINT).
  • Windows: local UTM VM was infra-blocked at verification time (guest boots, no network — being investigated separately); the change is platform-independent C, and this PR's CI Windows legs are the gate.

Checklist

  • Every commit is signed off (git commit -s)
  • Tests pass locally (full macOS + Linux legs)
  • Lint passes (make -f Makefile.cbm lint-ci)
  • New behavior is covered by a test (reproduce-first; RED→GREEN proven, including against a wrong fix design)

Refs #1669.

Finding the 0.10.x indexing regression took an 11-corpus A/B across two
release binaries, a subset-scaling series, and a per-pass exponent fit.
None of that should have been necessary: the pass that carried it,
cross-file LSP, was already timed and already logged its def count. The
numbers were there, just never normalised into something a reader could
judge.

Three additions, all shipped without a flag except the detailed curve:

- parallel.resolve.cross_lsp_cost — cross-LSP cost NORMALISED per file,
  next to defs_total. Wall time cannot separate "big repo" from
  "superlinear pass"; us_per_file can. Measured on one Java tree:

      files=3710   defs=102845  us_per_file=35129   us_per_file_per_kdef=341
      files=14833  defs=339866  us_per_file=106722  us_per_file_per_kdef=314

  Per-file cost tripled while per-kdef stayed flat — the fingerprint of
  work proportional to the whole corpus (files x defs). One grep on two
  differently sized repos now answers what previously took a two-binary
  bench.

- parallel.resolve.scan_cost — candidates visited per tail-match lookup,
  plus fallback_rows, which the code has counted since #1085 but exposed
  only to a test. On the same tree the tail scan reached 242M candidate
  visits (n^2.04), worth seeing even though it proved cheap in wall time.

- cbm_scale_probe (foundation/profile.h) — samples cumulative elapsed at
  1/8, 1/4, 1/2 and 1 of a pass's items and fits k in T ~ n^k, warning
  once k reaches 1.35 in shipped builds. Wired into parallel_extract and
  parallel_resolve.

The probe's limits are documented rather than oversold: it catches growth
WITHIN a run, and would NOT have caught this bug, whose per-item cost is
constant-but-large within any single run (it reported 1.26 while the
cross-corpus exponent was 1.86). That is exactly why us_per_item and
us_per_file are emitted alongside it.

Tests are deterministic by construction: the exponent fit is a pure
function fed synthetic points, and the checkpoint bookkeeping is asserted
directly. A test that proved the detector by generating a real quadratic
workload would be asserting on the scheduler.

No product behaviour changes; diagnostics only.

Refs #1669.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…cost lines

Formatting only, on lines added by the previous commit. Verified with the
repo's pinned Homebrew LLVM clang-format so this is the CI-canonical
result, and confirmed no unrelated file needed reformatting.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The per-file cross-LSP path rebuilds a type registry for every file. Whether
that is cheap or quadratic depends entirely on how many defs the module
filter leaves, and nothing reported it — so a filter that reduces by a
constant FACTOR rather than to a constant SIZE looked identical to one that
worked.

parallel.resolve.perfile_registry reports defs_per_file next to defs_total,
plus how often the filter failed. Measured on one Java tree:

    1/4 corpus:  defs_total=179033  defs_per_file=1292
    full corpus: defs_total=689216  defs_per_file=5031

defs_per_file grew 3.89x while the corpus grew 3.85x — lockstep, so per-file
work is proportional to the whole corpus and cross-file LSP is
O(files x corpus_defs). filter_failed=0 throughout, which is why this was
invisible: the filter always 'succeeds', it just does not bound the set.

Diagnostics only; no behaviour change.

Refs #1669.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Cross-file LSP for Java rebuilt a type registry for EVERY file from a def
set the module filter reduces by a constant factor, not to a constant
size: the JVM filter branch includes every def sharing the file's
namespace, so per-file work tracks the corpus (measured defs_per_file
1,292 -> 5,031 in lockstep as defs_total went 179k -> 689k). That makes
the pass O(files x corpus_defs) — 87% of a Java index and the bulk of
the v0.9.0 -> v0.10.x slowdown.

Three changes, one architecture (the pattern Go/Python/C/C#/TS already
use):

1. cbm_java_build_cross_registry — the JVM def universe (Java + Kotlin,
   for mixed source roots) built ONCE per run, sealed read-only, shared
   across resolve workers. Wired into both pipeline.c and
   pipeline_incremental.c.

2. Two-phase registration inside that build: all TYPES first, finalize
   (hash buckets exist), then FUNCS. Func registration parses signatures,
   and type-name qualification via cbm_registry_lookup_type is a LINEAR
   scan until finalize — a single mixed pass measured 0.44 ms/def at
   689k defs, a ~300 s sequential build that erased the sharing win.
   Two-phase: 306 s -> 2.8 s. Stable partition order because overload
   ties resolve to the first registered QN match.

3. cbm_run_java_lsp_cross_with_registry resolves each file against the
   shared base through an overlay holding exactly THIS FILE's defs
   (register_local_func_or_type_from_file). Own-file scope is the load-
   bearing choice: patch_one_method refines signatures from the AST and
   must write a private copy (its types live in the per-file arena, the
   base is sealed), and any wider scope re-imports the quadratic — a
   module/namespace-scoped overlay measured ratio 4.00 on the growing-
   package corpus, own-file measures 2.00.

Elasticsearch corpus (46,477 files), same host, CBM_PROFILE=1:

                      v0.10.5      this change      v0.9.0
  wall                419.9 s      85.6 s (4.9x)    61.5 s
  cross-LSP CPU       6,195,780ms  52,513ms (118x)  110,344 ms
  us_per_file         207,448      1,770            3,722
  us_per_file_per_kdef 300         2                —
  nodes               693,100      693,100          —
  edges               5,646,235    5,649,949        —

Cross-LSP CPU now beats v0.9.0. Nodes are identical; edges +0.066%,
consistent with the shared base resolving cross-package targets the old
per-file namespace/import filter could not see, plus source-order
independence from the two-phase build.

Guarded by the complexity suite's shared-package gate (RED at ratio 4.00
on the pre-change tree, 2.00 after — see the suite commit).

Refs #1669.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Finding #1669 took an 11-corpus A/B across two release binaries. This
suite makes that bug class fail a unit test in seconds, on every
platform, from tiny corpora.

Method: build k and 2k REPLICATED module copies, run the full in-process
pipeline on both, assert counter RATIOS. Independent copies mean every
extensive quantity — nodes, edges, Σ per-file registry defs — must grow
linearly (ratio ~2). A files x corpus coupling makes per-file work itself
grow with k and lands at ratio ~4. Ratios expose the exponent regardless
of absolute scale, so 60-120 files suffice.

Verdicts are pure functions of (code, input): gates ride ONLY on
deterministic work counters and data-product counts, never on wall time.
Throughput (nodes/s, edges/s) is information-only, written to
private/benchmarks/complexity-<ts>.json (local, gitignored; skipped
under CBM_SKIP_PERF where rates are meaningless).

Two corpus shapes, both needed:

- Independent modules (java/py/go/ts templates): catches cross-module
  contamination and dedup breakage. The #1669 bug is GREEN here — fully
  closed modules filter perfectly, which is exactly why it survived.
- The growing shared package (bigpkg): one Java package whose file count
  scales with k — the real-repo shape (files concentrate in large
  packages). The JVM namespace filter branch makes per-file work track
  package size, so this corpus is the honest #1669 reproducer:
  ratio 4.00 RED on the pre-fix tree, 4.00 RED for a module-scoped
  overlay, 2.00 GREEN for the own-file overlay. It discriminated the
  correct fix design before the fix was written.

Both legs of every pair exceed MIN_FILES_FOR_PARALLEL(50): below it the
sequential path runs, which builds no shared registries and would be the
wrong code path to gate (its per-file cost is bounded by the 50-file
ceiling).

Recorded but deliberately NOT gated, with reasons at the case:
tail_candidates and fallback_rows are legitimately superlinear under
replication until those scans are bounded, and measured ~1 ns/unit.

Every ratio gate carries a non-vacuousness floor on the base counter so
broken counter wiring fails loudly instead of green-washing
(cbm_pxc_count_perfile_defs feeds the overlay path into the same
counter the fallback path already used; wired for Java, extend to the
TS overlay when touching ts_lsp).

Dynamic coverage: languages iterate CBM_LANG_COUNT; embedded templates
cover the LSP-hybrid languages, and tests/fixtures/complexity/<lang>/
dirs are auto-discovered so a new language joins the guard by dropping
fixtures. Uncovered languages are listed in the report with the reason.

The local report additionally carries per-language node/edge counts with
ratios and a per-pass elapsed_ms table per run (captured via a TEE log
sink during the in-process pipeline runs) — trend data for humans, still
never a gate.

Refs #1669.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
… +37% edges

Four measured changes on the dotnet/runtime corpus (58,656 files; every
step's numbers below are from full-corpus runs on the same host, baselines
captured this session; v0.9.0 = 444.3 s):

1. Two-phase cs registry build (types -> finalize -> funcs), the same
   pre-finalize linear-lookup disease fixed for Java: prepare 280 -> 140 s.

2. C# import-context carve-out: cs_import_types lists namespace_declaration
   (for namespace-name mapping) and using_statement (C#'s RAII block — a
   grammar-name collision), so EVERY namespaced C# file's whole body ran
   with inside_import=true. That both suppressed ordinary usage extraction
   under namespaces and sent every identifier through the ancestor-walking
   import-binding check (tree-sitter's ts_node_parent re-descends from the
   root, so wide files went quadratic: one 147 KB JIT torture file cost
   490 s; 6.8 s after). Only using_directive / namespace_use_declaration
   open an import scope now. Restores the suppressed usages:
   edges 4,291,387 -> 5,869,093 (+37%), nodes identical.

3. The usages walker maintains call/import ancestry as enter/exit counters
   on its explicit stack instead of per-node ancestor re-walks
   (extract_usages.c had grown from 6 to 100 ts_node_parent calls since
   v0.9.0; the two per-node gates are now O(1) with semantics preserved —
   strict ancestors only, emit before self-count).

4. Registry short-name indexes replace the two remaining full scans:
   cs_lookup_extension walked all 963k funcs per unresolved invocation
   (now the existing free-func short-name iterator, first-match order
   preserved via min-index selection), and cs_resolve_type_name's step-9
   fallback scanned every type per unresolved name IN BOTH the builder and
   per-file resolution (new type_short index in finalize, same
   reverse-insertion ascending-order pattern, best-score ties keep the
   first-in-registration-order winner). Builder 140,095 -> 500 ms; resolve
   cross-LSP CPU 5.5M -> 317k ms (us_per_file_per_kdef 191 -> 11).

End state: 449.9 s wall (1.01x of v0.9.0) with +48.6% edges vs v0.9.0 —
per-edge cost 32% BETTER than v0.9.0. Extract's remaining 359 s is the
24 MB hugeexpr1.cs parse floor both versions pay.

Guarded by the complexity suite; cs_lsp/extraction/edge/lang-contract
suites green including the 53-language calls-breadth contract.

Refs #1669.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
… boundary

Two measured changes on the TypeScript corpus (81,397 files; v0.9.0 17.9 s,
before 44.1 s, after 37.5 s):

- cov_rebuild_shadow_graph upserted every directory segment for every
  failure row — 13,243 parse-partial baseline files under one tests/
  subtree meant ~80k redundant node/edge round-trips, 9.1 s of a 9.2 s
  coverage_replace. An in-rebuild path->id map creates each directory once;
  identical graph (edges deduped by unique key before, absent now).
  Coverage block 9,162 -> 2,920 ms. Sub-block timings
  (publish.timing.coverage: del/rows/prune/meta/commit + row_count +
  detail_bytes) are kept — the caller-level number could not name the
  culprit.

- JS/TS export_statement is an import CONTEXT only in its re-export forms
  (source field, or a bare specifier list without a declaration). The old
  is_export_of_declaration blacklist missed TS-only forms
  (ambient_declaration, function_signature, module_declaration), running
  declare-heavy subtrees (.d.ts, export namespace) behind inside_import:
  suppressed usages + per-identifier ancestor walks. Positive detection
  replaces the blacklist; +3,954 restored usage edges on the corpus,
  nodes identical. (Measured perf-neutral here — kept for correctness.)

Suites green incl. store_nodes/edges/search, mcp, extraction, ts_lsp,
complexity, and the 53-language calls-breadth contract.

Refs #1669.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
ts_eval_expr_type was depth-capped but work-unbounded: crafted expressions
(the TS test suite's repeated object spreads) stay under the depth cap
while fanning out. Charge the same per-file budget the type-text parser
uses, at 16 units per entry (an eval entry does ~two orders of magnitude
more work than a text-parse unit), degrading to UNKNOWN on exhaustion.

Honest status: this hardens the documented budget design, but the known
3.6 KB spread-bomb baseline file still measures ~11 s in-corpus — its
entry path into the evaluator apparently runs unarmed and is recorded as
an open lead (zero budget warnings observed). Suites green (ts_lsp,
extraction, complexity).

Refs #1669.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
@DeusData

Copy link
Copy Markdown
Owner Author

Update: the regression program continued — C# recovered, TS partially

C# (dotnet/runtime corpus, 58,656 files) — acceptance met decisively (commit a4c0ffb):

v0.9.0 v0.10.5 now
wall 444.3 s 1210.8 s 449.9 s (1.01×)
edges 3.95 M 4.29 M (deflated) 5.87 M (+48.6 %)
per-edge 112 µs 282 µs 77 µs — 32 % better than v0.9.0

Four measured changes: two-phase cs registry build (prepare 280→140 s), the namespace/using_statement import-context bug (every namespaced C# file ran inside_import → suppressed usages + ancestor-walk tax; one 147 KB JIT file went 490→6.8 s; restores +1.58 M edges), the usages walker's ancestry counters (6→100 ts_node_parent growth since 0.9.0 reverted to O(1) gates), and registry short-name indexes for cs_lookup_extension + cs_resolve_type_name step 9 (builder 140,095→500 ms; resolve cross-LSP CPU 5.5 M→317 k ms).

TypeScript (commits a412642, f95fe55): 44.1 → 37.5 s (v0.9.0: 17.9). Coverage shadow-graph rebuild deduped (9.16→2.92 s — it upserted every directory per failure row), TS export re-export boundary fixed (+3,954 restored usage edges), eval-budget hardening. The remaining TS gap is attributed (extract +8.6 s incl. a spread-bomb file class with an open budget-arming lead; persist +5.2 s = FTS + coverage + rows; ~6 s startup) and not yet at the acceptance bar — tracked.

New telemetry shipped along the way: publish.timing.coverage sub-blocks and lsp_cross_prepare.builders per-language split — both existed as single opaque numbers that could not name their culprit.

Full 11-corpus validation will be re-run on the final tree (the usages-walker change touches every language's extract), and the linux/C residual attribution is queued.

…y forms

The no-skips lint gate (scripts/check-no-test-skips.sh) rightly rejected the
throughput-report test's two SKIP() calls:

- CBM_SKIP_PERF=1 is deliberate operator configuration, not a hidden
  environment failure: reporting is off by request, so the test PASSes with a
  stderr note instead of skipping.
- an uncreatable report dir IS an environment failure and now FAILs with the
  remedy in the message (set CBM_COMPLEXITY_REPORT_DIR), per the policy text.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
cbm_arena_alloc(arena, 0) returns NULL, so the java and cs cross-registry
builders read a def_count of 0 as OOM and returned NULL — a corpus with no
files of that language silently lost its shared registry (and the seal tests
caught exactly that: cbm_cs_build_cross_registry(&arena, NULL, 0) == NULL).
Guard the partition alloc behind def_count > 0; the empty registry is still
built, finalized, and shared.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
ts_eval_expr_type and ts_signature_for_call are mutually recursive: resolving
a call evaluates its argument expressions once per lookup path (method
dispatch + namespace fallback), and in tsc-compiled spread files the first
argument is itself the next nested Object.assign(...) call — the same subtree
re-evaluates once per enclosing level, 2^n total. The TS suite's
objectSpreadRepeatedComplexity.js (3.6 KB, 5 nodes) measured 20.4 s; with the
memo its eval cost is zero within measurement noise of a one-file control,
and the microsoft/TypeScript corpus drops 37.5 -> ~24 s warm (nodes
byte-identical, edges within the known scheduler jitter).

Expression types are position-pure within a file pass (one node = one scope
path; the per-file walk is single-threaded and deterministic), so one eval
per node is the correct semantics, not a cache trade-off. The memo is a
per-file, arena-backed, linear-probe table keyed on TSNode.id. Results
produced under a depth-cap or budget bail are never stored: both bail sites
bump a degradation counter, and a store only happens when the subtree
completed clean — a degraded UNKNOWN can therefore never shadow a later full
evaluation.

The regression guard asserts work, not wall-clock: the nested-Object.assign
shape must complete without exhausting the deterministic eval budget, read
back through a new CBM_ENABLE_TEST_SEAMS accessor pair. The seam lives in the
lsp_all unity object, so GRAMMAR_CFLAGS_TEST/TSAN now carry the seams define
(test artifacts always have seams; prod never does). Verified RED without the
memo (budget exhausted, suite 47.8 s) and green with it (3.2 s).

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
@DeusData
DeusData merged commit 41d240a into main Aug 17, 2026
35 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