Reducing allocations in completion#61620
Conversation
50665b8 to
8798536
Compare
2678096 to
770d8aa
Compare
|
|
||
| public static bool IsExpanded(this CompletionItemFlags flags) | ||
| [MethodImpl(MethodImplOptions.AggressiveInlining)] | ||
| public static bool IsExpanded(CompletionItemFlags flags) |
There was a problem hiding this comment.
what was the benefit of this change? (Also, it feels like in extension method form, it would still be ok).
There was a problem hiding this comment.
This stood out to me as well. The changes related to this helper type seem unnecessary.
There was a problem hiding this comment.
No, I was just trying to see if it makes any difference (it didn't). Reverted
| (s.Locations.Any(isSymbolDefinedInSource) || !s.HasUnsupportedMetadata) && | ||
| // Check if symbol is namespace (which is always visible) first to avoid realizing all locations | ||
| // of each namespace symbol, which might end up allocating in LOH | ||
| (s.IsNamespace() || s.Locations.Any(isSymbolDefinedInSource) || !s.HasUnsupportedMetadata) && |
There was a problem hiding this comment.
interesting. there are enough namespaces to matter? how many were you seeing in practice?
There was a problem hiding this comment.
From the trace, allocations of 1.3MB on LOH, and 30MB on regular heap is eliminated by this single check
There was a problem hiding this comment.
I have seen this in the past as well - accessing Locations or DeclaringSyntaxReferences for namespace symbols is costly as the declaring syntax references are not cached on the namespace symbol by the compiler.
| if (items is ImmutableArray<CompletionItem>) | ||
| _lazyItems = new(() => (ImmutableArray<CompletionItem>)ItemsInternal); | ||
| else | ||
| _lazyItems = new(() => ItemsInternal.ToImmutableArray(), System.Threading.LazyThreadSafetyMode.PublicationOnly); |
There was a problem hiding this comment.
why PublicationOnly?
There was a problem hiding this comment.
Should I use ExecutionAndPublication? I don't think it matters much though, since the value is not accessed concurrently in Roslyn.
There was a problem hiding this comment.
well, i'm curious why this line is different than the one above. :) i would expect you'd have the same thread-safety-choices for both.
Note: you can also simplify this to a single lazy that does ToImmutableArrayOrEmpty inside (that extension already checks if the underlying type is an ImmutableArray).
| else | ||
| { | ||
| return Create(newSpan, newItems, newRules, newSuggestionModeItem); | ||
| return Create(newSpan, newItems, newRules, newSuggestionModeItem, isExclusive: false); |
There was a problem hiding this comment.
why isExclusive: false here instead of _isExclusive?
There was a problem hiding this comment.
This was the previous behavior, can't remember why though. Will take a closer look later, but not gonna change in this PR.
There was a problem hiding this comment.
gotcha. it is concerning. but i agree it matches from before. can you file issue?
| // so use a dedicated pool to minimize array allocations. Set the size of pool to a small | ||
| // number 5 because we don't expect more than a couple of callers at the same time. | ||
| private static readonly ObjectPool<Dictionary<string, object>> s_uniqueSourcesPool = new(factory: () => new(), size: 3); | ||
| private static readonly ObjectPool<List<CompletionItem>> s_sortListPool = new(factory: () => new(), size: 3); |
There was a problem hiding this comment.
comment says '5', but code says '3' :)
There was a problem hiding this comment.
Is it really helpful to have our own pools here? I would expect that just using PooledDictionary would be fine. There would b an underlying array, but we'd never resize it in practice.
or is thsi because we want to reuse the Dictionary entirely, even if it grew large (something the normal pools don't do).
There was a problem hiding this comment.
but we'd never resize it in practice.
I think you are right, but since the Dictionary<string, object> might be a commonly used type, and we know the dictionary would get large here, hold on to the these few in our own pool would make sure we don't end up with a shared pool of large dictionaries.
| private readonly bool _showCompletionItemFilters; | ||
|
|
||
| private readonly Func<ImmutableArray<(RoslynCompletionItem, PatternMatch?)>, string, ImmutableArray<RoslynCompletionItem>> _filterMethod; | ||
| private readonly Action<IEnumerable<(RoslynCompletionItem, PatternMatch?)>, string, IList<RoslynCompletionItem>> _filterMethod; |
There was a problem hiding this comment.
don't love taking a collection type weakly as IEnumerable. Can we tie it to a more strong interface which indicates the value will be realized and not lazy?
There was a problem hiding this comment.
I think it really is an enumerable in this case, I used it instead of a collection type to avoid using another pooled list, but I guess it'd be fine to have another one-time allocation.
| internal override ImmutableArray<CompletionItem> FilterItems(Document document, ImmutableArray<(CompletionItem, PatternMatch?)> itemsWithPatternMatch, string filterText) | ||
| internal override void FilterItems( | ||
| Document document, | ||
| IEnumerable<(CompletionItem, PatternMatch?)> itemsWithPatternMatch, |
There was a problem hiding this comment.
same concern about IEnuemrable.
|
|
||
| var builder = ImmutableArray.CreateBuilder<CompletionItem>(); | ||
| FilterItems(helper, itemsWithPatternMatch, filterText, builder); | ||
| return builder.MoveToImmutable(); |
There was a problem hiding this comment.
doc how you know MoveToImmutable is safe here.
There was a problem hiding this comment.
😬 This method (MoveToImmutable) is a scary trap
|
Overall i really like this. I'm not a huge fan though of weak-types like IEnumerable (which make lazyness unclear, and which may interface dispatch a ton), or even the IReadOnlyList-like types. Would it be possible to just update all these paths that take ImmutableArray and just move them to SegmentedArray to avoid the LOH issues? Note: much of the PR is 100% ok with me, it's more about seeing if there's a simpler choice in terms of types to use that can give most/all of hte benefit without having to mix/match between all these different intermediary types. |
| internal static class CompletionItemFlagsHelper | ||
| { | ||
| public static bool IsCached(this CompletionItemFlags flags) | ||
| [MethodImpl(MethodImplOptions.AggressiveInlining)] |
There was a problem hiding this comment.
❔ Do we have an indication that this is not currently getting inlined?
| // so use a dedicated pool to minimize array allocations. Set the size of pool to a small | ||
| // number 5 because we don't expect more than a couple of callers at the same time. | ||
| private static readonly ObjectPool<Dictionary<string, object>> s_uniqueSourcesPool = new(factory: () => new(), size: 3); | ||
| private static readonly ObjectPool<List<CompletionItem>> s_sortListPool = new(factory: () => new(), size: 3); |
There was a problem hiding this comment.
💡 explicit type would be better than new() here for readability
| // Check if symbol is namespace (which is always visible) first to avoid realizing all locations | ||
| // of each namespace symbol, which might end up allocating in LOH | ||
| (s.IsNamespace() || s.Locations.Any(isSymbolDefinedInSource) || !s.HasUnsupportedMetadata) && |
There was a problem hiding this comment.
CodeGen in Microsoft.CodeAnalysis.CodeGen is defined in Microsoft.CodeAnalysis.dll, but will not appear in completion within Microsoft.CodeAnalysis.Features.dll). Are we sure this code change will not impact this behavior?
There was a problem hiding this comment.
It doesn't look like this change would have impacted this behavior though, given that s.Locations.Any(isSymbolDefinedInSource) || !s.HasUnsupportedMetadata would be true for your example? I will validate and figure out where exactly that filtering is handled. Thanks for bringing this up!
Well, yes, such trade-off is definitely possible, and we'd still be able to avoid the problematic LOH allocation. However, given that we can eliminate all those allocationa with very small pools of size 1 or 2, I think it'd still be a better option (basically IReadOnlyList vs SegmentedArray) |
|
@CyrusNajmabadi @sharwell PTAL, thanks! |
That's a good point. I'll review today. If you haven't already, it might be good to doc the cases with the reasoning. But up to you. Thanks! |
Sure. I will wait for your review. If this is the only thing left, I will probably do that in another PR instead |
| string filterText) | ||
| IReadOnlyList<(CompletionItem, PatternMatch?)> itemsWithPatternMatch, | ||
| string filterText, | ||
| ImmutableArray<CompletionItem>.Builder builder) |
There was a problem hiding this comment.
not an ArrayBuilder?
| /// <summary> | ||
| /// The completion items to present to the user. | ||
| /// </summary> | ||
| internal IReadOnlyList<CompletionItem> ItemsInternal { get; } |
There was a problem hiding this comment.
def doc what's going on here.
There was a problem hiding this comment.
personally, i'd prefer calling this ItemsList.
|
|
||
| var service = GetCompletionService(document.Project); | ||
| var completionList = await GetCompletionListAsync(service, document, position, RoslynTrigger.Invoke); | ||
| var item = completionList.Items.First(i => (i.DisplayText + i.DisplayTextSuffix).StartsWith(textTypedSoFar)); |
There was a problem hiding this comment.
should we add this API the banned list internally, so we're forces to use ItemsInternal everywhere?
| private readonly bool _showCompletionItemFilters; | ||
|
|
||
| private readonly Func<ImmutableArray<(RoslynCompletionItem, PatternMatch?)>, string, ImmutableArray<RoslynCompletionItem>> _filterMethod; | ||
| private readonly Action<IReadOnlyList<(RoslynCompletionItem, PatternMatch?)>, string, ImmutableArray<RoslynCompletionItem>.Builder> _filterMethod; |
There was a problem hiding this comment.
always surprised to see ImmutableArray.Builder, instead of ArrayBuilder. Also, should these be segmentedarrays to help with the LOH issue? i'm not sure how some things are picked.
There was a problem hiding this comment.
Since we are holding on to those objects, it's a one time allocation, which won't be an issue
| // but we still use ObjectPool for concurrency handling just to be robust. | ||
| private static readonly ObjectPool<List<MatchResult<VSCompletionItem>>> s_listOfMatchResultPool = new(factory: () => new(), size: 1); | ||
| private static readonly ObjectPool<List<(RoslynCompletionItem, PatternMatch?)>> s_listOfItemMatchPairPool = new(factory: () => new(), size: 1); | ||
| private static readonly ObjectPool<ImmutableArray<RoslynCompletionItem>.Builder> s_filteredItemBuilderPool = new(factory: () => ImmutableArray.CreateBuilder<RoslynCompletionItem>(), size: 1); |
There was a problem hiding this comment.
overall very confused about why we do a List in one place, but a IA.Builder in another. Feels very strange. Similarly, i'm personally surprised to see us capping the size at '1'. in that, in practice the default cap feels like it's totally fine. if we then have multiple, we still pool. But if we only have one, it's still fine.
There was a problem hiding this comment.
us capping the size at '1'
I've done this because we absolutely don't expect this code path get executed in parallel (which would be a bug), and setting the size to 1 makes the expectation clear. But I agree that it should behave the same if everything works correctly (and avoid excessive allocations otherwise)
Fixed
| var itemsWithPatternMatch = new SegmentedList<(CompletionItem, PatternMatch?)>(items.Select( | ||
| item => (item, helper.GetMatch(item.FilterText, filterText, includeMatchSpans: false, CultureInfo.CurrentCulture)))); | ||
|
|
||
| var builder = ImmutableArray.CreateBuilder<CompletionItem>(); |
There was a problem hiding this comment.
surprised this is an IA.Builder, not a ArrayBuilder.
| } | ||
| // The FilterItems method might need to handle a large list of items when import completion is enabled and filter text is very short i.e. <= 1 | ||
| // Therefore, use pooled list to avoid repeated (potentially LOH) allocations. | ||
| private static readonly ObjectPool<List<(CompletionItem item, PatternMatch? match)>> s_listOfItemMatchPairPool = new(factory: () => new(), size: 2); |
There was a problem hiding this comment.
why a list, and not an arraybuilder? (generally speaking, this is feedback on consistency across this part of hte codebase).
There was a problem hiding this comment.
OK, changed it back to List, should be all consistent now
| !onlyDifferInCaseSensitivity || | ||
| highestMatchPriorityInBest <= pair.item.Rules.MatchPriority) | ||
| { | ||
| itemsWithCasingMismatchButHigherMatchPriority.Clear(); |
There was a problem hiding this comment.
doc plz. this part isn't super clear to me as to what's happening.
| return CompletionList.Create( | ||
| finalCompletionListSpan, | ||
| builder.ToImmutable(), | ||
| displayNameToItemsMap.SortToSegmentedList(), // TODO(DustinCa): Revisit performance of this. |
There was a problem hiding this comment.
can likely remove. this comment doesn't seem actionable :)
CyrusNajmabadi
left a comment
There was a problem hiding this comment.
Signing off. but would love more uniformity of the collection types we use in this area.

separate from #61477, which only include changes related to new editor API. This one deals with allocations completely controlled by Roslyn
The traces below are collected for a simple scenario (enabled import completion, triggered completion list 5 times on empty filter text, and each time refreshed the list once by typing an additional character). Top results are the baseline, and the bottom is with this change.
Total allocation is reduced from 251mb to 133mb (-47%), when comparing stacks including "Microsoft.CodeAnalysis" and "Completion"
Here's some of the most noticeable improvements (~ -100mb from those 4 types alone)

Here's the comparison of LOH allocation

Allocations from following types are no longer in LOH. and the rest will be addressed in #61477