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

Simplify the computation and caching of 'CompilationWithAnalyzers' for a particular project in the diagnostics layer. #77113

Merged
merged 8 commits into from
Feb 9, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ErrorReporting;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.SolutionCrawler;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
Expand Down Expand Up @@ -127,84 +128,6 @@ static string GetLanguageSpecificId(string? language, string noLanguageId, strin
language: language);
}

public static async Task<CompilationWithAnalyzersPair?> CreateCompilationWithAnalyzersAsync(
Copy link
Member Author

Choose a reason for hiding this comment

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

moved to local function in single place it is needed.

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 also lifted the 'compilation == null' check below out of it. but it is otherwise unchanged.

Project project,
ImmutableArray<DiagnosticAnalyzer> projectAnalyzers,
ImmutableArray<DiagnosticAnalyzer> hostAnalyzers,
bool crashOnAnalyzerException,
CancellationToken cancellationToken)
{
var compilation = await project.GetCompilationAsync(cancellationToken).ConfigureAwait(false);
if (compilation == null)
{
// project doesn't support compilation
return null;
}

// Create driver that holds onto compilation and associated analyzers
var filteredProjectAnalyzers = projectAnalyzers.WhereAsArray(static a => !a.IsWorkspaceDiagnosticAnalyzer());
var filteredHostAnalyzers = hostAnalyzers.WhereAsArray(static a => !a.IsWorkspaceDiagnosticAnalyzer());
var filteredProjectSuppressors = filteredProjectAnalyzers.WhereAsArray(static a => a is DiagnosticSuppressor);
filteredHostAnalyzers = filteredHostAnalyzers.AddRange(filteredProjectSuppressors);

// PERF: there is no analyzers for this compilation.
// compilationWithAnalyzer will throw if it is created with no analyzers which is perf optimization.
if (filteredProjectAnalyzers.IsEmpty && filteredHostAnalyzers.IsEmpty)
{
return null;
}

Contract.ThrowIfFalse(project.SupportsCompilation);
AssertCompilation(project, compilation);

// in IDE, we always set concurrentAnalysis == false otherwise, we can get into thread starvation due to
// async being used with synchronous blocking concurrency.
var projectAnalyzerOptions = new CompilationWithAnalyzersOptions(
options: project.AnalyzerOptions,
onAnalyzerException: null,
analyzerExceptionFilter: GetAnalyzerExceptionFilter(),
concurrentAnalysis: false,
logAnalyzerExecutionTime: true,
reportSuppressedDiagnostics: true);
var hostAnalyzerOptions = new CompilationWithAnalyzersOptions(
options: project.HostAnalyzerOptions,
onAnalyzerException: null,
analyzerExceptionFilter: GetAnalyzerExceptionFilter(),
concurrentAnalysis: false,
logAnalyzerExecutionTime: true,
reportSuppressedDiagnostics: true);

// Create driver that holds onto compilation and associated analyzers
return new CompilationWithAnalyzersPair(
filteredProjectAnalyzers.Any() ? compilation.WithAnalyzers(filteredProjectAnalyzers, projectAnalyzerOptions) : null,
filteredHostAnalyzers.Any() ? compilation.WithAnalyzers(filteredHostAnalyzers, hostAnalyzerOptions) : null);

Func<Exception, bool> GetAnalyzerExceptionFilter()
{
return ex =>
{
if (ex is not OperationCanceledException && crashOnAnalyzerException)
{
// report telemetry
FatalError.ReportAndPropagate(ex);

// force fail fast (the host might not crash when reporting telemetry):
FailFast.OnFatalException(ex);
}

return true;
};
}
}

[Conditional("DEBUG")]
private static void AssertCompilation(Project project, Compilation compilation1)
{
// given compilation must be from given project.
Contract.ThrowIfFalse(project.TryGetCompilation(out var compilation2));
Contract.ThrowIfFalse(compilation1 == compilation2);
}

/// <summary>
/// Return true if the given <paramref name="analyzer"/> is not suppressed for the given project.
/// NOTE: This API is intended to be used only for performance optimization.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,145 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ErrorReporting;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis.Diagnostics;

