Performance: profiling and benchmarking
Make code fast the professional way: measure first, use Stopwatch and BenchmarkDotNet correctly, find hot spots with profilers, and fix the common causes of slow .NET code.
The workflow
Define the goal: “opening a 50 MB model must take under 3 s”. Measure in a Release build on realistic data. Profile to find the hot spot (the method using most time or memory). Fix that one thing, then measure again to prove it helped. Stop when the goal is met.
Quick timing with Stopwatch
using System.Diagnostics; var sw = Stopwatch.StartNew();var model = await loader.LoadAsync("tower.ifc");sw.Stop();Console.WriteLine($"Load: {sw.ElapsedMilliseconds} ms, {model.Count:N0} elements");Load: 1840 ms, 48,210 elements
BenchmarkDotNet
// dotnet new console -n Bench && dotnet add package BenchmarkDotNetusing BenchmarkDotNet.Attributes;using BenchmarkDotNet.Running; BenchmarkRunner.Run<StringBench>(); [MemoryDiagnoser] // also report allocationspublic class StringBench{ [Params(100, 1000)] public int N; [Benchmark(Baseline = true)] public string Concat() { string s = ""; for (int i = 0; i < N; i++) s += "B" + i + ";"; return s; } [Benchmark] public string Builder() { var sb = new StringBuilder(); for (int i = 0; i < N; i++) sb.Append('B').Append(i).Append(';'); return sb.ToString(); }}dotnet run -c Release. BenchmarkDotNet warms up the JIT, runs each method many times, removes outliers and prints a table with Mean, Ratio to the baseline and Allocated memory.
Profilers: finding the hot spot
| Tool | Use it to |
|---|---|
dotnet-counters monitor -n MyApp | |
dotnet-trace collect -n MyApp |
Common causes and fixes
| Symptom | Usual cause | Fix |
|---|---|---|
List.Contains/First inside a loop (O(n²)) | Dictionary/HashSet once | |
+=, boxing, LINQ in hot loops | StringBuilder, generics, spans, pooling | |
await I/O, Task.Run for CPU work | ||
Parallel.For, PLINQ |
// ✗ O(n × m): for 20,000 beams × 20,000 IDs that is 400 million comparisonsvar clashing = beams.Where(b => clashIds.Contains(b.Id)).ToList(); // clashIds is a List<string> // ✓ O(n + m): one HashSet, then fast lookupsvar clashSet = clashIds.ToHashSet();var clashing2 = beams.Where(b => clashSet.Contains(b.Id)).ToList();List<int>.Contains with HashSet<int>.Contains for checking 1,000 random IDs against a collection of N = 1,000 and 100,000 IDs. Build the collections in a [GlobalSetup] method so setup isn’t measured.
Show the solution
[MemoryDiagnoser]public class LookupBench{ [Params(1_000, 100_000)] public int N; private List<int> _list = null!; private HashSet<int> _set = null!; private int[] _queries = null!; [GlobalSetup] public void Setup() { var rnd = new Random(42); // fixed seed: repeatable _list = Enumerable.Range(0, N).ToList(); _set = _list.ToHashSet(); _queries = Enumerable.Range(0, 1000).Select(_ => rnd.Next(2 * N)).ToArray(); } [Benchmark(Baseline = true)] public int ListContains() => _queries.Count(q => _list.Contains(q)); [Benchmark] public int SetContains() => _queries.Count(q => _set.Contains(q));}| Method | N | Mean | Ratio | |------------- |------- |-----------:|------:| | ListContains | 100000 | 7,410.2 μs | 1.00 | | SetContains | 100000 | 6.1 μs | 0.00 |
Remember
Measure first, in Release, on realistic data; fix the hot path, then measure again. Stopwatch for whole operations; BenchmarkDotNet with [MemoryDiagnoser]for small methods.Profilers show where time and allocations go: inclusive vs exclusive time. Big wins come from better algorithms and fewer allocations, not micro-tweaks.