CXL · Module 4
Transaction Layer
The layer that knows what a request means, and where the three CXL protocol classes stop being interchangeable — different ordering requirements, different latency targets, and why they need separate resources.
Chapter 4.2 delivered units intact and said nothing about what they mean. That was the point — the link layer must not know.
This chapter is the layer that does know, and knowing turns out to be the place where the three protocol classes stop being interchangeable.
1. The Engineering Problem — One Layer, Three Different Contracts
Below this layer, everything is symmetric. The physical layer moves flits; the link layer delivers them intact. Neither distinguishes a configuration write from a coherent read, and neither should.
Above this layer, the differences are total. A configuration write to a device register and a coherent read of a cache line are not two instances of one thing with different payloads. They have different ordering requirements, different latency expectations, and different consequences when they are delayed.
So the transaction layer faces a problem the layers below never had: it must serve three classes that agree on almost nothing, over shared machinery, without letting the requirements of one become the requirements of all.
The failure mode when it gets this wrong is not incorrectness. A design that applies the strictest class's rules to everything is correct — and slow, in a way that looks like a hardware limitation rather than a policy choice. Section 9 measures exactly that.
2. The One-Sentence Model
The transaction layer is where meaning enters the stack, and meaning is what makes the three classes different: each needs its own ordering rule, its own resources and its own latency budget — so a transaction layer that treats them uniformly is not simpler, it is one that has silently adopted the strictest requirement for all three and the weakest guarantee for each.
Call it the three contracts model. One layer, three service agreements, and the engineering is in keeping them separate.
3. What This Chapter Owns
| Question | Owned by |
|---|---|
| Getting flits onto the wire | 4.1 |
| Delivering them intact | 4.2 |
| Transaction semantics and per-class divergence | this chapter |
| The three protocols compared in full | 4.4 |
| Stack-level PCIe comparison | 4.5 |
| Coherence semantics | 3.4 |
A deliberate division with 4.4. This chapter is about what the layer must do given that classes differ. Chapter 4.4 is about what the three classes are, side by side. Here they are a source of divergent requirements; there they are the subject.
4. Why the Classes Diverge
Start from what each class is for, using the Consortium's own descriptions from Chapter 3.2:
| Class | Consortium says | Implies |
|---|---|---|
.io | discovery, register access, DMA | order matters to software |
.cache | device caches host memory | per line; latency critical |
.mem | host uses device memory | per addr; latency critical |
Three consequences follow, and they are the chapter.
.io needs producer/consumer ordering. A register write that arms a device followed by one that triggers it must arrive in that order — reordering them is not slow, it is wrong. That requirement comes from software semantics that predate CXL entirely.
.cache and .mem need ordering per address, not globally. Chapter 3.4 established that coherence is a per-line property: two requests to different lines have no relationship and serialising them buys nothing. Forcing global order on coherent traffic is the single most expensive mistake available at this layer.
.cache and .mem have a latency target that .io does not. The Consortium states it directly:
5. The Mental Model — Three Contracts Over Shared Machinery
.io .cache .mem
┌───────────┐ ┌───────────┐ ┌───────────┐
│ in order │ │ per-line │ │ per-addr │
│ no target │ │ near-cache│ │ near-cache│
│ own pool │ │ own pool │ │ own pool │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
└───────────────────┼──────────────────┘
▼
shared link and PHY belowThe three boxes must stay separate all the way down to the multiplexing point. Merge them earlier — one ordering rule, one resource pool, one latency assumption — and the merge is invisible until load, at which point the strictest rule applies to everything and the weakest guarantee applies to each.
6. Quantitative Reasoning
Ordering costs concurrency
Under global ordering, a request waits for everything older. Under per-address ordering it waits only for older requests to the same address. With A distinct addresses in flight and requests uniformly spread:
P(a given request is blocked) ≈ 1/A (per-address)
P(a given request is blocked) ≈ 1 (global, if anything is outstanding)So per-address ordering scales with address diversity and global ordering does not scale at all. Section 9 measures the extreme case: 2 proceeded versus 0 on identical stimulus.
Latency budget is a maximum, not a mean
For a class targeting near-cache latency, the mean is nearly useless. A distribution with a good mean and a long tail fails the requirement on every tail event, and the tail is what software notices.
Section 12 measures a run whose mean is 8 and whose maximum is 25 against a budget of 20 — the mean passes comfortably and two transactions exceed the budget. Reporting the mean would have declared the design compliant.
Outstanding depth, per class
Each class needs enough entries for its own rate and latency, by the same Little's Law argument as Chapter 3.2 and Chapter 4.2:
entries_class ≈ rate_class × latency_classThe classes have different rates and different latencies, so they need different depths — which is a second, independent reason not to share a pool. A shared pool cannot be sized correctly for three different products of rate and latency simultaneously.
7. Teaching-model boundary
8. RTL 1 — Ordering, Per Class
Purpose
Apply each class's actual ordering requirement, and no more.
// Different protocol classes need different ordering guarantees, and a layer
// that applies one rule to all of them either over-serialises the classes that
// do not need it or under-serialises the ones that do.
//
// .io -- producer/consumer ordering matters; keep it in order
// .cache -- ordering is per address; independent lines may pass
// .mem -- ordering is per address; independent addresses may pass
//
// ARCHITECTURAL TEACHING MODEL. No CXL ordering rule, transaction-layer
// requirement or completion rule is modelled or claimed.
module class_ordering #(
parameter bit ORDER_EVERYTHING = 1'b0 // 1 = one rule for all classes
) (
input logic clk,
input logic rst_n,
input logic req_valid,
input logic [1:0] req_class, // 0=io, 1=cache, 2=mem
input logic [7:0] req_addr,
input logic older_outstanding, // any older request still in flight
input logic older_same_addr, // an older request to THIS address
output logic may_proceed,
output logic blocked,
output logic [15:0] n_proceed_q,
output logic [15:0] n_blocked_q,
output logic order_violation_err
);
logic needs_strict, needs_addr;
assign needs_strict = ORDER_EVERYTHING || (req_class == 2'd0);
assign needs_addr = !ORDER_EVERYTHING && (req_class != 2'd0);
// Strict ordering waits for EVERYTHING older. Address ordering waits only
// for an older request to the same address -- which is what lets independent
// lines proceed in parallel.
assign may_proceed = req_valid &&
!(needs_strict && older_outstanding) &&
!(needs_addr && older_same_addr);
assign blocked = req_valid && !may_proceed;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin /* ... */ end
else begin
if (may_proceed) n_proceed_q <= n_proceed_q + 16'd1;
if (blocked) n_blocked_q <= n_blocked_q + 16'd1;
// The invariant that must hold whatever the policy: two requests to the
// same address never proceed out of order.
if (may_proceed && older_same_addr) order_violation_err <= 1'b1;
end
end
endmoduleThe invariant is written against the address, not against the policy. order_violation_err fires whenever a request proceeds past an older one to the same address, regardless of which class it is or which mode the module is in. That is deliberate: it is the one rule no policy may break, so it must be checkable independently of the policy that is supposed to enforce it.
Synthesis. Two comparisons and a small amount of gating — the module's cost is trivial. The expensive part is what feeds it: older_same_addr requires comparing against every outstanding address, which is a CAM-style structure whose width and depth are the real design decision. That is why per-address ordering is architecturally cheap and structurally expensive, and why a design might reasonably choose a coarser granularity than exact address match.
Simulation evidence
=== EXP1: ordering requirements differ by class ===
.io, older request outstanding per-class: proceed=0 | one-rule: proceed=0
.cache, older req to a DIFFERENT address per-class: proceed=1 | one-rule: proceed=0
.mem, older req to a DIFFERENT address per-class: proceed=1 | one-rule: proceed=0
.cache, older req to the SAME address per-class: proceed=0 | one-rule: proceed=0
.mem, older req to the SAME address per-class: proceed=0 | one-rule: proceed=0
proceeded: per-class=2 one-rule=0 | blocked: per-class=3 one-rule=5Rows one, four and five are identical. Both policies block .io behind older traffic, and both block coherent traffic behind an older request to the same address — so on any stimulus containing only those cases the two designs are indistinguishable.
Rows two and three are the entire difference, and they are the common case. Coherent traffic to different addresses is what a working system mostly does, and the uniform policy blocks all of it. Two proceeded versus zero, on five requests.
Neither design violated the address invariant. The uniform policy is not incorrect — it is correct and it has thrown away the concurrency that made per-line coherence worth building. That is the shape to recognise: a policy failure that no correctness property can detect.
9. RTL 2 — Matching a Response to Its Request
Purpose
The transaction layer's defining job, and the term people drop.
// The transaction layer's defining job: a response must find the request that
// produced it, and must arrive on the same protocol class it left on.
//
// ARCHITECTURAL TEACHING MODEL. NOT a CXL transaction-ID or completion
// mechanism; no CXL field, encoding or matching rule is modelled.
module req_rsp_match #(
parameter int unsigned NTAG = 8,
parameter bit IGNORE_CLASS = 1'b0 // 1 = match on tag alone
) (
input logic clk,
input logic rst_n,
input logic issue,
input logic [1:0] issue_class,
input logic rsp_valid,
input logic [$clog2(NTAG)-1:0] rsp_tag,
input logic [1:0] rsp_class,
output logic issue_ok,
output logic [$clog2(NTAG)-1:0] issue_tag,
output logic [1:0] matched_class,
output logic rsp_matched,
output logic class_mismatch_err,
output logic orphan_rsp_err,
output logic [7:0] outstanding_q
);
assign matched_class = cls_q[rsp_tag];
assign class_ok = IGNORE_CLASS || (rsp_class == matched_class);
// A response matches only when the tag is live AND the class agrees.
assign rsp_matched = rsp_valid && live_q[rsp_tag] && class_ok;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin /* ... */ end
else begin
if (issue_ok) begin
live_q[issue_tag] <= 1'b1;
cls_q[issue_tag] <= issue_class; // remember which contract it was
end
if (rsp_valid) begin
if (!live_q[rsp_tag]) orphan_rsp_err <= 1'b1;
else if (rsp_class != cls_q[rsp_tag]) class_mismatch_err <= 1'b1;
if (rsp_matched) live_q[rsp_tag] <= 1'b0;
end
outstanding_q <= outstanding_q + {7'b0, issue_ok} - {7'b0, rsp_matched};
end
end
endmoduleClass is stored per tag because the tag namespaces may overlap. If each class has its own tag space — which is the natural design, since each has its own resources — then tag 1 exists in all three, and matching on tag alone will happily resolve a .cache response against a .mem request. Storing the class is what makes the tag meaningful.
Two distinct errors again. An orphan response means the tag is not live; a class mismatch means it is live and belongs to a different contract. Different causes, different fixes, and a design reporting one code for both loses the distinction.
Simulation evidence
Two requests issued on different classes, then a response arriving on the wrong one:
=== EXP2: a response must match tag AND class ===
2 issued: outstanding=2
response tag 0 class .cache : class-aware matched=1 | tag-only matched=1
response tag 1 class .cache : class-aware matched=0 | tag-only matched=1 <-- WRONG class
(tag 1 was issued on .mem)
class-aware class_mismatch_err=1 | tag-only=1The tag-only matcher accepted a .cache response for a .mem request. The tag was live, so from its perspective everything was fine — and the request it retired was not the one that completed. The class-aware matcher refused and flagged it.
Both designs set the mismatch flag, which is worth noticing for the same reason as Chapter 4.1's unknown-flit counter: the tag-only matcher detected the mismatch and accepted the response anyway. Detection wired to a status bit rather than to the decision is a recurring shape in this course, and it is always the same fix — put the term in the decision.
10. RTL 3 — Resources, Per Class
Purpose
Protect the classes with a latency target from the class without one.
// Each protocol class gets its own transaction-layer resources, because they
// have different latency obligations. The Consortium states the design target
// for .cache and .mem as near CPU cache latency; .io has no such target.
//
// ARCHITECTURAL TEACHING MODEL. Entry counts are teaching values; no CXL
// resource, credit or buffer allocation is modelled.
module class_resources #(
parameter int unsigned N_IO = 6,
parameter int unsigned N_CACHE = 6,
parameter int unsigned N_MEM = 6,
parameter bit SHARED = 1'b0
) (
input logic clk,
input logic rst_n,
input logic req_io, req_cache, req_mem,
input logic ret_io, ret_cache, ret_mem,
output logic can_io, can_cache, can_mem,
output logic [7:0] used_io_q, used_cache_q, used_mem_q,
output logic [7:0] pool_q,
output logic latency_class_blocked_err,
output logic overflow_err
);
localparam logic [7:0] POOL = N_IO[7:0] + N_CACHE[7:0] + N_MEM[7:0];
assign can_io = SHARED ? (pool_q < POOL) : (used_io_q < N_IO[7:0]);
assign can_cache = SHARED ? (pool_q < POOL) : (used_cache_q < N_CACHE[7:0]);
assign can_mem = SHARED ? (pool_q < POOL) : (used_mem_q < N_MEM[7:0]);
// The specific harm a shared pool does: a latency-critical class refused
// while the entries reserved for it are sitting free.
assign latency_class_blocked_err =
SHARED && ((!can_cache && (used_cache_q < N_CACHE[7:0])) ||
(!can_mem && (used_mem_q < N_MEM[7:0])));
// ... occupancy maintained per class and as a pool, each in one assignment ...
endmodulelatency_class_blocked_err names the harm rather than the mechanism. It does not say "the pool is full" — it says a class with a latency target was refused while its own reservation was free. That distinction is what makes it a useful diagnostic rather than a restatement of !can_cache.
Simulation evidence
Twenty cycles of .io traffic with no returns, then .cache and .mem ask:
=== EXP3: bulk .io against latency-critical classes ===
after a 20-cycle .io flood:
split : io=6 cache=0 mem=0 | can_cache=1 can_mem=1
shared : io=18 cache=0 mem=0 | can_cache=0 can_mem=0 latency_blocked=1
.cache and .mem now ask:
split : cache accepted=1 mem accepted=1
shared : cache accepted=0 mem accepted=0 <-- refusedThe split configuration capped .io at its own six entries. The shared configuration let .io take eighteen — the entire pool — and then refused both latency-critical classes, with zero entries used by either.
Read the harm precisely: the two classes the Consortium targets at near-CPU-cache latency were blocked by the one class with no latency target, and they were blocked while the resources notionally reserved for them sat empty. This is Chapter 2.4's shared-budget coupling at the transaction layer, and the reason it matters more here is the latency target: a .io transfer delayed by 10 cycles is unmeasurable, and a coherent access delayed by 10 cycles may be the difference between meeting the target and missing it.
Note that the split configuration also throttled .io. That is the cost, and it is real — bulk traffic gets less than it could take. Whether that is acceptable depends entirely on whether the latency target is a requirement or an aspiration.
11. RTL 4 — Latency Against a Budget
Purpose
Make "near CPU cache latency" into something a regression can fail.
// Per-class latency accounting, because the classes have different targets.
//
// ARCHITECTURAL TEACHING MODEL. The budget is a parameter supplied by the
// instantiator; no CXL latency figure is asserted.
module txn_latency #(
parameter int unsigned BUDGET = 20
) (
input logic clk,
input logic rst_n,
input logic start,
input logic done,
output logic [15:0] n_done_q,
output logic [31:0] lat_sum_q,
output logic [15:0] max_lat_q,
output logic [15:0] n_over_budget_q,
output logic [15:0] inflight_q
);
logic [15:0] age_q;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin /* ... */ end
else begin
inflight_q <= inflight_q + {15'b0, start} - {15'b0, done};
if (inflight_q != 0) age_q <= age_q + 16'd1;
else age_q <= '0;
if (done) begin
n_done_q <= n_done_q + 16'd1;
lat_sum_q <= lat_sum_q + {16'b0, age_q};
if (age_q > max_lat_q) max_lat_q <= age_q;
// A maximum matters more than a mean for a latency target.
if (age_q > BUDGET[15:0]) n_over_budget_q <= n_over_budget_q + 16'd1;
end
end
end
endmodulen_over_budget_q is the counter that matters and it is the one usually missing. A mean and a maximum together still do not tell you how often the budget was missed — and for a latency target, frequency is the requirement. One excursion in a million is a different product from one in ten.
Simulation evidence
Ten transactions, eight short and two deliberately long, against a 20-cycle budget:
=== EXP4: latency against a stated budget ===
10 completed: max latency=25, over a budget of 20: 2
mean = 82/10 = 8 <-- the mean hides both outliersA mean of 8 against a budget of 20 looks like a design with 60% headroom. The maximum is 25 and two of ten transactions missed the budget entirely. Any report built on the mean would have declared this compliant.
That is the general result for latency-targeted traffic, and it is why Chapter 3.6 argued for maxima at every stage: a mean describes the typical case, and a latency target is a claim about the worst one.
Same stream, two ordering policies
10 cycles12. Assertions
Concurrent SVA execution: NOT SUPPORTED BY Icarus Verilog. Not executed; each property maps to its procedural stand-in and to the mutation that proves it fires.
// SAFETY -------------------------------------------------------------------
// T1 — two requests to the same address never proceed out of order. Written
// against the ADDRESS, not the policy, so it holds whatever the policy is.
a_addr_order: assert property (@(posedge clk) disable iff (!rst_n)
may_proceed |-> !older_same_addr);
// T2 — a response is accepted only when tag AND class agree.
a_rsp_class: assert property (@(posedge clk) disable iff (!rst_n)
rsp_matched |-> (rsp_class == matched_class));
// T3 — a response is accepted only against a live request.
a_rsp_live: assert property (@(posedge clk) disable iff (!rst_n)
rsp_matched |-> live_q[rsp_tag]);
// T4 — outstanding count tracks accepted issues minus MATCHED responses.
// Decrementing on rsp_valid instead would leak on every rejected response.
a_outstanding_conserved: assert property (@(posedge clk) disable iff (!rst_n)
outstanding_q == ($countones(live_q)));
// T5 — no class exceeds its own allocation.
a_class_bounded: assert property (@(posedge clk) disable iff (!rst_n)
(used_io_q <= N_IO) && (used_cache_q <= N_CACHE) && (used_mem_q <= N_MEM));
// T6 — in a split configuration, a class is refused ONLY when its own
// entries are exhausted. This is what a copy-paste gate breaks.
a_own_gate: assert property (@(posedge clk) disable iff (!rst_n)
(!SHARED && !can_cache) |-> (used_cache_q >= N_CACHE));
// T7 — a maximum never decreases.
a_max_monotonic: assert property (@(posedge clk) disable iff (!rst_n)
max_lat_q >= $past(max_lat_q));
// LIVENESS -----------------------------------------------------------------
// T8 — a blocked request eventually proceeds once its blocker retires.
// ASSUMPTION: every outstanding request eventually completes. That is a
// constraint on the layers below, not on this module.
a_eventually_proceeds: assert property (@(posedge clk) disable iff (!rst_n)
(blocked && !older_same_addr && !older_outstanding) |-> ##[1:2] may_proceed);
// GOAL / PERFORMANCE -------------------------------------------------------
// T9 — the latency-targeted classes meet their budget. Not a correctness
// property: a design can be entirely correct and fail this.
a_latency_budget: assert property (@(posedge clk) disable iff (!rst_n)
(n_done_q > 0) |-> (n_over_budget_q == 0));
// T10 — a latency-targeted class is never refused while its own reservation
// is free. This is the shared-pool harm, stated as a property.
a_no_latency_starve: assert property (@(posedge clk) disable iff (!rst_n)
!latency_class_blocked_err);| SVA | Class | Result |
|---|---|---|
| T1 | safety | held by both |
| T2 | safety | held; tag-only accepted it |
| T3 | safety | held |
| T4 | safety | conserved |
| T5 | safety | bounded |
| T6 | safety | held; copy-paste gate flagged |
| T7 | safety | monotonic |
| T8 | liveness | proceeded |
| T9 | goal | 2 of 10 over budget |
| T10 | goal | fired, as designed |
T9 and T10 are goal properties and both are informative rather than fatal. T9 failed on a run whose mean was well inside budget; T10 fired on the shared configuration, which is the configuration being criticised. Neither indicates a bug — both indicate a design outside its intended envelope, which is exactly what a goal property is for and why it must be labelled differently from a safety property.
13. Mutation Testing
Five mutations injected into the exact tutorial RTL, compiled and simulated; good RTL restored and re-verified.
| # | Mutation | Detected by |
|---|---|---|
| P1 | same-address ordering dropped | same-address ordering violated |
| P2 | response matched on tag alone | matcher accepted a wrong-class response |
| P3 | cache gate reads the .io counter | split gate refused .cache with own entries free |
| P4 | latency max tracks the last, not the worst | max latency decreased |
| P5 | shared-pool harm flag stubbed to zero | diagnostic did not fire when it should have |
P3 also required a strengthened checker, and the first version of P3 was itself a bad mutation — changing the split gate to read the shared pool did not bite, because the .io gate still limited the pool. Replacing it with the realistic copy-paste bug (the .cache gate reading used_io_q) exposed the gap immediately. A mutation that does not change behaviour tests nothing, and choosing mutations that correspond to plausible mistakes is part of the technique.
14. Debug Lab
Coherent throughput collapses and every ordering check passes
STRICTEST-RULE-APPLIED-TO-ALL// Ordering is safety-critical, so enforce it everywhere.
assign may_proceed = req_valid && !older_outstanding;Correct data, correct ordering, and coherent traffic that will not overlap. Both policies on identical stimulus:
.cache, older req to a DIFFERENT address per-class: proceed=1 | one-rule: proceed=0
.mem, older req to a DIFFERENT address per-class: proceed=1 | one-rule: proceed=0
proceeded: per-class=2 one-rule=0.io's producer/consumer requirement was applied to classes whose ordering is per address. Chapter 3.4 established that coherence is a per-line property — two requests to different lines have no relationship, and serialising them buys nothing while costing all the concurrency the per-line design was for.
No correctness property can catch this. The uniform policy satisfies every ordering invariant, including the same-address one; it is strictly more conservative than required. The failure is a policy failure, visible only as throughput.
Give each class its actual requirement:
assign needs_strict = (req_class == CLASS_IO);
assign needs_addr = (req_class != CLASS_IO);
assign may_proceed = req_valid && !(needs_strict && older_outstanding)
&& !(needs_addr && older_same_addr);Prevention. Keep the safety property written against the address rather than the policy, so it holds under any policy and cannot be satisfied by simply blocking everything. Then measure concurrency: a goal property on proceed-versus-blocked ratio under multi-address stimulus is what turns this from invisible into reportable.
A response retires the wrong request
MATCHED-ON-TAG-ALONE// The tag identifies the transaction.
assign rsp_matched = rsp_valid && live_q[rsp_tag];A .cache response retires a .mem request. The tag was live, so nothing looked wrong at the moment of the match:
response tag 1 class .cache : class-aware matched=0 | tag-only matched=1 <-- WRONG class
(tag 1 was issued on .mem)
class-aware class_mismatch_err=1 | tag-only=1The classes have separate tag spaces — which is the natural consequence of giving them separate resources — so tag 1 exists in all three and a tag alone does not identify a transaction. The matcher was missing the term that disambiguates.
Note that the tag-only design also raised the mismatch flag and accepted the response anyway. Detection was wired to a status bit rather than to the decision, which is the same shape as Chapter 4.1's unknown-flit counter and Chapter 3.3's computed-and-ignored permission.
Store the class with the tag, and require both:
if (issue_ok) cls_q[issue_tag] <= issue_class;
assign rsp_matched = rsp_valid && live_q[rsp_tag] && (rsp_class == cls_q[rsp_tag]);Prevention. Assert rsp_matched |-> (rsp_class == matched_class), and issue on multiple classes concurrently so tags collide across classes — a single-class regression cannot produce the stimulus.
Latency-critical traffic is blocked by traffic with no latency target
SHARED-TRANSACTION-RESOURCES// One pool is simpler and uses the entries better.
assign can_cache = (pool_q < POOL);
assign can_mem = (pool_q < POOL);Coherent and memory access latency degrades whenever bulk I/O is active, with the entries notionally reserved for them completely unused:
after a 20-cycle .io flood:
split : io=6 cache=0 mem=0 | can_cache=1 can_mem=1
shared : io=18 cache=0 mem=0 | can_cache=0 can_mem=0 latency_blocked=1A shared pool couples classes with incompatible requirements. The Consortium targets .cache and .mem at near CPU cache latency; .io has no such target and unbounded volume. Sharing lets the class with no target consume the entries of the two that have one.
There is a second, independent reason sharing cannot work here: each class needs rate × latency entries, and the three classes have different rates and different latencies. A single pool cannot be sized correctly for three different products at once.
Separate allocations per class:
assign can_io = (used_io_q < N_IO);
assign can_cache = (used_cache_q < N_CACHE);
assign can_mem = (used_mem_q < N_MEM);Prevention. Instrument the harm directly — a latency-targeted class refused while its own entries are free — rather than inferring it from occupancy. And be explicit about the cost: the split configuration also capped .io at six, which is real throughput given up in exchange for a latency guarantee.
A latency budget is reported as met by a design that misses it
MEAN-REPORTED-FOR-A-TAIL-REQUIREMENT// Report average latency.
mean_latency = lat_sum_q / n_done_q; // and nothing elseThe regression passes and software reports stalls. Measured over ten transactions against a 20-cycle budget:
10 completed: max latency=25, over a budget of 20: 2
mean = 82/10 = 8 <-- the mean hides both outliersA latency target is a claim about the worst case and the mean describes the typical one. A mean of 8 against a budget of 20 reads as 60% headroom on a design where 20% of transactions exceeded the budget.
The deeper error is treating a performance requirement as an average. "Near CPU cache latency" is a bound, and a distribution meets a bound or it does not — the mean is not evidence either way.
Record the maximum and the frequency of excursions:
if (age_q > max_lat_q) max_lat_q <= age_q;
if (age_q > BUDGET) n_over_budget_q <= n_over_budget_q + 1;Prevention. Make the budget a goal property — (n_done_q > 0) |-> (n_over_budget_q == 0) — and label it as a goal rather than a correctness check, so a failure reads as "outside the envelope" rather than "broken". Also assert the maximum is monotonic: a max that can decrease is tracking the last value, which mutation P4 confirmed a plain maximum check misses.
An error flag is stubbed to zero and the regression stays green
DIAGNOSTIC-NEVER-TESTED-POSITIVE// Simplify: this condition never happens in our configuration.
assign latency_class_blocked_err = 1'b0;Nothing. Every test passes, and the system has lost its only indication that latency-critical traffic is being blocked by bulk traffic. The condition still occurs — measured, the shared configuration blocks both latency classes — and now nothing reports it.
The diagnostic was only ever verified negatively: tests confirmed it stayed low on the correct configuration. Nothing confirmed it went high on the configuration where the condition genuinely exists.
A flag observed only in its inactive state has not been verified. It is logic like any other, and stubbing it to a constant is a one-line change that a large regression will not notice — which is exactly what mutation P5 demonstrated.
Test the diagnostic positively, under the condition it names:
// The shared configuration is SUPPOSED to raise this here.
if (!shared_latency_blocked) begin
$display("FAIL diagnostic did not fire when it should have");
errors = errors + 1;
endPrevention. Make it a rule: every error output needs at least one test that asserts it fires. Functional coverage on the flag's active state is the cheap version — an uncovered "flag asserted" bin is the same information, and it shows up in a coverage report rather than requiring someone to think of the mutation.
15. Verification Plan
Reference model. A per-class transaction model holding, for each outstanding tag, its class, its address and its issue cycle. Three things fall out: an ordering oracle (may this proceed given what is outstanding?), a matching oracle (does this response correspond to this request?), and a latency distribution per class. The scoreboard is keyed on class and tag together, because that pair is what actually identifies a transaction — as Debug Lab 2 measured.
Directed tests. Every class against every ordering condition (nothing outstanding, older to a different address, older to the same address); a response on the wrong class; a response for a tag never issued; a duplicate response; single-class floods against each of the other two; resource exhaustion per class; and a latency excursion that exceeds the budget.
Constrained-random dimensions. Class mix, address diversity (which drives the ordering-concurrency result directly), issue rate per class, response latency distribution per class, and return timing.
Functional coverage:
| Dimension | Bins |
|---|---|
| class | .io, .cache, .mem |
| ordering condition | none outstanding, older different addr, older same addr |
| response outcome | matched, wrong class, orphan, duplicate |
| per-class occupancy | 0, 1..N-1, N |
| latency vs budget | well under, near, over |
| diagnostic flags | each error output observed both low AND high |
The cross worth taking is class × ordering condition, because that is where the per-class policy lives and it is only nine bins. The bolded row is Debug Lab 5's lesson made mechanical: a coverage model that requires each error flag to be seen asserted turns "we never tested the diagnostic" into a visible hole.
Error injection. Wrong-class responses, orphan responses, duplicate responses, and a class held at full occupancy while another requests.
16. Design Review
On ordering. Does each class have its own rule, or does one rule cover all three? Is the safety property written against the address or against the policy — because a policy-shaped property is satisfied by blocking everything? What structure produces older_same_addr, and what is its depth and width, since that is where per-address ordering actually costs?
On matching. Is class stored alongside the tag? Do the classes share a tag space or have separate ones — and if separate, does anything match on tag alone? Are orphan and class-mismatch distinguishable? Is the mismatch wired to the decision or only to a status bit?
On resources. Are allocations per class or pooled? Was each class's depth derived from its own rate and latency? Is there a diagnostic for a latency class refused while its own entries are free? And what does the split cost the bulk class — because it does cost something, and the number should be known.
On latency. Is there a maximum, and a count of budget excursions? Is the budget expressed as a property? Is the maximum asserted monotonic?
On diagnostics. For each error output: is there a test that makes it fire? This is Debug Lab 5, and it is worth asking as a standing review question rather than a per-design one.
And the structural question. Where in this design do the three classes merge, and is that as late as it can be? Every point above the multiplexer where they share something is a point where one class's requirements have been imposed on another.
17. How This Appears in Real Engineering
CXL / protocol architect
The per-class divergence is the design. Ordering granularity, resource allocation and latency budget are three independent decisions that must be made three times, and the temptation to make them once is the main risk. The Consortium's near-CPU-cache-latency target for .cache and .mem is what makes the asymmetry non-negotiable.
RTL engineer
Four disciplines from the measured runs: give each class its own ordering rule; store class with tag and require both to match; allocate resources per class; and record maxima and budget excursions rather than means.
DV engineer
Two findings to carry. The scoreboard key is class-and-tag, not tag. And every error output needs a positive test — mutation P5 stubbed a diagnostic to zero and the entire regression stayed green until a test was added that required it to fire.
Performance engineer
Three numbers per class, not one aggregate: proceed-versus-blocked ratio (which measures whether ordering policy is costing concurrency), per-class occupancy against allocation, and the count of budget excursions. A mean latency figure is actively misleading for the two classes with a target — measured, a mean of 8 accompanied two excursions past a budget of 20.
Firmware and system software
.io ordering is what your driver relies on: a register write that arms followed by one that triggers must arrive in that order. .cache and .mem make no such promise across different addresses and do not need to — which is why memory barriers exist and why assuming global ordering from a coherent interconnect is a portability bug waiting for a faster machine.
Silicon debug
The transaction layer's most valuable counters are per-class blocked cycles and per-class budget excursions. Together they distinguish three different problems that present identically as "slow": an ordering policy that is too strict, a resource allocation that is too small, and a downstream latency that is too long.
18. Common Misconceptions
19. Interview Reasoning
20. Exercises
-
Explain. The uniform ordering policy satisfies every safety property in Section 12 and is strictly worse. Name the property class that would catch it, and write one.
-
Calculate. With 8 distinct addresses in flight and uniformly distributed requests, estimate the fraction of coherent requests blocked under per-address ordering. Then under global ordering. At what address diversity does the difference stop mattering?
-
Calculate.
.cachesustains 0.4 requests per cycle with a 30-cycle round trip;.iosustains 0.05 with a 400-cycle round trip. How many entries does each need? What single pool depth would serve both, and what does that tell you about pooling? -
RTL modification. Change
class_orderingso.memuses global ordering while.cachestays per-address. Predict the effect on the EXP1 counts, then verify. Which of Section 12's properties changes? -
DV task. Write the functional-coverage bin set that would have caught mutation P5 without anyone thinking of the mutation. How many bins, and what does it cost?
-
Debug task. A design reports mean latency 12, max 14, budget excursions 0, against a budget of 20 — and software still reports stalls on coherent access. The latency counters are correct. Name two places the delay could be that this layer's instrumentation would not see.
21. Summary
The transaction layer is where meaning enters the stack, and meaning is what makes the three classes stop being interchangeable. Three contracts, one layer.
Ordering differs by class. .io needs producer/consumer order because software depends on it; .cache and .mem need ordering per address because coherence is a per-line property. Measured, applying one rule to all three let 0 of 5 requests proceed where the per-class policy let 2 — and the requests that differed were coherent traffic to different addresses, which is the common case. No safety property distinguishes the two policies.
A tag does not identify a transaction. With separate tag spaces per class, matching on tag alone retired a .mem request against a .cache response — and the broken matcher raised its mismatch flag while accepting the response, which is detection wired to a status bit instead of to the decision.
Resources must be per class, for two independent reasons. Coupling: measured, .io consumed all 18 shared entries and both latency-targeted classes were refused with zero entries used. And sizing: each class needs rate × latency entries, and the three products differ.
A latency target is a claim about the worst case. Measured, a mean of 8 against a budget of 20 accompanied a maximum of 25 and two excursions in ten transactions. The Consortium targets .cache and .mem at near CPU cache latency, and a mean is not evidence about a bound.
Two verification results carry forward. A diagnostic needs a positive test — mutation P5 stubbed an error flag to zero and the whole regression stayed green, because every test had only ever confirmed it stayed low. And a mutation that does not change behaviour tests nothing: the first version of P3 was itself a bad mutation, and replacing it with the realistic copy-paste bug exposed a real gap immediately.
22. What Comes Next
This chapter treated the three classes as a source of divergent requirements. Chapter 4.4 treats them as the subject: what each one actually is, side by side, and what a device implements when it implements one.
For adjacent material: Data Link Layer has the delivery guarantee this layer builds on, Coherent Communication Model has the per-line semantics that justify per-address ordering, and The CXL Device has the device-side view of which classes exist. The path is on the CXL tutorials index.
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.
