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

Update tagging to avoid jumping back to the UI thread when finished. #73699

Merged
merged 21 commits into from
May 25, 2024

Conversation

CyrusNajmabadi
Copy link
Member

@CyrusNajmabadi CyrusNajmabadi commented May 24, 2024

This takes us from two UI jumps per ProduceTagsAsync call to just one.

@dotnet-issue-labeler dotnet-issue-labeler bot added Area-IDE untriaged Issues and PRs which have not yet been triaged by a lead labels May 24, 2024
var classified = await TryClassifyContainingMemberSpanAsync(
context, document, spanToTag.SnapshotSpan, classificationService, options, cancellationToken).ConfigureAwait(false);
context, document, spanToTag.SnapshotSpan, classificationService, options, currentSemanticVersion, cancellationToken).ConfigureAwait(false);
Copy link
Member Author

Choose a reason for hiding this comment

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

i removed the text-change-tracking that tagging did (which required the UI thread), and made it something only the SemanticClassifier does (since it is the only feature that needs it).

@@ -88,6 +88,12 @@ private sealed partial class TagSource

#endregion

#region Mutable state. Only accessed from _eventChangeQueue

private object? _state_accessOnlyFromEventChangeQueueCallback;
Copy link
Member Author

Choose a reason for hiding this comment

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

This is a move/rename. We used to require this have UI thread affinity. But there's no need for that. It's only read/written from the _eventChangeQueue callbacks, so it is safe to have no jumps for this.

@@ -124,9 +130,7 @@ private sealed partial class TagSource
/// <summary>
/// accumulated text changes since last tag calculation
/// </summary>
private TextChangeRange? _accumulatedTextChanges_doNotAccessDirectly;
Copy link
Member Author

Choose a reason for hiding this comment

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

removed entirely.

_dataSource.ThreadingContext.ThrowIfNotOnUIThread();
PauseIfNotVisible();
ResumeIfVisible();
};
Copy link
Member Author

Choose a reason for hiding this comment

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

previously, we would jump back to the UI thread after reporting tags to pause ourselves. Now, we just pause/unpause based on the events we get from the visibility service. This matches what we just did in anvbars.

// Then switch back to the UI thread to update our state and kick off the work to notify the editor.
await _dataSource.ThreadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken).NoThrowAwaitable();
if (cancellationToken.IsCancellationRequested)
return default;
Copy link
Member Author

Choose a reason for hiding this comment

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

this is hte main win. we're not switching back to the ui thread when done.

await _dataSource.ThreadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken).NoThrowAwaitable();
if (cancellationToken.IsCancellationRequested)
return default;

// Once we assign our state, we're uncancellable. We must report the changed information
// to the editor. The only case where it's ok not to is if the tagger itself is disposed.
cancellationToken = CancellationToken.None;

this.CachedTagTrees = newTagTrees;
Copy link
Member Author

Choose a reason for hiding this comment

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

WIP: this is an immutable dictionary. need to ensure that this is safe for all the reading threads.

{
// There was no text change range, we can't just reclassify a member body.
return false;
}
Copy link
Member Author

Choose a reason for hiding this comment

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

instead of having this range be tracked by the context, and passed in. we can fairly trivially compute it if we store the previous snapshot we computed against, and determine the change ranges between it and the current snapshot we're classifying.

var oldTagTrees = this.CachedTagTrees;
this.CachedTagTrees = ImmutableDictionary<ITextBuffer, TagSpanIntervalTree<TTag>>.Empty;
var oldTagTrees = Interlocked.Exchange(
ref _cachedTagTrees_mayChangeFromAnyThread, ImmutableDictionary<ITextBuffer, TagSpanIntervalTree<TTag>>.Empty);
Copy link
Member Author

Choose a reason for hiding this comment

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

because _cachedTagTrees may be mutated from several different locations (and threads) we have to switch to save ways of doing that atomically.

return;
// Everything we're passing in here is synchronous. So we can assert that this must complete synchronously
// as well.
var (oldTagTrees, newTagTrees) = CompareAndSwapTagTreesAsync(
Copy link
Member Author

Choose a reason for hiding this comment

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

core helper is this CompareAndSwapTagTreesAsync function. It performs the normal compare-and-swap algorithm, reading teh current value of _cachedTagTrees, calling a user-provided function with it, and attempting to then update that field with the new result as long as it didn't change in between. If it did change, it spins until it finally wins and then returns the before/after state.

var (oldTagTrees, newTagTrees) = CompareAndSwapTagTreesAsync(
oldTagTrees =>
{
if (oldTagTrees.TryGetValue(buffer, out var treeForBuffer))
Copy link
Member Author

Choose a reason for hiding this comment

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

inverted these checks for clarity.

}
}

private async Task<(ImmutableDictionary<ITextBuffer, TagSpanIntervalTree<TTag>> oldTagTrees, ImmutableDictionary<ITextBuffer, TagSpanIntervalTree<TTag>> newTagTrees)>
Copy link
Member Author

Choose a reason for hiding this comment

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

i really wish i could have an alias for ImmutableDictionary<ITextBuffer, TagSpanIntervalTree<TTag>>, but it references TTag which is a generic type parameter of this type.

@@ -40,10 +40,6 @@ internal partial class ActiveStatementTaggerProvider(
[Import(AllowDefault = true)] ITextBufferVisibilityTracker? visibilityTracker,
IAsynchronousOperationListenerProvider listenerProvider) : AsynchronousTaggerProvider<ITextMarkerTag>(threadingContext, globalOptions, visibilityTracker, listenerProvider.GetListener(FeatureAttribute.Classification))
{
// We want to track text changes so that we can try to only reclassify a method body if
// all edits were contained within one.
protected override TaggerTextChangeBehavior TextChangeBehavior => TaggerTextChangeBehavior.TrackTextChanges;
Copy link
Member Author

Choose a reason for hiding this comment

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

this was never used. this asked the tagger infrastructure to track this info. but then it was never used in this tagger.

Copy link
Member Author

Choose a reason for hiding this comment

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

Note: it's clear that this was just a copy/paste from the semantic-classifier

@@ -225,8 +235,11 @@ void Connect()

_eventSource.Changed += OnEventSourceChanged;

if (_dataSource.TextChangeBehavior.HasFlag(TaggerTextChangeBehavior.TrackTextChanges))
Copy link
Member Author

Choose a reason for hiding this comment

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

removed the top flag.

}

return default;
}
Copy link
Member Author

Choose a reason for hiding this comment

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

core CompareAndSwap helper.

bool highPriority,
bool frozenPartialSemantics,
bool calledFromJtfRun,
Copy link
Member Author

Choose a reason for hiding this comment

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

new addition. want to make sure we dont' do unnecessary hops when in a JTF call.

CancellationToken cancellationToken)
{
// Jump to the main thread, as we have to grab the spans to tag and the caret point.
await _dataSource.ThreadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken).NoThrowAwaitable();
Copy link
Member Author

Choose a reason for hiding this comment

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

existing UI jump that we need to preserve.

@CyrusNajmabadi
Copy link
Member Author

Note this is a complimentary PR to #67259. That pr makes it so that we can batch as many taggers together and have them all execute their UI work at the same time.

Comment on lines +289 to +291
if (_dataSource.TextChangeBehavior.HasFlag(TaggerTextChangeBehavior.RemoveAllTags) ||
_dataSource.TextChangeBehavior.HasFlag(TaggerTextChangeBehavior.RemoveTagsThatIntersectEdits))
{
Copy link
Member

Choose a reason for hiding this comment

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

Is it easier to skip this condition and just always do the delegate remove? That way there's no risk of the conditions getting out of sync.

Copy link
Member Author

Choose a reason for hiding this comment

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

i wasn't 100% certain the semantics of dleegate removal. and i was a tiny bit concerned that might introduce more chance of a problem... i'd like to keep this way for now if htat's ok!

// If we're being called from within a blocking JTF.Run call, we don't want to switch to the background
// if we can avoid it.
if (!calledFromJtfRun)
await TaskScheduler.Default;
Copy link
Member

Choose a reason for hiding this comment

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

You can also do this:

// The rest of this method can be ran off the UI thread. We'll only switch though if the UI thread isn't already blocked -- the legacy project
// system creates project synchronously, and during solution load we've seen traces where the thread pool is sufficiently saturated that this
// switch can't be completed quickly. For the rest of this method, we won't use ConfigureAwait(false) since we're expecting VS threading
// rules to apply.
if (!_threadingContext.JoinableTaskContext.IsMainThreadBlocked())
{
await TaskScheduler.Default;
}

Comment on lines 180 to 186
var lastSourceText = lastTextSnapshot.AsText();
var currentSourceText = snapshotSpan.Snapshot.AsText();

var textChangeRanges = currentSourceText.GetChangeRanges(lastSourceText);
var collapsedRange = TextChangeRange.Collapse(textChangeRanges);

var changedSpan = new TextSpan(collapsedRange.Span.Start, collapsedRange.NewLength);
Copy link
Member

Choose a reason for hiding this comment

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

Do we only need the span in the end? Because we can be a bit friendlier here on costs I think. That GetChangeRanges call is only looking at the ITextVersions I think:

INormalizedTextChangeCollection? changes = null;

If we extract out that then the state could just be the snapshot version.

Copy link
Member Author

Choose a reason for hiding this comment

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

yup. will do this. intiially it seemed complex. but it should be ok.

public TagSpanIntervalTree<TTag> this[ITextBuffer buffer] => Map[buffer];

internal static BufferToTagTree InterlockedExchange(ref BufferToTagTree location, BufferToTagTree value)
=> new(Interlocked.Exchange(ref Unsafe.AsRef(in location.Map), value.Map));
Copy link
Member

Choose a reason for hiding this comment

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

Is this just more sane to have the struct use a mutable field and then you don't need this directly? Or can dispense of the Unsafe.AsRef? I normally say that a mutable struct is terrible, but this is already effectively mutable so maybe it doesn't really matter.

Copy link
Member Author

Choose a reason for hiding this comment

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

removed this all. back to ImmutableDictionaries.

var newTagTree = new TagSpanIntervalTree<TTag>(
buffer,
treeForBuffer.SpanTrackingMode,
allTags.Except(tagsToRemove, comparer: this));
Copy link
Member

Choose a reason for hiding this comment

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

Unrelated to your change but I wonder if we can/should make this processing cheaper. It seems we enumerate the tree twice and allocate that intermediate list and then create a whole new tree from scratch. I assume there's a better algorithm.

(assuming this even appears in a trace)

Copy link
Member Author

Choose a reason for hiding this comment

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

haven't seen it yet. we likely can optimize more here though.

_dataSource.ThreadingContext.ThrowIfNotOnUIThread();
if (cancellationToken.IsCancellationRequested)
return default;

// Make a copy of all the data we need while we're on the foreground. Then switch to a threadpool
// thread to do the computation. Finally, once new tags have been computed, then we update our state
// again on the foreground.
var spansToTag = GetSpansAndDocumentsToTag();
var caretPosition = _dataSource.GetCaretPoint(_textView, _subjectBuffer);
Copy link
Member

Choose a reason for hiding this comment

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

I wonder how hard it would be to also allow the editor to make this free-threaded.

/// </summary>
TrackTextChanges = 1 << 0,
RemoveTagsThatIntersectEdits = 1 << 1,
Copy link
Contributor

Choose a reason for hiding this comment

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

1 << 1

Intentionally starting at 2?

Copy link
Member Author

Choose a reason for hiding this comment

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

No. Can fix later :-)

