Cache

In the architecture of modern computing, a significant bottleneck exists between the lightning-fast processing capabilities of the CPU and the relatively sluggish response times of main memory (DRAM). This discrepancy is often referred to as the "Memory Wall." To bridge this performance gap, engineers employ Cache—a small, high-speed, and expensive type of volatile memory situated between the processor and the main memory.

The primary objective of a cache is to store copies of the most frequently or recently accessed data from the main memory. By doing so, the CPU can retrieve information at speeds approaching its own internal clock rate, significantly reducing the average time spent waiting for data.
The effectiveness of a cache is not accidental; it relies on a fundamental behavioral characteristic of computer programs known as the Principle of Locality. Without locality, caching would be little more than a random guessing game.

  • Temporal Locality: This principle suggests that if a particular memory location is accessed, it is highly likely to be accessed again in the near future. Common examples include loop counters or frequently called function variables.
  • Spatial Locality: This suggests that if a memory location is accessed, the locations physically adjacent to it are likely to be accessed soon. This is most evident in array traversals or sequential instruction execution.

To exploit spatial locality, caches do not move data one byte at a time. Instead, they fetch data in contiguous blocks known as Cache Lines (or cache blocks). A common cache line size is 64 bytes, ensuring that when one piece of data is fetched, its neighbors are brought along for the ride.

Memory Hierarchy and Address Breakdown

Modern systems utilize a tiered memory hierarchy to balance cost, capacity, and speed. The hierarchy typically follows this progression:

Registers $\rightarrow$ L1 Cache $\rightarrow$ L2 Cache $\rightarrow$ L3 Cache $\rightarrow$ Main Memory (DRAM) $\rightarrow$ Secondary Storage (SSD/HDD)

As we move closer to the CPU, the memory becomes faster and more expensive, but its capacity decreases.

To manage this, the CPU uses a specific addressing scheme. When the processor requests a memory address, that address is logically partitioned into three distinct segments to facilitate rapid lookup:

  1. Tag: A unique identifier used to determine if the requested data is actually present in the cache (a "hit") or not (a "miss").
  2. Index: Used to select a specific set or row within the cache where the data might reside.
  3. Offset: Used to identify the exact byte within a specific cache line.

Cache Mapping Strategies

Since the cache is significantly smaller than the main memory, multiple memory addresses must compete for the same cache locations. There are three primary ways to map main memory blocks to cache lines:

  • Direct Mapping: Each block of main memory is mapped to exactly one specific cache line. While this is hardware-efficient and extremely fast to search, it suffers from high conflict misses if two frequently used memory blocks happen to map to the same line.
  • Fully Associative Mapping: A memory block can be placed in any available cache line. This offers maximum flexibility and minimizes conflicts, but it requires complex hardware to compare the requested tag against every single line in the cache simultaneously, which is costly and power-intensive.
  • Set-Associative Mapping: A hybrid approach and the industry standard. The cache is divided into sets, and each set contains $N$ lines (known as an "$N$-way set-associative cache"). A memory block maps to a specific set but can reside in any of the $N$ lines within that set. This provides an optimal balance between the simplicity of direct mapping and the flexibility of fully associative mapping.

Mathematical Example

Consider a system with a 32-bit address, a 32KB cache, a 64B block size, and a 4-way set-associative structure:

  • Offset bits: $\log_2(64) = 6$ bits.
  • Number of sets: $\frac{32\text{KB}}{64\text{B} \times 4} = 128$ sets.
  • Index bits: $\log_2(128) = 7$ bits.
  • Tag bits: $32 - 7 - 6 = 19$ bits.

Management: Replacement and Write Policies

Replacement Policies

When the cache is full and new data must be brought in, the system must decide which existing line to evict. Common strategies include:

  • LRU (Least Recently Used): Evicts the line that has not been accessed for the longest time. It is highly effective but requires more complex hardware tracking.
  • FIFO (First-In, First-Out): Evicts the oldest line in the cache, regardless of how often it was accessed.
  • Random: Selects a line at random. While seemingly inefficient, it is very easy to implement in high-speed hardware.
  • LFU (Least Frequently Used): Evicts the line with the lowest access count.

