<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. C#
  3. Master level
All lessons in this course

Lesson 57 of 60 17 minMaster

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 golden rule of performance: measure, don’t guess. Developers are famously bad at predicting where time goes. Usually a few lines (the hot path) take most of it, and optimising anything else just makes the code harder to read.

The workflow

  1. Define the goal: “opening a 50 MB model must take under 3 s”.
  2. Measure in a Release build on realistic data.
  3. Profile to find the hot spot (the method using most time or memory).
  4. Fix that one thing, then measure again to prove it helped.
  5. Stop when the goal is met.

Quick timing with Stopwatch

Timing.cs
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");
Output
Load: 1840 ms, 48,210 elements

Stopwatch is fine for whole operations taking milliseconds or more. For small methods (nanoseconds to microseconds), the first run includes JIT compilation and results jump around. That is what BenchmarkDotNet is for.

BenchmarkDotNet

Interactive

Benchmark results

N =
MethodMeanRatioAllocated
s += part2.0 ms1.007.7 MB
StringBuilder14.1 μs<0.0147.0 KB
string.Join9.0 μs<0.0115.7 KB
Each += copies the whole string so far, so the work grows with N². Ten times more parts costs about a hundred times more.

Modelled numbers to show the shape of real results. Always benchmark on your own machine and data.

StringBench.cs
// 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();    }}

Run it with 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

ToolUse it to
Visual Studio Performance Profiler (Alt+F2): CPU Usagesee which methods use the most CPU time, as a call tree
Visual Studio: .NET Object Allocationfind what allocates the most objects
Diagnostic Tools window (while debugging)watch memory and GC events live
dotnet-counters monitor -n MyApplive CPU, GC and allocation rate from the terminal
dotnet-trace collect -n MyApprecord a trace to open in Visual Studio or PerfView

In a CPU profile, sort by inclusive time (the method plus everything it calls) to find the slow feature, then by exclusive (self) time to find the actual line doing the work.

Common causes and fixes

SymptomUsual causeFix
slow loops over big collectionsList.Contains/First inside a loop (O(n²))build a Dictionary/HashSet once
high GC timemany temporary objects: string +=, boxing, LINQ in hot loopsStringBuilder, generics, spans, pooling
UI freezesheavy work on the UI threadawait I/O, Task.Run for CPU work
slow database screensN+1 queries (one query per row)load related data in one query
slow startuploading everything up frontlazy loading, caching, async loading
a single core at 100%CPU-bound work in one threadParallel.For, PLINQ
Nsquared.cs
// ✗ 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();
Quick check

Why should you not benchmark a Debug build?

Quick check

A profile shows FindBeam with 80% inclusive time but only 2% exclusive time. Where is the time really spent?

Practice

Write a BenchmarkDotNet class comparing 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
Solution.cs
[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));}
Output
| 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.
0 of 60 lessons completed