Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Compute PatternMatching result in parallel when refreshing completion list #67249

Merged
merged 4 commits into from
Mar 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
Expand Down Expand Up @@ -50,6 +51,9 @@ private sealed class CompletionListUpdater
private readonly bool _highlightMatchingPortions;
private readonly bool _showCompletionItemFilters;

// Used for building MatchResult list in parallel
private readonly object _gate = new();

private readonly Action<IReadOnlyList<MatchResult>, string, IList<MatchResult>> _filterMethod;

private bool ShouldSelectSuggestionItemWhenNoItemMatchesFilterText
Expand Down Expand Up @@ -128,20 +132,24 @@ public CompletionListUpdater(
// since the completion list could be long with import completion enabled.
var itemsToBeIncluded = s_listOfMatchResultPool.Allocate();
var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var threadLocalPatternMatchHelper = new ThreadLocal<PatternMatchHelper>(() => new PatternMatchHelper(_filterText), trackAllValues: true);
Copy link
Member

Choose a reason for hiding this comment

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

what does 'trackAllValues' do?

Copy link
Member

Choose a reason for hiding this comment

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

looking at the docs, unclear why we're setting that to true. can you doc in the code why?

also, doc in the code why we're using a ThreadLocal here (specifically that PatternMatchHelper is not threadsafe, so we want a dedicated one per thread that is active doing the filtering.


try
{
// Determine the list of items to be included in the completion list.
// This is computed based on the filter text as well as the current
// selection of filters and expander.
AddCompletionItems(itemsToBeIncluded, cancellationToken);
AddCompletionItems(itemsToBeIncluded, threadLocalPatternMatchHelper, cancellationToken);

// Decide if we want to dismiss an empty completion list based on CompletionRules and filter usage.
if (itemsToBeIncluded.Count == 0)
return HandleAllItemsFilteredOut();

// Sort items based on pattern matching result
itemsToBeIncluded.Sort(MatchResult.SortingComparer);

var highlightAndFilterTask = Task.Run(
() => GetHighlightedListAndUpdatedFilters(session, itemsToBeIncluded, cancellationTokenSource.Token),
() => GetHighlightedListAndUpdatedFilters(session, itemsToBeIncluded, threadLocalPatternMatchHelper, cancellationTokenSource.Token),
cancellationTokenSource.Token);

// Decide the item to be selected for this completion session.
Expand Down Expand Up @@ -178,12 +186,18 @@ public CompletionListUpdater(
// Don't call ClearAndFree, which resets the capacity to a default value.
itemsToBeIncluded.Clear();
s_listOfMatchResultPool.Free(itemsToBeIncluded);

// Dispose PatternMatchers
foreach (var helper in threadLocalPatternMatchHelper.Values)
helper.Dispose();
Copy link
Member

Choose a reason for hiding this comment

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

ah. interesting. you use it for disposal. interesting!


threadLocalPatternMatchHelper.Dispose();
Copy link
Member

Choose a reason for hiding this comment

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

can you just place the threadLocalPatternMatchHelper in a using block instead?

}

(CompletionList<CompletionItemWithHighlight>, ImmutableArray<CompletionFilterWithState>) GetHighlightedListAndUpdatedFilters(
IAsyncCompletionSession session, IReadOnlyList<MatchResult> itemsToBeIncluded, CancellationToken cancellationToken)
IAsyncCompletionSession session, IReadOnlyList<MatchResult> itemsToBeIncluded, ThreadLocal<PatternMatchHelper> patternMatcherHelper, CancellationToken cancellationToken)
{
var highLightedList = GetHighlightedList(session, itemsToBeIncluded, cancellationToken);
var highLightedList = GetHighlightedList(session, patternMatcherHelper.Value!, itemsToBeIncluded, cancellationToken);
var updatedFilters = GetUpdatedFilters(itemsToBeIncluded, cancellationToken);
return (highLightedList, updatedFilters);
}
Expand Down Expand Up @@ -228,7 +242,7 @@ static bool IsAfterDot(ITextSnapshot snapshot, ITrackingSpan applicableToSpan)
}
}