Write Policies

Maintaining consistency between the cache and the main memory is critical.

  • Write-Through: Every write operation updates both the cache and the main memory simultaneously. This ensures data integrity but creates significant bus traffic.
  • Write-Back: Updates are made only to the cache. The cache line is marked as "dirty" and is only written to the main memory when it is evicted. This is much more efficient for write-intensive workloads.

Furthermore, when a "write miss" occurs, the system can use Write-Allocate (loading the block into cache before writing) or No-Write-Allocate (writing directly to main memory without loading the block).

Cache Coherence in Multi-core Systems

In multi-core processors, each core often has its own private L1 and L2 caches. This introduces the Cache Coherence Problem: if Core A modifies a piece of data in its private cache, Core B might still be working with an outdated version of that same data in its own cache.

To solve this, hardware protocols like MESI are implemented to track the state of every cache line:

  • Modified (M): The line is present only in the current cache and has been modified (it is "dirty").
  • Exclusive (E): The line is present only in the current cache but matches the main memory (it is "clean").
  • Shared (S): The line may be stored in other caches and is clean.
  • Invalid (I): The line is no longer valid and must be refetched.

Performance Metrics and Miss Types

The efficiency of a cache is measured by its Average Memory Access Time (AMAT):

$$\text{AMAT} = \text{Hit Time} + (\text{Miss Rate} \times \text{Miss Penalty})$$

For example, if the L1 hit time is 1ns, the miss rate is 5%, and the penalty to fetch from L2 is 50ns, the $\text{AMAT} = 1 + (0.05 \times 50) = 3.5\text{ns}$. Reducing the miss rate even slightly can lead to massive performance gains.

Cache misses generally fall into three categories:

  1. Compulsory Misses: The very first time a piece of data is accessed.
  2. Capacity Misses: Occur when the cache is too small to hold the entire "working set" of the program.
  3. Conflict Misses: Occur when multiple memory locations compete for the same set in a set-associative or direct-mapped cache.

Practical Optimization: Software Awareness

High-performance programming requires an understanding of how data moves through the cache. A classic example is Matrix Multiplication.

If you access a large matrix by columns in a language that stores data in row-major order (like C), you will constantly jump across memory addresses, destroying spatial locality and causing frequent cache misses.

Inefficient (Column-major access on B):

for (i = 0; i < N; i++)
  for (k = 0; k < N; k++)
    for (j = 0; j < N; j++)
      C[i][j] += A[i][k] * B[k][j]; // B[k][j] jumps across rows

To optimize this, developers use a technique called Tiling (or Blocking). By breaking the matrix into smaller sub-matrices (blocks) that fit entirely within the cache, the processor can perform all necessary calculations on a block before moving to the next, drastically reducing the number of trips to main memory.

// Optimized using Tiling
for (ii = 0; ii < N; ii += B)
  for (kk = 0; kk < N; kk += B)
    for (jj = 0; jj < N; jj += B)
      for (i = ii; i < ii + B; i++)
        for (k = kk; k < kk + B; k++)
          for (j = jj; j < jj + B; j++)
            C[i][j] += A[i][k] * B[k][j];

Logic Implementation: Cache Lookup Pseudocode

At the hardware/low-level logic stage, a cache lookup can be conceptualized as follows:

bool cache_lookup(uint32_t address) {
    // Extract components from the address
    uint32_t index = (address >> offset_bits) & index_mask;
    uint32_t tag = address >> (offset_bits + index_bits);

    // Search through the ways in the selected set
    for (int way = 0; way < ways_per_set; way++) {
        if (cache_set[index][way].valid && cache_set[index][way].tag == tag) {
            return true; // Cache Hit
        }
    }
    return false; // Cache Miss
}