Skip to content

Add diagnostic instrumentation and regression-gate probes for issue #471 - #473

Merged
clrudolphi merged 6 commits into
masterfrom
investigate/471-lsp-large-solution-perf
Aug 24, 2026
Merged

Add diagnostic instrumentation and regression-gate probes for issue #471#473
clrudolphi merged 6 commits into
masterfrom
investigate/471-lsp-large-solution-perf

Conversation

@clrudolphi

Copy link
Copy Markdown
Collaborator

Summary

Diagnostics-only infrastructure from the issue #471 investigation (LSP server scaling badly on large solutions) — no fix yet, that's tracked as follow-up work on a new branch once this merges. Full investigation writeup and findings are on the issue: #471.

  • IOperationDurationRecorder.Measure/Record gain an optional detail string tag (source/binary-compatible trailing param) and now always log the managed thread id, so PERF log lines can be correlated against the state that grew and against thread-sharing evidence directly from the log.
  • IBindingMatchService.GetCacheStats() — cheap (DocumentCount, TotalStepCount) snapshot; StepCodeLensHandler tags its PERF line with it.
  • BindingRegistryChangedHandler's reconcile PERF line now carries the actual scanned/reparsed file counts (captured after the work runs, via manual Stopwatch + try/finally instead of the using-scope Measure it replaced, preserving record-on-exception behavior).
  • Two new test classes under tests/LSP/Reqnroll.IdeSupport.LSP.Server.Tests/Performance/, both promoted from diagnostic probes to real regression gates:
    • ConcurrencyProbeTests — asserts the confirmed dispatch-stall symptom (>5x slowdown on a cheap request under concurrent CodeLens load; real runs measured 40x-60x). Documents known-bad behavior — intentionally, per its own doc comment, to be flipped or removed once the dispatch/CodeLens-resolve fix lands.
    • FindUsagesScalingProbeTests — two forward-compatible gates (headroom over a naive linear-scaling prediction on each axis) that catch a real O(n²) regression in FindUsages/CodeLens cost without needing changes once the underlying index work lands.
  • A few new unit tests for BindingMatchService.GetCacheStats() and the recorder's new detail/thread-id behavior.

Test plan

  • dotnet build Reqnroll.IdeSupport.slnx — clean
  • dotnet test tests/LSP/Reqnroll.IdeSupport.LSP.Server.Tests — 776/776 passing
  • dotnet test tests/LSP/Reqnroll.IdeSupport.LSP.Core.Tests — 617/618 passing (1 pre-existing unrelated skip)
  • The two new probe tests re-run 4x to confirm the new thresholds aren't flaky (40x-60x observed against a 5x gate; well under headroom on the scaling gates each run)

🤖 Generated with Claude Code

clrudolphi and others added 6 commits August 23, 2026 14:55
Settles the "is request dispatch serial" question the issue leaves open:
solo textDocument/foldingRange = 13.0ms; the same request fired concurrently
with 20 textDocument/codeLens calls against the corpus's 1,350-step cache
(BindingMatchService.FindUsages is an unindexed full-cache scan, called once
per binding by StepCodeLensHandler) = 593.3ms. A cheap, unrelated request
genuinely queues behind CodeLens/FindUsages work rather than running
concurrently, confirming serial dispatch and corroborating FindUsages as the
workload doing the blocking.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…gation

