CXL · Module 14
Coherent Read Flows
A coherent read is not an access, it is a transaction: classify, gather every snoop response, accept data from an agent the request never went to, and complete only when both halves have arrived. Four flows, one graph, and the failures that live between the phases.
Module 13 built the machinery — permissions, windows, ownership, a state space and a domain boundary — and deferred the flows five separate times.
This is where that deferral is paid. A coherent read is not one thing, it is four, and only one of them is not a transaction at all.
1. The Engineering Problem — A Read Is A Transaction
A read that hits is an access: the line is present, permission is sufficient, and the data is returned in a cycle. Nothing in this chapter applies to it.
Every other read is a transaction — a sequence of messages with a beginning, a middle in which the line is in a state no diagram names, and an end that is not the moment the data arrives. Three things make that hard.
The line is in a transient for the whole middle. 13.4 established that a transaction spends most of its life in a state between the old one and the new one. A read flow is what drives that transient, and a flow that abandons it leaves a line nothing owns.
The data does not come from where the request went. A read miss for a line another agent has modified goes to the serialisation point and is answered by a peer. Three parties, two of which never talk to each other directly, and the requester has to know which of them is allowed to answer.
The flow can be interrupted at every phase. A snoop for the same line can arrive mid-flow. The home can tell the requester to try again. A response can arrive for a transaction that has already retired. Each of these is legitimate traffic, and each has a wrong answer that looks like a reasonable optimisation.
The correctness argument for the whole module is one sentence: a flow is a sequence of state transitions, and every one of them must be an edge the graph in 13.4 actually has. Section 6 builds that check as RTL and uses it on a real flow.
2. The One-Sentence Model
A read is a path, not a step. It classifies itself from the line's state, walks a sequence of legal transitions through a transient, accepts data only from the agent the serialisation point named, and is finished only when every outstanding answer has arrived.
Call it classify, gather, accept, complete. Every defect in this chapter is a phase skipped, a phase left early, or a phase that accepted something from the wrong place.
3. What This Chapter Owns
| Ground | Owner |
|---|---|
| Permissions, SWMR, non-guarantees | 13.1 |
| Link windows, out-of-order responses | 13.2 |
| Ownership as a duty, MOESI, the directory | 13.3 |
| The state space and the legal-edge graph | 13.4 |
| Domain boundaries and points of coherence | 13.5 |
| The end-to-end message sequence of a read | this chapter |
Deferred:
| Deferred ground | Owner |
|---|---|
| Write flows and the upgrade transaction | 14.2 |
| Ownership transfer as a flow | 14.3 |
| Host and device cache interaction | 14.4 |
| Which transitions fire, in what order, during a flow | 14.5 |
| Topology, switches, snoop-filter scaling | Modules 15 and 16 |
| Latency anatomy and bandwidth modelling | Module 18 |
This chapter owns the read and nothing else. Where a write appears it is stimulus, not subject. The state graph is used here and owned by 13.4 — section 6 walks it and does not extend it.
4. Teaching-Model Boundary
Every model below is a teaching model, compiled and simulated with Icarus Verilog 13.0, checked by a testbench whose oracle is structurally different from the design.
What these models are not: a coherency controller. There is no link layer, no credit scheme, no MSHR allocation policy, no address decode and no error correction. A production read path has one state machine per outstanding transaction, a deep response-matching structure, and a retry policy tuned against real contention traces.
The conventions from Module 13 carry over and are used without further comment. Every checker tests cond !== 1'b1, never !cond. Unreachable monitors get a FAULT_INJECT build of the same source. Comparisons between two policies are one source under a parameter, instantiated twice and driven from one stimulus stream. Every displayed value is a captured signal. Every combinational sample is preceded by a settle.
This chapter uses eight parameterised twin builds — SKIP_SNOOP, FAULT_INJECT on the classifier, FIRST_WINS, TRUST_ANY_SOURCE, FAULT_INJECT and DROP_LOSER on the race, NO_CEILING, DATA_IS_ENOUGH and LEAK_TRANSIENT. Every comparison below is a measured difference between two instances of one file.
5. RTL 1 — The Four Phases Of A Read
A read miss has four phases, and the phase is not the line's state. The line is in a transient for most of this; the phase is what the flow is doing about it.
// Accepting data while a snoop is still outstanding is the single most
// common read-flow bug: the data may be superseded by what the snoop returns.
assign data_before_snoop_err = data_arrived && (ph_q == SNOOP) && !snoop_done;
assign early_complete_err = ack && (ph_q != DATA) && (ph_q != DONE);and the phase machine itself:
ISSUED: begin
// SKIP_SNOOP goes straight for the data even when peers hold
// the line -- fast, and wrong.
if (snoop_needed_q) begin
ph_q <= SNOOP; n_snooped <= n_snooped + 8'd1;
end else begin
ph_q <= DATA; n_direct <= n_direct + 8'd1;
end
endThree flows were driven: one with no peers, one that must snoop, and one acknowledged before its data arrived. Measured:
flow : flows=3 snooped=1 direct=1 | data-before-snoop=1 early-complete=1Both premature-completion cases were caught. The flow held in the data phase across multiple cycles with no data and did not advance; it held in the snoop phase with no snoop response and did not advance. Those two hold checks matter more than they look: a phase machine that advances on elapsed time rather than on an event will pass every directed test in which the event happens to be prompt.
The SKIP_SNOOP build went straight for the data on the same stimulus that sent the correct build to the snoop phase. It is faster, it is simpler, and the data it accepts may be the value a peer is about to supersede.
Memory has no line in this diagram at all, and that is the point. The data moved between two caches, and the only reason the serialisation point was involved is that somebody has to decide which peer answers and in what order competing requests are served.
6. RTL 2 — Every Flow Is A Path Through The Graph
This is the correctness argument for the whole module, built as RTL.
// The same eight states and six causes as 13.4. This module does not own the
// graph -- it walks it, which is exactly the boundary between the modules.The model exposes one question: is this step an edge the graph has?
assign illegal_path_err = step_en && !edge_legal;
assign path_complete = step_en && edge_legal && (to_st <= O);The testbench sweeps all 8 source states × 6 causes × 8 destinations = 384 combinations against an independently written edge list — the same list 13.4 used, transcribed separately so a transcription error cannot appear in both. Measured:
graph : 28 legal edges of 384; illegal steps taken=1Twenty-eight edges, matching 13.4 exactly. The sweep is run with the step counter disabled, because 356 illegal combinations pouring into a counter the path walk uses would make that counter meaningless — a small point, and the first version of this bench got it wrong.
Then the flow from section 5 is walked as a path:
I --(local read)--> IS_D legal, not yet landed
IS_D --(fill)-----> E legal, landed on a stable stateand the flow the SKIP_SNOOP build would take:
I --(local read)--> E NO SUCH EDGEThat is the whole argument. A design that fetches a line straight into E without passing through the fill transient is not taking a shortcut through the state machine — it is taking a transition the state machine does not have, and every invariant that depends on the transient holding the line is unenforced for the duration. The check costs a lookup and it is the cheapest correctness argument available in this module.
7. RTL 3 — Which Read Is This?
Four outcomes, exactly one per access, decided from the line's state and where the current data lives:
// A hit is the only read that is not a flow at all. Everything else is a
// transaction, and which one depends on where the current data lives.
assign hit = acc && (resident || ((FAULT_INJECT != 0) && transient));
assign blocked = acc && transient;
assign miss_fwd = acc && !resident && !transient && peer_has_dirty;
assign miss_mem = acc && !resident && !transient && !peer_has_dirty;The whole state space was driven, for a clean and a dirty peer. Measured:
classify: hit=4 mem=1 fwd=1 blocked=4 | multi correct=0 faulty=1Four hits — S, E, M and O all hold a readable copy, which is the 13.1 permission table restated. Four blocked — the three transients plus one more, and every one of those is an access that failed for a reason that has nothing to do with permission. One forward and one memory fetch, distinguished only by whether a peer holds dirty data.
The miss_fwd versus miss_mem split is the one that matters, and it is not a performance distinction. A miss with a dirty peer must be forwarded: memory's copy is stale, so fetching it returns a value that was superseded. The oracle for this is four mutually exclusive integer outcomes rather than four booleans, so a design bug that asserts two of them cannot be reproduced by a reference that structurally has only one.
multi_class_err cannot fire in a correct design — the four conditions are mutually exclusive by construction. That makes it an unreachable checker, so the same source carries a FAULT_INJECT build that drops the transient guard from the hit test and classifies an in-flight line as both a hit and blocked. Measured: correct=0, faulty=1.
8. Waveform — A Read Miss End To End
Transcribed from the printed cycle trace of the assembled flow in section 15.
A read miss, and a second one abandoned mid-gather
10 cyclesThe line row is in a transient for four of the seven cycles, which is 13.4's measurement restated from the flow's side rather than the line's.
The landing state is the detail worth pausing on. The fill lands in S, not E, because the peer that supplied the data still holds its copy. A design that lands every fill in E has granted exclusivity while another agent is still reading — and the flow that produced it looks identical up to the final cycle.
The trace continues past what the waveform shows. A second flow is started and abandoned mid-gather:
11 0 0 0 gather IS_D 0 0
12 0 0 0 idle I 0 1At cycle 12 the correct build has restored the line to I and the LEAK_TRANSIENT build has not — it is idle with the line still in IS_D, a transient with no flow driving it and nothing that will ever complete it. That is the last column, and it is the failure section 15 exists to demonstrate.
9. RTL 4 — Gathering Every Answer
A read miss asks every peer that might hold the line. The flow may not proceed until all of them have answered:
// FIRST_WINS declares the gather complete as soon as one peer supplies data,
// which leaves the other peers' answers to arrive after the flow has moved on.
assign all_answered = busy_q && ((FIRST_WINS != 0) ? data_q : (cnt == 3'd0));
assign dup_rsp_err = rsp_valid && busy_q && seen_q[rsp_peer];
assign two_data_err = rsp_valid && busy_q && rsp_has_data && data_q
&& !seen_q[rsp_peer];Three peers were asked; the second to answer supplied the data. Measured:
gather : outstanding falls to 0, all_answered=1, data from peer 3
first-wins build complete with a peer still outstanding=1
dup_rsp=1 two_data=1 responses=6The FIRST_WINS build declared the gather complete with a peer still outstanding, and that peer's response then arrives after the flow has moved on — where it is either dropped, or applied to a transaction that no longer exists. Waiting for "enough" answers rather than all of them is the shape of that bug, and it is attractive because the data is already in hand.
The two error signals separate failures that look identical from the outside:
dup_rsp_err— one peer answered twice. Usually a retransmission, and the second copy must not be counted.two_data_err— two different peers both claimed to hold the data. That is a directory that named two suppliers, which is the 13.3 two-owner failure surfacing in a flow.
The && !seen_q[rsp_peer] term is what keeps them apart. Without it, a repeat from the peer that already supplied data is reported as two claimants — an alarm that fires on a retransmission, which is exactly the kind of false positive that gets a monitor disabled. Measured on that stimulus: duplicate reported, two-claimants not.
The oracle for the gather is four plain integers counting who still owes an answer, so a bit-vector indexing bug in the design cannot reproduce itself in the reference.
10. RTL 5 — The Data Comes From Somewhere Else
The requester sent one message, to the serialisation point. The data arrives from a peer it never contacted. It must accept that data from exactly one agent — the one the home named.
// The requester accepts data only from the agent the home named. Accepting
// from anyone is how a stale copy from a peer that was just invalidated
// gets installed as current.
assign accept = data_valid && armed_q
&& ((TRUST_ANY_SOURCE != 0) || (data_from == src_q));
assign wrong_source_err = data_valid && armed_q && (data_from != src_q);
assign unsolicited_err = data_valid && !armed_q;Data was driven from the wrong peer, then the right one, then with nothing outstanding. Measured:
forward : rejected=1 forwarded=1 wrong_source=1 unsolicited=1The wrong-source case is not paranoia. A peer that held the line a moment ago, was invalidated, and had a data message already in flight will deliver a stale copy that is perfectly well-formed. The requester has no way to tell it is stale except that it came from an agent the home did not name. The TRUST_ANY_SOURCE build accepted it on identical stimulus.
unsolicited_err catches the other direction — data arriving with nothing outstanding. That is a duplicate, a response to a transaction that already timed out, or a response for a line this agent never requested, and all three are worth separating from a response that is merely late.
The req && home_directs guard is worth its own sentence. A request the home does not redirect leaves the requester expecting nothing from a peer, and the model checks that explicitly: with no forwarder named, expect_from_peer stays low and any peer data is unsolicited.
11. RTL 6 — A Read Racing A Snoop
A read for a line crosses a snoop for the same line. Both are legitimate, and they cannot both be applied to the same starting state:
// The snoop wins: it carries an ordering decision the serialisation point has
// already made, while the read can be replayed against whatever it leaves.
assign serve_snoop = snoop_req;
assign serve_read = (FAULT_INJECT != 0) ? read_req : (read_req && !snoop_req);
assign read_replay = read_req && snoop_req && (FAULT_INJECT == 0)
&& (DROP_LOSER == 0);
assign both_served_err = serve_read && serve_snoop;
assign dropped_err = (read_req && !serve_read && !read_replay)
|| (snoop_req && !serve_snoop);The snoop wins, and the priority is not arbitrary. A snoop carries an ordering decision the serialisation point has already committed to; the read has not been ordered yet and can be replayed against whatever the snoop leaves behind. Reversing the priority means a snoop that has already been counted as delivered is applied to a state the read has since changed.
Three builds were driven on identical stimulus. Measured:
race : replays=1 both_served correct=0 faulty=1 dropped(drop build)=1Read those three numbers together, because they are three different bugs:
- The correct build served the snoop, refused the read, and replayed it.
- The
FAULT_INJECTbuild served both, applying two changes to one starting state — and note that itsdropped_erris clean, because every request was answered. A monitor watching for unanswered requests reports a healthy run on this bug. - The
DROP_LOSERbuild neither served the read nor replayed it. It is the only build that can reach the dropped-request monitor at all, which is why it exists.
12. RTL 7 — The Read That Has To Be Retried
A read the home cannot serve yet is nacked and retried. A retry budget with no ceiling is a livelock that never reports itself:
// NO_CEILING retries forever, so a line that is permanently contended never
// produces a report -- it just stops making progress, silently.
assign give_up = act_q && (NO_CEILING == 0) && (try_q >= MAX_TRIES[3:0]);
assign livelock_err = act_q && (try_q >= MAX_TRIES[3:0]);with the peak latched rather than sampled:
// Latch the peak: the contended line is long gone by the time
// anyone investigates.
if ({4'd0, try_q} + 8'd1 > max_tries_seen)
max_tries_seen <= {4'd0, try_q} + 8'd1;Measured across a read that succeeded on its third attempt and one that never succeeded:
retry : peak attempts=4 give_up=1 | no-ceiling build still active=1The bounded build gave up and reported a livelock; the unbounded build was still retrying nothing. That is the difference between a fault an engineer can find and a system that has quietly stopped making progress on one address.
Two details in the bench are worth naming because both hid a real mutation. Waiting is not retrying — the attempt counter must only advance on a nack, and a build that advances it every cycle while active passes any test that never idles mid-flow. And a later shorter attempt must not reduce the latched peak — the check needs a short run that does retry, because a run with no nack never touches the latch at all.
13. RTL 8 — When Is A Read Actually Finished
A read completes when the data and the completion response have both arrived, for the same transaction:
// DATA_IS_ENOUGH retires on the data alone, which loses the response that
// carries the final permission the requester is allowed to assume.
assign complete = live_q && ((DATA_IS_ENOUGH != 0) ? d_q : (d_q && c_q));
assign tag_mismatch_err = (data_en && live_q && (data_tag != tag_q))
|| (comp_en && live_q && (comp_tag != tag_q));
assign half_complete_err = complete && !(d_q && c_q);Measured:
complete: data-only build declared complete early=1 half_complete=1 tag_mismatch=1The data is not the answer. The data tells the requester what the value is; the completion response tells it what permission it now holds and that the serialisation point has finished ordering the transaction. Retiring on the data alone means the requester proceeds with an assumption about its permission that nothing confirmed — and if the flow was a forwarded read, that assumption is often E when the correct answer is S.
Both halves are tag-matched, in both directions. A wrong-tagged data beat and a wrong-tagged response were both driven while a transaction was live, and both were reported and neither counted. That is the 13.2 identity-matching result applied to a two-part completion, and the response half is the one usually left unchecked.
The retirement path clears both halves. Without that, a fresh transaction inherits the previous one's state and completes on the arrival of a single beat — measured explicitly by checking that with nothing outstanding, neither half is still showing.
14. RTL 9 — What Each Path Cost
logic [31:0] weighted; // 16 x 100 needs 23 bits; 16 would silently wrap
assign total = {1'b0, n_hit} + {1'b0, n_mem} + {1'b0, n_fwd};
assign weighted = {16'd0, n_hit} * 32'd100;
assign hit_rate_pct = (total == 17'd0) ? 8'd0 : (weighted / {15'd0, total});Ten reads: seven hits, two memory fetches at 40 and 60 cycles, one forward at 20. Measured:
cost : hits=7 fetches=2 forwards=1 | hit rate=70% mean fetch=50 fwd=20The forward was faster than the fetch — 20 cycles against 50 — which is the single-die case where cache-to-cache transfer is the win it is reputed to be. 13.3 measured the opposite across a CXL link, and the two results are not in conflict: the comparison depends entirely on whether the supplying peer is nearer than memory. Separating the two means the counter can answer the question rather than assuming it.
The width comment and the empty-sample guard are the same defect classes as Modules 12 and 13. The hit rate reports zero before any access, not a hundred, and the product is computed in 32 bits because a 16-bit multiply by 100 wraps above 655 samples.
15. RTL 10 — The Flow Assembled
Classify, gather, fill, done — with one invariant that spans the whole thing:
// The invariant: when the flow is not running, the line must be stable. A
// flow that aborts without restoring the line leaks a transient nobody owns.
assign stuck_transient_err = (st_q == IDLE) && (line_q > 4'd4);and the landing state that depends on how the data arrived:
FILL: if (data_ok) begin
line_q <= fwd_q ? S : E; // forwarded lines are shared
st_q <= DONE;
endFour flows were driven — a hit, a forwarded miss, a memory miss, and an abandoned flow. Measured:
assembled: completed=3 aborted=1 stuck_transient correct=0 leaking=1
holds : gather stage=2 fill stage=3 (both wait, neither advances)A hit reached the done stage without entering a transient at all, which is the section 7 classification showing up in the flow. A forwarded miss landed SHARED and a memory miss landed EXCLUSIVE, from identical flow logic differing only in whether a peer supplied the data.
The abort is the interesting case. The correct build restored the line to I; the LEAK_TRANSIENT build left it in IS_D with the flow idle. That line is now unreachable: every access to it classifies as blocked, and nothing will ever complete the transient because the flow that owned it is gone. It is a permanent, silent loss of one cache line, and the only thing that detects it is a monitor comparing the flow's stage against the line's state.
16. Quantitative Reasoning
How much of a read is the transient. Measured on the assembled flow: the line was in IS_D for four of the seven cycles a forwarded miss took — 57%. That fraction is set by the snoop round trip, not by the workload, so it rises with link latency and is the reason 13.4's transient discussion is not an implementation detail.
What the classification is worth. At the measured 70% hit rate, seven of ten reads are not transactions at all. The remaining three cost 50 cycles (fetch) or 20 (forward). Mean read latency is therefore 0.7 × 1 + 0.2 × 50 + 0.1 × 20 = 12.7 cycles — and moving one fetch to a forward moves it to 9.7, a 24% improvement from a change that touches only where the data lives.
Snoop fan-out sets the floor on a miss. The flow cannot proceed until every asked peer answers, so the gather costs the slowest response, not the average. With four peers and a 6-cycle mean response at a 12-cycle worst case, the expected gather is much closer to 12 than to 6 — and adding peers moves the maximum, not the mean. This is why snoop filters exist, and Modules 15 and 16 own that.
The retry budget is a latency-versus-detection trade. A budget of 4 caps the wasted work on a contended line at four round trips before the flow gives up and reports. An unbounded retry has no cap and no report. At the measured peak of 4 attempts, the difference between the two builds is a bounded 4-round-trip loss against an unbounded silent stall.
Completion needs two tags, not one. Matching both the data and the response against the transaction identity costs one comparator per half, per outstanding transaction. At 16 outstanding transactions and a 3-bit tag that is 96 bits of comparison — trivially cheap against the alternative, which is retiring the wrong transaction the first time two are in flight.
17. Assertions
Presented as SystemVerilog and executed as procedural checkers — see section 19.
Data is never accepted while a snoop is outstanding.
property p_no_early_data;
@(posedge clk) disable iff (!rst_n)
(phase == SNOOP && !snoop_done) |-> !data_accepted;
endpropertyA flow does not complete before its data.
property p_no_early_complete;
@(posedge clk) disable iff (!rst_n) ack |-> (phase == DATA || phase == DONE);
endpropertyEvery step of a flow is an edge the graph has.
property p_legal_path;
@(posedge clk) disable iff (!rst_n) step_en |-> edge_legal;
endpropertyExactly one classification per access.
property p_one_class;
@(posedge clk) disable iff (!rst_n)
acc |-> $countones({hit, miss_mem, miss_fwd, blocked}) == 1;
endpropertyA miss with a dirty peer is never served from memory.
property p_no_stale_fetch;
@(posedge clk) disable iff (!rst_n) (acc && peer_has_dirty) |-> !miss_mem;
endpropertyThe gather completes only when every peer has answered.
property p_full_gather;
@(posedge clk) disable iff (!rst_n) all_answered |-> (outstanding == 0);
endpropertyNever two peers claiming the data.
property p_one_supplier;
@(posedge clk) disable iff (!rst_n)
(rsp_valid && rsp_has_data && data_seen && !already_seen) |-> two_data_err;
endpropertyData is accepted only from the named forwarder.
property p_named_source;
@(posedge clk) disable iff (!rst_n) accept |-> (data_from == expected_source);
endpropertyA read and a snoop are never both applied to one state.
property p_no_double_apply;
@(posedge clk) disable iff (!rst_n) !(serve_read && serve_snoop);
endpropertyEvery request is served, replayed, or reported.
property p_nothing_dropped;
@(posedge clk) disable iff (!rst_n) read_req |-> (serve_read || read_replay);
endpropertyA read completes only with both halves.
property p_both_halves;
@(posedge clk) disable iff (!rst_n) complete |-> (got_data && got_comp);
endpropertyAn idle flow leaves no line in a transient.
property p_no_stranded_line;
@(posedge clk) disable iff (!rst_n) (stage == IDLE) |-> (line_st <= O);
endproperty18. Mutation Testing
94 mutations were injected into the ten models, one at a time, each a single-line change a competent engineer could plausibly write. Every one must make the testbench print RESULT: FAIL.
| Model | Mutations killed |
|---|---|
read_flow | 12 / 12 |
path_check | 9 / 9 |
read_classify | 8 / 8 |
snoop_fanout | 12 / 12 |
data_forward | 8 / 8 |
read_snoop_race | 8 / 8 |
read_retry | 9 / 9 |
read_completion | 11 / 11 |
read_cost | 7 / 7 |
read_top | 10 / 10 |
| Total | 94 / 94 |
Representative mutations, all killed:
| Mutation | What it models |
|---|---|
| Data accepted before the snoop resolves is not reported | a superseded value installed as current |
| The flow advances without the snoop completing | a phase machine driven by time, not events |
| A read miss may land directly in E | a transition the graph does not have |
| A remote read of M drops to S | the Owned state silently removed from the graph |
| Every miss is fetched from memory | a stale value returned with no error |
| Two classifications are not detected | an access that is both a hit and blocked |
| The flow proceeds on the first data response | peers abandoned mid-gather |
| A repeat from the data peer counts as two claimants | an alarm that fires on a retransmission |
| Data from any source is accepted | a stale copy from an invalidated peer |
| The read wins against a snoop | an ordering decision overwritten |
| The correct build also retries forever | a silent livelock |
| The peak is sampled rather than latched | the contended line is gone before anyone looks |
| The data alone retires the flow | a permission assumed but never confirmed |
| A response for another tag still counts | the wrong transaction completed |
| A forwarded line lands exclusive | exclusivity granted while a peer still reads |
| The correct build also leaks the transient | one cache line permanently unreachable |
Twenty mutations survived the first run. None was patched away; each was classified and either the testbench or the design was extended.
Thirteen stimulus gaps. The bench never held a flow in the data phase with no data, never held it in the snoop phase with no response, never held the assembled flow in the gather or the fill, never idled while a retry was active, never issued a second gather start while one was running, never issued a request the home declined to redirect, never sent a wrong-tagged completion response (only a wrong-tagged data beat), never started a second transaction while one was live, never repeated a response from the peer that had already supplied data, and never checked the outstanding count while idle. Thirteen cases added, thirteen mutations killed.
Five unobserved outputs. The outstanding count on a closed gather, the response count after a duplicate, the attempt count while merely waiting, the data half after retirement, and the hit rate before any access were all computed and never read.
Two unreachable checkers. multi_class_err and dropped_err are both unreachable in a correct design — that is what makes them invariants. The first needed a FAULT_INJECT build that drops the transient guard; the second needed a third build of read_snoop_race, DROP_LOSER, because neither the correct build nor the both-served build ever drops a request. Adding a parameter specifically to reach a monitor is not a workaround; it is the only honest way to know the monitor works.
Three findings worth stating separately, because each hid a mutation and each is a real lesson:
- The exhaustive sweep must not feed the path counter. Sweeping 384 combinations with the step enable high poured 356 illegal steps into the counter the path walk uses, making it meaningless. The sweep reads the combinational legality only.
- Waiting is not retrying. A build that advances the attempt counter every cycle while a flow is active, rather than only on a nack, passes every test in which the flow never idles.
- A short run that does not retry cannot test the peak latch. The latch lives inside the nack branch, so the "a later shorter window must not reduce the peak" check needs a shorter run that does retry.
A survivor is a finding about the testbench, not a nuisance. Recording one as an acceptable escape converts a verification gap into a documented feature.
19. Verification Strategy
The oracle must not be the design. Each testbench models the same behaviour in a structurally different representation.
For read_classify the design produces four one-hot booleans. The oracle produces a single integer outcome code, so a design bug that asserts two of them cannot be reproduced by a reference that structurally has only one:
function integer o_class(input integer st, input integer dirty);
begin
if (st > 4) o_class = 3;
else if (st != 0) o_class = 0;
else if (dirty != 0) o_class = 2;
else o_class = 1;
end
endfunctionFor path_check the design is a nested case statement over states and causes. The oracle is an explicit edge list written as integer comparisons, transcribed independently from 13.4's table, and the two are compared across all 384 combinations rather than on a sampled subset. Exhaustion is what finds an edge that exists and should not — a directed test can only find edges that are missing, because nobody writes a test for a transition they did not intend to implement.
For snoop_fanout the design holds a bit vector. The oracle holds four plain integers, so a vector-indexing bug cannot appear identically in both.
For read_completion the design has one complete expression. The oracle holds two independent booleans and an explicit AND, so a design that collapses the two halves cannot be validated by a reference that has already collapsed them.
Every displayed value is a captured signal. No $display in these benches prints a literal, and where a value is sampled before a later event changes it, it is latched into a named integer first.
Delta-cycle discipline. Every sample of a combinational output is preceded by a settle, because a continuous assignment read in the same delta as its driver changes returns the previous value. The waveform harness has a #1 at the top of its per-cycle task for exactly this reason.
Coverage recorded: 161 assertion sites across three testbenches; all four read classifications driven across the whole state space; all 384 graph combinations swept; gathers driven with duplicates, two claimants, and a second start mid-gather; forwarding driven with the right source, the wrong source, and nothing outstanding; the race driven uncontested in both directions and contested across three builds; retries driven to success, to the budget, and while idle; completion driven with each half alone, both, and mismatched tags in both directions; and the assembled flow driven as a hit, a forwarded miss, a memory miss and an abort.
20. Synthesis and Implementation Reality
The phase machine is per outstanding transaction, not per line. The model here has one; a real read path has one per MSHR, and the MSHR count is what bounds how many misses can be in flight. Every structure in this chapter is replicated that many times.
The gather is the expensive structure. It needs one bit per peer for "asked", one per peer for "answered", plus the supplier identity — and it is written on every response and read every cycle to compute the outstanding count. That combinational reduction across the peer vector is on the flow's critical path, and at large peer counts it is pipelined, which introduces a window in which the count is stale.
Two-tag completion matching is a small CAM. Each outstanding transaction's tag is compared against the incoming data tag and the incoming response tag, every cycle, in parallel. It is not large, but it is wide and it is on the response path.
The retry counter must be per transaction and must reset. A shared counter turns one contended line into a budget exhaustion for every flow behind it. The latched peak, by contrast, is deliberately global — it is a diagnostic, not a control.
stuck_transient_err is a handful of gates and belongs in silicon. It compares the flow's stage against the line's state, both of which already exist. A machine check on it converts a permanently unreachable cache line into a fault with an address attached, and the alternative is a line that silently stops serving accesses for the lifetime of the system.
Reset must leave no flow active and no line in a transient. A read path that comes out of reset with a stage register in GATHER will wait forever for responses to a request that was never sent — and its line is in a transient nothing will complete.
21. Silicon Observability
| Counter | Question it answers |
|---|---|
hit_rate_pct | what fraction of reads are transactions at all |
n_fwd against n_mem | how often a peer, rather than memory, holds the current data |
mean_fwd against mean_mem | whether forwarding is actually faster on this topology |
max_tries_seen | the worst contention ever seen on any line, latched |
n_retries | how much work is being repeated |
n_races and n_replays | how often reads and snoops collide on one line |
n_rejected | data arriving from agents the home did not name |
n_started minus n_completed | transactions that never finished |
Five error signals belong in silicon, not just in simulation. data_before_snoop_err, two_data_err, wrong_source_err, half_complete_err and stuck_transient_err all detect states from which no correct behaviour is possible, and all five are a handful of gates over signals that already exist. Each is unreachable in a correct design, so a zero reading proves nothing until a fault-injection build has demonstrated it firing.
n_started minus n_completed should equal the transactions currently in flight. A persistent drift means a flow was abandoned — an MSHR freed without its phase machine retiring — and it is silent until the line is touched again, at which point it classifies as blocked forever.
mean_fwd against mean_mem is the counter that decides a placement question. If forwarding is slower than fetching on this system, the peers holding the data are further away than memory, and the fix is where the data lives rather than anything in the read path.
22. Debug Lab
A read returns a value that was overwritten
STALE-FETCHAn agent reads a location and receives a value another agent overwrote. Every access completed successfully. No coherency error counter moved.
classify: hit=4 mem=1 fwd=1 blocked=4 | multi correct=0 faulty=1Compare n_mem against n_fwd for the address. A miss served from memory while a peer held dirty data is the signature: memory's copy is stale by definition, and fetching it is a correct-looking transaction that returns a superseded value.
A classifier that ignores whether a peer holds dirty data; a snoop filter that under-approximated and suppressed the snoop that would have revealed the peer; a directory entry whose dirty bit was lost.
Drive a miss with a dirty peer and confirm miss_fwd rather than miss_mem. Then confirm exactly one classification fires per access — a design that can produce two has an ordering assumption somewhere that is not written down.
The read took the memory path when the current data was in a peer cache. Nothing in the flow is wrong; the flow chosen was the wrong one.
assign miss_fwd = acc && !resident && !transient && peer_has_dirty;
assign miss_mem = acc && !resident && !transient && !peer_has_dirty;The two paths must be distinguished by the dirty fact, not by convenience. Add the one-hot classification check alongside them — it costs an adder and catches the case where a design is simultaneously taking two paths.
A cache line becomes permanently unreadable
LEAKED-TRANSIENTEvery access to one address is blocked and never completes. The address is not valid in any cache's tag array, and no error is reported.
assembled: completed=3 aborted=1 stuck_transient correct=0 leaking=1stuck_transient_err compares the flow's stage against the line's state and is true whenever an idle flow has left a line in a transient. Then compare n_started minus n_completed against the transactions actually in flight.
A flow aborted on a timeout or an error without restoring the line; an MSHR freed by a reset that did not touch the tag array; a cancellation path that clears the transaction and forgets the line.
Abort a flow mid-gather. The correct build restores the line to invalid; the leaking build leaves it in the fill transient with nothing driving it. Every subsequent access classifies as blocked, forever, because the transient can only be left by a fill that will never arrive.
The transaction owned the transient. Ending the transaction without ending the transient orphans the line permanently.
GATHER: if (abort) begin
line_q <= I; // restore before releasing the flow
st_q <= IDLE;
endWire stuck_transient_err to a machine check. It is two comparisons over registers that already exist, and the alternative is one cache line silently lost for the lifetime of the system.
A read completes with data that is about to be wrong
EARLY-DATAUnder load, occasional reads return a value that was correct microseconds earlier. The failure never reproduces in a directed test.
flow : flows=3 snooped=1 direct=1 | data-before-snoop=1 early-complete=1data_before_snoop_err fires when a data beat is accepted while the snoop fan-out is still outstanding. If it is clean, check whether the design even has a snoop phase — a build that skips it entirely never reaches the condition.
A flow that accepts data as soon as it arrives; a snoop phase skipped when the data path is faster; a phase machine that advances on elapsed time rather than on the snoop responses.
Drive a data beat during the snoop phase and confirm the flow does not advance. Then hold the flow in the snoop phase with no responses at all for several cycles and confirm it still does not advance — a machine that advances on time passes the first test and fails the second.
The data was correct when it was sent and superseded by what the snoop returned. Both messages are well-formed; only their order matters.
SNOOP: if (snoop_done) ph_q <= DATA; // never on elapsed timeWalk the flow against the legal-edge graph. A design that fetches straight into a stable state without passing through the transient is taking a transition the state machine does not have, and that check is a lookup.
A snoop response arrives for a transaction that finished
ABANDONED-PEERLate snoop responses arrive with nothing outstanding and are discarded. Occasionally a line stays cached in a peer the requester believes was invalidated.
gather : first-wins build complete with a peer still outstanding=1Check whether the gather completes on the data or on the full peer set. A gather that closes when one peer supplies the data abandons every peer that had not answered, and their responses arrive after the flow has moved on.
An all_answered condition driven by the data flag rather than the outstanding count; a gather whose peer mask was under-populated; a snoop filter that suppressed a peer that genuinely held the line.
Ask three peers and have the second supply the data. The correct gather is still outstanding; the first-wins gather has already closed. Then check the outstanding count on the closed gather — a closed gather must report nobody outstanding, because the peers it abandoned are not outstanding, they are lost.
Having the data is not the same as having every answer. The other peers' responses carry state changes the flow needed to observe.
assign all_answered = busy_q && (outstanding_count == 0);Count answers, not data. The two coincide in every test where the supplying peer answers last, which is why this bug survives directed testing.
A requester installs a stale line from a peer
WRONG-SOURCEA read completes with data from a peer that no longer holds the line. The data is well-formed and on time. The peer that should have supplied it also sends data, which is discarded as a duplicate.
forward : rejected=1 forwarded=1 wrong_source=1 unsolicited=1wrong_source_err compares the sender against the agent the serialisation point named. If the design accepts data from any sender, the first arrival wins and the correct one is dropped as a duplicate — which inverts the symptom.
A requester that accepts the first data beat for its transaction; a peer invalidated with a data message already in flight; a forwarder identity that was not carried in the response.
Arm the requester with a named forwarder, then send data from a different peer. The correct build rejects and stays armed; the trusting build accepts and completes. Separately, send data with nothing outstanding and confirm it is reported rather than silently dropped.
An invalidated peer's in-flight data is indistinguishable from a valid response except by its source. The source is the only evidence available.
assign accept = data_valid && armed_q && (data_from == expected_source);Carry the forwarder identity from the home to the requester and check it on arrival. Alarm on a mismatch rather than dropping silently — a rejected beat and a missing beat need different investigations.
Two changes applied to one line in one cycle
READ-SNOOP-RACEA line ends up in a state that no single transition produces. Every request was answered. No request was dropped.
race : replays=1 both_served correct=0 faulty=1 dropped(drop build)=1both_served_err is one gate on two signals. Check it before anything else, and note that dropped_err is clean on this bug — the faulty build answers every requester, so a monitor watching for unanswered requests reports a healthy run.
A read path and a snoop path that both write the line state; an arbiter that serves whichever arrives first; a pipelined design where the two paths commit in different stages.
Drive a read and a snoop for one line in one cycle. The snoop must win and the read must be replayed against what the snoop leaves. Confirm the read is replayed rather than dropped: a third build that neither serves nor replays it is the only way to reach the dropped-request monitor at all.
The snoop carries an ordering decision the serialisation point already committed to; the read has not been ordered yet. Applying both means one of those two facts is now false.
assign serve_snoop = snoop_req;
assign serve_read = read_req && !snoop_req;
assign read_replay = read_req && snoop_req;Replay, do not drop. A dropped read is indistinguishable from a slow one at the requester, so a design that drops the loser cannot be told apart from one that is merely congested.
One address stops making progress and nothing reports it
SILENT-LIVELOCKThroughput on one address collapses. The system is otherwise healthy. Retry counters rise steadily and no error is raised.
retry : peak attempts=4 give_up=1 | no-ceiling build still active=1Read max_tries_seen, latched, rather than the current attempt count — by the time anyone looks the contended flow has been replaced. Then check whether the retry path has a ceiling at all.
An unbounded retry loop; a shared retry counter that one contended line exhausts on behalf of every flow behind it; an arbiter that starves one requester under a specific arrival pattern.
Nack a read repeatedly. The bounded build gives up and raises a livelock report; the unbounded build is still retrying nothing. Then check that merely waiting does not advance the attempt count — a build that increments every cycle while active makes the counter useless as evidence.
An unbounded retry does not fail. It stops making progress, which looks like a performance problem rather than a fault, and no counter distinguishes the two.
assign give_up = act_q && (try_q >= MAX_TRIES);
assign livelock_err = act_q && (try_q >= MAX_TRIES);Every retry loop needs a ceiling and a report. The ceiling bounds the wasted work; the report is the only thing that turns a silent stall into a diagnosable fault.
A requester assumes exclusivity it was never granted
HALF-COMPLETIONAn agent writes a line it believes it holds exclusively. Another agent is reading the same line. Both reads and the write completed normally.
complete: data-only build declared complete early=1 half_complete=1 tag_mismatch=1half_complete_err fires when a flow is declared complete with only one of its two halves. Check whether the design retires on the data alone — the data says what the value is, the completion response says what permission the requester now holds.
A completion condition driven by the data beat; a response dropped and never noticed; a forwarded read whose response would have said SHARED and whose absence left the requester assuming EXCLUSIVE.
Start a transaction, deliver the data alone, and check whether the flow retires. Then deliver a wrong-tagged response while a transaction is live — the response half is the one usually left unchecked, and a mutation removing that check survives any test that only mismatches the data.
The requester proceeded on an assumption about its permission that nothing confirmed. On a forwarded read the correct answer is usually SHARED, and assuming EXCLUSIVE breaks the single-writer invariant directly.
assign complete = live_q && got_data && got_comp;
assign tag_mismatch_err = (data_en && live_q && (data_tag != tag_q))
|| (comp_en && live_q && (comp_tag != tag_q));Clear both halves on retirement. Without that, a fresh transaction inherits the previous one's state and completes on a single beat — check explicitly that with nothing outstanding, neither half is still showing.
23. Design Review
What was built. Ten models: a four-phase flow machine with a snoop-skipping twin, a path checker that walks 13.4's graph, a four-way classifier with a fault-injection build, a snoop gather with a first-wins twin, a forwarding acceptor with a trust-anyone twin, a race resolver in three builds, a bounded retry with an unbounded twin, a two-half completion with a data-only twin, a cost model, and an assembled flow with a transient-leaking twin.
What was measured. Three flows with both premature-completion cases caught. 28 legal edges out of 384, matching 13.4 exactly, with the skip-snoop flow shown to have no edge. Four classifications across the whole state space with exactly one firing per access. A gather that stayed open with a peer outstanding while the first-wins build had already closed. Data rejected from an unnamed source and accepted from the named one. A race in which the snoop won, the read was replayed, and three different builds produced three different failures. A retry that gave up at a latched peak of 4 while the unbounded build was still going. A completion that needed both halves, with mismatched tags caught in both directions. A 70% hit rate with forwards at 20 cycles against fetches at 50. An abort that restored the line while the leaking build stranded it.
What would be different in production. Every structure here is replicated per MSHR. The gather's outstanding count is pipelined at scale, which reintroduces a stale-count window. The retry policy is tuned against real contention traces rather than a fixed budget. The forwarding path carries an address and a full transaction identity rather than a two-bit peer id. None of that changes the four phases; all of it multiplies the places they can be got wrong.
The strongest argument against this design. Waiting for every snoop response before accepting data costs the slowest peer on every miss, and the data is usually already in hand when the first peer answers. That argument is correct about the latency, and the measured consequence of acting on it is that abandoned peers' responses arrive after the flow has retired — where they are applied to a transaction that no longer exists or dropped, with the peer left holding a copy the requester believes was invalidated. The honest form of the optimisation is to use the data early and retire late, which is a different design from the one the FIRST_WINS build implements.
What would be built differently next time. The exhaustive graph sweep and the path counter should have been separated from the start. Feeding 356 illegal combinations into the counter the real path walk uses made that counter meaningless, and it took a mutation to notice. A sweep is a query, not a sequence of steps, and the RTL should have made that distinction structurally rather than leaving it to the testbench.
24. How This Appears In Real Engineering
In a microarchitecture review, the question that separates a specified read path from an aspirational one is what happens to a snoop that arrives mid-flow. If the answer is "it is handled by the arbiter", the design has a race nobody has enumerated.
In bring-up, n_started minus n_completed and stuck_transient_err are the first two things to look at. Both detect abandoned transactions, both are silent in every other view, and both are trivially cheap if they were designed in.
In a performance investigation, mean_fwd against mean_mem decides whether cache-to-cache transfer is helping. It is a single-die instinct that holds on a die and inverts across a link, and the counter is the only thing that will say which case this system is in.
In a verification plan review, the question to ask about the flow machine is whether it has been held in every phase with the advancing event absent. A phase machine driven by elapsed time passes every test in which the event is prompt, and prompt is what a directed test naturally produces.
In silicon debug, the distinction between a dropped read and a slow read does not exist at the requester — both are a timeout. The counter that separates them lives in the responder and has to be designed in before tapeout.
In a design review of somebody else's read path, ask when the transaction retires. If the answer is "when the data arrives", the design is assuming a permission that nothing confirmed, and on a forwarded read that assumption is usually wrong in the direction that breaks the single-writer invariant.
25. Common Misconceptions
"A read is an access." Only when it hits. Measured: 7 of 10 reads were hits and not transactions at all; the other 3 were multi-message flows spending most of their life in a transient.
"The data arriving means the read is done." The data says what the value is; the completion response says what permission the requester now holds. Measured: the data-only build declared completion one half short, and half_complete_err fired.
"The response comes from wherever the request went." On a forwarded read the requester never sends the supplying peer a message. Measured: data accepted from the named forwarder, rejected from another peer, and reported when it arrived with nothing outstanding.
"Once you have the data you can stop snooping." The other peers' responses carry state changes the flow needed to observe. Measured: the first-wins build closed its gather with a peer still outstanding.
"A read losing a race should be retried by the requester." It should be replayed against the state the winner left. A write that was legal against one state may need a different flow against the next, and simply reissuing the same request re-enters the classifier from the top — which is a property of the surrounding pipeline, not of the race resolver.
"An unbounded retry is safer than giving up." It is the opposite. Measured: the bounded build reported a livelock; the unbounded build made no progress and raised nothing, which presents as a performance problem rather than a fault.
"Every fill lands in Exclusive." Only when nothing else holds the line. Measured: a forwarded fill landed SHARED and a memory fill landed EXCLUSIVE, from identical flow logic differing only in whether a peer supplied the data.
"An aborted flow just frees the transaction." It must also restore the line. Measured: the leaking build left a line in a fill transient with no flow driving it — permanently unreachable, and detected by nothing except a monitor comparing stage against state.
26. Interview Reasoning
27. Exercises
-
Calculation. A system reads at a 70% hit rate, with fetches at 50 cycles and forwards at 20. Compute the mean read latency, then compute it again if half the fetches become forwards, and express the improvement as a percentage.
-
Analysis. A gather asks 8 peers whose response latencies are uniformly distributed between 4 and 16 cycles. Explain why the expected gather time is not 10, state what it is closer to, and name the structural change that reduces it.
-
RTL task. Extend
snoop_fanoutto support two concurrent gathers for different lines. State what state that requires per gather, and the failure that becomes possible if the two share one supplier register. -
Assertion task. Write the property proving a read completes only with both halves. Then explain why it passes trivially on a design where the response is generated from the data beat, and what independent source of the completion response is required to make it meaningful.
-
Design task. Add a speculative-use path that forwards the data to the requesting core before the gather completes, without retiring the transaction. State what must be squashed if a snoop response contradicts the speculation, and which of this chapter's monitors must change.
-
Testbench design. Design the stimulus that distinguishes a flow machine driven by events from one driven by elapsed time. Explain why a test that drives the advancing event promptly passes on both, and state the minimum stimulus that separates them.
-
Debug task. A system reports
n_startedrising withn_completedtracking it exactly, and yet one address is permanently blocked. Give your investigation order, name the monitor that detects it directly, and explain why the transaction counters do not. -
Design review. A colleague proposes removing the source check on forwarded data, arguing that the home would not name a peer that cannot supply the line. Give the strongest version of that argument, then the situation in which it is false, and the measurement that would reveal it.
28. Summary
A read is a path, not a step.
- Only a hit is not a transaction. Measured across the whole state space: 4 hits, 1 memory fetch, 1 forward, and 4 blocked — the blocked case being more common than either permission problem.
- Every flow is a path through 13.4's graph. 28 legal edges out of 384 combinations, matching that chapter exactly, and the skip-snoop flow was shown to have no edge at all.
- A miss with a dirty peer is never served from memory. Memory's copy is stale by definition; fetching it completes normally and returns a superseded value.
- The gather completes on answers, not on data. The first-wins build closed with one of three peers still outstanding, abandoning responses that arrive after the flow has retired.
- The data comes from an agent the requester never contacted, and it must be accepted only from the one the serialisation point named. Rejected from another peer, accepted from the named one, reported when unsolicited.
- In a race the snoop wins and the read is replayed. Three builds produced three distinct failures, and the both-served build's
dropped_errstayed clean — a monitor watching for unanswered requests reports a healthy run on it. - A retry needs a ceiling and a report. Latched peak of 4 attempts, with the unbounded build still retrying nothing and raising nothing.
- A read finishes on both halves. The data says what the value is; the response says what permission was granted. The data-only build declared completion one half short.
- A forwarded fill lands SHARED, a memory fill lands EXCLUSIVE, from identical logic differing only in whether a peer supplied the data.
- An abandoned flow must restore its line. The leaking build stranded a line in a fill transient — permanently unreachable, detected by nothing but a monitor comparing stage against state.
- Verification: 161 assertion sites, 94 of 94 mutations killed, zero surviving. Twenty first-run escapes were thirteen stimulus gaps, five unobserved outputs, and two unreachable checkers — one of which needed a third build of the same module to be reachable at all.
Next: 14.2 Coherent Write Flows, which takes the same four-phase shape and adds the thing a read never needs: permission the agent does not yet have, acquired as a transaction of its own.
Continue learning
Related tutorials
- Related topic
Cache Coherency Over CXL
Why a device caching host memory needs transient state and not just MESI — CXL.cache's three channels each direction, why a tag hit is not permission, the same-line restrictions the specification imposes, the snoop-versus-eviction race, dirty-data ownership, why a coherence timeout cannot restore the previous state, channel-dependency deadlock, and the coherence reference model.
- Related topic
Why CXL Matters
Why PCIe transaction semantics are insufficient for coherent memory and accelerator attach — CXL.io, CXL.cache and CXL.mem as the CXL Consortium defines them, device types, why coherence needs distributed per-line state, memory-window routing, what coherence costs, and why a clean transport proves nothing about coherence.
- Related topic
CXL-over-UCIe Integration
Composing a CXL-coherent chiplet from three state planes that must agree — memory mapping, coherence ownership, and transport. Why one plane being valid proves nothing about another, why one transaction occupies four tracking entries that are not duplicates, why semantic state must not retire at a transport event, and the three-model scoreboard that attributes a failure to a plane.
- Related topic
End-to-End Data-Flow Examples
Three complete transaction classes traced cycle by cycle through the whole UCIe stack — a memory read with a transport retry, a multi-beat memory write with a stall and a partial final beat, and a coherent ownership change with a retry mid-flow — each with initial state, RTL exercised, injected failure, assertions, scoreboard snapshots, and retirement.
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.
