Reuse a C# project's in-memory PE reference while its semantic version holds - #20460
Reuse a C# project's in-memory PE reference while its semantic version holds#20460xperiandri wants to merge 4 commits into
Conversation
…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>
❗ Release notes requiredYou can open this PR in browser to add release notes: open in github.dev
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mpilation with <see cref> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
xperiandri
left a comment
There was a problem hiding this comment.
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.
- A cache hit still awaits
GetCompilationAsync. On a hit the compilation only feedsRefresh, 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. RefreshracesEmitted()(reactor thread vs. FCS emit thread, no shared lock) and can re-pin a compilation after its emit succeeded;TryGetCompilationalso ignorespinned. With (1),Refreshdisappears and the entry becomes single-writer.and!would be the first use in the repository (FSharp.instructions.md: no foothold) and buys nothing here. Gone with (1).- The new
peReferences.TryRemoveinClearOptionsis 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. TheClearSingleFileOptionsCachetwin is dead for the same reason (miscellaneous-files project), and so are the pre-existinglastSuccessfulCompilationslines next to them. - Nits: three
// Stop strongly holding…comments restateEmitted(); the comment abovepeReferencesis 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.
|
🔍 Tooling Safety Check — Affects-Design-Time
|
…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>
Description
FSharpProjectOptionsReactor.createPEReferencecached the in-memory PE reference of a referenced C# project in aConditionalWeakTable<Compilation, FSharpReferencedProject>and gave it a stamp ofDateTime.UtcNow, captured when the reference was created. So a new reference — with a new stamp — is produced whenever Roslyn hands back a differentCompilationinstance for that project.Roslyn does that a lot.
Project.GetCompilationAsyncreturns a new instance after any solution fork, andCompilationTrackerholds 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.Versionfor aPEReferenceismd5(getStamp()), which feedsProjectSnapshotBase.baseVersion, which is the key ofComputeBootstrapInfoStatic/ComputeBootstrapInfoand the prefix of everyFileKey/SignatureKey/FullKeyinTransparentCompiler. 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:
RegisterAndImportReferencedAssembliesreappears 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.String2.7 GB,TType_app1.2 GB,u_list_core[ReaderState, Attrib]0.36 GB allocated over 493 s, withInducedLowMemorycollections), and the process averages 2 cores while the IDE is idle.The change
The reference is cached per referenced
ProjectIdand reused while the project'sGetDependentSemanticVersionAsyncis unchanged; a newerCompilationfor the same version only refreshes the source that the delayed reader will emit from. Two consequences beyond the loop above: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.ConditionalWeakTablegoes 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-projectemitCacheattempt 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 ofCompilationinstances 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.GetAssemblyDatafor 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
Compilationinstances", 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.docs/release-notes/.VisualStudio/18.vNext.md.🤖 Generated with Claude Code