Skip to content

Reuse a C# project's in-memory PE reference while its semantic version holds - #20460

Open
xperiandri wants to merge 4 commits into
dotnet:mainfrom
xperiandri:fix/pe-reference-semantic-version
Open

Reuse a C# project's in-memory PE reference while its semantic version holds#20460
xperiandri wants to merge 4 commits into
dotnet:mainfrom
xperiandri:fix/pe-reference-semantic-version

Conversation

@xperiandri

Copy link
Copy Markdown
Contributor

Description

FSharpProjectOptionsReactor.createPEReference cached the in-memory PE reference of a referenced C# project in a ConditionalWeakTable<Compilation, FSharpReferencedProject> and gave it a stamp of DateTime.UtcNow, captured when the reference was created. So a new reference — with a new stamp — is produced whenever Roslyn hands back a different Compilation instance for that project.

Roslyn does that a lot. Project.GetCompilationAsync returns a new instance after any solution fork, and CompilationTracker holds the final compilation weakly, so under memory pressure it is dropped and rebuilt even when nothing in the project changed.

That stamp is not cosmetic. FSharpReferencedProjectSnapshot.Version for a PEReference is md5(getStamp()), which feeds ProjectSnapshotBase.baseVersion, which is the key of ComputeBootstrapInfoStatic / ComputeBootstrapInfo and the prefix of every FileKey / SignatureKey / FullKey in TransparentCompiler. A fresh stamp therefore invalidates the bootstrap of every dependent F# project, re-imports all of its references (TcImports.RegisterAndImportReferencedAssemblies, i.e. unpickling every referenced assembly's metadata) and re-checks every file — for an assembly whose metadata did not change.

A CPU trace of a 39-project solution (Uno app; 20 F# projects, 187 references on the project under the caret) shows the result: RegisterAndImportReferencedAssemblies reappears every 15–30 s for the entire 8-minute life of the process and never converges, GC runs at 20–50 % of CPU (System.Byte[] 2.7 GB, System.String 2.7 GB, TType_app 1.2 GB, u_list_core[ReaderState, Attrib] 0.36 GB allocated over 493 s, with InducedLowMemory collections), and the process averages 2 cores while the IDE is idle.

The change

The reference is cached per referenced ProjectId and reused while the project's GetDependentSemanticVersionAsync is unchanged; a newer Compilation for the same version only refreshes the source that the delayed reader will emit from. Two consequences beyond the loop above:

  • Metadata-only emit (metadataOnly = true, includePrivateMembers = false) depends on the public surface, which is what the dependent semantic version tracks, so C# edits below the declaration level no longer invalidate F# checking either.
  • The ConditionalWeakTable goes away. Its value strongly held the compilation until the first emit, which kept its own key alive, so entries could not be collected — the reason a previous per-project emitCache attempt was reverted as ineffective. The new entry pins one compilation per referenced project until that project's first emit result and holds it weakly afterwards, so what is retained is bounded by the number of C# projects rather than by the number of Compilation instances Roslyn has produced.

Cancellation behaviour is unchanged: a cancelled emit keeps the compilation pinned and bumps the stamp so the reference is retried.

Verification

Built and exercised in an experimental hive against the solution above. The behavioural check is the FCS trace in the Debug pane: ComputeTcConfigBuilder.GetAssemblyData for each referenced assembly should appear while the solution loads and then only after a real declaration change in a referenced C# project, instead of recurring while the IDE sits idle.

I do not have a benchmark harness for this path — the evidence is the trace described above rather than a repeatable measurement, so treat the numbers as an illustration of the loop, not as a claimed speed-up.

Checklist

  • Test cases added — none. The behaviour is "the same reference object is handed to FCS across Compilation instances", which the editor test host (no real Roslyn compilation churn, no memory pressure) cannot exercise meaningfully; suggestions for where this could be covered are welcome.
  • Performance benchmarks added in case of performance changes — see the note above.
  • Release notes entry updated: docs/release-notes/.VisualStudio/18.vNext.md.

🤖 Generated with Claude Code

…n holds

createPEReference keyed the reference on the identity of the Roslyn Compilation and stamped it with DateTime.UtcNow at creation. Roslyn recreates Compilation instances freely - on every solution fork, and under memory pressure because it holds the final compilation weakly - so a dependent F# project kept receiving a reference with a fresh stamp. That stamp feeds the project snapshot's base version, so BootstrapInfo was invalidated, every reference re-imported and every file re-checked, even though the referenced assembly's metadata had not changed.

The reference is now cached per referenced project and reused while the project's dependent semantic version is unchanged; a newer Compilation only refreshes the source the delayed reader emits from. Metadata-only emit depends on the public surface, which is what that version tracks, so C# edits below the declaration level no longer invalidate F# checking either.

The ConditionalWeakTable is gone with it: its value pinned the compilation until the first emit, which kept the key alive, so entries were never collected.

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

github-actions Bot commented Sep 6, 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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs Outdated
…mpilation with <see cref>

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

@xperiandri xperiandri left a comment

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.

Review from the architecture side. The direction is right, and it has a precedent worth citing in the description: Roslyn's own cross-language reference cache, SolutionCompilationState.SkeletonReferenceCache, keys the metadata-only skeleton of a project on Project.GetDependentSemanticVersionAsync and, for an unchanged version, returns what it has "regardless of whether it succeeded or not". The editor already treats that version as "the reference did not change" in WorkspaceExtensions.getReferencedProjectVersions (the snapshot-reuse guard), so this PR makes the PE-reference stamp consistent with that guard instead of racing ahead of it.

Findings, most important first. Each has an inline suggestion; the first three go together.

  1. A cache hit still awaits GetCompilationAsync. On a hit the compilation only feeds Refresh, and after the first emit result nobody reads it again — but when Roslyn has dropped the final compilation under memory pressure the call rebuilds it and the entry pins it: one full compilation per referenced C# project per options recompute, in exactly the scenario the PR targets. Roslyn's cache checks the version first and touches the compilation only on a miss.
  2. Refresh races Emitted() (reactor thread vs. FCS emit thread, no shared lock) and can re-pin a compilation after its emit succeeded; TryGetCompilation also ignores pinned. With (1), Refresh disappears and the entry becomes single-writer.
  3. and! would be the first use in the repository (FSharp.instructions.md: no foothold) and buys nothing here. Gone with (1).
  4. The new peReferences.TryRemove in ClearOptions is dead code — it sits in the arm that is entered for F# project ids only, so a removed C# project keeps its entry until the next sweep. The ClearSingleFileOptionsCache twin is dead for the same reason (miscellaneous-files project), and so are the pre-existing lastSuccessfulCompilations lines next to them.
  5. Nits: three // Stop strongly holding… comments restate Emitted(); the comment above peReferences is now covered by the type summary; the release note carries the war story.

Tests (the checklist asks where this could be covered): the editor test host cannot hold a C# project today — TestHostWorkspaceServices.GetLanguageServices in RoslynHelpers.fs throws for anything but F#, so SupportsCompilation is never true there. Two routes: (a) compose Microsoft.CodeAnalysis.CSharp.Workspaces into the test host and assert that TryGetOptionsByProject hands back the same ReferencedProjects.[0] object across a body-only edit of the C# project and a different one after a declaration edit; (b) cheaper — make the version/pin logic an internal type with the emit function injected and test it directly: same object for the same version, new object on a version change, pin dropped after a result, pin kept and stamp bumped on cancellation. I checked the suggested blocks with Fantomas and with a stand-in script covering the cases in (b); a full FSharp.Editor build was not run.

Not for this PR, but worth knowing: with the transparent compiler off, Stamp = hash(GetDependentVersionAsync) still creates a new IncrementalBuilder — which imports its non-framework references afresh — on every edit in a referenced C# project, body-level included, so the legacy path does not benefit. And FCS keys on a wall-clock DateTime: two version changes inside one clock tick share a stamp; carrying the previous entry's stamp forward (max UtcNow (previous + 1 tick)) would make it strictly increasing per project.

Comment thread vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs Outdated
Comment thread vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs Outdated
Comment thread vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs Outdated
Comment thread vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs Outdated
Comment thread vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs Outdated
Comment thread vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs Outdated
Comment thread docs/release-notes/.VisualStudio/18.vNext.md Outdated
@github-actions github-actions Bot added the ⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Design-Time
Affects-Design-Time: changes PE reference caching in IDE project options reactor

Generated by PR Tooling Safety Check · opus46 5M ·

…dropped

- Look the reference up by dependent semantic version before asking for a Compilation, so a cache hit no longer rebuilds and re-pins one Roslyn dropped under memory pressure (matching SolutionCompilationState.SkeletonReferenceCache). This removes Refresh, and with it the reactor/emit-thread race that could re-pin a compilation after a successful emit, so no lock is needed; and! is gone with it.
- TryGetCompilation prefers the pinned compilation and falls back to the weak reference, instead of assuming the two agree.
- Split tryGetPEReference from createPEReference and rename createNewPEReference to buildPEReference.
- ClearOptions: hoist the lastSuccessfulCompilations and peReferences removals out of the cache.TryRemove arm - cache holds F# project ids, both dictionaries hold referenced C# ids, so the removals never ran. Drop their ClearSingleFileOptionsCache twins, where the id is the miscellaneous-files project and can never be a key.
- Drop comments the code already states, and trim the release note to the user-visible effect.

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

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

1 participant