Skip to content

Make the invocation-based analyzers 15-35x faster - #153

Open
petrikero wants to merge 1 commit into
akkadotnet:devfrom
petrikero:perf/prefilter-invocations-before-binding
Open

Make the invocation-based analyzers 15-35x faster#153
petrikero wants to merge 1 commit into
akkadotnet:devfrom
petrikero:perf/prefilter-invocations-before-binding

Conversation

@petrikero

@petrikero petrikero commented Aug 15, 2026

Copy link
Copy Markdown

What this speeds up

Twelve of the sixteen analyzers register on SyntaxKind.InvocationExpression and call GetSymbolInfo() 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:

Target Akka.Analyzers self-time
Akka.Tests 15.83s → 1.01s −93.6%
Akka.Streams 7.55s → 0.53s −92.9%
a ~150k-line Akka.NET server codebase 24.40s → 0.70s −97.1%

Per 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: dev 15.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() and CouldInvokeAnyOf() in CodeAnalysisExtensions. 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.
  • 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 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.

The pre-filter is deliberately conservative (CouldInvoke, not DoesInvoke). 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.Tests passes 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() returns null for but the old code would have flagged. It handles a.Foo(), a?.Foo(), Foo() and Foo<T>(); everything else — Foo()(), x[0](), parenthesized method groups — binds to a delegate's Invoke rather than a target method, so the symbol comparison rejected those already.

If you reproduce the benchmark, note that a project which already gets Akka.Analyzers from a PackageReference will 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 the ReportAnalyzer output 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.

@petrikero
petrikero force-pushed the perf/prefilter-invocations-before-binding branch from e653cb8 to 11bc378 Compare August 15, 2026 06:51
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
petrikero force-pushed the perf/prefilter-invocations-before-binding branch from 11bc378 to 8b31f92 Compare August 15, 2026 07:32
@petrikero petrikero changed the title Filter invocations syntactically before binding them Make the invocation-based analyzers 15-35x faster Aug 15, 2026
@petrikero
petrikero marked this pull request as ready for review August 15, 2026 10:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant