Types of cache misses (compulsory, capacity, conflict, coherence) and TLB misses, the symptoms of memory-bound code, how to measure misses on Windows and Linux for .NET processes, and the design changes that reduce them.
A cache miss happens when the CPU needs a memory line that isn't in the cache it checked, so it waits for a slower level: a few nanoseconds for L2 or L3, and 60–100 ns for RAM. Misses come from first touch, a working set larger than the cache, conflicts, or another core writing the line. You suspect them when CPU is high but instructions per cycle are low and time is spread across simple loads. To confirm, I use perf stat/perf record on Linux (or VTune/uProf), and BenchmarkDotNet hardware counters on Windows for isolated code. To reduce them: shrink and compact the hot data (arrays and structs instead of object graphs), access it sequentially, split hot from cold fields, and allocate less.
| Type | Cause | Typical fix |
|---|---|---|
| Compulsory (cold) | First access to a line | Unavoidable; sequential access lets the prefetcher hide it |
| Capacity | Working set larger than the cache | Smaller data: fewer fields, compact types, struct arrays, less duplication |
| Conflict | Too many hot lines map to the same cache set (often power-of-two strides) | Change strides or padding; rare in managed code |
| Coherence | Another core wrote the line (true or false sharing) | Reduce shared writes; local accumulation; padding |
TLB misses are the cousin: the address translation isn't cached, so the CPU walks page tables. They grow with large, scattered heaps.
foreach over objects.perf stat shows below about 1 instruction per cycle on a modern core.Linux (containers, AKS, Azure VMs):
# Whole process: IPC and miss counts
perf stat -e cycles,instructions,cache-references,cache-misses,LLC-load-misses -p <pid> -- sleep 30
# Where the misses happen (enable perf maps so JIT-compiled frames have names)
DOTNET_PerfMapEnabled=1 dotnet MyApi.dll &
perf record -e LLC-load-misses -g -p <pid> -- sleep 30
perf report
In containers, hardware counters may need extra permissions or may be unavailable on some VM sizes; run on a VM or node where they're exposed.
Windows:
[HardwareCounters(HardwareCounter.CacheMisses, HardwareCounter.LlcMisses)] for isolated benchmarks (runs as administrator, uses ETW).Always: correlate with an end-to-end metric (p95/p99 latency, throughput) so you know the misses matter.
int instead of long where safe, Half/float where precision allows), deduplicate repeated strings, store IDs instead of object references.FrozenDictionary or sorted arrays for read-only lookups.A recommendation endpoint scored candidate products by walking Product objects that referenced Category, Brand and Pricing objects. perf stat showed IPC of 0.6 and a high last-level miss rate. Flattening the scoring inputs into a struct array rebuilt on catalogue changes raised IPC to about 2.0 and cut the scoring step's p99 from 45 ms to 12 ms.
Checks whether the candidate can diagnose memory-bound performance problems with evidence.
Green flags: names miss types, describes low IPC as the signature, uses perf/VTune/BenchmarkDotNet counters, knows about perf maps for JIT code, proposes layout and allocation changes, verifies with end-to-end metrics.
Red flags: "add more RAM" or "add caching (Redis)" as the answer; no measurement plan; can't explain why pointer chasing is slow.
load [addr]
|
L1? --hit--> ~4 cycles
| miss
L2? --hit--> ~12-15 cycles
| miss
L3? --hit--> ~40-70 cycles
| miss
TLB miss? -> page-table walk (more memory accesses)
|
RAM ------> ~200-300 cycles, line filled into L3, L2, L1
Out-of-order execution hides some latency by working on independent instructions, but a chain of dependent loads (pointer chasing) exposes the full cost on every hop.
List<Order> -> Order -> Customer -> Address -> Country four dependent misses per item
OrderRow[] { CustomerId, CountryCode, Total } one sequential stream
The first can't be prefetched because each address depends on the previous load. The second streams at memory bandwidth.
| Metric | Healthy hot loop | Memory-bound |
|---|---|---|
| IPC (instructions / cycles) | 2–4 | < 1 |
| LLC miss rate | Low single-digit % | Tens of % |
| Branch miss rate | < 1–2% | Unrelated, check separately |
Use ratios, not raw counts, and compare before and after the same load test.
DOTNET_PerfMapEnabled=1 writes /tmp/perf-<pid>.map so perf report can name JIT-compiled methods.dotnet-trace with the default CPU profile gives managed stacks without hardware counters; combine both views.// Before: one class, 20 fields, read 3 in the hot path
public sealed class Product { public int Id; public decimal Price; public bool InStock; /* ...17 more... */ }
// After: hot data compact and contiguous, cold data looked up when needed
public readonly record struct ProductHot(int Id, decimal Price, bool InStock);
ProductHot[] hot; // rebuilt when the catalogue changes
Dictionary<int, ProductDetails> cold; // rarely used fields
FrozenDictionary and FrozenSet do this by design.Order objects each referencing a Customer object, shuffle the list, and sum a customer field. Then build a struct array with the same data and compare.perf stat -e cycles,instructions,LLC-load-misses and compare IPC.perf record with DOTNET_PerfMapEnabled=1 and find which method the misses are attributed to.namespace CacheMisses;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
public sealed class Customer { public required string Name { get; init; } public int Tier { get; init; } }
public sealed class Order { public required Customer Customer { get; init; } public decimal Total { get; init; } }
public readonly record struct OrderRow(int CustomerTier, decimal Total);
[MemoryDiagnoser]
// On Windows (as administrator) add:
// [HardwareCounters(HardwareCounter.CacheMisses, HardwareCounter.LlcMisses)]
public class PointerChasing
{
private Order[] _orders = [];
private OrderRow[] _rows = [];
[GlobalSetup]
public void Setup()
{
var random = new Random(3);
var customers = Enumerable.Range(0, 200_000)
.Select(i => new Customer { Name = $"C{i}", Tier = i % 4 })
.ToArray();
_orders = Enumerable.Range(0, 2_000_000)
.Select(i => new Order { Customer = customers[random.Next(customers.Length)], Total = i % 100 })
.ToArray();
random.Shuffle(_orders);
_rows = _orders.Select(o => new OrderRow(o.Customer.Tier, o.Total)).ToArray();
}
[Benchmark(Baseline = true)]
public decimal ObjectGraph()
{
decimal sum = 0;
foreach (var order in _orders)
{
if (order.Customer.Tier == 3) sum += order.Total; // two dependent loads, likely misses
}
return sum;
}
[Benchmark]
public decimal FlatRows()
{
decimal sum = 0;
foreach (ref readonly var row in _rows.AsSpan())
{
if (row.CustomerTier == 3) sum += row.Total; // sequential stream
}
return sum;
}
}
public static class Program
{
public static void Main() => BenchmarkRunner.Run<PointerChasing>();
}
# Linux: compare instructions per cycle and last-level misses for a running service
perf stat -e cycles,instructions,LLC-loads,LLC-load-misses -p $(pgrep -f MyApi.dll) -- sleep 30
Mistake: diagnosing cache misses from intuition. High CPU can be caused by GC, locks, branch misses or plain extra work, and each has a different fix. Instead: confirm with IPC and miss counters, and correlate with latency before redesigning data structures.
Mistake: reading raw miss counts without context. A big number on a busy service may be normal; what matters is the miss rate and whether the hot path is affected. Instead: compare ratios (IPC, misses per instruction) before and after a change under the same load.
Mistake: profiling JIT code on Linux without perf maps.
perf report then shows unnamed addresses, and the investigation stalls.
Instead: set DOTNET_PerfMapEnabled=1 or use dotnet-trace for managed stacks alongside perf.
Mistake: flattening everything into structs. Data-oriented layouts make domain code harder to read and keep in sync, for little gain outside hot paths. Instead: apply hot/cold splitting and flat layouts only to measured hot paths, behind a clear abstraction.
Mistake: assuming an O(1) dictionary is always fast.
Each lookup hashes the key and touches buckets, entries and key objects, which can mean several misses on a large map.
Instead: use compact keys, FrozenDictionary for read-only data, or array indexes when keys are dense.
Mistake: ignoring allocation churn. Every new object evicts something useful from cache and adds GC work, so allocation-heavy code is also miss-heavy. Instead: reduce allocations in hot paths with spans, pooling and value types.
Cache misses are invisible in most profilers: they show up as "time spent in a simple line of code". Seniors ask the hardware before redesigning anything.
| Evidence | Likely cause | Next step |
|---|---|---|
| Low IPC, high LLC-load-misses in one loop | Capacity misses, pointer chasing | Flatten hot data, struct arrays, hot/cold split |
| Misses scale with thread count, poor scaling | Coherence (false or true sharing) | perf c2c, local accumulation, padding |
| Misses rise after GC-heavy periods | Allocation churn evicting useful lines | Reduce allocations, pooling, spans |
| High dTLB misses, huge heap | Scattered access over many pages | Compact data; consider GC large pages for very large heaps |
| Normal IPC, high CPU | Not memory-bound | Look at algorithm, GC, locks or branches instead |
The fixes are usually about design, not tricks: fewer objects per request, projections instead of full entities, compact read-only structures rebuilt on change, and hot/cold separation. Trade-off: flattened structures duplicate data that must be kept in sync and make domain code less natural, so seniors confine them behind a clear abstraction, document the reason (an ADR), and keep a production-sized benchmark to justify them.
Cores keep their caches coherent: when one core writes to a cache line, copies of that whole 64-byte line in other cores are invalidated. If thread A updates counters[0] and thread B updates counters[1], they're logically independent but sit on the same line, so the line ping-pongs between cores on every write. That's false sharing: more threads, more invalidations, and the loop gets slower. Symptoms are high CPU with poor scaling and no locks in sight. The best fix is to accumulate in a local variable per thread and combine once at the end (Parallel.For with localInit/localFinally). Where per-thread state must live in shared memory, pad each slot to its own cache line (128 bytes is safe across x64 and Apple Arm64).
CPUs load memory in cache lines of 64 bytes, so after one access the neighbouring bytes are already in cache (spatial locality), and recently used data is likely still there (temporal locality). Hardware prefetchers also spot sequential access and fetch ahead. A C# int[,] is stored row-major: one row's elements are next to each other. Walking row by row uses every byte of each line and the prefetcher keeps up; walking column by column jumps a whole row each step, wasting most of each line and missing cache, often 5–10× slower for large arrays. The same logic favours arrays of structs over lists of objects scattered on the heap, and struct-of-arrays layouts for hot numeric fields.
How to tell it (STAR)
**Situation:** At [company], our recommendation scoring step had a p99 of [45 ms], and API nodes ran hot even at moderate traffic. **Task:** I had to cut scoring latency without changing the recommendation logic. **Action:** The profiler pointed at simple property reads, so I checked hardware counters: IPC was about [0.6] with lots of last-level cache misses, meaning we were chasing pointers through the object graph. I built a flat struct array of just the scoring inputs, rebuilt on catalogue changes, and proved the gain with production-sized benchmarks. **Result:** IPC went to about [2.0], scoring p99 dropped to [12 ms], and CPU per request fell enough to remove [N] nodes.
Last updated 23 Oct 2026.