Cache Write Policies
Every store instruction forces a decision: where does the new data ultimately live? A CPU executes a `store` to memory perhaps once every three or four instructions. Each one raises the same question. Should the change go straight through to main memory immediately? Or can it wait in the cache until the memory system is less busy? This single design choice — the cache's write policy — shapes how a processor balances speed, bandwidth, and correctness. This happens in a system where a fast cache sits atop slow DRAM. The stakes are real. A poorly chosen write policy can saturate the memory bus with redundant traffic. It can stall the pipeline on every store. Or it can silently leave stale data in memory that a DMA-capable device or another core reads by mistake. Conversely, a well-designed write policy lets a cache absorb bursts of writes at cache speed. It can defer the expensive trip to memory. It can also batch many small writes into a single efficient transfer. This is why every real processor makes an explicit, engineered choice here. This applies from a microcontroller's tiny data cache to a server CPU's multi-level cache hierarchy. The choice is never left to accident. This article dissects the two orthogonal axes of that choice. The first axis is write-through vs. write-back. Does every write immediately propagate to the next level of memory? Or does it accumulate in the cache and get flushed later? The second axis is write-allocate vs. no-write-allocate. On a write miss, does the cache fetch the block in before writing, as it would for a read? Or does it write around the cache directly to memory? These two axes combine into the four write policies used in real hardware. They are glued together by two small but essential mechanisms. The write buffer decouples the processor from the latency of memory writes. The dirty bit tracks which cache lines disagree with memory. By the end, you will be able to trace a write through a cache line by line. You will be able to explain why write-back caches almost always pair with write-allocate. Write-through, in turn, almost always pairs with no-write-allocate. You will be able to compute the memory traffic each policy generates for a given access pattern. And you will see exactly why this choice feeds directly into Cache Performance and Average Memory Access Time.
- A cache's write policy has two independent choices: when to update memory (write-through vs. write-back) and whether to fetch on a write miss (write-allocate vs. no-write-allocate).
- Write-through sends every write to the next memory level immediately, keeping memory always current but generating heavy traffic; a write buffer hides most of that latency from the processor.
- Write-back updates only the cache on a hit and marks the line with a dirty bit; the line is written to memory only when it is evicted, dramatically cutting traffic for write-heavy workloads.
- Write-allocate loads the missing block into the cache before writing, exploiting spatial/temporal locality on subsequent accesses to the same block.
- No-write-allocate writes directly to the next level on a miss without pulling the block into the cache, avoiding cache pollution for data that won't be reused.
- In practice, write-back pairs with write-allocate, and write-through pairs with no-write-allocate — because each pairing minimizes the redundant work the other axis would otherwise cause.
A cache's write policy is really two independent decisions — when to update backing memory and whether to fetch on a write miss — and hardware performance hinges on choosing the pairing that avoids doing useless work twice.
The idea
A cache write policy governs the interaction between a store instruction and the memory hierarchy below the cache. Two questions must be answered independently, and conflating them is the most common source of confusion for students. The first question is a propagation question: on a write hit (the block is already in the cache), does the cache update the next lower level of memory right now, or does it defer that update? Write-through (also called store-through) answers "now": every write updates both the cache line and the corresponding location in the next memory level, keeping them synchronized at all times. Write-back (also called copy-back or store-in) answers "later": the write updates only the cache line, and the cache line is marked dirty — meaning it holds a value that memory has not yet seen. The dirty line is written back to memory only when it is evicted from the cache (or, in multiprocessor systems, when another core needs to see the current value). A dirty bit is a single bit of metadata per cache line, stored alongside the replacement-policy bits and the tag, that records exactly this fact. The second question is an allocation question: on a write miss (the block is not in the cache), does the cache bring the block in before performing the write, treating it like a read miss followed by a write hit? Write-allocate (fetch-on-write) says yes — the block is fetched into the cache, then the store completes as a hit. No-write-allocate (write-around) says no — the write bypasses the cache entirely and goes straight to the next memory level, leaving the cache's contents for that block untouched. The two axes are logically independent, giving four combinations, but only two are common in real designs because the other two waste effort: write-back with no-write-allocate would immediately re-fetch on the next write to that block, and write-through with write-allocate would fetch a block into the cache only to write through anyway, gaining little from the fetch if the block isn't reread.
How it works
Consider a single-level, single-core cache sitting between the CPU and main memory (the mechanism generalizes directly to any two adjacent levels in a hierarchy). Each cache line stores a tag, a valid bit, a dirty bit (only meaningful under write-back), and the data block itself. The CPU issues `load` and `store` operations; the cache controller intercepts each one and executes one of the following procedures. ``` function CACHE_ACCESS(address, op, data): (tag, index, offset) = SPLIT(address) line = SET[index] // set/way lookup, per Cache Replacement Policies hit = line.valid AND (line.tag == tag) if op == LOAD: if hit: return line.data[offset] // read hit else: new_line = FETCH_BLOCK_FROM_MEMORY(address) // read miss: always allocate INSTALL(SET[index], new_line) // may evict; see below return new_line.data[offset] if op == STORE: if hit: line.data[offset] = data if POLICY.write_back: line.dirty = true // defer propagation else: // write-through WRITE_TO_MEMORY(address, data) // propagate now // line.dirty stays false: cache and memory agree else: // store miss if POLICY.write_allocate: new_line = FETCH_BLOCK_FROM_MEMORY(address) new_line.data[offset] = data if POLICY.write_back: new_line.dirty = true else: WRITE_TO_MEMORY(address, data) INSTALL(SET[index], new_line) else: // no-write-allocate: write around the cache WRITE_TO_MEMORY(address, data) // cache contents for this block are unchanged function INSTALL(set, new_line): victim = CHOOSE_VICTIM(set) // e.g., LRU — see Cache Replacement Policies if victim.valid AND victim.dirty: WRITEBACK(victim.address, victim.data) // flush dirty victim before overwrite set[victim.way] = new_line ``` Two supporting mechanisms make this efficient in real hardware rather than merely correct on paper.
The write buffer. Under write-through, every hit generates a memory write, and a CPU cannot simply stall on every store waiting for DRAM (which can take tens to hundreds of cycles). Instead, the store's address and data are placed into a small FIFO write buffer; the CPU continues executing while the buffer drains to memory in the background. Only if the buffer is full does the CPU stall. Because most workloads have write-to-write gaps larger than the buffer's drain rate, the buffer hides nearly all write latency. A subtlety: subsequent loads must check the write buffer (or be delayed) so a load doesn't read stale memory for data still sitting in the buffer — this is a form of store-to-load forwarding that a correct write-through design must implement.
The dirty bit. Under write-back, the whole point is to avoid a memory write on every hit. The dirty bit is what makes eviction correct: when a line is chosen as a replacement victim, the controller checks the dirty bit. If clean, the line is simply discarded (memory already has the current value). If dirty, the line's data must be written back to memory first — this is the only moment write-back pays its "debt." Without the dirty bit, a write-back cache would have to write every evicted line back regardless of whether it changed, wasting bandwidth on lines that were only ever read.
Complexity & correctness
Per-access time complexity. A cache access itself is in the associativity (a small constant number of tag comparisons, independent of cache or memory size), regardless of write policy — this doesn't change; write policy affects when memory traffic happens, not the cost of the tag/index lookup. The interesting cost is memory traffic per write, which is where the policies genuinely differ. Let a cache line hold bytes, and consider consecutive stores that all hit the same cache line (a common pattern: initializing an array element by element, or updating a struct field by field).
- Write-through: each of the stores generates its own write to memory (of the store's granularity, typically a word), giving memory-write transactions — traffic proportional to the number of stores, not the number of distinct blocks touched.
- Write-back: the line is written back at most once, when it is eventually evicted (dirty), giving memory-write transactions for the whole burst — traffic proportional to the number of distinct dirty blocks evicted, not the number of stores.
This is the central quantitative argument for write-back: if a program exhibits write locality (multiple stores to the same block before it's evicted), write-back amortizes the cost of a memory write over all of them, while write-through pays the full DRAM bandwidth cost every single time. The tradeoff is that write-back requires a dirty-line writeback on every eviction that touches a modified line, an operation write-through never needs (its lines are always clean), and it also requires the extra dirty bit of state per line — extra bits, negligible in space.
Allocation policy and traffic. For a write miss:
- Write-allocate costs one full block fetch ( bytes) plus the store itself, but converts future accesses to that block (loads or stores) into cache hits — beneficial if the block will be touched again soon (temporal/spatial locality on the write side).
- No-write-allocate costs only the store's own bytes (no full-block fetch), but every subsequent access to that block — even a read — will also miss, since the block was never installed in the cache.
The combined reasoning for the canonical pairings:
- Write-back + write-allocate: since write-back cheaply absorbs many hits to the same line, it is worth paying to bring the block in on a miss (write-allocate) so that later stores to that block are hits, not separate memory transactions. This pairing is what most general-purpose CPU data caches (L1/L2/L3) use.
- Write-through + no-write-allocate: since write-through sends every store to memory regardless, there is little benefit to holding the block in cache purely for writes — no-write-allocate avoids polluting the cache with blocks whose only interest to the program is being written once (e.g., streaming writes, memory-mapped I/O), reserving cache capacity for data with genuine reuse.
Correctness argument (write-back). The invariant a write-back cache must maintain is: > At all times, either (a) a memory location's most recent value resides in memory (if no cache line currently holds it dirty), or (b) it resides uniquely in the dirty cache line that most recently wrote it, and memory's copy is stale but will be corrected exactly once, at eviction, before being overwritten by anything else. The dirty bit is precisely the boolean witness of which case holds. The `INSTALL` procedure preserves this invariant by construction: it writes back a dirty victim before discarding it, so the "most recent value" is never lost — it is copied to memory in the same step that removes it from the cache. If this write-back-before-overwrite step were ever skipped, the invariant would break and a stale value would silently become "the" value in memory, which is exactly the bug class this design must prevent (and precisely why the ordering in `INSTALL` — check dirty, flush if needed, then overwrite — is mandatory, not incidental).
Honest tradeoffs. Write-back is not free: it complicates cache coherence in multiprocessors (another core reading memory directly could see a stale value that lives only in a dirty line elsewhere), and it makes evictions variable-latency (clean evictions are instant, dirty evictions cost a memory write). Write-through simplifies coherence (memory is always current) at the cost of bandwidth, which is why write-through was historically common when caches were small and memory bandwidth was comparatively more available, while write-back dominates modern designs where bandwidth is the scarcer resource.
Worked example
Consider a cache with 16-byte lines and this instruction sequence (addresses given as `block:offset`, all mapping to the same initially-empty set), executed twice — once under write-back + write-allocate, once under write-through + no-write-allocate — with a write buffer assumed for the write-through case: ``` 1. store A:0 (miss) 2. store A:4 (hit, if allocated) 3. store A:8 (hit, if allocated) 4. load A:0 5. store B:0 (miss; evicts A if only one way) 6. store A:0 (miss again under write-through/no-allocate scheme) ```
Write-back + write-allocate trace:
| Step | Action | Cache state after | Memory writes issued |
|---|---|---|---|
| 1 | store A:0, miss → fetch A, write, mark dirty | A resident, dirty=1 | 0 (fetch is a read from memory, not a write) |
| 2 | store A:4, hit | A dirty=1 | 0 |
| 3 | store A:8, hit | A dirty=1 | 0 |
| 4 | load A:0, hit | A dirty=1 | 0 |
| 5 | store B:0, miss, evicts A (dirty!) → writeback A, fetch B, write, mark dirty | B resident, dirty=1; A flushed | 1 (writeback of A) |
| 6 | store A:0, miss, evicts B (dirty) → writeback B, fetch A, write, dirty | A resident, dirty=1 | 1 (writeback of B) |
Total memory writes: 2, for 4 store instructions plus one eviction-triggered re-fetch — write-back amortized three writes to block A into a single flush.
Write-through + no-write-allocate trace:
| Step | Action | Cache state after | Memory writes issued |
|---|---|---|---|
| 1 | store A:0, miss, no-allocate → write directly to memory | cache unchanged (invalid) | 1 |
| 2 | store A:4, miss (A never installed), write directly | cache unchanged | 1 |
| 3 | store A:8, miss, write directly | cache unchanged | 1 |
| 4 | load A:0, miss → allocate on read, fetch A | A resident, clean | 0 |
| 5 | store B:0, miss, no-allocate, write directly | cache unchanged (A still resident) | 1 |
| 6 | store A:0, hit (A resident from step 4) | A resident, clean | 1 (write-through always writes on hit) |
Total memory writes: 5 — nearly every store costs a memory transaction, because no-write-allocate never lets repeated writes to A accumulate as cache hits before step 4's incidental load brings A in. This mirrors the complexity argument exactly: with write locality on block A (three stores to the same line), write-back reduced memory-write count from what would be per-store traffic down to one flush per eviction, while write-through paid close to full price per store regardless of locality.
Common pitfalls & traps
| Pitfall | Why it happens | Fix |
|---|---|---|
| Treating write-through/write-back and write-allocate/no-allocate as one binary choice | Students conflate "when to write memory" with "whether to fetch on miss" | Always ask both questions separately; four combinations exist even if only two are common |
| Forgetting the write buffer, assuming write-through stalls every store | Naive mental model has the CPU wait for DRAM on each write | Recognize the write buffer as what makes write-through practical; note its finite depth can still stall on bursts |
| Evicting a dirty line without writing it back | Implementing `INSTALL` as a plain overwrite, ignoring the dirty bit | Always check dirty bit before overwrite; flush first, exactly as in `INSTALL` |
| Assuming write-back always saves memory traffic | Ignoring capacity/conflict misses that evict lines before write locality is exploited | Compare traffic empirically for the actual working set and reuse distance, not just in principle |
| Ignoring stale reads from the write buffer under write-through | A load to an address whose store is still sitting in the buffer, unflushed | Implement store-to-load forwarding or buffer lookup on every load |
| Assuming no-write-allocate never touches the cache | Confusing "write around" with "invalidate" | No-write-allocate leaves existing cache contents alone; it neither installs nor evicts unless another rule requires it |
Pioneers & history
The write-through/write-back distinction emerged directly out of the earliest cache memory research, where designers first confronted the tension between keeping memory consistent and minimizing bus traffic. Maurice Wilkes's early proposal for a "slave memory" (an early cache concept) already had to grapple with how updates to fast storage should reach the slower store behind it, and IBM's System/360 Model 85 — the first commercial machine with a cache — made this tradeoff concrete in hardware for the first time.
Origins · 1 minMaurice Vincent Wilkes and how this was discovered
Wilkes built the EDSAC, one of the first stored-program computers, and later proposed the "slave memory" concept in 1965 — a direct conceptual ancestor of the cache — explicitly raising the question of how a small fast memory should stay consistent with a larger slow one, the very problem write policies solve.
Where this matters
Write policy choices are embedded in essentially every commercial processor's cache hierarchy. Most modern CPUs (x86, ARM, RISC-V implementations) use write-back with write-allocate for their L1 data caches and beyond, because typical workloads exhibit strong write locality (loop-carried updates to arrays, stack variables, object fields) that write-back exploits well. Some designs use write-through only for the L1 to simplify coherence and rely on a write-back L2/L3 to absorb the resulting traffic before it reaches DRAM — a layered compromise that captures the strengths of both. Graphics processors and streaming architectures frequently favor no-write-allocate for write-only or write-once buffers (e.g., render targets, DMA staging buffers) to avoid cache pollution from data that will never be read back through that cache. Multiprocessor cache coherence protocols, such as MESI, are built directly on top of the dirty-bit mechanism: a line's "Modified" state is precisely a dirty write-back line, and coherence traffic (snoops, invalidations) exists to guarantee the write-back invariant holds correctly even when multiple caches can hold copies of the same block. Persistent-memory and database systems reason about write policies explicitly too, because a write-back cache's deferred flush interacts with crash-consistency guarantees — a topic that connects this material forward into how real systems reason about Cache Performance and Average Memory Access Time and, ultimately, whole-system correctness under failure.
Ask the tutor: How does the choice of write policy change the Cache Performance and Average Memory Access Time formula, and which term does each policy primarily affect?
What exactly does the dirty bit track, and where is it stored?
The dirty bit is one bit of metadata per cache line, stored alongside the tag and valid bit in the cache's tag array. It records whether the line's data currently differs from the corresponding data in the next (slower) level of memory. A dirty bit of 0 means the cache and memory agree; a dirty bit of 1 means the cache holds a more recent value than memory does, because a write-back cache deferred writing that value out to memory. The bit is set to 1 whenever a store hits the line (under write-back) and cleared to 0 whenever the line is written back to memory, either at eviction or during an explicit flush. Without this bit, the cache controller would have no way to know which lines actually need to be flushed on eviction, forcing it to write back every evicted line regardless of whether it changed — defeating much of write-back's bandwidth advantage.
Can a cache mix write-through and write-back at different levels of the hierarchy?
Yes, and many real designs do exactly this. It is common for an L1 cache to be write-through to the L2, while the L2 itself is write-back to main memory. This design keeps the L1 simple (it never needs a dirty bit or eviction-time writeback logic, easing timing closure at the L1's tight cycle budget) while still avoiding excessive DRAM traffic, because the L2 absorbs the write-through traffic from L1 and only flushes to DRAM on its own evictions. The L2, being larger, sees fewer evictions relative to the number of writes it absorbs, so most of the bandwidth savings of write-back are still realized at the level that matters most: the boundary with slow main memory.
Does write-allocate always improve performance compared to no-write-allocate?
No — it depends on whether the newly fetched block will actually be reused. Write-allocate pays the cost of a full block fetch on a write miss, which is wasted work if that block is never touched again (a common pattern with write-once buffers, streaming output, or memory-mapped device registers). In those cases, no-write-allocate is strictly better: it avoids the fetch and avoids evicting other useful data to make room for a block with no future reuse. Write-allocate wins when the program subsequently reads or writes the same block again soon, since those later accesses become cache hits instead of separate memory transactions. The right choice is workload-dependent, which is why some architectures expose non-temporal store instructions that explicitly request no-write-allocate behavior even in a cache that defaults to write-allocate.
How does a write-back cache handle a store instruction that misses, step by step?
First, the cache checks the tag; since it's a miss, no line currently holds the requested block. Under write-allocate (the typical pairing with write-back), the controller fetches the entire block from the next memory level into the cache, choosing a victim line via the replacement policy if the set is full. If that victim itself was dirty, its data is written back to memory first — this is the moment the dirty bit's bookkeeping pays off, since only dirty victims incur this cost. Once the new block is installed, the store completes as if it were a hit: the specific word is updated in the cache line, and the dirty bit for that line is set to 1, signaling that memory's copy is now stale until a future eviction or flush.
What happens if a write buffer fills up faster than it can drain to memory?
The CPU must stall on the next store until the write buffer has room, because there is nowhere else to safely place the pending write. This is the practical limit on how well a write buffer can hide write-through's memory traffic: it works well for isolated or bursty writes separated by enough non-write work to let the buffer drain, but a sustained stream of stores that arrives faster than memory bandwidth allows will eventually stall the pipeline regardless of buffer depth. This is one reason write-back caches, which generate far less total memory traffic for write-heavy code, tend to scale better as core counts and store rates increase, since a write-through design's buffer becomes a bottleneck sooner.
Why don't write-back caches need a write buffer for ordinary stores?
A write-back cache satisfies an ordinary store hit purely inside the cache — it updates the line's data and sets the dirty bit, with no memory transaction involved at all, so there is nothing to buffer. A write buffer becomes relevant for write-back caches only at eviction time, when a dirty victim must be flushed to memory; some designs use a small buffer here too (sometimes called a victim buffer or writeback buffer) so the flush of the old dirty line can proceed in the background while the new block is being fetched, rather than making the fetch wait for the flush to finish. Conceptually this serves the same purpose as a write-through write buffer — hiding memory latency from the pipeline — but it is triggered far less often, only on dirty evictions rather than on every store.
Is write-back ever the wrong choice, even with good write locality?
Yes, mainly in multiprocessor and I/O-heavis contexts where consistency matters more than bandwidth savings. Write-back complicates cache coherence, because another core or a DMA device reading memory directly might see a stale value while the current value sits, dirty, in some other cache only. Systems must implement coherence protocols (like MESI) or explicit cache-flush instructions to guarantee correctness, adding hardware and software complexity that write-through avoids by keeping memory always current. Real-time or safety-critical systems sometimes prefer write-through's predictability, since a write-back cache's eviction-triggered flush introduces variable latency that can be harder to bound, even though average-case bandwidth usage is worse.