private void AddCompletionItems(List<MatchResult> list, CancellationToken cancellationToken)
private void AddCompletionItems(List<MatchResult> list, ThreadLocal<PatternMatchHelper> threadLocalPatternMatchHelper, CancellationToken cancellationToken)
{
// Convert initial and update trigger reasons to corresponding Roslyn type so
// we can interact with Roslyn's completion system
Expand All @@ -238,33 +252,36 @@ private void AddCompletionItems(List<MatchResult> list, CancellationToken cancel
// FilterStateHelper is used to decide whether a given item should be included in the list based on the state of filter/expander buttons.
var filterHelper = new FilterStateHelper(_snapshotData.SelectedFilters);

using var _1 = PooledHashSet<string>.GetInstance(out var includedPreferredItems);
using var _2 = ArrayBuilder<MatchResult>.GetInstance(out var includedDefaults);
var unmatchedDefaults = _snapshotData.Defaults;
var includedPreferredItems = new ConcurrentSet<string>();
var includedDefaults = new ConcurrentDictionary<string, MatchResult>();

Enumerable.Range(0, _snapshotData.InitialSortedItemList.Count)
.AsParallel()
.WithCancellation(cancellationToken)
.ForAll(CreateMatchResultAndProcessMatchingDefaults);
Copy link
Member

Choose a reason for hiding this comment

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

curious why .ForAll, instead of .Where. Then we don't need a lock, right?

Copy link
Member Author

@genlu genlu Mar 9, 2023

Choose a reason for hiding this comment

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

Yes, you are right. I saw in my local measurement ForAll has small (but largely negligible) edge over Where, and since ForAll uses no buffer, I assume there would be less allocation (unconfrimed) That's why I picked ForAll.


// Filter items based on the selected filters and matching.
var totalCount = _snapshotData.InitialSortedItemList.Count;
for (var currentIndex = 0; currentIndex < totalCount; currentIndex++)
PromoteDefaultItemsToPreferredState();

void CreateMatchResultAndProcessMatchingDefaults(int index)
{
cancellationToken.ThrowIfCancellationRequested();
Copy link
Member

Choose a reason for hiding this comment

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

i would not mind in a followup pr if we basically tried to be much clearer about setting up parallel state, and what that state mutated. e.g. by extracting a helper function and passing in things like the collections it is expected to mutate. it's pretty hard to reason about things here given how complex this is and how much state is implicitly captured.

var item = _snapshotData.InitialSortedItemList[currentIndex];
var item = _snapshotData.InitialSortedItemList[index];

// All items passed in should contain a CompletionItemData object in the property bag,
// which is guaranteed in `ItemManager.SortCompletionListAsync`.
if (!CompletionItemData.TryGetData(item, out var itemData))
throw ExceptionUtilities.Unreachable();

if (filterHelper.ShouldBeFilteredOut(item))
continue;
return;

// currentIndex is used to track the index of the VS CompletionItem in the initial sorted list to maintain a map from Roslyn item to VS item.
// It's also used to sort the items by pattern matching results while preserving the original alphabetical order for items with
// same pattern match score since `List<T>.Sort` isn't stable.
if (CompletionHelper.TryCreateMatchResult(_completionHelper, itemData.RoslynItem, _filterText,
roslynInitialTriggerKind, roslynFilterReason, _recentItemsManager.GetRecentItemIndex(itemData.RoslynItem), _highlightMatchingPortions, currentIndex,
out var matchResult))
if (threadLocalPatternMatchHelper.Value!.TryCreateMatchResult(itemData.RoslynItem, roslynInitialTriggerKind, roslynFilterReason,
_recentItemsManager.GetRecentItemIndex(itemData.RoslynItem), _highlightMatchingPortions, index, out var matchResult))
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
_recentItemsManager.GetRecentItemIndex(itemData.RoslynItem), _highlightMatchingPortions, index, out var matchResult))
_recentItemsManager.GetRecentItemIndex(itemData.RoslynItem), _highlightMatchingPortions, index, out var matchResult))

{
list.Add(matchResult);
lock (_gate)
list.Add(matchResult);
Copy link
Member

Choose a reason for hiding this comment

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

don't love that inside the parallel we have to lock just to append to a list. would plinq's Where/Select methods (https://learn.microsoft.com/en-us/dotnet/api/system.linq.parallelenumerable.where?view=net-7.0) method work better? then you can just enumerate over those results that succeeded.


if (!_snapshotData.Defaults.IsEmpty)
{
Expand All @@ -277,26 +294,21 @@ private void AddCompletionItems(List<MatchResult> list, CancellationToken cancel
}
else
{
var defaultIndex = unmatchedDefaults.IndexOf(matchResult.CompletionItem.FilterText);
if (defaultIndex >= 0)
if (_snapshotData.Defaults.IndexOf(matchResult.CompletionItem.FilterText) >= 0)
{
unmatchedDefaults = unmatchedDefaults.RemoveAt(defaultIndex);
includedDefaults.Add(matchResult);
includedDefaults.TryAdd(matchResult.CompletionItem.FilterText, matchResult);
}
}
}
}
}

PromoteDefaultItemsToPreferredState();
list.Sort(MatchResult.SortingComparer);

