Skip to content

Reducing allocations in completion#61620

Merged
genlu merged 14 commits intodotnet:mainfrom
genlu:CompletionLOHAllocation
Jun 8, 2022
Merged

Reducing allocations in completion#61620
genlu merged 14 commits intodotnet:mainfrom
genlu:CompletionLOHAllocation

Conversation

@genlu
Copy link
Copy Markdown
Member

@genlu genlu commented Jun 1, 2022

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.

image

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)
image

Here's the comparison of LOH allocation
image

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

Microsoft.CodeAnalysis.Completion.CompletionItem[]  
System.ValueTuple`2[Microsoft.CodeAnalysis.Completion.CompletionItem,System.Nullable`1[Microsoft.CodeAnalysis.PatternMatching.PatternMatch]][]
Microsoft.CodeAnalysis.Location[] 

@ghost ghost added the Area-IDE label Jun 1, 2022
@genlu genlu force-pushed the CompletionLOHAllocation branch from 50665b8 to 8798536 Compare June 1, 2022 18:39
@genlu genlu marked this pull request as ready for review June 1, 2022 23:27
@genlu genlu requested a review from a team as a code owner June 1, 2022 23:27
@genlu genlu force-pushed the CompletionLOHAllocation branch from 2678096 to 770d8aa Compare June 1, 2022 23:31
@genlu genlu changed the title Avoid LOH allocation in completion Reduce allocations in completion Jun 1, 2022
@genlu genlu changed the title Reduce allocations in completion Reducing allocations in completion Jun 1, 2022

public static bool IsExpanded(this CompletionItemFlags flags)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsExpanded(CompletionItemFlags flags)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what was the benefit of this change? (Also, it feels like in extension method form, it would still be ok).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stood out to me as well. The changes related to this helper type seem unnecessary.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) &&
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting. there are enough namespaces to matter? how many were you seeing in practice?

Copy link
Copy Markdown
Member Author

@genlu genlu Jun 2, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the trace, allocations of 1.3MB on LOH, and 30MB on regular heap is eliminated by this single check

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why PublicationOnly?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I use ExecutionAndPublication? I don't think it matters much though, since the value is not accessed concurrently in Roslyn.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

else
{
return Create(newSpan, newItems, newRules, newSuggestionModeItem);
return Create(newSpan, newItems, newRules, newSuggestionModeItem, isExclusive: false);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why isExclusive: false here instead of _isExclusive?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the previous behavior, can't remember why though. Will take a closer look later, but not gonna change in this PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment says '5', but code says '3' :)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

internal override ImmutableArray<CompletionItem> FilterItems(Document document, ImmutableArray<(CompletionItem, PatternMatch?)> itemsWithPatternMatch, string filterText)
internal override void FilterItems(
Document document,
IEnumerable<(CompletionItem, PatternMatch?)> itemsWithPatternMatch,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same concern about IEnuemrable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed


var builder = ImmutableArray.CreateBuilder<CompletionItem>();
FilterItems(helper, itemsWithPatternMatch, filterText, builder);
return builder.MoveToImmutable();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doc how you know MoveToImmutable is safe here.

Copy link
Copy Markdown
Contributor

@sharwell sharwell Jun 2, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😬 This method (MoveToImmutable) is a scary trap

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

@CyrusNajmabadi
Copy link
Copy Markdown
Contributor

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)]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❔ Do we have an indication that this is not currently getting inlined?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reverted

// 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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 explicit type would be better than new() here for readability

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment on lines +682 to +684
// 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) &&
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Namespaces which are imported from metadata but do not contain any accessible symbols are not considered visible today (e.g. the 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?

Copy link
Copy Markdown
Member Author

@genlu genlu Jun 2, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a test

@genlu
Copy link
Copy Markdown
Member Author

genlu commented Jun 3, 2022

Would it be possible to just update all these paths that take ImmutableArray and just move them to SegmentedArray to avoid the LOH issues?

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)

@genlu
Copy link
Copy Markdown
Member Author

genlu commented Jun 3, 2022

@CyrusNajmabadi @sharwell PTAL, thanks!

@CyrusNajmabadi
Copy link
Copy Markdown
Contributor

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)

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!

@genlu
Copy link
Copy Markdown
Member Author

genlu commented Jun 3, 2022

it might be good to doc the cases with the reasoning.

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not an ArrayBuilder?

/// <summary>
/// The completion items to present to the user.
/// </summary>
internal IReadOnlyList<CompletionItem> ItemsInternal { get; }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def doc what's going on here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

personally, i'd prefer calling this ItemsList.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed


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));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we add this API the banned list internally, so we're forces to use ItemsInternal everywhere?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good idea. Done!
image

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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

@genlu genlu Jun 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why a list, and not an arraybuilder? (generally speaking, this is feedback on consistency across this part of hte codebase).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, changed it back to List, should be all consistent now

!onlyDifferInCaseSensitivity ||
highestMatchPriorityInBest <= pair.item.Rules.MatchPriority)
{
itemsWithCasingMismatchButHigherMatchPriority.Clear();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can likely remove. this comment doesn't seem actionable :)

Copy link
Copy Markdown
Contributor

@CyrusNajmabadi CyrusNajmabadi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signing off. but would love more uniformity of the collection types we use in this area.

@genlu genlu requested a review from a team as a code owner June 7, 2022 21:53
@genlu genlu enabled auto-merge June 7, 2022 21:55
@genlu genlu merged commit 15b68dc into dotnet:main Jun 8, 2022
@ghost ghost added this to the Next milestone Jun 8, 2022
@genlu genlu deleted the CompletionLOHAllocation branch June 8, 2022 04:23
@RikkiGibson RikkiGibson modified the milestones: Next, 17.3 P3 Jun 28, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants