Skip to content

Search each file of a multi-targeted F# project once in Go To All - #20483

Open
xperiandri wants to merge 10 commits into
dotnet:mainfrom
xperiandri:perf/navigate-to-multitarget
Open

xperiandri wants to merge 10 commits into
dotnet:mainfrom
xperiandri:perf/navigate-to-multitarget

Conversation

@xperiandri

@xperiandri xperiandri commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Go To All (Ctrl+T / Code Search) on a multi-targeted F# solution showed F# results late — often only on the second search — and the window stalled while it searched. Reproduced on a solution with 135 project instances (26 project files, five target frameworks each for the app projects).

Roslyn hands the F# service every project instance one after another and publishes a project's results only when the whole project is done. The service parsed every file of every instance, since the parse cache is per DocumentId and the defines differ; read the text of every document before matching anything, which for a closed document is a file read, per file per framework per keystroke; and started all of a project's documents at once, unthrottled.

The first instance of a project file now searches every file, and the others search only the files they alone compile and the files whose parse depends on the defines. A document's text is read only after one of its declarations matched, and a project's documents are searched at most ProcessorCount at a time. Results for one file come from one instance, except under conditional compilation.

Not changed: while a solution is still loading Roslyn searches only its own persisted index, so F# results still appear only once it has loaded; that needs an ExternalAccess extension.

No timings are claimed: per search the work goes from one parse and one file read per file per framework to one parse per file, plus the files with conditional directives, and a read per matched file. The first commit (test helpers) is shared with #20462.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

✅ Release notes checked


✅ Found changes and release notes in following paths:

Change path Release notes path Description
`vsintegration/src` docs/release-notes/.VisualStudio/18.vNext.md

Comment on lines +384 to +386
syntheticProject.GetAllProjects()
|> List.distinctBy _.Name
|> List.map (fun project -> project, ProjectId.CreateNewId())

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.

Suggested change
syntheticProject.GetAllProjects()
|> List.distinctBy _.Name
|> List.map (fun project -> project, ProjectId.CreateNewId())
syntheticProject.GetAllProjects()
|> Seq.distinctBy _.Name
|> Seq.map (fun project -> project, ProjectId.CreateNewId())
|> Seq.toList

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.

Applied in e81a37e: the chain goes through Seq and is materialized once with Seq.toList.

Comment thread vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs
Comment on lines +73 to +87
let instances =
project.Solution.Projects
|> Seq.filter (fun p -> p.FilePath = projectPath)
|> Seq.map _.Id
|> List.ofSeq

fun (document: Document) ->
match document.FilePath with
| null -> true
| path ->
let documentIds = project.Solution.GetDocumentIdsWithFilePath path

let owner =
instances
|> List.find (fun id -> documentIds |> Seq.exists (fun documentId -> documentId.ProjectId = id))

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.

I suppose it will be more performant, no?

Suggested change
let instances =
project.Solution.Projects
|> Seq.filter (fun p -> p.FilePath = projectPath)
|> Seq.map _.Id
|> List.ofSeq
fun (document: Document) ->
match document.FilePath with
| null -> true
| path ->
let documentIds = project.Solution.GetDocumentIdsWithFilePath path
let owner =
instances
|> List.find (fun id -> documentIds |> Seq.exists (fun documentId -> documentId.ProjectId = id))
let instances =
project.Solution.Projects
|> Seq.filter (fun p -> p.FilePath = projectPath)
|> Seq.map _.Id
|> Seq.toArray
fun (document: Document) ->
match document.FilePath with
| null -> true
| path ->
let documentIds = project.Solution.GetDocumentIdsWithFilePath path
let owner =
instances
|> Array.find (fun id -> documentIds |> Seq.exists (fun documentId -> documentId.ProjectId = id))

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.

Moot now: the rework removed the owner lookup this was about. Every instance reports what it compiles and only the parse is shared, so there is no instances array and no find left in NavigateToSearchService.fs to make faster.

@github-actions github-actions Bot added the AI-Tooling-Check-Scanned-Clean Tooling check: diff analyzed, no interesting infrastructure files label Sep 7, 2026
instances
|> List.find (fun id -> documentIds |> Seq.exists (fun documentId -> documentId.ProjectId = id))

owner = project.Id

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖🕵️ Shared declarations disappear from Current Project searches on a non-owner target after the cache warms.

// Existing multi-target fixture; fresh service, second target active.
let p = solution.GetProject instances[1]
let run () =
    service.SearchProjectAsync(p, ImmutableArray.Empty, "plainUse",
        service.KindsProvided, CancellationToken.None).Result
run () // contains plainUse
run () // empty

Roslyn submits only the active project for this scope. Preserve project-local results; the solution-order owner is not searched.

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.

Both are real, and they killed the design rather than a line of it. Pushed a rework.

The skip is gone. I had missed that NavigateToSearcher pools its seen set with NavigateToSearchResultComparer, which already collapses results by file path and span — so the duplicates the skip existed to prevent were never reaching the user anyway, and every instance can safely report what it compiles. That is your first case: the current-project scope submits one project, and the instance that was told to stay quiet is the only one asked.

