Memory Systems · Cache Hierarchy

Write-Back, Write-Through, and the Cost of a Dirty Line

How a cache decides whether a store waits for memory, what write-allocate does to a partial line, and why the dirty bit turns an eviction into a second transaction.

A load that misses has one obvious remedy: fetch the line. A store that misses has a choice, and the choice propagates through the whole hierarchy. Either the store waits for the line to arrive before it can be merged, or it is passed down to memory and the cache takes no interest. Either the cache keeps the only up-to-date copy and writes it out on eviction, or it forwards every store and keeps memory current. The four combinations of these two decisions define the write policies of real caches, and each combination has a distinct failure mode.

The dirty bit is a deferred write

In a write-back cache, a store that hits modifies the cached line and sets a dirty bit. Memory is not touched. The line remains the authoritative copy until something forces it out: capacity pressure, a coherence request from another core, or an explicit flush. On eviction, a dirty line generates a write-back transaction; a clean line is simply dropped.

The consequence is that a write-back cache converts many writes into one. A loop that updates a 64-byte line a hundred times produces a single 64-byte write to memory instead of a hundred transactions, and each of the hundred stores costs only the latency of an L1 access. That ratio is the entire justification for write-back, and it is why every general-purpose data cache in a modern processor is write-back rather than write-through.

The cost is deferred and lumpy. The dirty line occupies space that a clean line would also occupy, so the capacity cost is zero, but the eviction cost is doubled: the cache must both fetch the replacement line and write out the victim. A dirty eviction therefore consumes two memory transactions and, if the two are serialised, roughly twice the latency. Designs mitigate this by buffering the victim in a write-back buffer or a victim cache so the fill can proceed in parallel, which is the reason a non-blocking cache needs a write path as well as a read path.

There is a subtler accounting problem. A dirty line that is evicted and later re-fetched has cost three transactions — write out, read back, and eventually write out again — where a write-through cache would have cost one. Write-back wins only when the reuse distance of a modified line is short enough that the write coalescing pays for the eventual write-back. For streaming writes with no reuse, write-back is strictly worse than write-through, which is why streaming stores exist as an escape hatch.

Write-allocate decides what a partial store costs

When a store misses, the cache can allocate the line — fetching it from memory first, then merging the store — or it can decline, forwarding the store to the next level and leaving its own array untouched. Fetching before writing is called write-allocate; forwarding is no-write-allocate.

Write-allocate is correct and cheap when the program is about to write the whole line. It is wasteful when the program writes one byte of a 64-byte line, because the fill transfers 64 bytes to modify one. The wasted bandwidth is 63 bytes per store, and for a workload that scatters single-byte writes across memory the traffic is dominated by fills nobody uses. Most caches therefore pair write-back with write-allocate for ordinary stores and provide a separate mechanism for the streaming case.

That mechanism is the non-temporal or streaming store. The store bypasses the cache, or allocates the line without a fill, so the write goes to memory directly. On x86 the relevant instructions are the movnt* family; on other architectures the equivalent is a store with a cache-bypass hint, though the names differ. The semantics are architecturally weaker — ordering with respect to other stores is not guaranteed until a fence — so streaming stores are used for data that will not be read again soon, and a fence is required before any synchronisation that depends on them.

/* Streaming store: no fill, no dirty line, data goes toward memory.
   dst must be 16-byte aligned, as _mm_stream_pd requires.
   The fence makes the writes visible before the flag is published. */
#include <immintrin.h>

void fill_and_publish(double *dst, size_t n, int *flag)
{
    for (size_t i = 0; i < n; i += 2)              /* two doubles per 16 bytes */
        _mm_stream_pd(dst + i, _mm_setzero_pd());
    _mm_sfence();          /* order the streaming stores before the store to flag */
    *flag = 1;
}

The instruction is only a hint. The implementation may still allocate and write back; what the programmer is guaranteed is the architectural ordering after the fence, not the traffic pattern. Code that depends on the traffic pattern for correctness is wrong regardless of how the hardware behaves.