Diagnostics-only change (item #2 of the investigation plan): once the
concurrency probe confirmed serial dispatch and pointed at FindUsages as the
blocking workload, the PERF log needed a way to correlate a climbing duration
against what actually grew, from a live VS session, without re-deriving it
from timestamps alone.

- IOperationDurationRecorder.Measure/Record gain an optional `detail` string
  (source/binary-compatible trailing param); OperationDurationRecorder.Record
  now always logs the managed thread id too, so concurrent vs. serialized
  operations are directly visible in the log.
- IBindingMatchService.GetCacheStats() reports (DocumentCount, TotalStepCount)
  cheaply (O(1) + O(cached docs)); StepCodeLensHandler tags its
  textDocument/codeLens PERF line with it, since FindUsages's cost is
  expected to track cached step count.
- BindingRegistryChangedHandler's reconcile PERF line now carries the actual
  scanned/reparsed file counts, captured after the work runs (Measure's
  using-scope can't do this, so it moved to manual Stopwatch + try/finally,
  preserving record-on-exception behavior).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two empirical duration-vs-size curves, without needing the full VS +
Reqnroll.VeryLargeFeature manual repro:

- Cache-size axis (bindings-in-file fixed at 64): 12.5ms at 2 features
  (~54 steps) to 44.3ms at 50 features (~1350 steps) - only ~3.5x for a 25x
  step-count increase; per-feature cost actually decreases. Sub-linear, not
  O(n^2), at this scale.
- Bindings-in-file axis (cache fixed at 1350 steps): 64 bindings = 37.2ms,
  1000 bindings (synthetic generated file) = 533.2ms - ratio tracks binding
  count almost exactly (15.6x bindings -> 14.3x latency, ~0.55ms/binding
  constant). Linear.

Revises the working theory: FindUsages is linear in each axis individually,
not O(n^2) in either alone, but cost is the product of both - and at the
issue's real scale (~1,300-method file) that product alone reaches hundreds
of ms per single CodeLens call, which combined with item #1's confirmed
serial dispatch and VS's ~1/sec CodeLens polling is sufficient to explain
the reported tens-of-seconds numbers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Chris ran the F5/DEBUG experimental instance against Reqnroll.VeryLargeFeature
and captured reqnroll-vs-server-debug-20260823-32140.log +
reqnroll-vs-inspector-20260823-152122.log. Cross-referencing the two:

- cacheDocs=1 cacheSteps=6238: a single .feature file is the "large .feature
  file" the issue described.
- 10 consecutive textDocument/codeLens calls on the large step-definitions
  file, all at the same cache size, cost a stable ~1.22-1.29s each -
  confirms item #3's linear-in-the-product model with real data (~460
  bindings implied).
- internal/bindingRegistryReconcile (10050.2ms), textDocument/didOpen
  (10128.0ms), and the reqnroll/semanticTokens push (7470.8ms) all completed
  within ~2ms of each other on thread=20 - direct thread-sharing evidence.
- reqnroll/resolveTestTargets fired 51 times in ~8 seconds for one open
  feature file - a previously unflagged contributor.
- The clearest evidence yet for serial dispatch: VS acked the first
  workspace/inlayHint/refresh at 15:22:59.229, but the server's own
  SendRequest(...).ReturningVoid() didn't complete until 15:23:20.165 -
  ~21 seconds after VS's ack had already been sent. An already-received
  response frame sitting unprocessed because the server was busy with other
  queued work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>


Two follow-up findings, posted to the issue and logged here:

- Decompiled OmniSharp 0.19.9 to answer whether it supports concurrent
  dispatch: it does. Every handler this issue concerns is [Parallel] by the
  library's own interface attribute; textDocument/didOpen/didChange/didSave
  are hardwired [Serial], and the scheduler's batch design means a slow
  Serial item stalls the whole pipeline (including Parallel work queued
  behind it) until it drains. Revises "dispatch is effectively serial" to
  the more precise mechanism.
- Audited range/resolve support per Chris's request: textDocument/codeLens
  has none at all (no resolveProvider, no resolve handler, no Data token on
  lenses) despite being the standard fix for exactly this eager-full-file
  cost shape; textDocument/semanticTokens/range and textDocument/inlayHint
  both accept a range but compute the whole document anyway before
  filtering/discarding down to it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ConcurrencyProbeTests and FindUsagesScalingProbeTests were diagnostic-only
(log a finding, no assertion). Converts them to real pass/fail gates now
that root cause is confirmed and findings are posted to issue #471:

- ConcurrencyProbeTests: asserts the confirmed dispatch-stall symptom
  (>5x slowdown; real runs measured 40x-60x) still reproduces, plus a
  baseline sanity check. This documents known-bad behavior, not desired
  behavior -- flip or delete once the dispatch/CodeLens-resolve fix lands.
- FindUsagesScalingProbeTests (both axes): asserts cost stays within a
  generous headroom (5x / 3x) of a naive linear-scaling prediction --
  forward-compatible regression gates that catch a real O(n^2) regression
  without needing to change once FindUsages is eventually indexed.

Also removes docs/Archive/Issue471-Investigation-TODO.md: scratch working
notes for the investigation, per its own stated purpose now fulfilled --
all findings are posted to issue #471's comment thread instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@clrudolphi
clrudolphi merged commit b712e97 into master Aug 24, 2026
16 checks passed
@clrudolphi
clrudolphi deleted the investigate/471-lsp-large-solution-perf branch August 24, 2026 13:45
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