What the instances should share is the parse, which is the cost. A parse whose tree holds no conditional directives does not depend on the defines, so it is stored under a key no define set can equal and every instance reuses it; one that does hold them is stored per define set. The entry carries the text version, which is your second case: an edit that puts a declaration behind #if is a new entry rather than a flag left over from the parse before it.

The key for the define-independent entry is "?" on purpose — defines are identifiers, so an instance that happens to define nothing cannot read that entry as its own.

The test asserting a declaration is reported once across the instances went with the skip; that is the searcher's job. In its place are your two cases plus one that keeps the define-keyed parse honest — a declaration behind #if FOO must not reach the instance without FOO.

One thing I could not do: run the new tests against the old implementation as a control. Reverting just that file makes fsc.exe die with 0x80131506 in my worktree, reproducibly and regardless of my change, and I did not chase it. So the evidence that these tests catch the two bugs is your repro, not a run of mine. Full FSharp.Editor.Tests on the rework: 7205 passed, 0 failed.

The release note said the old scheme out loud, so it is rewritten too.

let cache = ConcurrentDictionary<DocumentId, VersionStamp * NavigableItem array>()

/// Whether the file's parse depends on the defines, by file path: known once any instance has parsed it.
let conditionalDirectives =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖🕵️ Newly conditional declarations are missing from the first solution search after an edit. Warm the cache on a shared file without directives, then replace its text in both target instances with:

module ModuleSecond
#if FOO
let addedFoo = 1
#endif

Searching addedFoo in the FOO instance first, then the plain owner, returns no result; repeating finds it. Roslyn prioritizes the active project, so this order occurs in normal searches. Validate the cached flag against the current text version before skipping.

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.

Covered by the same rework as the other thread. The cache no longer keeps a directives flag that can outlive the text it was read from: an entry carries the text version it was parsed at, so the edit that puts addedFoo behind #if FOO produces a new entry rather than a skip decided by the parse before it.

The repro is a declaration an edit puts behind a directive is reported by the instance that defines it in MultiTargetNavigateToSearchTests.fs — warm the shared file without directives, replace its text in both instances, search the FOO instance first.

@T-Gro T-Gro added the AI-reviewed PR reviewed by AI review council label Sep 9, 2026
@T-Gro
T-Gro self-requested a review September 9, 2026 09:24
@github-actions github-actions Bot added the ⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager label Sep 10, 2026
@github-actions

This comment has been minimized.

@xperiandri
xperiandri force-pushed the perf/navigate-to-multitarget branch from 576ffda to e81a37e Compare September 11, 2026 15:40

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖🕵️ Please shorten the description using this guidance. Focus on the problem and why the change is needed, in simplified technical English. Leave the implementation inventory to the Files tab and retain necessary caveats.

@github-project-automation github-project-automation Bot moved this from New to In Progress in F# Compiler and Tooling Sep 15, 2026
@T-Gro T-Gro added the vsintegration-only Changes only Visual Studio integration, plus optional docs, release notes, or tests label Sep 21, 2026
@xperiandri
xperiandri force-pushed the perf/navigate-to-multitarget branch from e81a37e to 952d987 Compare September 22, 2026 11:34
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Design-Time
Affects-Design-Time: Compiler service or Visual Studio behavior changed.

Generated by PR Tooling Safety Check · gpt56 1.9M · ◷

xperiandri and others added 8 commits September 26, 2026 03:00
…r tests

Test helpers so far put every synthetic file into one Roslyn project. CreateMultiProjectSolution
creates one project per synthetic project with project references, the way VS wires
project-to-project references; CreateMultiTargetSolution creates one project per target
instance sharing the project path and the document paths, the way VS loads a multi-targeted
project.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ead its text only for matches

Roslyn's NavigateTo searcher hands the F# service every target-framework
instance of a project, one after another, and the service parsed every file of
each instance, read the text of every file before matching anything, and
started all of that for a project at once. On a solution with 135 project
instances that meant one parse and one file read per file per framework,
thousands of concurrent tasks, and results that arrived long after the user
stopped typing.

The first instance of a project file in the solution now searches every file;
the other instances only search the files they alone compile and the files
whose parse depends on the defines, known from whichever instance parsed the
file first. A file's text is read only when one of its declarations matched,
and a project's files are searched at most ProcessorCount at a time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Skipping a file in every instance but the first is wrong twice over, as the
review showed. Go To All scoped to the current project submits only that
project, so the instance that was told to stay quiet is the only one asked, and
a shared declaration disappears from it once the parse has taught the service
the file holds no directives. And the flag that decision reads was keyed by path
alone, so an edit that puts a declaration behind `#if` left the previous answer
in place.

Nothing needs skipping. `NavigateToSearcher` pools its seen set with
`NavigateToSearchResultComparer`, which already collapses results by file path
and span, so every instance can report what it compiles.

What the instances should share is the parse, which is what costs. A parse whose
tree holds no conditional directives does not depend on the defines, so it is
kept under a key no define set can equal and every instance reuses it; one that
does hold them is kept per define set, since those instances genuinely parse the
file differently. The entry carries the text version, so an edit is a new entry
rather than a stale flag.

The test that asserted a declaration is reported once across the instances went
with the skip: that is the searcher's job, not this service's. In its place are
the two cases from the review — a lone instance reporting a shared declaration,
and a declaration an edit puts behind a directive — and one that keeps the
define-keyed parse honest by checking such a declaration does not reach the
instance without the define.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cache was a dictionary from a struct tuple of two strings to an anonymous
struct record, spelled out in the type arguments at the one place it is
declared, and read back as `struct (key, path)` wherever it is used. Nothing at
those sites said which string was the defines and which the path.

Give both a name at the top of the file. They stay struct records, so the key
keeps the same equality and the entries allocate no more than before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 26, 2026 01:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

One or more issues must be addressed before approval.

Review effort: Lite
Findings: 1 Medium severity · 1 Low severity

Open (2)
What changed in this PR

This PR optimizes Go To All for multi-targeted F# projects by caching parses, delaying text reads, and throttling document searches.

Changes:

  • Adds define-aware navigable-item caching and deferred source reads.
  • Adds multi-target test infrastructure and regression tests.
  • Documents the performance improvement in Visual Studio release notes.
File Description
vsintegration/​tests/​FSharp.Editor.Tests/​MultiTargetNavigateToSearchTests.fs Updated as part of this pull request.
vsintegration/​tests/​FSharp.Editor.Tests/​Helpers/​RoslynHelpers.fs Updated as part of this pull request.
vsintegration/​tests/​FSharp.Editor.Tests/​FSharp.Editor.Tests.fsproj Updated as part of this pull request.
vsintegration/​src/​FSharp.Editor/​Navigation/​NavigateToSearchService.fs Updated as part of this pull request.
docs/​release-notes/​.VisualStudio/​18.vNext.md Updated as part of this pull request.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

/// Where a parse of a file is kept: under the defines it was parsed with, or under `AnyDefines` when its tree
/// holds no conditional directives and so reads the same under any of them.
[<Struct>]
type private NavigableItemsKey = { Defines: string; FilePath: string }

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.

Fixed in 6e68aace9e. The key carries the language version beside the defines now, and both come from TryGetCompilationDefinesAndLangVersionForEditingDocument, which answers nothing where the project has not produced its options — so cross-project reuse is off exactly while the identity is unavailable, and such a document parses for itself.

The fallback was worse than incomplete: the editing defaults are not a neutral placeholder but the very define set a project that defines nothing of its own really has, so a document keyed under them read that project's parse, with the branch of a #if that project chose.

The other parsing options cannot differ for one path: ApplyLineDirectives is false for every document the editor parses, and IsInteractive follows from the file's extension.

New test, a project whose options have not arrived does not answer from another project's parse: two projects hold the same file behind one TextLoader — a linked file open in the editor, which is what makes the two documents report the same text version and lets one read the other's entry at all — one with options that define nothing, the other with none. Before the fix the second answered from the first's parse; now it asks for the parse it cannot have and is cancelled. Twelve *NavigateTo* tests pass.

Comment on lines +23 to +24
* Go To All (Ctrl+T) on a multi-targeted F# project no longer parses and reads every file once per target framework: the first instance searches every file, the others only the files they alone compile or that use conditional compilation, and a file's text is read only for a matched declaration. ([PR #20483](https://github.com/dotnet/fsharp/pull/20483))
* Go To All (Ctrl+T) on a multi-targeted F# project no longer parses every file once per target framework: a file whose parse holds no conditional directives does not depend on the defines, so every instance reuses the one parse, and a file's text is read only for a matched declaration. ([PR #20483](https://github.com/dotnet/fsharp/pull/20483))

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.

Fixed in 840c454ce5. The two bullets are the two designs this PR went through: the first describes the instance that skipped files, which the rework replaced with sharing the parse, and it should have gone with it. Only the second remains.

xperiandri and others added 2 commits September 26, 2026 14:05
The defines a file was parsed with are not the whole of what its tree depends on: the
language version decides what the parser accepts, and the defines were taken from the
editing defaults when the project had not produced its options yet - a set another
project really has, so its parse answered for a project that never asked for it, with
the branch of a `#if` that project chose.

The key now carries the language version, and both it and the defines are read only
where the project has produced them: a project without options parses for itself
instead of reading an entry that is not about it. The rest of the parsing options
cannot differ for one path - the editor never applies line directives, and whether a
file is interactive follows from its extension.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The note for skipping files stayed behind when sharing the parse replaced it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager AI-reviewed PR reviewed by AI review council AI-Tooling-Check-Scanned-Clean Tooling check: diff analyzed, no interesting infrastructure files vsintegration-only Changes only Visual Studio integration, plus optional docs, release notes, or tests

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

3 participants