Performance, Benchmarking, Profiling and Caching
The order of wins: algorithm and data structure (Big-O) → I/O (batching, async, fewer round trips) → allocations/GC pressure → micro-optimizations.
Concept
The order of wins: algorithm and data structure (Big-O) → I/O (batching, async, fewer round trips) → allocations/GC pressure → micro-optimizations.
HashSet/Dictionary lookups instead of List.Contains in loops; avoid nested loops over large data
Pre-size collections, StringBuilder/string.Create, Span<T>, ArrayPool<T> (Module 41), structs for small data, avoid boxing and closures in hot paths
Avoid += in loops; string.Equals(a, b, StringComparison.Ordinal); span-based parsing
Fine for most code; in hot loops avoid repeated enumeration and intermediate lists; Any() not Count() > 0
Don't block (.Result); ValueTask for frequently synchronous results; avoid async wrappers that add nothing
Too many threads/tasks cost context switches; lock contention serializes work — reduce lock scope, use concurrent collections or partitioning
Object pools for expensive objects; connection pooling (Module 49)
Group I/O: buffered writers, bulk inserts, batched messages Keep results of expensive operations (below)
BenchmarkDotNet is the standard: it runs a pilot to choose iteration counts, warms up (JIT, caches), runs many iterations, reports mean/error/stddev, and with [MemoryDiagnoser] the allocated bytes per operation. Rules for honest benchmarks: build in Release, no debugger, return results (so the JIT can't eliminate the work), realistic data sizes, one variable changed at a time, compare against a baseline. Distinguish throughput (ops/second) from latency (time per operation, including percentiles like p99).
Profiling and Production Diagnostics Where is CPU time spent?
Visual Studio / Rider profiler, dotnet-trace + PerfView/Speedscope What allocates?
Allocation profiler, dotnet-counters (gc-heap-size, alloc-rate) Why is memory growing?
dotnet-gcdump/dotnet-dump → dumpheap -stat, gcroot <address> to find what keeps objects alive Deadlock / hang?
dotnet-dump → clrstack -all, syncblk; Parallel Stacks in the IDE Thread-pool starvation?
dotnet-counters (threadpool-queue-length rising, threadpool-thread-count climbing slowly) — usually sync-over-async High CPU in production?
dotnet-trace collect -p <pid> for 30 s, then inspect hot paths
Measure first, change one thing, measure again.
In-memory cache IMemoryCache — per process, fastest Distributed cache
IDistributedCache (Redis, SQL Server) — shared by all instances, survives restarts HybridCache (.NET 9+)
Two-level (memory + distributed) with stampede protection Absolute expiration
Entry expires at a fixed time regardless of use Sliding expiration
Entry expires after a period without access Read: cache → on miss load from source and store; write: update source, then invalidate the cache
The hard part: explicit removal after writes, short TTLs, change tokens, versioned keys
Why It Matters
Processing hours of multi-channel CAN logs, serving dashboards to hundreds of engineers, or keeping a HIL bench loop within its cycle time all depend on these techniques — and on measuring rather than guessing.
Syntax
[MemoryDiagnoser] public class Bench { [Benchmark(Baseline = true)] public string A() => ...; [Benchmark] public string B() => ...; }
BenchmarkRunner.Run<Bench>();
string value = cache.GetOrCreate(key, e => { e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10); return Load(); });Example
57.1 A BenchmarkDotNet benchmark: three ways to build a string
The program uses the BenchmarkDotNet package with a short job (fewer iterations, for demonstration) and prints a compact summary instead of BenchmarkDotNet's full table.
57.2 Big-O, allocations, batching and caching
#:package [email protected]
#:property PublishAot=false
#:property Optimize=true
using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using BenchmarkDotNet.Toolchains.InProcess.Emit;
System.Globalization.CultureInfo.CurrentCulture = System.Globalization.CultureInfo.InvariantCulture;
// BenchmarkDotNet handles warm-up, pilot runs, many iterations, statistics and allocation measurement.
var config = ManualConfig.CreateEmpty()
.AddJob(Job.ShortRun.WithToolchain(InProcessEmitToolchain.Instance)) // ShortRun: fewer iterations, for a demo
.AddColumnProvider(BenchmarkDotNet.Columns.DefaultColumnProviders.Instance)
.AddDiagnoser(BenchmarkDotNet.Diagnosers.MemoryDiagnoser.Default)
.AddLogger(BenchmarkDotNet.Loggers.NullLogger.Instance) // hide the long progress log
.WithOptions(ConfigOptions.DisableLogFile);
var summary = BenchmarkRunner.Run<FrameFormatting>(config);
foreach (var report in summary.Reports.OrderBy(r => r.ResultStatistics!.Mean))
{
double meanUs = report.ResultStatistics!.Mean / 1000.0;
long bytes = report.GcStats.GetBytesAllocatedPerOp
…#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using System.Diagnostics;
using Microsoft.Extensions.Caching.Memory;
// ---- Big-O dominates micro-optimizations: List.Contains is O(n), HashSet.Contains is O(1) ----
int[] ids = Enumerable.Range(0, 20_000).ToArray();
var list = ids.ToList(); var set = ids.ToHashSet();
var sw = Stopwatch.StartNew();
int hits = 0; foreach (int i in ids) if (list.Contains(i)) hits++;
long listMs = sw.ElapsedMilliseconds;
sw.Restart();
hits = 0; foreach (int i in ids) if (set.Contains(i)) hits++;
long setMs = sw.ElapsedMilliseconds;
Console.WriteLine($"20000 lookups: List O(n) slower than HashSet O(1): {listMs > setMs} (hits {hits})");
// ---- Allocation reduction: pre-size collections, avoid LINQ in hot loops, reuse buffers ----
long before = GC.GetAllocatedBytesForCurrentThread();
var grown = new List<int>(); for (int i = 0; i < 100_000; i++) grown.Add(i);
long grownBytes = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
var sized = new List<int>(100_000); for (int i = 0; i < 100_000; i++) sized.Add(i);
long sizedBytes = GC.GetAllocatedBytesForCurrentThread() - before;
Console.WriteLine($"List
…Expected Result
Absolute times depend on the machine; the ratios are what matter: += in a loop copied the growing string 1000 times (about 17 MB allocated for a 17 KB result), StringBuilder was dozens of times faster, and string.Create with span formatting allocated almost only the final string.
The cache used a manual clock so expiration could be demonstrated deterministically: sliding expiration dropped the idle entry, frequent reads kept it alive, but absolute expiration still forced periodic reloads — the safety net against stale data.
SpanFormatting mean 6.1 us allocated 34,024 B/op
StringBuilder mean 24.9 us allocated 124,096 B/op
Concatenation mean 1614.8 us allocated 17,098,000 B/op20000 lookups: List O(n) slower than HashSet O(1): True (hits 20000)
List growth allocated 1024 KB, pre-sized 390 KB
2000 lines: batched buffered writer faster than 2000 appends: True
two reads, database hits: 1
after 1.5 min (inside sliding window), database hits: 1
after 3 min idle (sliding expired), database hits: 2
kept alive by frequent reads, but absolute expiration still forces reloads: database hits 3
after invalidation, database hits: 4Common Mistakes
Optimizing without measuring; measuring Debug builds or with a Stopwatch around one call.
Benchmarks whose result is unused (dead-code elimination) or that measure setup.
Caching without expiration or size limits (memory leak), or without invalidation (stale data).
Caching per-user or sensitive data in a shared cache key.
Micro-optimizing code that runs once while an O(n²) loop dominates.
Parallelizing tiny work items (overhead exceeds gain).
Best Practices
Define performance goals (latency p99, throughput, memory) and track them in CI benchmarks.
Profile in production-like conditions; use counters and traces in production.
Fix algorithmic problems first, then allocations in the hottest paths.
Cache with explicit expiration, size limits and an invalidation strategy.
Keep optimized code readable and covered by tests.
Interview Relevance
Very high for senior roles: "how would you find a performance problem?", "how do you reduce allocations?", "what is GC pressure?", "how do you benchmark correctly?", "absolute vs sliding expiration?", "how would you investigate thread-pool starvation or high memory usage?".
Practice Questions
Benchmark List<int>.Contains vs HashSet<int>.Contains for 10, 1 000 and 100 000 elements with [Params].
Benchmark LINQ Where().Select().ToList() vs a for loop over 1 million items.
Use dotnet-counters monitor on a program that leaks by adding to a static list.
Add a distributed cache (AddDistributedMemoryCache) behind IDistributedCache and store JSON.
Quick Quiz
Q1. (Multiple choice) Which expiration removes an entry after a period without reads? A) Absolute B) Sliding C) Priority D) Size
Answer: B (verified: the entry expired after 3 idle minutes with a 2-minute sliding window).
Q2. (Predict the output) Allocations of s += ... over 1000 frames vs string.Create.
Answer: About 17 MB vs about 34 KB per operation (verified) — += copies the whole string each time.
Q3. (True/False) BenchmarkDotNet results from a Debug build are meaningful.
Answer: False. The JIT doesn't optimize Debug builds; BenchmarkDotNet warns about it.
Q4. (Find the bug) foreach (var id in incoming) if (knownIds.Contains(id)) ... where knownIds is a List<uint> of 50 000 items.
Answer: O(n·m); convert knownIds to a HashSet<uint> once.
Coding Challenge
Implement a thread-safe cache-aside helper GetOrLoadAsync(string key, Func<Task<T>> load, TimeSpan ttl) on top of IMemoryCache, and show that two calls load only once.
(For heavy concurrent use, HybridCache additionally ensures that simultaneous misses trigger only one load.)
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using Microsoft.Extensions.Caching.Memory;
using var cache = new MemoryCache(new MemoryCacheOptions());
int loads = 0;
async Task<string> LoadCalibration() { loads++; await Task.Delay(10); return "VCU calibration"; }
string a = await GetOrLoadAsync(cache, "cal:VCU", LoadCalibration, TimeSpan.FromMinutes(5));
string b = await GetOrLoadAsync(cache, "cal:VCU", LoadCalibration, TimeSpan.FromMinutes(5));
Console.WriteLine($"{a} / {b}, loaded {loads} time(s)");
static async Task<T> GetOrLoadAsync<T>(IMemoryCache cache, string key, Func<Task<T>> load, TimeSpan ttl)
{
if (cache.TryGetValue(key, out T? cached)) return cached!;
T value = await load();
cache.Set(key, value, ttl);
return value;
}