CXL · Module 8
CXL.cache Overview
What CXL.cache actually obliges a device to build: line state that outlives the data, three answers to a lookup instead of two, one transaction per line, and permission the device may drop but never grant itself. Five RTL models simulated, fifteen mutations, fifteen killed.
Chapter 6.1 placed CXL.cache next to its two siblings and said what it is for. This chapter goes inside it and asks what it costs — because the moment a device keeps a copy of host memory, it has signed up to answer questions about that copy for as long as it holds it.
1. The Engineering Problem — A Cached Copy Is Not a Private Copy
A device that wants host data has two ways to get it. It can read the data, use it, and forget it. Or it can keep it.
The first costs a round trip per use. The second costs a round trip once — and then costs something quite different, forever: the device now holds state that the rest of the system has an opinion about. Someone else may want to write that line. Someone else may need to know whether the device's copy is newer than memory. The device cannot answer "I'm busy" and it cannot answer "I threw it away" unless it actually did, in a way the system agreed to.
That is the trade CXL.cache offers, and it is not a small one:
| You get | You owe |
|---|---|
| Reuse without a round trip | State per line, held as long as the line is |
| Data next to the compute | An answer whenever coherence asks |
| No explicit software copies | Hardware that can be asked at any time |
The obligation is the subject of this module. A device that treats CXL.cache as "DMA with a buffer" builds hardware that is correct in isolation and wrong in a system.
2. The One-Sentence Model
Borrowed data, host-coordinated ownership. The device may hold a copy of host memory, but the host orchestrates who is allowed to do what with that line — so every line the device caches creates state the device must maintain, defend and eventually surrender in a way the system agreed to.
Call it the borrowed line. Not "the device's cache" — the data is on loan, and the loan has terms.
3. What This Chapter Owns
| Question | Owned by |
|---|---|
| What the three protocols are for | 6.1 |
| Device structure in general | 3.2 |
| The generic coherence conversation | 3.4 |
| What CXL.cache obliges a device to build | this chapter |
| Finding the authoritative value | 8.2 |
| Answering inbound coherence actions | 8.3 |
| Living inside a real accelerator | 8.4 |
| Moving writable ownership | 8.5 |
| Generic coherency theory and ownership fundamentals | Module 13 |
| End-to-end annotated coherency flows | Module 14 |
4. The Shape of the Thing
Four responsibilities, and it is worth being precise about each.
Host memory is where the line lives when nobody has borrowed it. It is host-owned throughout — caching a line never transfers the memory, only a copy and some permission.
Host coherence logic is the coordinator the sourced quote names. Chapter 3.4 calls this the serialisation point: it decides the order of conflicting requests to a line and it knows who holds copies.
The device coherence agent is the piece that exists only because the device caches. Its job is to be answerable — to maintain per-line state, to act when coherence reaches it, and to make sure the accelerator never sees a line the device is not entitled to serve. This is what 8.3 is about.
The device cache holds tags, state and data. The state is the part engineers under-build, and §5 is about why.
The accelerator never touches host memory directly. Every access goes through the cache, and the cache is the thing that is answerable. That indirection is the whole architecture.
5. What the Device Must Remember
A non-coherent buffer needs to remember two things about a line: which address it holds, and whether it holds anything. A coherent cache needs more, and the extra fields are not bookkeeping — each one exists because a specific question can be asked.
| Field | The question it answers |
|---|---|
| tag | Which line is this? |
| state | What am I allowed to do with it? |
| dirty | Is my copy newer than memory? |
| pending | Is a transition in flight right now? |
Architectural. The last two are the ones that surprise people.
dirty determines who has the authoritative value. If the device modified the line and memory has not been updated, then reading memory yields a stale value — and the device is the only place the real one exists. That makes the device's copy load-bearing for the whole system, which is why it cannot simply be dropped (§10).
pending exists because transitions are not instantaneous. A line whose permission is being renegotiated is in neither the old state nor the new one. It has a third status, and code that recognises only "valid" and "invalid" will serve it — which is the defect in §8.
Those four fields have a shape when you draw them out: a line moves between permission levels, and every move toward more permission passes through a state where the line is unusable.
Teaching model. These are not CXL.cache state encodings. Read three things off the shape.
Every arrow toward more permission passes through a PENDING state, and the accelerator cannot be served from either of them. That is the WAIT answer in §8, drawn.
The arrow out of OWNED says "write back, demote", not simply "demote". A modified line owes its data to the system before it gives up the right to hold it — the omission that Debug Lab 4 is about.
There is no arrow from SHARED directly to OWNED. Gaining permission is not a local decision, so the path goes through a request and a grant. A design with that shortcut arrow has the defect in Debug Lab 5.
6. Teaching-model boundary
7. RTL 1 — A Tag Match Is Not a Hit
The most common way to get a coherent cache wrong is to build a non-coherent one and add state later.
// A hit needs THREE facts, not one: the tag matches, the line is not
// INVALID, and no transition is in flight. Dropping any of the three is a
// separate real bug, and HIT_IGNORES_VALID models the first.
assign usable_hit = HIT_IGNORES_VALID
? tag_match
: (tag_match && (state_m[rd_idx] != 2'd0) && !pend_m[rd_idx]);=== EXP1: a tag match is not a hit ===
valid shared line : tag_match=1 usable_hit=1
tag + valid + settled -> hit : ok
invalid line : tag_match=1 correct hit=0 | ignores-valid hit=1
tag matched but the line is invalid: not a hit : ok
the broken variant served stale data on tag alone : ok
transient line : tag_match=1 usable_hit=0
a line mid-transition is not usable yet : ok
valid-line population = 2
population counted transitions, not writes : oktag_match is high in all three cases and usable_hit is high in exactly one. The tag answers "which line", not "may I use it" — and a device that conflates them serves data it invalidated, or data whose permission is mid-negotiation.
The population counter is a second, quieter lesson. It moves on the transition into and out of the valid states, not on every write:
if ((state_m[wr_idx] == 2'd0) && (wr_state != 2'd0)) n_valid_q <= n_valid_q + 8'd1;
if ((state_m[wr_idx] != 2'd0) && (wr_state == 2'd0)) n_valid_q <= n_valid_q - 8'd1;That distinction is invisible while every line goes invalid-to-valid exactly once, which is why the first testbench missed it:
=== EXP11: rewriting a line that is already valid ===
two rewrites of a live line : population 2 -> 2
population tracked transitions, not writes : ok
and it decremented when the line went invalid : ok8. RTL 2 — Three Answers, Not Two
A non-coherent cache lookup returns hit or miss. A coherent one has a third answer, and needs a fourth concept.
// WAIT dominates: a transition in flight settles the question later.
assign is_wait = req_valid && tag_match && line_pending && !TRANSIENT_IS_HIT;
assign is_hit = req_valid && present && !is_wait &&
(need_write ? writable : 1'b1);
// Present-but-not-writable is not a miss: the data is here, the PERMISSION
// is not. Treating it as a miss re-fetches data the device already holds.
assign need_upgrade = req_valid && present && !is_wait && need_write && !writable;
assign is_miss = req_valid && !present && !is_wait;=== EXP3: hit, miss, and WAIT are three answers ===
settled shared, read : hit=1 miss=0 wait=0 upgrade=0
transient line, read : correct wait=1 hit=0 | transient-is-hit hit=1
a line mid-transition returns WAIT, not HIT : ok
the broken variant served it anyway : ok
absent line -> miss : ok
=== EXP4: present but not writable is an UPGRADE, not a miss ===
shared line, write : hit=0 miss=0 upgrade=1
data present, permission absent -> upgrade : okTwo ideas worth keeping.
WAIT is not a miss. A miss starts a transaction. A line already mid-transition has a transaction — starting a second one is exactly the conflict §9 exists to prevent. The right answer is "ask again shortly".
An upgrade is not a miss either. The data is already in the cache; only the permission is missing. Classifying it as a miss re-fetches bytes the device is already holding — correct output, wasted bandwidth, and on a write-heavy workload it is a large waste.
9. RTL 3 — One Line, One Transaction
// A device must not run two coherent transactions on ONE line: their
// state transitions would interleave.
for (k = 0; k < NENT; k = k + 1)
if (busy_q[k[1:0]] && (line_m[k] == alloc_line)) line_busy = 1'b1;=== EXP5: one line, one transaction ===
two different lines : occupancy=2 allocated=2
different lines proceed concurrently : ok
same line again : allocated=2 denied=1 two-live-error=0
the second same-line request was refused : okDifferent lines proceed concurrently; the same line serialises. That is the whole scheduling rule, and it is why coherent caches can still be fast: the serialisation is per line, not global.
A checker that was measuring the wrong thing
The first version of this module raised an error flag when it refused a same-line request. That is backwards, and simulation caught it immediately: the good instance failed its own test while behaving correctly.
Refusing is the mechanism. The property is that two live entries never name the same line, and those are different statements — one is an action the design takes, the other is a fact the design maintains. The fix separates them:
// The real invariant: no two LIVE entries may name the same line. Refusing a
// request is the mechanism; this is the property that mechanism protects.
for (k = 0; k < NENT; k = k + 1)
for (j = 0; j < NENT; j = j + 1)
if ((k != j) && busy_q[k[1:0]] && busy_q[j[1:0]] &&
(line_m[k] == line_m[j])) dup_live = 1'b1;n_same_line_q now counts refusals as an observation — useful telemetry, since a high count means the workload is hammering one line — while two_live_same_line_err is the actual safety property. Never assert on the mechanism when you can assert on the property it protects.
Freeing an entry is not the same as answering it
=== EXP7: an entry frees on COMPLETION, not on the response ===
after response only : correct busy=1111 | free-on-response busy=0111
the entry is still held after the response : ok
the broken variant released it early : ok
completion released it : ok
the broken variant then saw a recycled entry : okA response arrives; the device still has work to do with it. Releasing the entry at the response lets the next allocation take that slot while the previous transaction is still finishing — and a late message from the old transaction then lands on the new occupant. This is the same identity hazard 7.5 raised about tag reuse, and 8.5 takes it further still.
10. RTL 4 — Drop Yes, Gain No
Architectural, and a direct consequence of the sourced quote. If the host orchestrates coherency management, then a device cannot help itself to more permission than it was given.
// Dropping permission is the device's own business. Gaining it is not:
// a promotion needs the host's grant, or the device has invented rights.
assign legal = req && (ALLOW_SILENT_UPGRADE ? 1'b1
: (promote ? host_granted : 1'b1));=== EXP8: the device may drop permission, not grant itself more ===
SHARED->OWNED, no grant : correct legal=0 state=1 | silent-upgrade legal=1
promotion without a host grant was refused : ok
the broken variant promoted itself : ok
with the host's grant the promotion applied : ok
dropping permission needs no grant : okThe asymmetry is the point. Losing permission is always safe; the device simply becomes less able to act. Gaining permission is never safe on the device's own authority, because the host may have granted that permission elsewhere.
And one thing the device may not silently do, even though it looks like dropping:
=== EXP9: a dirty line cannot simply go invalid ===
abuse instance flagged discarding the modified copy : okGoing to INVALID while holding the only modified copy is not dropping permission — it is destroying the authoritative value. The data has to go somewhere first. That check needed an instance reserved for illegal stimulus, because a correct device never does it and driving it into the instance under normal test would trip that instance's own checker and be scored a failure.
11. RTL 5 — Counters That Describe a Coherent Cache
=== EXP10: hit rate needs the right denominator ===
lookups=24 completions=16 hits=8 | attempts-variant hits=12
hit count matched an independent oracle : ok
counting attempts inflated the hit count : ok
correct hit rate = 8/16 | inflated = 12/16
hits never exceeded completions : okTwenty-four lookups produced sixteen completions — eight lookups did not complete, because they were waits or upgrades. Counting hits per lookup reports 12/16; counting per completion reports 8/16. The first number is not merely optimistic, it is measuring a different thing.
The oracle here is a testbench-side model built from the definition of a hit, with no connection to the design's expressions. That independence is what makes the comparison worth anything.
12. The Structures, Side by Side
Five modules, and it is worth seeing which part of a cache each one is.
The guard is the one that sits in an unexpected place. It does not gate the accelerator's path at all — it gates writes into the state array, which is where every permission change lands regardless of whether it came from the accelerator, the coherence agent or an eviction. Putting the check at the storage rather than at each caller is why no call site can forget it.
13. Quantitative Reasoning — What Coherence Costs in Bits
Illustrative. Take a device cache with 256 sets, 8 ways and 64-byte lines, on a 48-bit physical address.
| Quantity | Value |
|---|---|
| Data capacity | 256 × 8 × 64 = 128 KiB |
| Lines | 2048 |
| Tag bits | 48 − 6 offset − 8 index = 34 |
Now the metadata, using this chapter's teaching fields:
| Field | Bits |
|---|---|
| tag | 34 |
| state | 2 |
| dirty | 1 |
| pending | 1 |
| per line | 38 |
So the metadata array is 2048 × 38 = 77,824 bits = 9,728 bytes ≈ 9.5 KiB, which is 7.42% of the data array.
Then read the split. Of those 38 bits, only three — state and pending — exist because the cache is coherent. That is 0.59% of the cache.
Coherence is cheap in bits and expensive in everything else. Three bits per line buys the storage. What it costs is a lookup that returns three answers, a serialisation rule per line, a transaction tracker, a legality check on every transition, and a verification space that grows with the product of all of them. Anyone who budgets coherence by counting metadata bits has budgeted the wrong resource.
That sentence is the honest summary of the module, and every later chapter is an instance of it.
14. Assertions
Icarus Verilog 13.0 is the simulator available here and does not support concurrent SystemVerilog assertions, so every property is implemented as synthesisable checker logic and verified in simulation. Properties are classified, because a safety property never proves progress.
Safety
| Property | Intent |
|---|---|
| Hit needs permission | usable_hit |-> valid && !pending |
| No transient service | is_hit |-> !line_pending |
| One class per request | at most one of hit / miss / wait |
| One transaction per line | no two live entries share a line |
| No unknown response | rsp_valid |-> entry live |
| No self-promotion | promote && applied |-> host_granted |
| No lost authoritative data | dirty && next == INVALID is an error |
| Hits are a subset | n_hit <= n_complete |
Liveness
| Property | Assumption it needs |
|---|---|
| A pending line eventually settles | the transition it waits on completes |
| A refused same-line request eventually allocates | the holding transaction completes |
Both are stated with their assumption attached, because neither is true unconditionally — and a design that cannot state the assumption has not established the property.
Performance goals
| Goal | Measured by |
|---|---|
| Bounded same-line waiting | n_same_line_q, tracker occupancy |
| Hit rate against the right denominator | n_hit_q / n_complete_q |
| Transient occupancy stays low | count of lines with pending set |
15. Mutation Testing
Fifteen mutations, each a plausible coherence mistake. Fifteen killed.
| Mutation | Result |
|---|---|
| Tag match alone counts as a hit | killed |
| Line mid-transition served as a hit | killed |
| Valid population counted per write | killed |
| Dirty-in-invalid not flagged | killed |
| No WAIT class | killed |
| Upgrade re-fetched as a miss | killed |
| One-class-per-request check disabled | killed |
| Second transaction opened on a busy line | killed |
| Entry freed on response, not completion | killed |
| Response for no live transaction not flagged | killed |
| Refused allocation not counted | killed |
| Device promotes itself without a grant | killed |
| Discarding a dirty line not flagged | killed |
| Hits counted on lookup | killed |
| Hits-subset-of-completions check disabled | killed |
The first run scored 11 of 15, and the four survivors are the interesting part.
Population counted per write — missing stimulus. Every line in the test went invalid-to-valid exactly once, so the two counting rules agreed. Rewriting a live line separated them (EXP11).
Two checkers were unreachable by construction. multi_class_err could never fire, because hit requires present and miss requires !present — they are mutually exclusive by construction, so the checker was decoration. Same for hit_exceeds_complete_err, which is gated by complete on the correct design.
The fix is the one this track keeps arriving at, applied twice:
// OVERLAP_CLASSES drops the presence test, so a present line reports HIT
// and MISS at once -- which is what makes the one-class checker earn its keep.
assign is_miss = req_valid && (OVERLAP_CLASSES ? 1'b1 : !present) && !is_wait; overlap variant flagged multi_class : ok
abuse instance flagged hits exceeding completions : okA checker that cannot fire is not verification, it is documentation. Either build the variant that makes it fire, or delete it and admit the property is structural.
A response for no live transaction — missing stimulus, needing an abuse instance. Driving a stray response into the tracker under test trips that tracker's own checker, so the positive test got a dedicated DUT, and asserts two things: that the stray response is flagged, and that it frees nothing.
abuse instance flagged a response with no request : ok
and it freed nothing: an unknown id is void : ok16. Verification Plan
| Area | Approach |
|---|---|
| Lookup | Valid, invalid, transient, and tag-match-only cases |
| Classification | Hit / miss / wait / upgrade, each asserted individually |
| Same-line | Two requests to one line; different lines concurrently |
| Tracker capacity | Fill, refuse, count denials |
| Entry lifetime | Response then completion, checked separately |
| Transitions | Promote with and without grant; demote; dirty-to-invalid |
| Counters | Independent oracle; attempts-vs-completions comparison |
| Diagnostics | Every checker proven reachable |
The coverage model that matters is a cross of line state against request type: invalid / shared / owned × read / write × settled / pending. The pending column is the one a non-coherent testbench does not have, and it is where the WAIT class lives.
17. Silicon Observability
If this block misbehaves in a real device, these are the registers worth having:
| Counter | Diagnoses |
|---|---|
| valid-line population | capacity pressure |
lines with pending set | transient occupancy, and whether transitions are draining |
| tracker occupancy and peak | whether the outstanding table is the bottleneck |
n_same_line_q | workload hammering one line |
| denied allocations | back-pressure rate |
| unknown-response count | identity bugs, or a partner misbehaving |
| hits / completions / lookups | hit rate against the right denominator |
| refused transitions | a device asking for permission it does not get |
The pair that resolves the most confusing class of bug is unknown-response count against denied allocations. A device that is slow because it is full looks nothing like a device that is slow because responses are arriving for transactions it has already retired, and one counter cannot tell you which.
18. Debug Lab
The accelerator reads data the device already invalidated
TAG-ONLY-HIT// Cache lookup.
assign hit = (tag_m[idx] == probe_tag);Rare stale reads that correlate with host write activity to the same addresses. The device's own trace shows a hit, so the miss path is never entered and nothing is fetched. Re-running the workload with the host idle makes it disappear.
invalid line : tag_match=1 correct hit=0 | ignores-valid hit=1The lookup answers "which line is in this way", not "may I use it". When coherence invalidates a line, the state changes and the tag does not — because there is no reason to clear a tag you are about to overwrite. So the tag keeps matching and a tag-only comparison keeps hitting.
The bug is invisible without a coherence partner, which is why it survives unit test and fails in a system.
assign usable_hit = tag_match && (state_m[idx] != INVALID) && !pend_m[idx];Three facts, all required. Name the signal for what it authorises — usable_hit, not hit — so the next reader cannot use it for the wrong question.
Test a lookup against a line whose tag matches and whose state is invalid. That single directed case kills the entire defect class, and no random address stream reliably produces it.
A line being renegotiated is served to the accelerator
TRANSIENT-HITassign hit = tag_match && (state != INVALID); // pending not consideredIntermittent wrong results under concurrent host access, clustered on hot lines. The window is a few cycles wide, so it scales with how often the host and device contend for the same data — worst exactly when the workload is most interesting.
transient line, read : correct wait=1 hit=0 | transient-is-hit hit=1A line whose permission is mid-transition is in neither the old state nor the new one. The old state is no longer authoritative and the new one has not been granted. Serving from either is a guess.
This is the field engineers most often omit, because a non-coherent cache genuinely has no use for it — there is nothing to negotiate with.
assign is_wait = req_valid && tag_match && line_pending; // a third answer
assign is_hit = req_valid && present && !is_wait && ...;Make WAIT a first-class result and let it dominate. The requester retries; nothing is served from an unsettled line.
Assert that is_hit and line_pending are never simultaneously true, and make sure a test actually drives a lookup at a pending line — otherwise the assertion is decoration.
A late message corrupts a newly allocated transaction
EARLY-FREEif (rsp_valid) begin
busy_q[rsp_id] <= 1'b0; // released as soon as it is answered
endWrong data delivered for a line the device requested correctly, under load only, and only when the tracker is near full. The transaction that receives the bad data is well-formed; the one that caused it has already retired and left no trace.
after response only : correct busy=1111 | free-on-response busy=0111
the broken variant then saw a recycled entry : okA response is not a completion. The device still has to act on it — install the line, update state, release the accelerator. Freeing the entry at the response makes the slot available while the old transaction is still finishing, so the next allocation inherits it and any late message from the old transaction lands on the new occupant.
The tracker is small, so the reuse distance is short, which is why it needs load to appear.
if (rsp_valid) answered_q[rsp_id] <= 1'b1; // answered
if (done_valid) busy_q[done_id] <= 1'b0; // finished — only nowTwo events, two flags. The entry lives until the transaction is complete.
Ask of every resource: what is the last thing that can reference this, and does the release happen after it? Then test with the tracker deliberately near capacity so the reuse distance is one.
The only modified copy of a line disappears
DIRTY-DROPPED// Accelerator no longer needs the line — drop it.
state_m[idx] <= INVALID;Silent data corruption. A value the accelerator computed and stored is simply not there later — memory holds the pre-modification value and nothing reports an error. It reproduces only when the line was written and then evicted without an intervening coherence action.
abuse instance flagged discarding the modified copy : okDropping permission is normally safe: the device just becomes less able to act. That intuition breaks for a dirty line, because the device is not only holding a copy — it is holding the authoritative value. Memory is stale by definition.
Invalidating it does not lose permission, it loses data, and nothing downstream can detect that the value was ever different.
if (legal && dirty && (nxt_state == INVALID)) dirty_dropped_err <= 1'b1;Flag it, and architecturally require the data to be written back or handed off before the line goes invalid. The check belongs in the design, not only in the testbench, because in silicon this is otherwise undetectable.
Maintain the invariant "dirty implies a valid line" continuously. Then test eviction of a dirty line explicitly — including the eviction the accelerator initiates for its own reasons, which is the path that skips coherence entirely.
The device grants itself permission it was never given
SILENT-UPGRADE// Accelerator wants to write a line we already hold shared.
state_m[idx] <= OWNED; // no grant consultedTwo agents modify the same line and both believe they own it. Losses are silent and asymmetric — one writer's data survives, the other's vanishes — and which one wins depends on timing. Single-agent tests pass perfectly.
SHARED->OWNED, no grant : correct legal=0 state=1 | silent-upgrade legal=1The device treated "I already have the data" as "I may modify it". Those are different rights. Holding a shared copy means others may hold it too; writing requires the others to be dealt with first, and only the coordinator knows who they are.
This directly contradicts the sourced contract — the host processor orchestrates coherency management, so a device-side promotion is the device inventing authority it does not have.
assign legal = req && (promote ? host_granted : 1'b1);Promotions need a grant; demotions do not. Encode the asymmetry directly so the check cannot be forgotten at one call site.
Assert that no transition toward more permission is applied without a grant, then test the write-to-a-shared-line path specifically. It is the single most likely place for the shortcut, because the data is right there.
Reported hit rate is far better than measured performance
ATTEMPT-COUNTINGif (lookup && was_hit) n_hit_q <= n_hit_q + 1; // counted per lookupTelemetry reports an excellent hit rate while the accelerator stalls. Cache sizing decisions based on the number make things worse, because the cache was never the constraint. The gap widens exactly when contention rises.
correct hit rate = 8/16 | inflated = 12/16A lookup is not an access. Some lookups return WAIT and some return UPGRADE, and neither completed anything. Counting hits per lookup measures "how often did the tag match", which is not a performance quantity — the stalls it is hiding are precisely the coherence costs the counter was supposed to expose.
if (complete && was_hit) n_hit_q <= n_hit_q + 1;Count at completion, expose lookups and completions separately, and let whoever reads the telemetry compute the ratio they want.
For every counter, state what it counts and whether that thing can fail to complete. Then assert the subset law — hits cannot exceed completions — and prove the assertion can fire, or it will not protect you.
19. Design Review
- Does a hit require state, or only a tag? If only a tag, invalidation does not work.
- How many answers does a lookup have? Two means transient lines are being served.
- Is an upgrade distinguished from a miss? If not, the device re-fetches data it holds.
- What prevents two transactions on one line? And is the assertion on the mechanism or on the property?
- When is a tracker entry released — at the response or at completion?
- Can the device raise its own permission? It must not.
- What happens to a dirty line on eviction? If it can go straight to invalid, data is lost.
- Which denominator does the hit rate use?
- Which counters distinguish "full" from "confused"?
20. How This Appears in Real Engineering
Architecture. The decision to support CXL.cache at all is the decision to build a coherence agent, and it is not incremental — it changes the cache's lookup, its eviction path, its transaction tracking and its verification plan simultaneously.
RTL. The state and pending fields are three bits that touch every path in the cache. Engineers who scope the work by counting bits under-scope it badly.
DV. The pending column doubles the state space of every lookup test, and the same-line concurrency case cannot be reached by random address streams that do not deliberately collide.
Post-silicon. Transient-line occupancy and unknown-response counts are what separate "the cache is too small" from "the coherence path is misbehaving", and they cost almost nothing to add.
Performance. The hit-rate denominator question is not pedantry: teams have sized caches from an inflated number and shipped a device that stalls on coherence rather than capacity.
21. Common Misconceptions
| Belief | Correction |
|---|---|
| CXL.cache is faster DMA | DMA leaves no state behind; a cached line does |
| A tag match is a hit | It answers "which line", not "may I use it" |
| A lookup returns hit or miss | It returns hit, miss, or wait |
| A write to a held line is a miss | It is an upgrade — the data is already there |
| The device owns its cached data | It borrowed it; the host orchestrates |
| Dropping a line is always safe | Not if the line is dirty |
| Coherence costs a few metadata bits | It costs three bits and most of the complexity |
| A response means the transaction is done | It means it was answered |
22. Interview Reasoning
23. Exercises
-
Analysis. A device reports
lookups=1000,completions=1000,hits=980, and the accelerator stalls 40% of cycles. Nothing in those numbers is inconsistent. Name the two structures whose counters you would read next, and what each would tell you. -
Design. The teaching metadata has one
pendingbit. Give a case where one bit is insufficient, state what the second bit would encode, and say what new invariant it introduces. -
RTL task. Extend
hit_classifyso an upgrade on a line that is alreadypendingreturns WAIT rather than UPGRADE. State which existing assertion must be strengthened and why the current design is nevertheless not wrong. -
DV task. Write the coverage cross for the cache lookup, then explain why a random address stream cannot close it, and which two points must be directed.
-
Debug task. An accelerator intermittently reads a value it previously wrote, but one revision old. Give your investigation order and the single counter pair that distinguishes an eviction bug from a lookup bug.
-
Design review. A colleague proposes dropping the
dirtybit because "the accelerator writes through, so memory is always current." Give the strongest version of that argument, then name what it costs and the one workload that breaks it.
24. Summary
CXL.cache is a loan, and the terms are the module.
- The sourced contract is that CXL.cache "supports device caching of host memory with host processor orchestrating the coherency management" — the memory stays host-owned, and the host coordinates.
- A cached copy is not a private copy. It is state the system has an opinion about, for as long as it is held.
- A device must remember tag, state, dirty and pending. The last two are the ones people omit, and they are the ones that make the copy answerable.
- A tag match is not a hit. Invalidation changes state and leaves the tag alone.
- A coherent lookup has three answers plus an upgrade: hit, miss, wait — and present-but-not-writable, which is none of them.
- One line, one transaction. Different lines proceed concurrently; the same line serialises.
- An entry is released at completion, not at the response.
- The device may drop permission freely and gain it never — and may not drop a dirty line at all, because that is not permission, it is data.
- Coherence costs three bits per line and most of the complexity. In the illustrative cache, metadata is 7.42% of the array and the coherence-specific part is 0.59%.
- Verification lesson: a checker that cannot fire is documentation. Two of this chapter's fifteen mutations survived on exactly that.
Chapter 8.2 takes the first real consequence: when the device needs a line, memory may not hold the current value — so the device cannot simply read it.
Standards & specifications
- Governing standard
- CXL Specification (CXL Consortium)(opens CXL Consortium in a new tab)
Defines CXL.io, CXL.cache and CXL.mem, and the coherence and memory-pooling behaviour built on them. System design and deployment topology are not mandated.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the CXL curriculum.
