Profiling
By the end of this lesson you'll be able to measure how fast your code runs instead of guessing — timing a block with Stopwatch , comparing two approaches fairly, writing rigorous benchmarks with BenchmarkDotNet , watching memory allocations, and knowing when to reach for a full profiler. The golden rule of all performance work: measure first, optimise second .
Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.
Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
Think of two tools an athlete uses. A Stopwatch is the handheld stopwatch a coach clicks at the start and end of a sprint — instant, simple, and perfect for a quick "how long did that take?". BenchmarkDotNet is the full fitness tracker : it makes you do warmup laps, runs the test many times, throws out the flukes, reports the average and the variation, and even logs how much energy (memory) you burned. The stopwatch tells you a rough number in seconds; the fitness tracker gives you trustworthy, repeatable data you can stake a decision on. The rule both share: never judge fitness by feel — measure it. Optimising code you haven't measured is like training harder at the wrong thing.
A benchmark answers "is A faster than B?". A profiler answers "which of my 500 methods is the slow one?". You usually profile to find the hotspot, then benchmark to fix it.
1. Measure, Don't Guess: Stopwatch
The fastest way to answer "how long does this take?" is System.Diagnostics.Stopwatch . Stopwatch.StartNew() creates and starts a high-resolution timer in one line; you do your work, call .Stop() , then read .ElapsedMilliseconds . Crucially, do not use DateTime.Now for this — it can jump backwards (clock changes, NTP syncs) and has poor resolution. Stopwatch is monotonic and built for exactly this. Read this worked example, run it, then you'll time your own block.
Your turn. The program below times a counting loop — it just needs the three Stopwatch calls filled in. Replace each ___ using the hints, then run it.
2. Comparing Two Approaches
A single timing in isolation rarely tells you much — what you really want is "is A faster than B?". The classic example: building a big string. Using += in a loop secretly creates a brand-new string on every pass and copies everything so far, so the cost grows with the square of the size. A StringBuilder keeps one growable buffer and appends into it. Time both side by side and the gap is dramatic — and now you can prove it rather than assert it.
Now you try. Time both approaches yourself and print them so you can compare. Fill in the three ___ blanks — two stopwatch calls and one result read:
The JIT warmup trap. .NET compiles your method to machine code the first time it runs (Just-In-Time compilation). So the very first call includes the cost of compiling — it can be 10–100x slower than steady state. If you time only one run, you're often timing the compiler, not your code. The fix: do one or two throwaway runs first, then start the stopwatch.
Debug vs Release. In a Debug build the JIT turns off optimisations so debugging is easier. Always measure performance in Release ( dotnet run -c Release ); Debug numbers are not representative.
Tiny operations. A Stopwatch resolves to fractions of a millisecond at best. Trying to time something that takes nanoseconds (a single method call) by running it once is hopeless — measurement noise swamps the signal. To time tiny things you must run them millions of times in a loop and divide... which is exactly the fiddly, error-prone work that BenchmarkDotNet does correctly for you.
3. The Right Tool: BenchmarkDotNet
BenchmarkDotNet is the industry-standard .NET benchmarking library, and it handles every pitfall above for you. You tag methods with [Benchmark] , mark one as the Baseline , and call BenchmarkRunner.Run YourClass () . It warms up the JIT, runs each method many times, discards outliers, computes the mean and standard deviation, and prints a clean comparison table. You read it, not write the timing scaffolding yourself. (BenchmarkDotNet needs a real .NET project, so study this worked example here, then run it in your own project with dotnet run -c Release .)
Notice what the table gives you that a bare Stopwatch can't: a Mean (in microseconds, far finer than ms), a Ratio against the baseline so the relative speed is obvious at a glance, and — because we added [MemoryDiagnoser] — the memory each method allocated. That last column is the one beginners forget.
4. Allocations & GC Pressure
Speed isn't the only cost. Every object you create on the heap is future work for the garbage collector (GC) — the part of .NET that reclaims memory you're no longer using. Allocate a lot and the GC runs more often, and a GC pause briefly freezes your program. Under load that's frequently what makes a "fast" method slow in production. Add [MemoryDiagnoser] to a benchmark and watch the Allocated and Gen0 columns: a method that's twice as fast but allocates ten times more memory may lose once real traffic hits it.
A benchmark compares two known options. But when a whole app is slow and you don't know which method is to blame, you need a profiler — a tool that watches your program run and reports where the CPU time and the allocations actually went.
The healthy workflow: profile to find the one method eating 80% of the time (it's almost never the one you'd guess), then benchmark two fixes for that method to prove which is actually better.
When you profile real C# code, the same culprits show up again and again:
Here's a small, reusable helper that puts the whole lesson into practice: it does a warmup run (to dodge the JIT trap), runs the work many times, and reports both total and per-run time — so you can compare two approaches fairly . It's a poor man's BenchmarkDotNet, but it's honest about warmup and repeats, which is more than a naive single timing ever is.
Notice the helper runs work() once before starting the clock — that warmup run gets the JIT compilation out of the way so it doesn't pollute the measurement. For anything you'll publish a number about, graduate to BenchmarkDotNet.
Q: When is Stopwatch enough and when do I need BenchmarkDotNet?
Stopwatch is great for a quick, rough "how long did that take?" on a chunk of work that takes milliseconds or more. The moment you're comparing two approaches and the answer matters — or the operation is tiny (microseconds) — use BenchmarkDotNet. It handles warmup, many iterations, statistics, and memory for you, so the number you report is trustworthy.
Q: Why is my first timed run so much slower than the rest?
That's JIT compilation. .NET compiles each method to machine code the first time it runs, and that one-off cost lands on your first measurement. Do a throwaway warmup run before you start the Stopwatch (BenchmarkDotNet does this automatically).
Q: Should I worry about memory or just speed?
Both. Memory allocations create work for the garbage collector, and GC pauses are a leading cause of latency spikes under load. A method that's faster but allocates far more can perform worse in production. Use [MemoryDiagnoser] and check the Allocated column, not just Mean .
Q: What's the difference between a benchmark and a profiler?
A benchmark answers "is approach A faster than B?" for code you already suspect. A profiler answers "which method in my whole app is the slow one?" — it watches a running program and shows where time and allocations actually go. Profile to find the hotspot, then benchmark to fix it.
Q: Isn't optimising early just good engineering?
No — "premature optimisation" makes code harder to read for a gain the program may never need. Write it correctly and clearly first, measure, and then optimise only the spots a profiler proves are hot. Most code isn't on a hot path at all.
No blanks this time — just a brief and an outline to keep you on track. Build a large array, sum it two ways (a for loop and LINQ's .Sum() ), time each with a Stopwatch after a warmup run, and report which is faster. Run it and check your output against the example in the comments — this is the core skill of the whole lesson: let the measurement decide .
Practice quiz
What is the golden rule of performance work?
- Optimise everything first
- Never optimise
- Measure first, optimise second
- Guess the hotspot
Answer: Measure first, optimise second. Always measure (profile or benchmark) before optimising — optimising a guess wastes effort.
What does Stopwatch.StartNew() do?
- Creates and starts a high-resolution timer in one line
- Stops a timer
- Returns the current date
- Resets to zero only
Answer: Creates and starts a high-resolution timer in one line. StartNew() creates a Stopwatch and starts it immediately in one call.
Why should you use Stopwatch instead of DateTime.Now for timing?
- DateTime.Now is faster
- They are the same
- DateTime.Now can't measure time
- Stopwatch is monotonic and high-resolution; DateTime.Now can jump backwards and has poor resolution
Answer: Stopwatch is monotonic and high-resolution; DateTime.Now can jump backwards and has poor resolution. DateTime.Now isn't monotonic (clock changes can make elapsed time negative); Stopwatch is built for elapsed timing.
What is the 'JIT warmup trap' when timing code?
- The CPU overheats
- The first run includes the cost of compiling the method, so it can be far slower
- The timer is too slow
- The loop never ends
Answer: The first run includes the cost of compiling the method, so it can be far slower. .NET JIT-compiles a method the first time it runs, so timing only one run often measures the compiler. Do a throwaway warmup first.
Why must you benchmark in Release, not Debug?
- In Debug the JIT skips optimisations, so the numbers are meaningless
- Debug is slower to start
- Release has no logging
- Debug can't run benchmarks
Answer: In Debug the JIT skips optimisations, so the numbers are meaningless. Debug builds disable JIT optimisations; always measure with dotnet run -c Release.
Why is building a big string with += in a loop slow?
- It uses too many threads
- It allocates nothing
- Each += creates a brand-new string and copies everything, so cost grows with the square of the size
- The compiler forbids it
Answer: Each += creates a brand-new string and copies everything, so cost grows with the square of the size. += in a loop re-allocates and copies the whole string each pass (O(n^2)); StringBuilder uses one growable buffer.
What does the [MemoryDiagnoser] attribute add to a BenchmarkDotNet report?
- Only the Mean column
- Allocation and GC columns (Allocated, Gen0)
- Thread counts
- The source code
Answer: Allocation and GC columns (Allocated, Gen0). [MemoryDiagnoser] reports allocations and GC pressure, not just time — often the cost that matters most under load.
What does BenchmarkDotNet do for you that a bare Stopwatch can't?
- Nothing
- It runs in Debug
- It deletes slow methods
- Warmup, many iterations, outlier removal, statistics, and a comparison table
Answer: Warmup, many iterations, outlier removal, statistics, and a comparison table. BenchmarkDotNet handles warmup, repeated runs, statistics and memory, so reported numbers are trustworthy.
What question does a profiler (dotTrace, PerfView) answer that a benchmark doesn't?
- Is A faster than B?
- Which method in the whole app is the slow one?
- What is the syntax?
- How to compile?
Answer: Which method in the whole app is the slow one?. A profiler finds where time/allocations go across a whole app; profile to find the hotspot, then benchmark to fix it.
Why can a method that's faster but allocates much more memory lose in production?
- It uses more disk
- It can't compile
- More allocations create work for the GC, and GC pauses cause latency spikes under load
- It is always slower
Answer: More allocations create work for the GC, and GC pauses cause latency spikes under load. Every allocation is future GC work; under load, GC pauses freeze the program, so a higher-allocating method can perform worse.