Make the invocation-based analyzers 15-35x faster - #153
Open
petrikero wants to merge 1 commit into
Open
Conversation
petrikero
force-pushed
the
perf/prefilter-invocations-before-binding
branch
from
August 15, 2026 06:51
e653cb8 to
11bc378
Compare
Twelve of the sixteen analyzers register on SyntaxKind.InvocationExpression and
call GetSymbolInfo() first, so each binds every call site in the compilation
before discovering that the method is not one of the few the rule matches. On one
170k-line project that was 30.2s of 105.3s of analyzer time; on a 30k-line project
of mostly generated EF Core migrations, 32.5s of 36.3s -- 89% of analyzer time,
and two thirds of the whole compile.
The invoked method's simple name is available syntactically and rejects nearly
every call site, so check it first. InvokedSimpleName(), MethodNames() and
CouldInvokeAnyOf() in CodeAnalysisExtensions do that; the name set is built once
per compilation from the reference symbols each analyzer already resolves, so it
tracks those automatically.
All twelve get the pre-filter, and their reference symbol lookups move out of the
per-node action. The lookups that cost the most:
- AK1006 can skip registration entirely when Akka.Persistence is absent, and its
Persist.AddRange(PersistAsync) allocated an ImmutableArray per call site.
- AK2007's GetAllAggregateMethods() built a ten-element List and copied it into an
ImmutableArray per call site.
- AK1007 rebuilt two arrays per call site; AK2001, AK2003, AK2004 and AK2005 each
called GetTypeByMetadataName per call site.
AK1002 also moves its `Parent is not AwaitExpressionSyntax` test first: pure
syntax, and it discards almost everything. AK1004 was the worst offender overall
-- before binding the invocation it resolved the enclosing class symbol, for every
call site in the compilation.
Measured with csc run from a captured response file, comparing only the analyzer
assembly (process CPU, min of 4 runs):
Akka self-time whole compile
170k-line project 30.2s -> 3.7s 125.4s -> 103.0s (99.8s without it)
30k-line project 32.5s -> 1.5s 34.5s -> 11.6s (11.0s without it)
That is 88% and 97% of what removing the assembly entirely would save. Those runs
predate converting AK1003, AK1004, AK1007, AK2000, AK2001 and AK2007, and the
residual 3.7s and 1.5s is largely what those six were still costing, so the table
understates this branch.
The four analyzers not registered on InvocationExpression -- AK1000, AK1005,
AK2002 and AK2006 -- are untouched.
No behavioural change intended: the pre-filter only skips nodes the subsequent
symbol comparison would have rejected. Akka.Analyzers.Tests passes 329/331 with
2 pre-existing skips.
petrikero
force-pushed
the
perf/prefilter-invocations-before-binding
branch
from
August 15, 2026 07:32
11bc378 to
8b31f92
Compare
petrikero
marked this pull request as ready for review
August 15, 2026 10:53
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this speeds up
Twelve of the sixteen analyzers register on
SyntaxKind.InvocationExpressionand callGetSymbolInfo()as their first statement. That binds every call site in the compilation before discovering that the method is not one of the few the rule matches. On a large tree that is seconds of CPU per analyzer to answer "no".Akka.Analyzers self-time, measured with
ReportAnalyzer=true:Akka.TestsAkka.StreamsPer rule, the six heaviest go from seconds to milliseconds — on
Akka.Tests: AK1002 4.488s → 0.013s, AK1006 2.099s → 0.003s, AK2005 2.034s → 0.002s, AK1008 2.012s → 0.199s, AK2004 1.925s → 0.068s, AK2003 1.808s → 0.036s.Measured by injecting each build of the analyzer into a real build — one project per run,
Rebuild, shared compilation off, ABBA-ordered, min of 9. The effect is far outside the measurement error: the per-iteration samples do not overlap at all (e.g.Akka.Tests:dev15.8–30.4s across nine runs, branch 1.0–2.1s).How
The invoked method's simple name is available syntactically and rejects nearly every call site, so check it first.
InvokedSimpleName(),MethodNames()andCouldInvokeAnyOf()inCodeAnalysisExtensions. The name set is built once per compilation from the reference symbols each analyzer already resolves, so it tracks those automatically; AK2000 and AK2001 match a single literal name instead.Persist.AddRange(PersistAsync)allocated anImmutableArrayper call site; AK2007'sGetAllAggregateMethods()built a ten-elementListper call site; AK1007 rebuilt two arrays per call site; AK2001, AK2003, AK2004 and AK2005 each calledGetTypeByMetadataNameper call site.Parent is not AwaitExpressionSyntaxtest first: pure syntax, and it discards almost everything.The pre-filter is deliberately conservative (
CouldInvoke, notDoesInvoke). It sits in front of the existing symbol comparisons, which are unchanged, so it can only skip nodes those comparisons would have rejected.The four analyzers not registered on
InvocationExpression— AK1000, AK1005, AK2002, AK2006 — are untouched.Testing
No behaviour change intended, and no test changes: the existing suite is the correctness claim.
Akka.Analyzers.Testspasses 329/331 with the 2 pre-existing skips.Every benchmark run above also compared the
AK####diagnostics emitted by the two builds; they were identical in all of them. That is the check that matters most here, since a pre-filter bug surfaces as a silently missing diagnostic rather than a failure.For reviewers
The one way this regresses is a call syntax that
InvokedSimpleName()returnsnullfor but the old code would have flagged. It handlesa.Foo(),a?.Foo(),Foo()andFoo<T>(); everything else —Foo()(),x[0](), parenthesized method groups — binds to a delegate'sInvokerather than a target method, so the symbol comparison rejected those already.If you reproduce the benchmark, note that a project which already gets
Akka.Analyzersfrom aPackageReferencewill silently shadow an injected same-named DLL: the SDK drops the duplicate and csc loads the NuGet copy for both variants, which produces a very convincing null result. Check the analyzer version in theReportAnalyzeroutput matches the build you think you are measuring.Disclaimer: drafted with AI assistance, reviewed by me. All numbers above are real measurements from actual builds.