Write-through survives where ordering matters

A write-through cache forwards every store to the next level while keeping its own copy clean or updating it in place. Memory, or the next level, always has the current value. The advantage is that no line is ever dirty, so eviction is a single drop, and the coherence protocol never needs to write back a modified line — it can invalidate or share without a data transfer.

The disadvantage is bandwidth. Every store becomes a downstream transaction, so a loop doing a hundred updates to one line issues a hundred writes instead of one. Write-through designs compensate with a write buffer: a small FIFO that absorbs the stores and drains to memory at the bus rate, letting the core continue. As long as the average store rate does not exceed the drain rate, the buffer hides the cost; when it does, the buffer fills and the core stalls. A buffer of a few entries is enough for bursty code and useless for a sustained stream, which is why write-through is confined to structures where the buffer model fits: small first-level caches in some embedded cores, and the L2 of machines whose coherence protocol is simpler without dirty data.

Write-through also changes the coherence story in a way worth noting before the protocol articles. Because the next level is always current, an invalidation from another core never requires the owner to supply data; the requester can read from memory or from the shared level. That removes the owner-to-requester data transfer from the critical path, at the cost of a memory read on every transfer. Write-back protocols avoid the read by designating an owner that must respond, which is faster on a hit but requires tracking which cache holds the dirty copy — the state machine covered in MESI and the cost of invalidation.

Store buffers and the appearance of order

The store buffer is the mechanism that lets a write-back cache hide its own latency, and it is also the reason memory ordering is subtle. A store retires into the buffer and the core continues; the buffer drains to the cache when the cache port is free. The store is therefore architecturally complete before it is visible to any other core, which is exactly the situation the memory model has to describe.

Two rules keep the buffer from breaking single-threaded semantics. A load must check the buffer for a matching address before reading the cache, otherwise a store followed by a load of the same address would read stale data — store-to-load forwarding. And the buffer drains in program order for the architectures that require store-store ordering; weaker models may allow later stores to become visible first, which is why the fence instructions in those instruction sets exist and why the streaming-store example above needs one.

The buffer’s depth is a real constraint. A core that issues stores faster than the buffer drains will stall at the point of the store that finds no free entry, and the stall appears in profiles as a mysterious cost attributed to a store instruction. Buffers are typically a few tens of entries; sustained store bandwidth is set by the drain path, not by the buffer.

What each policy costs

PolicyStore on missMemory trafficEviction costCoherence
Write-back, write-allocateFill, then mergeOne fill per line, one write-back per dirty evictionTwo transactions if dirtyOwner supplies data
Write-back, no-write-allocateForward, no lineWrite per store, plus later fillsOne transactionOwner supplies data
Write-through, write-allocateFill, then merge and forwardFill plus one write per storeOne transaction (clean)Next level is current
Write-through, no-write-allocateForward onlyOne write per storeOne transaction (clean)Next level is current

The default for a general-purpose data cache is the first row. The second row describes the behaviour of a cache that treats stores as streaming by default, which is rare for an L1 because most stores are followed by reads of the same line. The third and fourth rows trade bandwidth for simplicity, and the simplicity is real: no dirty state means no write-back path, no owner designation, and no need to handle a write-back that races with an invalidation.

The failure modes cluster around partial lines. A store to a line that is not present, in a no-write-allocate cache, produces a partial write to memory. DRAM handles byte-enable writes, but a memory controller may have to read-modify-write for error-correcting codes, which turns one store into a read and a write. A cache that allocates on write avoids that by merging in the cache, which is the strongest argument for write-allocate beyond bandwidth: it keeps every write to memory line-granular.

References

  1. A Case for Direct-Mapped Caches — Mark D. Hill, IEEE Computer, 1988
  2. Computer Architecture: A Quantitative Approach — John L. Hennessy and David A. Patterson, Morgan Kaufmann
  3. Memory Ordering in Modern Microprocessors — Paul E. McKenney, Linux Journal, 2005