Performance and Benchmarking in .NET
Make .NET code fast the disciplined way: set goals, measure with BenchmarkDotNet and the dotnet diagnostic tools, cut allocations, pick the right data structures, and tune the runtime only when data says so.
🎯 What you'll learn
- Choosing the right measurement tool
- Writing trustworthy BenchmarkDotNet benchmarks
- Common, measurable optimizations
- Runtime settings: GC modes, PGO, AOT
📚 Before you start
1Measure First
Performance work starts with a goal (p99 < 200 ms, 5,000 req/s per instance) and measurements, not guesses:
| Question | Tool |
|---|---|
| Is a function faster after my change? | BenchmarkDotNet micro-benchmarks |
| Where does CPU time go in the app? | dotnet-trace + PerfView/Speedscope, Visual Studio/Rider profilers |
| Is memory/GC the problem? | dotnet-counters (allocation rate, GC count, % time in GC), dotnet-gcdump |
| Which requests are slow and why? | APM / OpenTelemetry traces |
| How does it behave under load? | k6, NBomber, bombardier |
2BenchmarkDotNet
[MemoryDiagnoser]
public class ParsingBenchmarks
{
private readonly string _line = "10,20,30,40,50";
[Benchmark(Baseline = true)] public int Split() => _line.Split(',').Sum(int.Parse);
[Benchmark] public int Span() => SumCsv(_line);
}
// dotnet run -c Release → Mean, Error, StdDev, Allocated per operationIt handles warm-up, JIT tiers, many iterations and statistics. Always run in Release, without a debugger, and compare against a baseline. A Stopwatch around one call is not a benchmark.
3Common Wins (When Measured)
- Fewer allocations: avoid string concatenation in loops (use
StringBuilderwith capacity orstring.Create), avoid LINQ in hot paths, reuse buffers (ArrayPool), pool objects (ObjectPool<T>), use spans for parsing. - Right data structures:
Dictionary/HashSetlookups instead of list scans;FrozenDictionaryfor read-mostly data; pre-size collections. - Avoid exceptions for control flow:
TryParse, result types. - Async I/O everywhere on servers; batching database calls; caching.
- Serialization:
System.Text.Jsonwith source generators avoids reflection. - Logging: use structured logging with
LoggerMessagesource generators; checkIsEnabledbefore building expensive messages.
4Runtime Settings
- Server GC for throughput; consider DATAS to reduce memory in containers.
- Dynamic PGO (on by default since .NET 8) optimizes hot paths using runtime profiles.
- ReadyToRun / Native AOT for startup-sensitive apps.
- Keep up with .NET versions: each release brings significant performance improvements for free.
5Reading Results Honestly
Look at the distribution (p95/p99), not just averages; check allocations alongside time; make sure the benchmark does the same work as production (realistic input sizes); beware of the JIT eliminating dead code (return results from benchmarks).
⚠️ Common Pitfalls
- Optimizing without profiling: speeding up code that isn't the bottleneck.
- Benchmarking in Debug or with a debugger attached.
- Micro-optimizing at the cost of readability where it doesn't matter.
- Ignoring allocations: GC time shows up as latency spikes.
- Caching without bounds or invalidation.
📋 Quick Reference
goal → measure → profile → fix the biggest bottleneck → measure again
BenchmarkDotNet (Release, [MemoryDiagnoser], baseline) · dotnet-counters / dotnet-trace / gcdump
wins: fewer allocations (StringBuilder, spans, pools), right collections, no exceptions as control flow,
async I/O, STJ source generators, LoggerMessage · Server GC + DATAS · PGO · upgrade .NET🧠 Key takeaways
- Start from a goal and measurements; profile before optimizing.
- Use BenchmarkDotNet in Release with [MemoryDiagnoser] and a baseline.
- Typical wins: fewer allocations, right collections, async I/O, source-generated serialization and logging.
- Server GC, DATAS, PGO and newer .NET versions give free speed.
🎤 Interview Q&A
Q1How do you approach a performance problem in a .NET service?core▾
Define the target, reproduce under realistic load, look at traces/APM to find slow requests, profile CPU (dotnet-trace) and memory/GC (dotnet-counters, gcdump), fix the largest bottleneck, and verify with benchmarks and load tests.
- Most common bottleneck? Database access patterns, then allocations/GC and blocking calls.
Q2How do you write a reliable micro-benchmark?core▾
BenchmarkDotNet in Release without a debugger, [MemoryDiagnoser], a baseline method, realistic inputs, returning results to prevent dead-code elimination, and multiple runtimes if relevant.
- Why not Debug builds? No JIT optimizations; results are meaningless.
Q3Name allocation-reducing techniques.core▾
StringBuilder/string.Create, spans for parsing, ArrayPool/ObjectPool, avoiding LINQ and boxing in hot paths, pre-sizing collections, caching results, struct enumerators, and source-generated serializers/loggers.
- Why do allocations matter? More GC work and pauses; tail latency suffers.
Q4When does Server GC help or hurt?senior▾
It maximizes throughput on multi-core servers but uses more memory (heap per core). In memory-constrained containers, enable DATAS or limit heap count.
- GCHeapAffinitizeMask / heap count? Advanced knobs for dense hosting.
Q5What is dynamic PGO?core▾
Profile-guided optimization at runtime: tier-0 code collects profiles (hot paths, common types), and tier-1 recompiles with optimizations like guarded devirtualization and inlining. On by default since .NET 8.
- Effect? Often double-digit percent improvements without code changes.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.
s += x in a loop over 10,000 items is…