dotnet / dotnet/sdk

CA1303, CA1508, CA5390 and CA5403 are quadratic in the number of call sites into a method: 1.0s without the rule, 66.8s with it

Open
#56,280 1 comment 0 reactions 1 assignee Claimed by @jaredpar View on GitHub
Area-Microsoft.CodeAnalysis.NetAnalyzers Bug performance
Dominant language
C#
Stars
3.2k
Forks
1.3k
PR merge metrics
PR metrics pending

Description

Filed originally as dotnet/roslyn-analyzers#7849 and moved here per that repository's consolidation notice — the `Microsoft.CodeAnalysis.NetAnalyzers` package, which ships all four rules below, moved into `dotnet/sdk`. The measurements are unchanged; the original issue holds the comment that sent it here.

## Describe the bug

A generated single-file program whose entry method calls one small helper N times costs **O(N²)** in `CoreCompile` when any one of `CA1303`, `CA1508`, `CA5390` or `CA5403` is enabled, while the same file with the rule off is linear in N. At 1,096 call sites into a 20-line method, the file compiles in **1.0 second** with the rule off and **66.8 seconds** with it on.

The helper is 20 lines and the file has one call graph three levels deep. Nothing in it is generated-looking, deeply nested, or unusually branchy.

## Steps to reproduce

Generate the file with this script — `python3 gen.py repro.cs 1096`:

```python
import sys, pathlib
out, C = sys.argv[1], int(sys.argv[2])
b = ['using System.Text;\n', 'return Probe() >= 0 ? 0 : 1;\n']
b.append('''static Tally Scan(string content, out int flagged)
{
flagged = 0;
int hits = 0, misses = 0;
StringBuilder trace = new();
foreach (char c in content)
{
if (char.IsDigit(c)) { hits++; trace.Append(c); }
else if (char.IsLetter(c)) { misses++; trace.Append('.'); }
else if (c == ' ') { flagged++; }
else { trace.Append('?'); }

if (hits > 4 && misses > 2) { flagged += hits - misses; }
else if (trace.Length > 8) { flagged ^= trace.Length; }
}

byte[] raw = Encoding.UTF8.GetBytes(trace.ToString());
for (int i = 0; i < raw.Length && i < 16; i++) { flagged += raw[i]; }
return new Tally(hits, misses, trace.ToString());
}

static int Probe()
{
int failures = 0;

void Check(string label, string content, int hits, int misses)
{
Tally got = Scan(content, out int flagged);
if (got.Hits == hits && got.Misses == misses && flagged >= 0) { return; }
failures++;
Console.Error.WriteLine($"{label} gave hits={got.Hits} misses={got.Misses} and wants hits={hits} misses={misses}.");
}
''')
for c in range(C):
b.append(f' Check("case{c}", "sample {c} text", {c % 7}, {c % 5});')
b.append(' return failures;\n}\n')
b.append('readonly record struct Tally(int Hits, int Misses, string Trace);')
pathlib.Path(out).write_text("\n".join(b) + "\n")
```

Put this `.editorconfig` beside it:

```ini
root = true

[*.cs]
dotnet_diagnostic.CA5390.severity = warning
```

Then compare:

```
dotnet build repro.cs -p:AnalysisLevel=latest-recommended -clp:PerformanceSummary
```

with and without the `dotnet_diagnostic` line, reading the `CoreCompile` row from the performance summary. `-p:AnalysisLevel=latest-recommended` keeps every other rule in the `latest-all` set off, so the delta is that one rule.

## Expected behavior

Enabling one of these rules costs time proportional to the code it analyzes, so doubling the call sites into a helper roughly doubles the rule's contribution to `CoreCompile` — the way the same file behaves with the rule off.

## Actual behavior

Doubling the call sites roughly quadruples the rule's time, and the constant is large enough that a single method with a few hundred calls into a 20-line helper costs a minute of build time per rule.

`CA5390` alone, against the same file built with no rule from the `latest-all` set enabled:

| call sites | file lines | rule off | CA5390 on | ratio |
| --- | --- | --- | --- | --- |
| 137 | 178 | 0.14s | 1.36s | 10x |
| 274 | 315 | 0.27s | 4.20s | 16x |
| 548 | 589 | 0.50s | 16.5s | 33x |
| 1096 | 1137 | 1.00s | 66.8s | 67x |

Doubling the call sites doubles the rule-off time and roughly quadruples the rule-on time (3.1x, 3.9x, 4.0x across the three steps).

Four rules show it, measured on the 548 call-site file:

| rule | CoreCompile |
| --- | --- |
| none of the set enabled | 0.52s |
| CA5390 (do not hard-code encryption key) | 17.0s |
| CA5403 (do not hard-code certificate) | 16.7s |
| CA1508 (avoid dead conditional code) | 16.6s |
| CA1303 (do not pass literals as localized parameters), armed | 16.9s |

Each is enough on its own, and they are additive when combined.

**`CA1303` needs one sink before it costs anything, and then it costs the same as the others.** On the file exactly as generated it stays at 0.52s, because nothing passes a literal to a parameter it considers localizable — `Console.Error.WriteLine` is a `TextWriter` method and not one of its sinks. Adding a single line to `Probe`:

```csharp
Console.WriteLine("a plain literal on stdout.");
```

takes it from 0.52s to 16.9s on the same 548 call sites, and it then scales like the rest: 1.26s at 137, 4.42s at 274, 16.9s at 548, 68.2s at 1,096. A parameter marked `[Localizable(true)]` receiving a literal arms it identically (17.1s). One literal in the file decides whether the rule costs milliseconds or a minute, and the cost lands on call sites that have nothing to do with that literal.

## Is this a regression?

Not measured against an earlier SDK. dotnet/roslyn-analyzers#7125 reports CA1508 getting slower in .NET 8 than in .NET 7, so the growth may predate this SDK; what is measured here is the shape of the growth rather than a change in it.

## Are there any workarounds?

`dotnet_diagnostic..severity = none` skips the analyzer and restores the rule-off time. `NoWarn` does not, because it suppresses the output after the analyzer has run. For `CA1303` specifically, removing the one literal that arms it returns the file to the rule-off time, which is a workaround only by accident.

## dotnet --info output

- .NET SDK 10.0.400, analyzers as shipped with that SDK
- macOS, arm64

## Notes

- `NoWarn` does not change any of this, because it suppresses output without skipping the analyzer. `severity = none` is what skips it.
- It is not rule count. Each of the three rules above reaches tens of seconds on its own, with every other rule in the set off.
- The helper's shape matters, not just the file's size. A variant whose helper was a chain of small methods with no loop-carried state showed no growth at all: at 100 call sites the rule added under 0.1s over a 0.25s baseline.

## Question

Is quadratic growth in call-site count expected for these rules, or is an interprocedural result being recomputed per call site where it could be cached? The practical effect is that one method with a few hundred calls into a helper costs a minute of build time per rule.

## Possibly related

Same dataflow machinery, found after filing. None of them names `CA5390` or `CA5403`, and none reports growth against call-site count:

- dotnet/roslyn-analyzers#4914 — improve performance of CA1508, open since 2021 and labelled `Performance` / `DataFlow`
- dotnet/roslyn-analyzers#4656 — AvoidDeadConditionalCode slowing down a build on a couple of thousand lines
- dotnet/roslyn-analyzers#7125 — CA1508 performance decrease in .NET 8 against .NET 7
- dotnet/roslyn-analyzers#7209 and dotnet/roslyn-analyzers#2790 — CA1303 slowing a build badly, and hanging
- dotnet/roslyn-analyzers#4915 and dotnet/roslyn-analyzers#7134 — CA2000 and DisposeObjectsBeforeLosingScope, which share the interprocedural analysis
- dotnet/roslyn-analyzers#7455 — measuring the cost of individual diagnostics

Happy for this to be folded into any of them if the maintainers read it as the same root cause.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.