Engineering Notes
Home/Languages/C#/Performance and Benchmarking in .NET
Languages · C#

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.

💻 Languages● Advanced⏱ 3 min read🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Choosing the right measurement tool
  • Writing trustworthy BenchmarkDotNet benchmarks
  • Common, measurable optimizations
  • Runtime settings: GC modes, PGO, AOT

1Measure First

Performance work starts with a goal (p99 < 200 ms, 5,000 req/s per instance) and measurements, not guesses:

QuestionTool
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

csharp
[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 operation

It 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 StringBuilder with capacity or string.Create), avoid LINQ in hot paths, reuse buffers (ArrayPool), pool objects (ObjectPool<T>), use spans for parsing.
  • Right data structures: Dictionary/HashSet lookups instead of list scans; FrozenDictionary for 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.Json with source generators avoids reflection.
  • Logging: use structured logging with LoggerMessage source generators; check IsEnabled before 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

  1. Optimizing without profiling: speeding up code that isn't the bottleneck.
  2. Benchmarking in Debug or with a debugger attached.
  3. Micro-optimizing at the cost of readability where it doesn't matter.
  4. Ignoring allocations: GC time shows up as latency spikes.
  5. Caching without bounds or invalidation.

📋 Quick Reference

text
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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

1. Why is a Stopwatch around one call a poor benchmark?
Use BenchmarkDotNet.
2. s += x in a loop over 10,000 items is…
Use StringBuilder.
3. Which tool shows allocation rate and GC counts live?
Runtime counters.
4. What should you check besides mean time?
GC causes spikes.