// Go through items matched with defaults. If it doesn't have
// a corresponding preferred items, we will add one that mimic
// the "starred" item from Pythia.
void PromoteDefaultItemsToPreferredState()
{
foreach (var includedDefault in includedDefaults)
foreach (var includedDefault in includedDefaults.Values)
{
var completionItem = includedDefault.CompletionItem;

Expand Down Expand Up @@ -568,23 +580,21 @@ static int GetPriority(RoslynCompletionItem item)

private CompletionList<CompletionItemWithHighlight> GetHighlightedList(
IAsyncCompletionSession session,
PatternMatchHelper patternMatchers,
IReadOnlyList<MatchResult> matchResults,
CancellationToken cancellationToken)
{
return session.CreateCompletionList(matchResults.Select(matchResult =>
{
var vsItem = GetCorrespondingVsCompletionItem(matchResult, cancellationToken);
var highlightedSpans = _highlightMatchingPortions
? GetHighlightedSpans(matchResult, _completionHelper, _filterText)
? GetHighlightedSpans(matchResult, patternMatchers)
: ImmutableArray<Span>.Empty;

return new CompletionItemWithHighlight(vsItem, highlightedSpans);
}));

static ImmutableArray<Span> GetHighlightedSpans(
MatchResult matchResult,
CompletionHelper completionHelper,
string filterText)
static ImmutableArray<Span> GetHighlightedSpans(MatchResult matchResult, PatternMatchHelper patternMatchers)
{
if (matchResult.CompletionItem.HasDifferentFilterText || matchResult.CompletionItem.HasAdditionalFilterTexts)
{
Expand All @@ -593,8 +603,8 @@ static ImmutableArray<Span> GetHighlightedSpans(
// However, if the Roslyn item's FilterText is different from its DisplayText, we need to do the match against the
// display text of the VS item directly to get the highlighted spans. This is done in a best effort fashion and there
// is no guarantee a proper match would be found for highlighting.
return completionHelper.GetHighlightedSpans(
matchResult.CompletionItem, filterText, CultureInfo.CurrentCulture).SelectAsArray(s => s.ToSpan());
return patternMatchers.GetHighlightedSpans(matchResult.CompletionItem.GetEntireDisplayText(), CultureInfo.CurrentCulture)
.SelectAsArray(s => s.ToSpan());
}

var patternMatch = matchResult.PatternMatch;
Expand Down
17 changes: 11 additions & 6 deletions src/EditorFeatures/Test2/IntelliSense/CompletionRulesTests.vb
Original file line number Diff line number Diff line change
Expand Up @@ -56,34 +56,39 @@ Namespace Microsoft.CodeAnalysis.Editor.UnitTests.IntelliSense
Dim culture = New CultureInfo("tr-TR", useUserOverride:=False)

Dim workspace = New TestWorkspace
Dim helper = New CompletionHelper(isCaseSensitive:=False)
Dim helper = New PatternMatchHelper(pattern)
Copy link
Member

Choose a reason for hiding this comment

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

using


For Each wordMarkup In wordsToMatch
Dim word As String = Nothing
Dim wordMatchSpan As TextSpan = Nothing
MarkupTestFile.GetSpan(wordMarkup, word, wordMatchSpan)

Dim item = CompletionItem.Create(word)
Assert.True(helper.MatchesPattern(item, pattern, culture), $"Expected item {word} does not match {pattern}")
Assert.True(helper.MatchesPattern(item, culture), $"Expected item {word} does not match {pattern}")

Dim highlightedSpans = helper.GetHighlightedSpans(item, pattern, culture)
Dim highlightedSpans = helper.GetHighlightedSpans(item.GetEntireDisplayText(), culture)
Assert.NotEmpty(highlightedSpans)
Assert.Equal(1, highlightedSpans.Length)
Assert.Equal(wordMatchSpan, highlightedSpans(0))
Next

helper.Dispose()
End Sub

Private Shared Sub TestNotMatches(pattern As String, wordsToNotMatch() As String)
Dim culture = New CultureInfo("tr-TR", useUserOverride:=False)
Dim workspace = New TestWorkspace
Dim helper = New CompletionHelper(isCaseSensitive:=True)
Dim helper = New PatternMatchHelper(pattern)
Copy link
Member

Choose a reason for hiding this comment

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

using?


For Each word In wordsToNotMatch
Dim item = CompletionItem.Create(word)
Assert.False(helper.MatchesPattern(item, pattern, culture), $"Unexpected item {word} matches {pattern}")
Assert.False(helper.MatchesPattern(item, culture), $"Unexpected item {word} matches {pattern}")

Dim highlightedSpans = helper.GetHighlightedSpans(item, pattern, culture)
Dim highlightedSpans = helper.GetHighlightedSpans(item.GetEntireDisplayText(), culture)
Assert.Empty(highlightedSpans)
Next

helper.Dispose()
End Sub
End Class
End Namespace
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ internal override void FilterItems(
string filterText,
IList<MatchResult> builder)
{
var helper = CompletionHelper.GetHelper(document);
CompletionService.FilterItems(helper, matchResults, filterText, builder);
CompletionService.FilterItems(CompletionHelper.GetHelper(document), matchResults, filterText, builder);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ public CompletionItem? SuggestionModeItem
internal Task<SyntaxContext> GetSyntaxContextWithExistingSpeculativeModelAsync(Document document, CancellationToken cancellationToken)
{
if (SharedSyntaxContextsWithSpeculativeModel is null)
return CompletionHelper.CreateSyntaxContextWithExistingSpeculativeModelAsync(document, Position, cancellationToken);
return Utilities.CreateSyntaxContextWithExistingSpeculativeModelAsync(document, Position, cancellationToken);

return SharedSyntaxContextsWithSpeculativeModel.GetSyntaxContextAsync(document, cancellationToken);
}
Expand Down
Loading