Skip to content
Open
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
15 changes: 15 additions & 0 deletions src/.idea/.idea.Acuminator/.idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/.idea/.idea.Acuminator/.idea/.name

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions src/.idea/.idea.Acuminator/.idea/indexLayout.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/.idea/.idea.Acuminator/.idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ public abstract class NestedInvocationWalker : CSharpSyntaxWalker

private readonly ISet<(SyntaxNode, DiagnosticDescriptor)> _reportedDiagnostics = new HashSet<(SyntaxNode, DiagnosticDescriptor)>();

private readonly SymbolInfoCache _cache;
Comment thread
gelverpl marked this conversation as resolved.

/// <summary>
/// Cancellation token
/// </summary>
Expand Down Expand Up @@ -87,6 +89,8 @@ protected NestedInvocationWalker(PXContext pxContext, CancellationToken cancella

//Use lazy to avoid calling virtual methods inside the constructor
_typesToBypass = new Lazy<HashSet<INamedTypeSymbol>>(valueFactory: GetTypesToBypass, isThreadSafe: false);

_cache = new SymbolInfoCache(GetType());
}

/// <summary>
Expand Down Expand Up @@ -119,20 +123,22 @@ protected virtual HashSet<INamedTypeSymbol> GetTypesToBypass() =>
protected virtual T? GetSymbol<T>(ExpressionSyntax node)
where T : class, ISymbol
{
var semanticModel = GetSemanticModel(node.SyntaxTree);

if (semanticModel != null)
SymbolInfo? cached = _cache.GetOrCreate(node, () =>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Here should be the retrieval of the symbol from cache, not SymbolInfo.
As I wrote in https://github.com/Acumatica/Acuminator/pull/668/changes#r3197690911, cache should keep ISymbol?.

Also, since the symbol is not always cached, IMHO it's better to call it just symbol

{
var symbolInfo = semanticModel.GetSymbolInfo(node, CancellationToken);
SemanticModel? semanticModel = GetSemanticModel(node.SyntaxTree);
return semanticModel?.GetSymbolInfo(node, CancellationToken);
});

if (symbolInfo.Symbol is T symbol)
if (cached is not null)
{
if (cached.Value.Symbol is T symbol)
{
return symbol;
}

if (!symbolInfo.CandidateSymbols.IsEmpty)
if (!cached.Value.CandidateSymbols.IsEmpty)
{
return symbolInfo.CandidateSymbols.OfType<T>().FirstOrDefault();
return cached.Value.CandidateSymbols.OfType<T>().FirstOrDefault();
}
}

Expand Down Expand Up @@ -204,8 +210,16 @@ public override void VisitInvocationExpression(InvocationExpressionSyntax node)

public override void VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
{
VisitPropertyOrIndexerAccessExpression(node);
base.VisitMemberAccessExpression(node);
if (node.Parent is InvocationExpressionSyntax invocation && invocation.Expression == node)
{
// we already visit this node by VisitInvocationExpression, so we just skip it here
base.VisitMemberAccessExpression(node);
}
else
{
VisitPropertyOrIndexerAccessExpression(node);
base.VisitMemberAccessExpression(node);
}
}

public override void VisitElementAccessExpression(ElementAccessExpressionSyntax node)
Expand Down
145 changes: 145 additions & 0 deletions src/Acuminator/Acuminator.Utilities/Roslyn/SymbolInfoCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Acuminator.Utilities.Roslyn;

/// <summary>
/// Caches Roslyn symbol lookup results for expression syntax nodes visited by a syntax walker.
Copy link
Copy Markdown
Collaborator

@SENya1990 SENya1990 May 6, 2026

Choose a reason for hiding this comment

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

IMHO, this can be already a bit generalized to be lookup for syntax nodes, not just expression syntax nodes. There are many other types of nodes that can be passed to the semantic model:
https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.semanticmodel?view=roslyn-dotnet-5.0.0

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The only user of this cache is GetSymbol<T>(ExpressionSyntax node), And no one else should use it.
Let me point out that the critical issue I fixed alongside introducing the cache in the VisitMemberAccessExpression method was caused by over-generalization. I disagree with your point.

Won't be fixed.

/// </summary>
public sealed class SymbolInfoCache
{
private readonly Dictionary<ExpressionSyntax, SymbolInfo> _map = new();
Copy link
Copy Markdown
Collaborator

@SENya1990 SENya1990 May 6, 2026

Choose a reason for hiding this comment

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

@gelverpl we should cache ISymbol?, there is no need to keep intermediate symbol info DTO.

#if SYMBOL_INFO_CACHE_STATISTICS
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@gelverpl we spoke today about using some kind of feature flags for performance profiling features. Did you decide not to use it in the end?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I was more talking about enabling/disabling certain optimizations to make the measurement process more convenient. As for these statistics, I think the only person who will need them is me. So I don't see any need for anything more sophisticated here.

private readonly SymbolInfoCacheStatistics _statistics;
#endif

public SymbolInfoCache(Type owner)
{
#if SYMBOL_INFO_CACHE_STATISTICS
_statistics = SymbolInfoCacheStatisticsContext.Register(owner);
#endif
}

/// <summary>
/// Gets the cached symbol information for the specified expression, or creates and stores it using the provided factory.
/// </summary>
public SymbolInfo? GetOrCreate(ExpressionSyntax key, Func<SymbolInfo?> factory)
{
if (_map.TryGetValue(key, out SymbolInfo cached))
{
#if SYMBOL_INFO_CACHE_STATISTICS
Copy link
Copy Markdown
Collaborator

@SENya1990 SENya1990 May 6, 2026

Choose a reason for hiding this comment

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

All counting methods can be annotated with the System.Diagnostics.ConditionalAttribute instead of using pragma directives at their every usage.
https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.conditionalattribute?view=netstandard-2.0

It won't compile without the pragma directive as is though. So, to make it compile and get rid of to many pragmas, maybe you can always keep the field with statistics object and for regular builds initialize it to null or an empty static object.

_statistics.Hit();
#endif
return cached;
}

#if SYMBOL_INFO_CACHE_STATISTICS
_statistics.Miss();
#endif

SymbolInfo? potentialValue = factory();
if (potentialValue is not null)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why not caching null results? It's not like the semantic model will return a different result with another call.

{
_map[key] = potentialValue.Value;
#if SYMBOL_INFO_CACHE_STATISTICS
_statistics.Set(key.GetType());
#endif
return potentialValue;
}

return null;
}

#if SYMBOL_INFO_CACHE_STATISTICS
public sealed class SymbolInfoCacheStatistics
Copy link
Copy Markdown
Collaborator

@SENya1990 SENya1990 May 6, 2026

Choose a reason for hiding this comment

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

Should be moved to a separate file.
The type should probably be marked as internal instead of public.

{
private readonly Dictionary<Type, long> _byTypeCounter = new();
Comment thread
gelverpl marked this conversation as resolved.
private readonly Type _owner;
private readonly int _cacheId;

private long _hitsTotal;
private long _missesTotal;
private long _setsTotal;

public SymbolInfoCacheStatistics(int cacheId, Type owner)
{
_owner = owner;
_cacheId = cacheId;
}

public void Hit() => _hitsTotal++;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

All counting methods should be annotated with the System.Diagnostics.ConditionalAttribute instead of using pragma directives at their every usage.
https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.conditionalattribute?view=netstandard-2.0


public void Miss() => _missesTotal++;

public void Set(Type type)
Comment thread
gelverpl marked this conversation as resolved.
{
if (!_byTypeCounter.ContainsKey(type))
{
_byTypeCounter[type] = 1;
}
else
{
_byTypeCounter[type]++;
}
_setsTotal++;
}
Comment on lines +73 to +88
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This shouldn't be a problem for now since each instance of symbol cache and related statistic is owned by only one dedicated nested invocation walker which is executed only by one thread.

Later, when this cache will be expanded to be shared with all analyzers, this indeed will be a problem.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This cache is strictly reserved for its intended code and must not be accessed or reused elsewhere.

When and if we decide to implement some kind of shared cache, it will be a completely new and unrelated implementation.

Won't be fixed.


public StatisticsSnapshot GetSnapshot()
{
var lookupsTotal = _hitsTotal + _missesTotal;
return new StatisticsSnapshot(
_cacheId,
_owner,
_hitsTotal,
_missesTotal,
_setsTotal,
_byTypeCounter,
CalcRatio(_hitsTotal, lookupsTotal),
CalcRatio(_missesTotal, lookupsTotal)
);
Comment on lines +90 to +102
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@gelverpl this is a good point. It's not really a snapshot if you are passing the original reference which will be mutated after the creation of the snapshot.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Actually, this is not. All of the above is irrelevant, as no one collects (and should never do) these statistics except the person who requests these statistics in debug mode.
I've broken a lot of rules while working on this code, since the only situation in which this code runs is a controlled run in a debugger. Furthermore, I suspect the only "consumer" of this code will be me.
The plan is to get rid of cache hits -> get rid of statistics -> get rid of cache.

Won't be fixed.

Copy link
Copy Markdown
Collaborator

@SENya1990 SENya1990 May 7, 2026

Choose a reason for hiding this comment

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

All of the above is irrelevant, as no one collects (and should never do) these statistics except the person who requests these statistics in debug mode.

@gelverpl you can't guarantee that or predict who will work on this code in two-three years.

I've broken a lot of rules while working on this code, since the only situation in which this code runs is a controlled run in a debugger. Furthermore, I suspect the only "consumer" of this code will be me.

Again:

  1. The support and work on this code only by yourself is not guaranteed, at least from my POV. This is the first contribution and with all due respect for the contribution, as a maintainer I just don't have enough info to predict whether there will be other contributions. There are things like the bus factor too. So, I would prefer to keep the code consistent. Considering that making a copy of a dictionary is simple, saying that somewhere in the future it will be removed IMHO is not enough to justify this.

  2. I wouldn't be so sure that the symbol cache will be deleted. IMHO, it can be reworked and extended, so it will be reused by all analyzers which access the same syntax tree and call GetSymbolInfo on its nodes. In that case this no longer will be a situation when some analyzer redundantly checks the same node twice.

  3. In general, "only I will work on this code" is not a good justification for breaking the rules. Especially, if the fix is quite simple and there is no urgent deadline.

  4. Nothing says that these statistics should be used only in the debug mode (a comment will be good).
    And even if you use them in debug mode, you still won't have a correct snapshot for _byTypeCounter in the general case. Suppose, that you want to compare statistics during the debug between two snapshots, 1 and 2 and see the difference in the collected _byTypeCounter values. You won't be able to see it since both snapshots keep the reference to the same dictionary.

}

private static double CalcRatio(long value, long total) => total == 0 ? 0 : (double)value / total;
}

public static class SymbolInfoCacheStatisticsContext
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@gelverpl please use 1 type per file convention (except maybe 1 line records types) and move this to a separate file.

{
private static readonly object Gate = new();
Copy link
Copy Markdown
Collaborator

@SENya1990 SENya1990 May 6, 2026

Choose a reason for hiding this comment

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

For private fields the current convention is to use underscore-prefixed camel case.

Also it's not very important, but the established naming convention for these objects is to call them _locker

private static readonly List<SymbolInfoCacheStatistics> Statistics = new();
private static int _nextCacheId;

internal static SymbolInfoCacheStatistics Register(Type owner)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@gelverpl there can be multiple statistics registered for the same owner (walker). Is it okay to not aggregate them into one statistic?

{
var cacheId = Interlocked.Increment(ref _nextCacheId);
var statistics = new SymbolInfoCacheStatistics(cacheId, owner);

lock (Gate)
{
Statistics.Add(statistics);
}

return statistics;
}

Comment thread
SENya1990 marked this conversation as resolved.
public static StatisticsSnapshot[] GetStatistics()
{
lock (Gate)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is not a big improvement, since GetSnapshot doesn't do a lot currently, but you can reduce the time under the lock in this algorithm, if you make under the lock a copy of the Statistics list. Then you can call GetSnapshot on the copy of the list without locking the Statistics collection.

{
var result = new StatisticsSnapshot[Statistics.Count];
for (var i = 0; i < Statistics.Count; i++)
{
result[i] = Statistics[i].GetSnapshot();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since it's a static context, it can be shared by multiple analyzers running on different threads. Which means that while the call to GetStatistics is executed on one thread, the other thread may update the counters for a concrete statistic. This make this collection a bit different from a full snapshot and makes the measurement a bit inaccurate - snapshot for one statistic is collected at the moment X, snapshot for the next statistic will be collected for the moment X + some small delta.

For full snapshots you would need to use sync the updates of the counters with the retrieval of statistics... which will make things more difficult for us and introduce undesirable threads synchronization.

I think it's OK to leave it as is since we are not micro benchmarking and looking for milliseconds. But this should be a conscious decision, so I think a comment about this decision should be added here describing it.

}

return result;
}
}
}

public record StatisticsSnapshot(int CacheId, Type Owner, long HitsTotal, long MissesTotal, long SetsTotal, Dictionary<Type, long> ByTypeCounter, double HitRatio, double MissRatio);
Copy link
Copy Markdown
Collaborator

@SENya1990 SENya1990 May 6, 2026

Choose a reason for hiding this comment

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

Imho, this DTO can also be in a separate file, but I won't mind if it will be placed in a file with the SymbolInfoCacheStatistics type.

And it also should be internal.

#endif
}