C# performance guide (v0)
Fewer reallocating lists, quieter hot paths.
Scope: modern C# (.NET). Speedup claims cite a local bench, an upstream source, or are marked provisional. Corpus residual counts are lint hit counts, not wall-time savings.
Profile first (BenchmarkDotNet, dotnet-trace). Runtime and GC settings differ — re-bench.
Contents
1. Lists and strings
List.Add in a loop without setting Capacity reallocates. Prefer sizing once. String += in a tight loop allocates; prefer StringBuilder.
Calling static Regex.IsMatch / Regex.Match with a pattern string on every iteration looks the pattern up in the static regex cache each time; prefer one reused Regex instance (RegexOptions.Compiled when the pattern is hot).
Lint: cs-list-add-no-capacity, cs-string-plus-hot (default off); cs-regex-hot (default hint).
2. Hot I/O
Console.Write / WriteLine in a tight loop is expensive. Gate debug output or buffer.
Lint: cs-console-hot (default off).
3. Materialize and format
ToList / ToArray / ToDictionary inside a loop allocates a fresh collection every iteration. string.Format inside a loop allocates a new string every iteration. JsonSerializer.Serialize / Deserialize and JsonConvert.SerializeObject / DeserializeObject inside a loop encode or decode on every iteration. cs-json-hot stays hint. The microbench is a fixed payload. It is not part of the 2026-10-22 corpus-ready verdict.
File.ReadAllText of one path was about 338× on .NET 10 versus reading once. Stopwatch.GetTimestamp was about 37× versus one sample. new Regex every iteration, with both sides still matching, was about 7× and is the same hint as the static Regex.IsMatch rule. A changing path cannot be hoisted, and a loop that needs a fresh timestamp has nothing to hoist.
GetField plus GetValue every iteration was about 62× versus reading the field. Holding the FieldInfo and still calling GetValue was about 42× versus the field, so moving only GetField outside is a small change. A name that changes every iteration cannot be turned into a direct field. This is the same family as Go FieldByName.
Lint: cs-linq-materialize-hot, cs-format-hot (default hint; pending measure); cs-json-hot, cs-regex-hot, cs-file-read-hot, cs-clock-hot, cs-field-by-name-hot (default hint).
Lint map
| ID | Smell | Default |
|---|---|---|
| cs-list-add-no-capacity | List.Add in loop without Capacity | off |
| cs-string-plus-hot | string += in loop | off |
| cs-console-hot | Console.Write* in loop | off |
| cs-regex-hot | static Regex.IsMatch/Match in loop | hint |
| cs-linq-materialize-hot | ToList / ToArray / ToDictionary in loop | hint (pending measure) |
| cs-format-hot | string.Format in loop | hint (pending measure) |
| cs-json-hot | JSON encode/decode in loop | hint (JSONL, fixed payload only) |
Escalate only with evidence/<rule>.jsonl — see evidence-policy.md.