Skip to content

Cut allocations in the VS project options caches and drop a boxing check - #20413

Merged
T-Gro merged 7 commits into
dotnet:mainfrom
xperiandri:perf/allocation-and-nullness-cleanups
Sep 7, 2026
Merged

Cut allocations in the VS project options caches and drop a boxing check#20413
T-Gro merged 7 commits into
dotnet:mainfrom
xperiandri:perf/allocation-and-nullness-cleanups

Conversation

@xperiandri

@xperiandri xperiandri commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Three independent allocation cleanups, one per commit, no behaviour change.

DelayedILModuleReader.result — declare it nullable instead of boxing

The field was declared non-nullable and initialised with Unchecked.defaultof<_>, so both the fast path and the inside-the-lock recheck had to box it just to test for null. Typing it ILModuleReader | null lets match narrow the type directly and drops the boxes.

This is confined to the private field; the public DelayedILModuleReader surface is unchanged.

Array.tryPickV + IProjectSite.CompilationBinOutputPath

CompilationBinOutputPath is probed for the -o: flag on every project-site mapping and its result is immediately turned into a string or null. Added an Array.tryPickV next to the existing Array.chooseV and changed the (internal) IProjectSite member to string voption, so neither the pick nor the result allocates. Call sites move from Option.toObj to ValueOption.toObj.

IProjectSite here is Microsoft.VisualStudio.FSharp.Editor.IProjectSite, which is internal; the identically-shaped Microsoft.VisualStudio.FSharp.LanguageService.IProjectSite used by the legacy project system and Salsa is untouched.

Struct tuples for the project options caches

FSharpProjectOptionsReactor's commandLineOptions and project options cache dictionaries, and the AsyncReplyChannel payloads of TryGetOptionsByDocument/TryGetOptionsByProject, all carried reference tuples allocated on every cache write and every reply. None of them escape the reactor, so they are now struct tuples.

Validation

FSharp.Compiler.Service and FSharp.Editor both build clean in Debug.


Update: this PR originally also included a small IlxGen.fs change (HashRangeSorted and two @ concatenations rewritten to avoid intermediate lists). It's been reverted (see the "Revert the IlxGen.fs list-creation micro-optimization" commit): CI's ILVerify job flagged FSharp.Compiler.IlxGen::HashRangeSorted with a StackUnexpected error in Release builds that didn't match the checked-in baseline, even though the identical error text is already present in that baseline as a known, pre-existing issue. I could not pin down the exact mechanism locally (a byte-identical origin/main IlxGen.fs reproduces the same baselined error through the same build path, but a partial revert didn't clear the CI mismatch either), and the allocation savings there were marginal — not worth the risk of shipping IL that ILVerify disagrees with in a shipped compiler DLL. Happy to revisit that piece separately with more thorough (bootstrap-build-equivalent) local verification.

@github-actions

github-actions Bot commented Sep 1, 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
`src/Compiler` docs/release-notes/.FSharp.Compiler.Service/11.0.100.md
`vsintegration/src` docs/release-notes/.VisualStudio/18.vNext.md

@github-actions github-actions Bot added the AI-Tooling-Check-Scanned-Clean Tooling check: diff analyzed, no interesting infrastructure files label Sep 1, 2026
@xperiandri xperiandri changed the title Cut allocations in IlxGen list building and the VS project options caches Cut allocations in the VS project options caches and drop a boxing check Sep 1, 2026
@xperiandri
xperiandri force-pushed the perf/allocation-and-nullness-cleanups branch from e74989f to 83da275 Compare September 2, 2026 10:21

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

Three correct, low-risk allocation cleanups — the struct-tuple swap is safe under ConcurrentDictionary's node replacement and the nullable-field change is semantically identical. No open threads. I'm fine taking the small FSharpCheckerResults.fs change along with the VS cache work rather than splitting it. Thanks!

@github-project-automation github-project-automation Bot moved this from New to In Progress in F# Compiler and Tooling Sep 4, 2026
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Sep 5, 2026
`FSharpProjectOptionsReactor.singleFileCache` stored each script or
single-file entry as a 5-tuple `Project * VersionStamp *
FSharpParsingOptions * FSharpProjectOptions * ConnectionPointSubscription`,
destructured positionally at every consumer. It is now a private record,
`SingleFileCacheEntry`, so the consumers name the fields they use and
`addToCacheAndSubscribe` is a copy-and-update of the incoming entry.

A reference record was chosen deliberately: a struct entry saves one heap
object per cache write but, on .NET Framework where FSharp.Editor runs,
copies 48 bytes out of the dictionary on every hit and forces
`ConcurrentDictionary` to allocate a new node on update. The benchmark in
the PR shows the reference record matching the reference tuple in time and
allocations on both runtimes.

Follows up on dotnet#20413 and the discussion in dotnet#20274.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Sep 5, 2026
`FSharpProjectOptionsReactor.singleFileCache` stored each script or
single-file entry as a 5-tuple `Project * VersionStamp *
FSharpParsingOptions * FSharpProjectOptions * ConnectionPointSubscription`,
destructured positionally at every consumer. It is now a private record,
`SingleFileCacheEntry`, so the consumers name the fields they use and
`addToCacheAndSubscribe` is a copy-and-update of the incoming entry.

A reference record was chosen deliberately: a struct entry saves one heap
object per cache write but, on .NET Framework where FSharp.Editor runs,
copies 48 bytes out of the dictionary on every hit and forces
`ConcurrentDictionary` to allocate a new node on update. The benchmark in
the PR shows the reference record matching the reference tuple in time and
allocations on both runtimes.

Follows up on dotnet#20413 and the discussion in dotnet#20274.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit b19bedc)
`result` was declared non-nullable and initialised with
`Unchecked.defaultof<_>`, so every read of the fast path had to `box` it just
to test for null. Declaring it `ILModuleReader | null` lets the match narrow
the type directly, dropping the boxes and the `Unchecked` initialiser.
…utPath`

`IProjectSite.CompilationBinOutputPath` is probed for the `-o:` flag on every
project site mapping and its result is immediately converted to a string or
null. Add an `Array.tryPickV` next to the existing `chooseV` and return
`string voption`, so neither the pick nor the result allocates.
The reactor's `commandLineOptions` and project options `cache` dictionaries,
and the `AsyncReplyChannel` payloads of `TryGetOptionsByDocument`/
`TryGetOptionsByProject`, all carried reference tuples that were allocated on
every cache write and every reply. They never escape the reactor, so make them
`struct` tuples.
…tion`

`Project.fs` implements `Microsoft.VisualStudio.FSharp.Editor.IProjectSite`
in two more places (`CreateRunningProjectSite` and `CreateStaticProjectSite`)
that the earlier `voption` change to `IProjectSite.CompilationBinOutputPath`
missed, breaking the build with FS0001.
ILVerify flags FSharp.Compiler.IlxGen::HashRangeSorted with a
StackUnexpected error in Release builds on this branch that does not
match the checked-in baseline, even though the exact same error text
is already present in that baseline as a known pre-existing issue.
Isolating the change (including testing byte-identical origin/main
source through the same local Release build) could not reproduce a
difference tied to this commit's content, but the CI-observed mismatch
persisted across a partial revert. The allocation savings here are
marginal; not worth risking invalid IL in a shipped compiler DLL over,
so this reverts to the original List-based HashRangeSorted and the
original `@` concatenations in TypeDefBuilder.Close and GenTypeDef.
@xperiandri
xperiandri force-pushed the perf/allocation-and-nullness-cleanups branch from 83da275 to ae11e8e Compare September 5, 2026 21:01
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Sep 7, 2026
`FSharpProjectOptionsReactor.singleFileCache` stored each script or
single-file entry as a 5-tuple `Project * VersionStamp *
FSharpParsingOptions * FSharpProjectOptions * ConnectionPointSubscription`,
destructured positionally at every consumer. It is now a private record,
`SingleFileCacheEntry`, so the consumers name the fields they use and
`addToCacheAndSubscribe` is a copy-and-update of the incoming entry.

A reference record was chosen deliberately: a struct entry saves one heap
object per cache write but, on .NET Framework where FSharp.Editor runs,
copies 48 bytes out of the dictionary on every hit and forces
`ConcurrentDictionary` to allocate a new node on update. The benchmark in
the PR shows the reference record matching the reference tuple in time and
allocations on both runtimes.

Follows up on dotnet#20413 and the discussion in dotnet#20274.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit b19bedc)
@T-Gro
T-Gro merged commit 00e0828 into dotnet:main Sep 7, 2026
52 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in F# Compiler and Tooling Sep 7, 2026
xperiandri added a commit to xperiandri/fsharp that referenced this pull request Sep 11, 2026
`FSharpProjectOptionsReactor.singleFileCache` stored each script or
single-file entry as a 5-tuple `Project * VersionStamp *
FSharpParsingOptions * FSharpProjectOptions * ConnectionPointSubscription`,
destructured positionally at every consumer. It is now a private record,
`SingleFileCacheEntry`, so the consumers name the fields they use and
`addToCacheAndSubscribe` is a copy-and-update of the incoming entry.

A reference record was chosen deliberately: a struct entry saves one heap
object per cache write but, on .NET Framework where FSharp.Editor runs,
copies 48 bytes out of the dictionary on every hit and forces
`ConcurrentDictionary` to allocate a new node on update. The benchmark in
the PR shows the reference record matching the reference tuple in time and
allocations on both runtimes.

Follows up on dotnet#20413 and the discussion in dotnet#20274.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit b19bedc)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI-Tooling-Check-Scanned-Clean Tooling check: diff analyzed, no interesting infrastructure files

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

2 participants