Skip to content

perf(diagnostics): share analyzer results across document requests - #404

Merged
razzmatazz merged 2 commits into
razzmatazz:mainfrom
alsi-lawr:perf/shared-analyzer-diagnostics-a4-002
Sep 8, 2026
Merged

razzmatazz merged 2 commits into
razzmatazz:mainfrom
alsi-lawr:perf/shared-analyzer-diagnostics-a4-002

Conversation

@alsi-lawr

Copy link
Copy Markdown
Contributor

Fixes #403.

Diagnosis

Document diagnostics currently create a new CompilationWithAnalyzers and run GetAllDiagnosticsAsync for every requested document. The result covers the whole project, but each request keeps only diagnostics for its own syntax tree. Pulling several documents from one unchanged solution snapshot therefore repeats the same project-wide analyzer work.

Change

Cache one lazy analyzer task per project and immutable Roslyn Solution snapshot. Document and compilation diagnostics share that result. A new solution snapshot naturally gets a new cache, and the weak solution key allows old snapshots to be collected.

Request cancellation only cancels that request's wait. It does not cancel analyzer work already shared with another document request.

The production change is 43 added lines and 18 removed lines in Roslyn/Analyzers.fs.

User impact

Diagnostic payloads and update behavior do not change. The first analyzer request for a solution snapshot still performs the full analysis. Later document requests for the same snapshot reuse it. Editing any document creates a new snapshot, so the next request analyzes the updated project before reuse begins again.

A real-LSP four-file evaluation covered same-file edits that clear and add IDE diagnostics, saves, unrelated document pulls, cross-file edits that add and clear CS0103, workspace pulls, and push diagnostics. Normalized diagnostic payloads matched the baseline in all three fresh sessions.

Benchmark

tests/benchmarks/AnalyzerDiagnostics.fsx starts the real LSP, enables analyzers, warms one document, then pulls diagnostics for 12 other documents in the same project snapshot. It prints the actual request times and diagnostic counts.

nix develop -c dotnet build tests/CSharpLanguageServer.Tests/CSharpLanguageServer.Tests.fsproj
nix develop -c dotnet fsi tests/benchmarks/AnalyzerDiagnostics.fsx

Upstream main (e2efc47), representative run:

request times (ms): [|47L; 42L; 43L; 72L; 44L; 41L; 42L; 42L; 40L; 63L; 42L; 38L|]
diagnostics per request: [|6; 6; 6; 6; 6; 6; 6; 6; 6; 6; 6; 6|]
wall time: 563 ms
server CPU time: 2150 ms

This branch (c2002e0), representative run:

request times (ms): [|4L; 2L; 2L; 2L; 2L; 2L; 2L; 2L; 2L; 2L; 2L; 2L|]
diagnostics per request: [|6; 6; 6; 6; 6; 6; 6; 6; 6; 6; 6; 6|]
wall time: 27 ms
server CPU time: 40 ms

Across three fresh server processes, the median wall time fell from 572 ms to 27 ms. Median server CPU time fell from 2,150 ms to 30 ms. Every sample returned the same 72 diagnostics.

Verification

Passed: 293, Failed: 0, Skipped: 0

The analyzer cache regression test also covers concurrent document requests, request cancellation isolation, completed-result reuse, and invalidation after a document edit.


type private SolutionAnalysisCache = ConcurrentDictionary<ProjectId, Lazy<Task<ImmutableArray<Diagnostic>>>>

let private solutionAnalysisCaches =

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a fan of this global cache. can we add this to wf structure? unsure how state transfers/caching/locking would work though

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a fan of this global cache. can we add this to wf structure? unsure how state transfers/caching/locking would work though

What exactly do you mean by "add this to wf"? Do you mean:

  • LspWorkspaceFolder holds the cache, which is then plugged into the WorkspaceFolder life-cycle by being created at solution load, and dropped in teardown? This would be a big improvement, I agree.
  • The cache itself is objectionable and we should just thread the record data through LspWorkspaceFolderUpdateFn? This would gut the PR, because it'll no longer cache the in-flight Task properly. The UpdateFns apply only after the handlers are evaluated, so the burst of diagnostics will always just see an empty set of diagnostics and re-request them all again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went ahead with option 1 there since Option 2 should just close the PR/Issue as rejected

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm!

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just have one question, re concurrency below

Comment on lines +40 to +47
cache.GetOrStartAnalysis(
project.Solution,
project.Id,
fun () ->
// Cancellation applies to each waiter, not to the shared analysis.
let cwa = compilation.WithAnalyzers(analyzers, project.AnalyzerOptions)
cwa.GetAllDiagnosticsAsync(CancellationToken.None)
)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: how does this work in face of concurrent requests for the same document/project? i.e. would there still be two concurrent diagnostics recomputes–and a semaphore is needed to avoid that? or does that not happen?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They can independently construct the same Lazy<> wrapped Task and add it to the ConcurrentDictionary, but they don't compute the task multiple times. No semaphore needed because they'll all converge on the same thread-safe .Value call. This is covered by the testProjectAnalyzerAnalysisIsSharedAcrossDocumentsAndRequestCancellation test.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@razzmatazz

razzmatazz commented Sep 6, 2026 •

Copy link
Copy Markdown
Owner

Just getting back to this PR–is there a particular observable-by-human performance or correctness issue that this PR addresses?

The reason I am somewhat reluctant to merge it is that this adds quite a bit of complexity for unclear (to me) wins.

@alsi-lawr

Copy link
Copy Markdown
Contributor Author

Just getting back to this PR–is there a particular observable-by-human performance or correctness issue that this PR addresses?

The reason I am somewhat reluctant to merge it is that this adds quite a bit of complexity for unclear (to me) wins.

Yes, the benchmark in the PR is a real benchmark that shows a ~0.5-2s before change wait time for 12 documents going down to ~50 ms on my machine when waiting for diagnostics with analysers enabled. I've been using this build for a while now and it's been a significant improvement when using it in Neovim. The actual production code is +43/-18, most of the PR is tests/benchmarks.

Move the shared analyzer cache from a module-level global into
LspWorkspaceFolder so it is reachable from ServerState and released by
workspaceFolderTeardown rather than when the GC clears the weak keys.

Locking, invalidation, and retention are unchanged: the
ConditionalWeakTable moves intact into AnalyzerDiagnosticsCache, still
keyed by immutable Solution snapshot.
@alsi-lawr
alsi-lawr force-pushed the perf/shared-analyzer-diagnostics-a4-002 branch from ed8c529 to f6de893 Compare September 7, 2026 22:48
Comment on lines +40 to +47
cache.GetOrStartAnalysis(
project.Solution,
project.Id,
fun () ->
// Cancellation applies to each waiter, not to the shared analysis.
let cwa = compilation.WithAnalyzers(analyzers, project.AnalyzerOptions)
cwa.GetAllDiagnosticsAsync(CancellationToken.None)
)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@razzmatazz
razzmatazz merged commit e0562fd into razzmatazz:main Sep 8, 2026
3 checks passed
@alsi-lawr
alsi-lawr deleted the perf/shared-analyzer-diagnostics-a4-002 branch September 8, 2026 09:06
@alsi-lawr
alsi-lawr restored the perf/shared-analyzer-diagnostics-a4-002 branch September 16, 2026 08:24
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.

Document diagnostics rerun whole-project analyzers for every document

2 participants