Skip to content

Commit 567495e

Browse files
authored
Switch Project data structures from ImmutableDictionary => Dictionary and lock (#78287)
Solution/Project classes keep several immutable dictionaries for mapping (Document/Project)Id to various data. ImmutableDictionaries are a fine (but slow) data structure when data between versions is to be shared, but none of the values in these ImmutableDictionaries persist between solution/project transformations. In fact, the only operation performed on these dictionaries is GetOrAdd calls. As immutable dictionaries are quite poor performing wrt lookups/adds, I thought I would try out two approaches to see if they showed improvements: Switch to ConcurrentDictionary (see *** WIP: Switch Project data structures from ImmutableDictionary => ConcurrentDictionary #78285). Switch to simple Dictionaries guarded by locks (this PR) I created test insertions for each of these PRs to gather speedometer numbers, particularly during the solution load of the c# editing speedometer test. The measurements below are from GC Heap Allocs and CPU samples for those tests under (Solution/Project).GetDocument calls ConcurrentDictionary change test insertion: https://dev.azure.com/devdiv/DevDiv/_git/VS/pullrequest/631277 Dictionary/lock test insertion: https://dev.azure.com/devdiv/DevDiv/_git/VS/pullrequest/631278 *** Baseline *** Alloc: 120 MB (1.0%) CPU: 560 ms (0.3%) *** Concurrent Dictionary *** Alloc: 59 MB CPU: 258 ms (0.1%) *** Dictionary with lock *** Alloc: 28 MB CPU: 172 ms (< 0.1%) So, it looks like the best performing of the bunch is this PR, which uses simple dictionaries and locks, thus why I'm elevating this PR out of draft mode. The ConcurrentDictionary PR is interesting as the code changes are simple and do give a nice perf improvement.
1 parent c3fc0f0 commit 567495e

File tree

2 files changed

+48
-17
lines changed

2 files changed

+48
-17
lines changed

src/Workspaces/Core/Portable/Workspace/Solution/Project.cs

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@ namespace Microsoft.CodeAnalysis;
2626
[DebuggerDisplay("{GetDebuggerDisplay(),nq}")]
2727
public partial class Project
2828
{
29-
private ImmutableDictionary<DocumentId, Document?> _idToDocumentMap = ImmutableDictionary<DocumentId, Document?>.Empty;
30-
private ImmutableDictionary<DocumentId, SourceGeneratedDocument> _idToSourceGeneratedDocumentMap = ImmutableDictionary<DocumentId, SourceGeneratedDocument>.Empty;
31-
private ImmutableDictionary<DocumentId, AdditionalDocument?> _idToAdditionalDocumentMap = ImmutableDictionary<DocumentId, AdditionalDocument?>.Empty;
32-
private ImmutableDictionary<DocumentId, AnalyzerConfigDocument?> _idToAnalyzerConfigDocumentMap = ImmutableDictionary<DocumentId, AnalyzerConfigDocument?>.Empty;
29+
// Only access these dictionaries when holding them as a lock
30+
private Dictionary<DocumentId, Document?>? _idToDocumentMap;
31+
private Dictionary<DocumentId, SourceGeneratedDocument>? _idToSourceGeneratedDocumentMap;
32+
private Dictionary<DocumentId, AdditionalDocument?>? _idToAdditionalDocumentMap;
33+
private Dictionary<DocumentId, AnalyzerConfigDocument?>? _idToAnalyzerConfigDocumentMap;
3334

3435
internal Project(Solution solution, ProjectState projectState)
3536
{
@@ -239,19 +240,48 @@ public bool ContainsAnalyzerConfigDocument(DocumentId documentId)
239240
/// Get the document in this project with the specified document Id.
240241
/// </summary>
241242
public Document? GetDocument(DocumentId documentId)
242-
=> ImmutableInterlocked.GetOrAdd(ref _idToDocumentMap, documentId, s_tryCreateDocumentFunction, this);
243+
=> GetOrAddDocumentUnderLock(documentId, ref _idToDocumentMap, s_tryCreateDocumentFunction, this);
243244

244245
/// <summary>
245246
/// Get the additional document in this project with the specified document Id.
246247
/// </summary>
247248
public TextDocument? GetAdditionalDocument(DocumentId documentId)
248-
=> ImmutableInterlocked.GetOrAdd(ref _idToAdditionalDocumentMap, documentId, s_tryCreateAdditionalDocumentFunction, this);
249+
=> GetOrAddDocumentUnderLock(documentId, ref _idToAdditionalDocumentMap, s_tryCreateAdditionalDocumentFunction, this);
249250

250251
/// <summary>
251252
/// Get the analyzer config document in this project with the specified document Id.
252253
/// </summary>
253254
public AnalyzerConfigDocument? GetAnalyzerConfigDocument(DocumentId documentId)
254-
=> ImmutableInterlocked.GetOrAdd(ref _idToAnalyzerConfigDocumentMap, documentId, s_tryCreateAnalyzerConfigDocumentFunction, this);
255+
=> GetOrAddDocumentUnderLock(documentId, ref _idToAnalyzerConfigDocumentMap, s_tryCreateAnalyzerConfigDocumentFunction, this);
256+
257+
private static TDocument GetOrAddDocumentUnderLock<TDocument, TArg>(DocumentId documentId, ref Dictionary<DocumentId, TDocument>? idMap, Func<DocumentId, TArg, TDocument> tryCreate, TArg arg)
258+
{
259+
if (idMap == null)
260+
{
261+
// First call assigns a new dictionary. Any other simultaneous requests will not assign the
262+
// dictionary they created to idMap. At that point, normal locking rules apply.
263+
Interlocked.CompareExchange(ref idMap, new Dictionary<DocumentId, TDocument>(), null);
264+
}
265+
266+
lock (idMap)
267+
{
268+
return idMap.GetOrAdd(documentId, tryCreate, arg);
269+
}
270+
}
271+
272+
private static bool TryGetDocumentValueUnderLock<TDocument>(DocumentId documentId, ref Dictionary<DocumentId, TDocument>? idMap, out TDocument? document)
273+
{
274+
if (idMap == null)
275+
{
276+
document = default;
277+
return false;
278+
}
279+
280+
lock (idMap)
281+
{
282+
return idMap.TryGetValue(documentId, out document);
283+
}
284+
}
255285

256286
/// <summary>
257287
/// Gets a document or a source generated document in this solution with the specified document ID.
@@ -288,7 +318,7 @@ public async ValueTask<IEnumerable<SourceGeneratedDocument>> GetSourceGeneratedD
288318

289319
// return an iterator to avoid eagerly allocating all the document instances
290320
return generatedDocumentStates.States.Values.Select(state =>
291-
ImmutableInterlocked.GetOrAdd(ref _idToSourceGeneratedDocumentMap, state.Id, s_createSourceGeneratedDocumentFunction, (state, this)));
321+
GetOrAddDocumentUnderLock(state.Id, ref _idToSourceGeneratedDocumentMap, s_createSourceGeneratedDocumentFunction, (state, this)));
292322
}
293323

294324
internal async IAsyncEnumerable<Document> GetAllRegularAndSourceGeneratedDocumentsAsync([EnumeratorCancellation] CancellationToken cancellationToken)
@@ -312,7 +342,7 @@ internal async IAsyncEnumerable<Document> GetAllRegularAndSourceGeneratedDocumen
312342
return null;
313343

314344
// Quick check first: if we already have created a SourceGeneratedDocument wrapper, we're good
315-
if (_idToSourceGeneratedDocumentMap.TryGetValue(documentId, out var sourceGeneratedDocument))
345+
if (TryGetDocumentValueUnderLock(documentId, ref _idToSourceGeneratedDocumentMap, out var sourceGeneratedDocument))
316346
return sourceGeneratedDocument;
317347

318348
// We'll have to run generators if we haven't already and now try to find it.
@@ -325,7 +355,7 @@ internal async IAsyncEnumerable<Document> GetAllRegularAndSourceGeneratedDocumen
325355
}
326356

327357
internal SourceGeneratedDocument GetOrCreateSourceGeneratedDocument(SourceGeneratedDocumentState state)
328-
=> ImmutableInterlocked.GetOrAdd(ref _idToSourceGeneratedDocumentMap, state.Id, s_createSourceGeneratedDocumentFunction, (state, this));
358+
=> GetOrAddDocumentUnderLock(state.Id, ref _idToSourceGeneratedDocumentMap, s_createSourceGeneratedDocumentFunction, (state, this));
329359

330360
/// <summary>
331361
/// Returns the <see cref="SourceGeneratedDocumentState"/> for a source generated document that has already been generated and observed.
@@ -346,7 +376,7 @@ internal SourceGeneratedDocument GetOrCreateSourceGeneratedDocument(SourceGenera
346376
return null;
347377

348378
// Easy case: do we already have the SourceGeneratedDocument created?
349-
if (_idToSourceGeneratedDocumentMap.TryGetValue(documentId, out var document))
379+
if (TryGetDocumentValueUnderLock(documentId, ref _idToSourceGeneratedDocumentMap, out var document))
350380
return document;
351381

352382
// Trickier case now: it's possible we generated this, but we don't actually have the SourceGeneratedDocument for it, so let's go
@@ -355,7 +385,7 @@ internal SourceGeneratedDocument GetOrCreateSourceGeneratedDocument(SourceGenera
355385
if (documentState == null)
356386
return null;
357387

358-
return ImmutableInterlocked.GetOrAdd(ref _idToSourceGeneratedDocumentMap, documentId, s_createSourceGeneratedDocumentFunction, (documentState, this));
388+
return GetOrAddDocumentUnderLock(documentId, ref _idToSourceGeneratedDocumentMap, s_createSourceGeneratedDocumentFunction, (documentState, this));
359389
}
360390

361391
internal ValueTask<ImmutableArray<Diagnostic>> GetSourceGeneratorDiagnosticsAsync(CancellationToken cancellationToken)

src/Workspaces/Core/Portable/Workspace/Solution/Solution.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,8 @@ namespace Microsoft.CodeAnalysis;
2727
/// </summary>
2828
public partial class Solution
2929
{
30-
31-
// Values for all these are created on demand.
32-
private ImmutableDictionary<ProjectId, Project> _projectIdToProjectMap;
30+
// Values for all these are created on demand. Only access when holding the dictionary as a lock.
31+
private readonly Dictionary<ProjectId, Project> _projectIdToProjectMap = [];
3332

3433
/// <summary>
3534
/// Result of calling <see cref="WithFrozenPartialCompilationsAsync"/>.
@@ -46,7 +45,6 @@ private Solution(
4645
SolutionCompilationState compilationState,
4746
AsyncLazy<Solution>? cachedFrozenSolution = null)
4847
{
49-
_projectIdToProjectMap = ImmutableDictionary<ProjectId, Project>.Empty;
5048
CompilationState = compilationState;
5149

5250
_cachedFrozenSolution = cachedFrozenSolution ??
@@ -152,7 +150,10 @@ public bool ContainsProject([NotNullWhen(returnValue: true)] ProjectId? projectI
152150
{
153151
if (this.ContainsProject(projectId))
154152
{
155-
return ImmutableInterlocked.GetOrAdd(ref _projectIdToProjectMap, projectId, s_createProjectFunction, this);
153+
lock (_projectIdToProjectMap)
154+
{
155+
return _projectIdToProjectMap.GetOrAdd(projectId, s_createProjectFunction, this);
156+
}
156157
}
157158

158159
return null;

0 commit comments

Comments
 (0)