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.
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.
| 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.
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.
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.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.
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.
[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
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.
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.
| 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 |
lscpu (Linux) or CPU-Z (Windows) to find your cache sizes and compare with the plot.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.
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.
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.
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.
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.
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.
Last updated 23 Oct 2026.