📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is a cache miss? How would you find out whether cache misses are slowing down a .NET service, and how do you reduce them?

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.

Senior Debugging Advanced benchmarkdotnet cache-miss memory perf performance profiling

🎯 Key points

  • A cache miss is a load or store whose line is not in the cache level checked, so the CPU waits for a slower level (a few ns for L2/L3, 60-100 ns for RAM; exact cycles vary by CPU family and clock)
  • Classic 'three Cs': compulsory (first touch), capacity (working set too big), conflict (too many hot lines in one set); multi-core systems add coherence misses, where another core's write invalidated the line (true or false sharing)
  • TLB misses are the address-translation equivalent and grow with large, scattered heaps
  • Symptoms: high CPU with low instructions per cycle (below ~1), profiler time spread across trivial loads such as property getters, latency that worsens as data grows
  • Measure with perf stat / perf record (with DOTNET_PerfMapEnabled=1 for JIT frames) on Linux, Intel VTune or AMD uProf on Linux and Windows, and BenchmarkDotNet hardware counters for isolated code
  • Reduce by shrinking the working set, using contiguous layouts, avoiding pointer chasing, splitting hot and cold fields, and allocating less
  • A Dictionary lookup is O(1) on average (O(n) only with pathological collisions) but still touches buckets, entries and key objects; FrozenDictionary (System.Collections.Frozen) or dense array indexes suit read-only hot lookups
  • Confirm improvements with the same counters (IPC, misses per instruction) and an end-to-end latency metric under the same load

⏱ 30-second answer

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.

🗣 Full answer (senior)

The four kinds of miss

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.

Symptoms in a .NET service

  • CPU is high, yet the profiler shows time in trivial code: property getters, dictionary lookups, foreach over objects.
  • Low IPC: perf stat shows below about 1 instruction per cycle on a modern core.
  • Latency gets worse as data grows, even though the algorithm is O(1) or O(log n).
  • Performance varies between identical instances (noisy neighbours evicting shared L3).

How to measure

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:

  • BenchmarkDotNet [HardwareCounters(HardwareCounter.CacheMisses, HardwareCounter.LlcMisses)] for isolated benchmarks (runs as administrator, uses ETW).
  • Intel VTune or AMD uProf for memory access analysis of a running process.
  • Visual Studio CPU Usage to find hot methods first, then counters to explain them.

Always: correlate with an end-to-end metric (p95/p99 latency, throughput) so you know the misses matter.

How to reduce them

  1. Shrink the hot working set: project only needed fields, use smaller types (int instead of long where safe, Half/float where precision allows), deduplicate repeated strings, store IDs instead of object references.
  2. Make it contiguous: arrays of structs or struct-of-arrays instead of lists of objects; FrozenDictionary or sorted arrays for read-only lookups.
  3. Access sequentially: process in order of memory layout, batch work by key, avoid random hops through object graphs.
  4. Split hot and cold data: keep frequently read fields in a compact structure and rarely used details elsewhere.
  5. Allocate less: new allocations push useful lines out of cache and create GC work.
  6. Avoid shared writes across cores (see false sharing).

Production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. What happens on a miss

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.

2. Pointer chasing versus streaming

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.

3. Reading perf output

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.

4. Getting symbol names for JIT code on Linux

  • DOTNET_PerfMapEnabled=1 writes /tmp/perf-<pid>.map so perf report can name JIT-compiled methods.
  • Alternatively, dotnet-trace with the default CPU profile gives managed stacks without hardware counters; combine both views.

5. Hot/cold splitting example

// 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

6. GC and locality

  • The GC's compaction tends to keep objects allocated together close together, which helps locality for freshly built structures.
  • Long-lived structures that are mutated over time drift apart as new objects are allocated elsewhere.
  • Rebuilding a read-mostly structure periodically (for example on catalogue refresh) restores locality; FrozenDictionary and FrozenSet do this by design.

7. Try it yourself

  1. Build a list of 1 million 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.
  2. On Linux, run both under perf stat -e cycles,instructions,LLC-load-misses and compare IPC.
  3. Use perf record with DOTNET_PerfMapEnabled=1 and find which method the misses are attributed to.

💻 Code

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

⚠ Common mistakes

  • 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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate lists coherence misses. Why: Checks knowledge of coherence misses caused by multiple threads.
    How to answer

    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).

  2. When: Candidate recommends flattening object graphs into arrays. Why: Checks the layout techniques that reduce misses.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Recommendation scoring p99 cut from 45 ms to 12 ms by flattening data
Situation
A retail recommendation API scored about 5,000 candidate products per request by walking `Product` objects and their referenced `Category`, `Brand` and `Pricing` objects, with about 4 million products in memory.
Signals
High CPU on API nodes at moderate traffic, scoring p99 of 45 ms, a profiler showing time in property getters, and `perf stat` reporting IPC of about 0.6 with a high last-level cache miss rate.
Investigation
Used `perf record` with perf maps enabled to attribute misses to the scoring loop, then benchmarked the loop with production-sized data comparing the object graph with a flattened struct array.
Resolution
Introduced a `ScoringRow` struct array (IDs, price band, category index, popularity) rebuilt whenever the catalogue changes, and indexed it by product ordinal. IPC rose to about 2.0 and scoring p99 fell to 12 ms.
Prevention
Benchmark with production-sized data in CI, an ADR documenting why the flattened structure exists and how it's rebuilt, and an alert on scoring latency.

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.

↩ Usually asked after

More in Memory

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me