internal partial class DiagnosticAnalyzerService
{
private partial class DiagnosticIncrementalAnalyzer
/// <summary>
/// Cached data from a <see cref="Project"/> to the last <see cref="CompilationWithAnalyzersPair"/> instance created
/// for it. Note: the CompilationWithAnalyzersPair instance is dependent on the set of <see cref="StateSet"/>s
/// passed along with the project. As such, we might not be able to use a prior cached value if the set of state
/// sets changes. In that case, a new instance will be created and will be cached for the next caller.
/// </summary>
private static readonly ConditionalWeakTable<Project, CompilationWithAnalyzersPair?> s_projectToCompilationWithAnalyzers = new();
Copy link
Member Author

Choose a reason for hiding this comment

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

changed from WeakRef to CWT. that way we can reuse when asking about other projects as well. and, as long as the project is alive we keep the CompPair (instead of dumping on first GC).


private static async Task<CompilationWithAnalyzersPair?> GetOrCreateCompilationWithAnalyzersAsync(
Project project,
ImmutableArray<StateSet> stateSets,
bool crashOnAnalyzerException,
CancellationToken cancellationToken)
{
if (!project.SupportsCompilation)
return null;
Copy link
Member Author

Choose a reason for hiding this comment

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

lifted this up from CreateCompilationWithAnalyzersAsync


// Make sure the cached pair matches the state sets we're asking about. if not, recompute and cache with
// the new state sets.
if (!s_projectToCompilationWithAnalyzers.TryGetValue(project, out var compilationWithAnalyzersPair) ||
!HasAllAnalyzers(stateSets, compilationWithAnalyzersPair))
{
compilationWithAnalyzersPair = await CreateCompilationWithAnalyzersAsync().ConfigureAwait(false);

// Make a best effort attempt to store the latest computed value against these state sets. If this
// fails (because another thread interleaves with this), that's ok. We still return the pair we
// computed, so our caller will still see the right data
s_projectToCompilationWithAnalyzers.Remove(project);

// Intentionally ignore the result of this. We still want to use the value we computed above, even if
// another thread interleaves and sets a different value.
s_projectToCompilationWithAnalyzers.GetValue(project, _ => compilationWithAnalyzersPair);
}

return compilationWithAnalyzersPair;

static bool HasAllAnalyzers(ImmutableArray<StateSet> stateSets, CompilationWithAnalyzersPair? compilationWithAnalyzers)
{
if (compilationWithAnalyzers is null)
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.

this check is new, but is also a bug fix. we might have stored 'null' before because we decided to not produce a compilation-pair for a particular set of state-sets. taht doesn't mean we should fail from trying to produce a compilation with our current set.


foreach (var stateSet in stateSets)
{
if (stateSet.IsHostAnalyzer && !compilationWithAnalyzers.HostAnalyzers.Contains(stateSet.Analyzer))
return false;
else if (!stateSet.IsHostAnalyzer && !compilationWithAnalyzers.ProjectAnalyzers.Contains(stateSet.Analyzer))
return false;
}

return true;
}

// <summary>
// Should only be called on a <see cref="Project"/> that <see cref="Project.SupportsCompilation"/>.
// </summary>
async Task<CompilationWithAnalyzersPair?> CreateCompilationWithAnalyzersAsync()
{
var projectAnalyzers = stateSets.SelectAsArray(s => !s.IsHostAnalyzer, s => s.Analyzer);
var hostAnalyzers = stateSets.SelectAsArray(s => s.IsHostAnalyzer, s => s.Analyzer);
Copy link
Contributor

Choose a reason for hiding this comment

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

Both these arrays look to just be intermediaries used only once. Could they be removed and the filtered arrays populated something like:

var filteredProjectAnalyzers = stateSets.SelectAsArray(s => !s.IsHostAnalyzer && !s.Analyzer.IsWorkspaceDiagnosticAnalyzer(), s => s.Analyzer);
var filteredHostAnalyzers = stateSets.SelectAsArray(s => s.IsHostAnalyzer && !s.Analyzer.IsWorkspaceDiagnosticAnalyzer(), s => s.Analyzer);


var compilation = await project.GetRequiredCompilationAsync(cancellationToken).ConfigureAwait(false);

// Create driver that holds onto compilation and associated analyzers
var filteredProjectAnalyzers = projectAnalyzers.WhereAsArray(static a => !a.IsWorkspaceDiagnosticAnalyzer());
var filteredHostAnalyzers = hostAnalyzers.WhereAsArray(static a => !a.IsWorkspaceDiagnosticAnalyzer());
var filteredProjectSuppressors = filteredProjectAnalyzers.WhereAsArray(static a => a is DiagnosticSuppressor);
filteredHostAnalyzers = filteredHostAnalyzers.AddRange(filteredProjectSuppressors);

// PERF: there is no analyzers for this compilation.
// compilationWithAnalyzer will throw if it is created with no analyzers which is perf optimization.
if (filteredProjectAnalyzers.IsEmpty && filteredHostAnalyzers.IsEmpty)
{
return null;
}

Contract.ThrowIfFalse(project.SupportsCompilation);
AssertCompilation(project, compilation);

// in IDE, we always set concurrentAnalysis == false otherwise, we can get into thread starvation due to
// async being used with synchronous blocking concurrency.
var projectAnalyzerOptions = new CompilationWithAnalyzersOptions(
Copy link
Member

Choose a reason for hiding this comment

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

Would it be worthwhile to only construct the options if we knew we were going to use them?

Copy link
Member Author

Choose a reason for hiding this comment

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

yes. that would make sense. this logic exists from before. but i can make that change later!

Copy link
Member Author

Choose a reason for hiding this comment

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

Made in 3c5c686

options: project.AnalyzerOptions,
onAnalyzerException: null,
analyzerExceptionFilter: GetAnalyzerExceptionFilter(),
concurrentAnalysis: false,
logAnalyzerExecutionTime: true,
reportSuppressedDiagnostics: true);
var hostAnalyzerOptions = new CompilationWithAnalyzersOptions(
options: project.HostAnalyzerOptions,
onAnalyzerException: null,
analyzerExceptionFilter: GetAnalyzerExceptionFilter(),
concurrentAnalysis: false,
logAnalyzerExecutionTime: true,
reportSuppressedDiagnostics: true);

// Create driver that holds onto compilation and associated analyzers
return new CompilationWithAnalyzersPair(
filteredProjectAnalyzers.Any() ? compilation.WithAnalyzers(filteredProjectAnalyzers, projectAnalyzerOptions) : null,
filteredHostAnalyzers.Any() ? compilation.WithAnalyzers(filteredHostAnalyzers, hostAnalyzerOptions) : null);

Func<Exception, bool> GetAnalyzerExceptionFilter()
CyrusNajmabadi marked this conversation as resolved.
Show resolved Hide resolved
{
return ex =>
{
if (ex is not OperationCanceledException && crashOnAnalyzerException)
{
// report telemetry
FatalError.ReportAndPropagate(ex);

// force fail fast (the host might not crash when reporting telemetry):
FailFast.OnFatalException(ex);
}

return true;
};
}
}
}

[Conditional("DEBUG")]
private static void AssertCompilation(Project project, Compilation compilation1)
{
private static Task<CompilationWithAnalyzersPair?> CreateCompilationWithAnalyzersAsync(Project project, ImmutableArray<StateSet> stateSets, bool crashOnAnalyzerException, CancellationToken cancellationToken)
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 wanted to hide this method. no one should just make the CompPair. They shoudl go through the helper that returns if we have a cached one in the CWT.

=> DocumentAnalysisExecutor.CreateCompilationWithAnalyzersAsync(
project,
stateSets.SelectAsArray(s => !s.IsHostAnalyzer, s => s.Analyzer),
stateSets.SelectAsArray(s => s.IsHostAnalyzer, s => s.Analyzer),
crashOnAnalyzerException,
cancellationToken);
// given compilation must be from given project.
Contract.ThrowIfFalse(project.TryGetCompilation(out var compilation2));
Contract.ThrowIfFalse(compilation1 == compilation2);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,18 @@ namespace Microsoft.CodeAnalysis.Diagnostics;

internal partial class DiagnosticAnalyzerService
{
private partial class DiagnosticIncrementalAnalyzer
/// <summary>
/// this contains all states regarding a <see cref="DiagnosticAnalyzer"/>
/// </summary>
private sealed class StateSet
{
/// <summary>
/// this contains all states regarding a <see cref="DiagnosticAnalyzer"/>
/// </summary>
private sealed class StateSet
{
public readonly DiagnosticAnalyzer Analyzer;
public readonly bool IsHostAnalyzer;
public readonly DiagnosticAnalyzer Analyzer;
public readonly bool IsHostAnalyzer;

public StateSet(DiagnosticAnalyzer analyzer, bool isHostAnalyzer)
{
Analyzer = analyzer;
IsHostAnalyzer = isHostAnalyzer;
}
public StateSet(DiagnosticAnalyzer analyzer, bool isHostAnalyzer)
{
Analyzer = analyzer;
IsHostAnalyzer = isHostAnalyzer;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ async Task<ImmutableDictionary<DiagnosticAnalyzer, DiagnosticAnalysisResult>> Ge
return box.Value.diagnosticAnalysisResults;

// Otherwise, just compute for the state sets we care about.
var compilation = await CreateCompilationWithAnalyzersAsync(project, stateSets, Owner.AnalyzerService.CrashOnAnalyzerException, cancellationToken).ConfigureAwait(false);
var compilation = await GetOrCreateCompilationWithAnalyzersAsync(project, stateSets, Owner.AnalyzerService.CrashOnAnalyzerException, 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.

calls GetOrCreateCompilationWithAnalyzersAsync instead of CreateCompilationXXX. We want this to reuse a cached value if applicable.


var result = await Owner.ComputeDiagnosticAnalysisResultsAsync(compilation, project, stateSets, cancellationToken).ConfigureAwait(false);
return result;
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
Copy link
Contributor

Choose a reason for hiding this comment

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

Was this needed?

using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CodeActions;
Expand Down Expand Up @@ -47,11 +48,6 @@ public async Task<ImmutableArray<DiagnosticData>> GetDiagnosticsForSpanAsync(
/// </summary>
private sealed class LatestDiagnosticsForSpanGetter
{
// PERF: Cache the last Project and corresponding CompilationWithAnalyzers used to compute analyzer diagnostics for span.
// This is now required as async lightbulb will query and execute different priority buckets of analyzers with multiple
// calls, and we want to reuse CompilationWithAnalyzers instance if possible.
private static readonly WeakReference<ProjectAndCompilationWithAnalyzers?> s_lastProjectAndCompilationWithAnalyzers = new(null);

private readonly DiagnosticIncrementalAnalyzer _owner;
private readonly TextDocument _document;
private readonly SourceText _text;
Expand Down Expand Up @@ -104,44 +100,6 @@ public static async Task<LatestDiagnosticsForSpanGetter> CreateAsync(
range, priorityProvider, isExplicit, logPerformanceInfo, incrementalAnalysis, diagnosticKinds);
}

private static async Task<CompilationWithAnalyzersPair?> GetOrCreateCompilationWithAnalyzersAsync(
Project project,
ImmutableArray<StateSet> stateSets,
bool crashOnAnalyzerException,
CancellationToken cancellationToken)
{
if (s_lastProjectAndCompilationWithAnalyzers.TryGetTarget(out var projectAndCompilationWithAnalyzers) &&
projectAndCompilationWithAnalyzers?.Project == project)
{
if (projectAndCompilationWithAnalyzers.CompilationWithAnalyzers == null)
{
return null;
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: i'm virtually certain this is a bug. that's because a prior call with a DIFFERENT set of 'state sets' could have caused this to be computed and cached as 'null', while THIS set of 'state sets' should have produced a legal CompPair. But because of this check we would bail out. I've fixed this in the new code.

}

if (HasAllAnalyzers(stateSets, projectAndCompilationWithAnalyzers.CompilationWithAnalyzers))
{
return projectAndCompilationWithAnalyzers.CompilationWithAnalyzers;
}
}

var compilationWithAnalyzers = await CreateCompilationWithAnalyzersAsync(project, stateSets, crashOnAnalyzerException, cancellationToken).ConfigureAwait(false);
s_lastProjectAndCompilationWithAnalyzers.SetTarget(new ProjectAndCompilationWithAnalyzers(project, compilationWithAnalyzers));
return compilationWithAnalyzers;

static bool HasAllAnalyzers(IEnumerable<StateSet> stateSets, CompilationWithAnalyzersPair compilationWithAnalyzers)
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. instead of checking that the compWithAnalyzers has all the analyzers we're caring about. we just check that the state sets we're asking about are a subset of the last state sets we computed the CompWithAnalyzers for.

{
foreach (var stateSet in stateSets)
{
if (stateSet.IsHostAnalyzer && !compilationWithAnalyzers.HostAnalyzers.Contains(stateSet.Analyzer))
return false;
else if (!stateSet.IsHostAnalyzer && !compilationWithAnalyzers.ProjectAnalyzers.Contains(stateSet.Analyzer))
return false;
}

return true;
}
}

private LatestDiagnosticsForSpanGetter(
DiagnosticIncrementalAnalyzer owner,
CompilationWithAnalyzersPair? compilationWithAnalyzers,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public async Task<ImmutableArray<DiagnosticData>> ForceAnalyzeProjectAsync(Proje
static (stateSet, arg) => arg.self.IsCandidateForFullSolutionAnalysis(stateSet.Analyzer, stateSet.IsHostAnalyzer, arg.project),
(self: this, project));

var compilationWithAnalyzers = await CreateCompilationWithAnalyzersAsync(
var compilationWithAnalyzers = await GetOrCreateCompilationWithAnalyzersAsync(
Copy link
Member Author

Choose a reason for hiding this comment

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

calls GetOrCreateCompilationWithAnalyzersAsync instead of CreateCompilationXXX. We want this to reuse a cached value if applicable.

project, fullSolutionAnalysisStateSets, AnalyzerService.CrashOnAnalyzerException, cancellationToken).ConfigureAwait(false);

var projectAnalysisData = await ComputeDiagnosticAnalysisResultsAsync(compilationWithAnalyzers, project, fullSolutionAnalysisStateSets, cancellationToken).ConfigureAwait(false);
Expand Down
Loading