Skip to content

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

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

Search each file of a multi-targeted F# project once in Go To All#20483
xperiandri wants to merge 4 commits into
dotnet:mainfrom
xperiandri:perf/navigate-to-multitarget

Conversation

@xperiandri

Copy link
Copy Markdown
Contributor

Description

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).

How the search runs: Roslyn's NavigateToSearcher hands the F# service every project instance one after another (the ExternalAccess bridge loops SearchProjectAsync over the projects of a group), and only publishes a project's results when the whole project is done. The F# service then:

  • parsed every file of every instance — one parse per file per target framework, 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 — one per file per framework per keystroke;
  • started all of a project's documents at once, unthrottled, so one project meant hundreds of concurrent parses and reads.

Change (NavigateToSearchService.fs):

  • The first instance of a project file in the solution searches every file. The other instances search only the files they alone compile and the files whose parse depends on the defines (ParsedInput … Trivia.ConditionalDirectives), remembered per file path from whichever instance parsed the file first; an instance that meets a file before any parse of it searches it as before. Results for one file therefore come from one instance, except under conditional compilation.
  • A document's text is read only after one of its declarations matched.
  • A project's documents are searched at most ProcessorCount at a time (whenAllThrottled).

Not changed: Roslyn searches only its own persisted index while the solution is not fully loaded (SearchCachedDocumentsAsync is skipped for services without IAdvancedNavigateToSearchService), so F# results still appear only once the solution has loaded; that needs an ExternalAccess extension.

Tests: MultiTargetNavigateToSearchTests loads one project as two instances (one without FOO and without the fourth file, one with both) through RoslynTestHelpers.CreateMultiTargetSolution and searches both instances in solution order: a declaration in a file every instance compiles, one under #if FOO, and one in the instance-only file are each reported exactly once. The first commit (test helpers) is shared with #20462.

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.

Checklist

  • Test cases added

  • Performance benchmarks added in case of performance changes

  • Release notes entry updated:

    Please make sure to add an entry with short succinct description of the change as well as link to this pull request to the respective release notes file, if applicable.

    Release notes files:

    • If anything under src/Compiler has been changed, please make sure to make an entry in docs/release-notes/.FSharp.Compiler.Service/<version>.md, where <version> is usually "highest" one, e.g. 42.8.200
    • If language feature was added (i.e. LanguageFeatures.fsi was changed), please add it to docs/release-notes/.Language/preview.md
    • If a change to FSharp.Core was made, please make sure to edit docs/release-notes/.FSharp.Core/<version>.md where version is "highest" one, e.g. 8.0.200.

    Information about the release notes entries format can be found in the documentation.
    Example:

    If you believe that release notes are not necessary for this PR, please add NO_RELEASE_NOTES label to the pull request.

🤖 Generated with Claude Code

xperiandri and others added 4 commits September 7, 2026 20:13
…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>
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

❗ Release notes required

You can open this PR in browser to add release notes: open in github.dev


✅ 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

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))

@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.

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.

@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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI-reviewed PR reviewed by AI review council AI-Tooling-Check-Scanned-Clean Tooling check: diff analyzed, no interesting infrastructure files

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

2 participants