@@ -225,8 +238,11 @@ void Connect()

_eventSource.Changed += OnEventSourceChanged;

if (_dataSource.TextChangeBehavior.HasFlag(TaggerTextChangeBehavior.TrackTextChanges))
if (_dataSource.TextChangeBehavior.HasFlag(TaggerTextChangeBehavior.RemoveAllTags) ||
Copy link
Contributor

Choose a reason for hiding this comment

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

_dataSource.TextChangeBehavior

maybe

_dataSource.TextChangeBehavior != TaggerTextChangeBehavior.None

Copy link
Member Author

Choose a reason for hiding this comment

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

can change in followup.


// Compute the new tag trees, based on what the current tag trees are. Intentionally CA(true) here so
// we stay on the UI thread if we're in a JTF blocking call.
var newTagTrees = await callback(oldTagTrees).ConfigureAwait(true);
Copy link
Contributor

Choose a reason for hiding this comment

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

ConfigureAwait(true);

Can you walk me through this a bit, going off the common path with ConfigureAwait always confuses me.

Specifically, the case I'm curious about is when we enter this method on the main thread and the callback switches to a different thread to do it's work.

With ConfigureAwait(true), we get switched back to the main thread upon the callback return, right? Whereas, if ConfigureAwait(false), we stay on whatever thread the work completed on (or is always a threadpool thread)?

If that's so, wouldn't that be an extra switch to main thread, perhaps unnecessarilly in the case where the CompareExchange was successful? Or does the caller of this method expect to be on the main thread upon it's return?

Copy link
Member Author

Choose a reason for hiding this comment

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

If that's so, wouldn't that be an extra switch to main thread, perhaps unnecessarilly in the case where the CompareExchange was successful? Or does the caller of this method expect to be on the main thread upon it's return?

The caller can be in one of two modes.

  1. Normal threadpool/bg tagging. In this case, CA(true) is fine, as we come back to that context and continue i nthe bg.
  2. blocking JTF call waiting for tags, for certain blocking editor APis (specifically, code-folding). In this case, we want to return to he ui thread whenever we can since that thread is already blocking on us. thsi allows that true thread to contribute progress to this work, instead of it just sitting there blocked, waiting on teh theradpool to finish work.

await TaskScheduler.Default;
// If we're being called from within a blocking JTF.Run call, we don't want to switch to the background
// if we can avoid it.
if (!calledFromJtfRun)
Copy link
Contributor

Choose a reason for hiding this comment

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

calledFromJtfRun

I don't suppose the highPriority flag could be used for this check instead?

Copy link
Member Author

Choose a reason for hiding this comment

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

no. The first call to get tags is also high-pri.

{
if (changes != null)
{
// Oops - more than one "textual" change between these snapshots, bail and try to find smallest changes span
Copy link
Contributor

Choose a reason for hiding this comment

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

// Oops - more than one "textual" change between these snapshots, bail and try to find smallest changes span

I know this comment isn't new in this PR, but I was trying to understand this code, and the "oops" part of this is confusing. I would think this would commonly happen if the versions sent in had some intermediary versions between.

Is it intended to instead call the GetChangeRanges method that accepts the forward parameter if you might have versions that aren't adjacent?

Copy link
Member Author

Choose a reason for hiding this comment

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

@jasonmalinowski may know moer her.e

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area-IDE untriaged Issues and PRs which have not yet been triaged by a lead
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants