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

๐Ÿ“š Interview question

Explain the memory hierarchy: registers, L1, L2 and L3 caches, and RAM. How big and how fast is each level, and why does it matter for .NET code?

The levels of the memory hierarchy with realistic sizes and latencies, why caches exist, how cache lines and associativity work, what is private versus shared between cores, TLBs, and how the hierarchy shapes the performance of everyday .NET code.

Mid-Level Theory Intermediate cpu-cache latency memory performance ram registers

๐ŸŽฏ Key points

  • Registers are fastest and tiny; each cache level is bigger and slower; RAM is huge and roughly 50-100 times slower than L1
  • L1 (about 32-64 KB per core, ~1 ns, 4-5 cycles) and L2 (about 1-2 MB per core, ~3-4 ns) are private; L3 (tens of MB, ~10-20 ns) is shared by cores on a die and, in the cloud, with neighbouring tenants
  • RAM latency is about 60-100 ns, so the cycle cost depends on clock speed: roughly 200-300 cycles at 3 GHz; the ratios between levels matter more than exact numbers
  • Data moves in 64-byte cache lines; set-associative caches keep recently used lines, evict old ones, and write back dirty lines later; inclusive versus exclusive L3 policies change effective capacity
  • The TLB caches virtual-to-physical translations (typically a few thousand entries, covering only MBs with 4 KB pages); large, scattered working sets miss there too, which large pages can reduce
  • Small working sets and sequential access stay in cache and benefit from prefetching; pointer-chasing through large object graphs does not
  • In .NET: compact data (arrays, structs, fewer objects), fewer allocations, EF Core projections and AsNoTracking for read paths, and streaming instead of materialising large results
  • Measure with BenchmarkDotNet at realistic sizes ([Params] past L3), hardware counters (perf, VTune) and allocation profiles

โฑ 30-second answer

Memory is a hierarchy: the closer to the core, the faster and smaller. Registers (a few dozen per core) are read in under a cycle. L1 cache (about 32โ€“64 KB per core) takes about 1 ns, L2 (about 1โ€“2 MB per core) about 3โ€“4 ns, L3 (tens of MB, shared by cores) about 10โ€“20 ns, and RAM (gigabytes) about 60โ€“100 ns, which is hundreds of cycles. Data moves in 64-byte cache lines. A program whose working set fits in cache and is read sequentially runs many times faster than one that chases pointers across a large heap. For .NET code, that means compact data (arrays, structs, fewer objects), fewer allocations, and benchmarking at realistic data sizes.

๐Ÿ—ฃ Full answer (senior)

The levels

Level Typical size Latency Cycles (~3 GHz) Scope
Registers ~16โ€“32 general + vector registers < 0.3 ns 0โ€“1 Per core
L1 data / L1 instruction 32โ€“64 KB each ~1 ns ~4โ€“5 Per core
L2 1โ€“2 MB (up to 3 MB+ on some) ~3โ€“4 ns ~12โ€“15 Per core (or per core pair)
L3 (last-level cache) 16โ€“100+ MB ~10โ€“20 ns ~40โ€“70 Shared by cores on a die or socket
RAM (DDR4/DDR5) GBs to TBs ~60โ€“100 ns ~200โ€“300 Whole machine (NUMA: per socket)
NVMe SSD TBs ~10โ€“100 ยตs ~100,000 Device
Network round trip in the same Azure region n/a ~0.5โ€“2 ms millions Remote

Exact numbers vary by CPU, but the ratios are what matter: each step down is several times slower, and RAM is about 100ร— slower than L1.

Why caches work

Programs show locality: they reuse recent data (temporal) and touch nearby data (spatial). Caches exploit that by keeping recently used lines close to the core and by prefetching predictable access patterns. When the working set fits and access is regular, almost every load hits L1 or L2.

What it means for .NET code

  • Compact data wins: an int[] of 1 million items is 4 MB and streams through cache; 1 million small objects in a List<T> is about 30โ€“40 MB of scattered memory plus references.
  • Allocations cost twice: once for the GC, and again because new objects push useful data out of cache.
  • Hot paths should touch little memory: small dictionaries, frozen collections for read-only lookups, avoiding large object graphs per request.
  • Benchmarks must use realistic sizes: at 1,000 items everything is in L1; at 10 million it isn't.
  • NUMA on large multi-socket VMs: memory attached to the other socket is slower; the GC and OS try to keep allocations local, but it can matter for very large in-memory workloads.

Production example

An in-process cache held 3 million product records as objects with many string properties. Lookups were "O(1)" but p99 latency was poor under load. Profiling showed time stalled on memory loads. Moving the hot lookup data (ID, price, stock flag) into a compact struct array keyed by an index, and keeping the full objects for rare detail calls, cut p99 for the hot endpoint by about half.

๐Ÿ” What the interviewer is checking

Checks quantitative understanding of memory performance.

Green flags: gives sizes and latencies in the right orders of magnitude, explains cache lines and locality, private versus shared levels, mentions the TLB, connects to data layout, allocations and benchmarking in .NET.

Red flags: can't order the levels; thinks RAM is "fast enough" to ignore; no idea of relative costs; confuses CPU caches with Redis.

๐Ÿ“š Learn in depth

1. The pyramid

                 [registers]          < 1 ns       bytes
                [  L1 cache  ]         ~1 ns       32-64 KB per core
              [    L2 cache    ]       ~3-4 ns     1-2 MB per core
          [        L3 cache        ]   ~10-20 ns   tens of MB, shared
     [               RAM                ]  ~60-100 ns   GBs
 [                  SSD / network                    ]  micro- to milliseconds

2. Scaling latency to human time

If an L1 hit took 1 second:

Access Human-scale time
L1 hit 1 second
L2 hit 3 seconds
L3 hit 10โ€“15 seconds
RAM 1โ€“2 minutes
NVMe read about a day
Same-region network call about a week to a month

That's why one database call costs more than millions of in-memory operations, and why in-memory hot loops care about cache misses.

3. How a cache is organised

  • Memory is split into 64-byte lines. Each cache holds a fixed number of lines.
  • Caches are set-associative: an address maps to a small set (for example 8 or 12 "ways"), and the cache picks a victim in that set to evict (roughly least recently used).
  • Inclusive/exclusive policies differ between vendors (whether L3 also holds what's in L2), which affects effective capacity.
  • Writes go to the cache first and are written back to RAM later (write-back).
  • Separate L1i (instructions) and L1d (data) caches: large amounts of code (big methods, many generic instantiations) can also miss.

4. The TLB

  • Programs use virtual addresses; the CPU translates them to physical addresses using page tables.
  • The TLB caches recent translations (a few thousand entries). With 4 KB pages, that covers only a few MB to tens of MB.
  • Random access over a huge heap misses the TLB as well as the cache, adding a page-table walk. Large pages can help specialised workloads (the GC has a large-pages option for very large heaps on servers).

5. Shared caches and noisy neighbours

L3 is shared by cores, and in the cloud by other VMs or containers on the same host. A neighbour streaming through memory can evict your data, which is one reason latency varies between identical instances.

6. Rules of thumb for .NET

Situation Hierarchy-aware choice
Hot lookup table that never changes FrozenDictionary or a sorted array with binary search
Millions of small records Struct arrays or struct-of-arrays for the hot fields
Per-request work Avoid materialising large object graphs; stream with IAsyncEnumerable or spans
Parsing Work on ReadOnlySpan<byte> in place rather than creating strings
Caching Prefer fewer, smaller entries; measure memory per entry

7. Try it yourself

  1. Write a benchmark that walks an array with a random permutation of indexes (pointer-chasing style) for sizes 16 KB, 256 KB, 4 MB, 64 MB and 512 MB. Plot nanoseconds per access and spot the steps at L1, L2, L3 and RAM.
  2. Run lscpu (Linux) or CPU-Z (Windows) to find your cache sizes and compare with the plot.
  3. Repeat with sequential access and explain why the steps almost disappear (prefetching).

๐Ÿ’ป Code

namespace MemoryHierarchy;

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

// Measures average latency per dependent load at different working-set sizes.
public class LatencyLadder
{
    [Params(16 * 1024, 256 * 1024, 4 * 1024 * 1024, 64 * 1024 * 1024)]
    public int Bytes;

    private int[] _next = [];
    private const int Steps = 10_000_000;

    [GlobalSetup]
    public void Setup()
    {
        int count = Bytes / sizeof(int);
        // Build one random cycle through all slots so each load depends on the previous one.
        var order = Enumerable.Range(0, count).ToArray();
        new Random(7).Shuffle(order);
        _next = new int[count];
        for (var i = 0; i < count; i++)
        {
            _next[order[i]] = order[(i + 1) % count];
        }
    }

    [Benchmark(OperationsPerInvoke = Steps)]
    public int ChaseRandom()
    {
        int index = 0;
        for (var i = 0; i < Steps; i++)
        {
            index = _next[index];          // next address unknown until this load completes
        }
        return index;
    }

    [Benchmark(OperationsPerInvoke = Steps)]
    public int WalkSequential()
    {
        int sum = 0, length = _next.Length;
        for (var i = 0; i < Steps; i++)
        {
            sum += _next[i % length];      // predictable, prefetcher-friendly
        }
        return sum;
    }
}

public static class Program
{
    public static void Main() => BenchmarkRunner.Run<LatencyLadder>();
}

On typical hardware, ChaseRandom reports about 1 ns per step at 16 KB, a few ns at 256 KB, 10โ€“20 ns at 4 MB and 70โ€“100+ ns at 64 MB, while WalkSequential stays low throughout.

โš  Common mistakes

Mistake: Treating all memory access as equal cost. Why it is wrong: an L1 hit and a RAM access differ by about 100x, so two .NET loops with the same Big O (a foreach over int[] versus over List<SomeClass> reading one property) can differ several-fold in speed. Instead: think about working-set size and access pattern, not just operation counts.

Mistake: Benchmarking with small data and generalising. Why it is wrong: small inputs fit in L1 or L2, so results look great and then collapse at production sizes. Instead: benchmark with [Params] sizes that exceed L3.

Mistake: Believing more RAM fixes a memory-bound service. Why it is wrong: capacity does not change latency; a scattered working set is slow however many GB are installed. Instead: shrink and compact the hot data, and keep cold data out of the hot path.

Mistake: Loading large entity graphs per request. Why it is wrong: materialising thousands of tracked EF Core entities (with change-tracking snapshots and navigation fix-up) touches many cache lines, allocates heavily and evicts other requests' data. Instead: for read paths, project only the needed columns with Select into a DTO, use AsNoTracking, avoid lazy loading in loops (it causes N+1 queries), and stream with AsAsyncEnumerable when results are large.

Mistake: Confusing CPU caches with application caches. Why it is wrong: "add a cache" in architecture usually means IMemoryCache, HybridCache or Redis, which work at micro- to millisecond scale, far from L1-L3. Instead: be explicit about which level you mean and know the latency of each.

๐Ÿ› Senior insight: trade-offs

Seniors carry rough latency numbers in their heads and use them for back-of-the-envelope design.

Level Approx. latency Design lever
L1 / L2 1-4 ns Tight loops over arrays and structs, Span<T>
L3 10-20 ns Keep hot working set in tens of MB; watch noisy neighbours
RAM 60-100 ns Fewer objects, compact layouts, FrozenDictionary for hot read-only lookups
In-process cache (IMemoryCache) ~100 ns-ยตs Cache computed results; bound size to protect memory
Redis in the same region ~0.5-1 ms Batch and pipeline calls; cache hot keys locally too
Database query ~1-10+ ms Fewer round trips, projections, indexes
Cross-region call 10s-100s ms Keep chatty calls in-region, cache at the edge

A network call is about a million times slower than an L1 hit, so batching calls dominates any in-process micro-optimisation; inside the process, memory layout often dominates algorithmic cleverness. The same principle runs through every level: keep hot data small and close to where it is used, and measure before and after.

๐Ÿ”— Follow-ups the interviewer may ask

  1. When: Candidate gives RAM latency in the 60 to 100 ns range. Why: Checks whether the candidate can diagnose memory-bound code in practice.
    How to 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.

  2. When: Candidate mentions 64-byte cache lines and locality. Why: Applies the hierarchy to loop order and data structure choices.
    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.

  3. When: Candidate talks about RAM capacity versus latency. Why: Moves from CPU caches to how a process uses RAM in production.
    How to answer

    RAM is physical memory. Each process gets a virtual address space that the OS maps onto RAM page by page. The working set is what's resident in RAM right now; private (committed) memory is what the process owns. The GC heap is only one part: native allocations (native libraries, NativeMemory, unmanaged buffers), thread stacks, JIT code, and runtime structures add to it. In Kubernetes, the cgroup limit counts all of it, and going over gets the container OOMKilled (exit code 137), not an OutOfMemoryException. So if the GC heap is small but the pod dies, I compare GC heap size with working set in dotnet-counters. A big gap points to native memory or too many threads; a growing GC heap points to a managed leak I'd chase with dotnet-gcdump. Then I fix the cause and set limits and GC settings deliberately.

โ†ฉ Usually asked after

More in Memory

Last updated 23 Oct 2026.

๐ŸŒ Sangam's Webring

โช Previous ๐Ÿ  Main Next โฉ
๐Ÿ’ฌ Chat with me