CXL · Module 7
CXL.io ↔ PCIe Transactions
The transaction layer underneath every other CXL.io chapter: why a memory write needs no answer and a configuration write does, what a tag is for, how out-of-order completions are matched, what a completion timeout costs, and why a read must not pass a write. Eight RTL models simulated, twenty-two mutations, twenty-two killed.
Every chapter of this module has been standing on something it did not explain. 7.2 said a configuration write is non-posted and moved on. 7.3 had software read an event record and assumed the read came back. 7.4 built a probe with a timeout and did not say what was timing out.
This chapter is that foundation. It is the deepest in the module because everything above it is a special case of what is here.
1. The Engineering Problem — Some Requests Need an Answer and Some Do Not
A request travels away from its issuer. Whether the issuer must wait for something to come back is not a performance choice; it is a property of the request type, fixed by the protocol, and it determines almost everything else about the hardware:
| If a request needs an answer | Then the hardware needs |
|---|---|
| A way to name it | A tag, unique among those in flight |
| A place to remember it | An outstanding-request table |
| A way to match the answer | Tag comparison, not arrival order |
| A way to give up | A per-request deadline |
A request that needs no answer needs none of that. It is issued and forgotten, and the issuer proceeds immediately.
So the split is not a detail of encoding. It is the line between a transaction that costs a tag, a table entry, a timer and a stall, and one that costs a cycle. Getting a request on the wrong side of that line produces either a hang or silent data corruption, and this chapter builds both.
2. The One-Sentence Model
A transaction is a promise, and the protocol says which promises are collected. A posted request is a statement — issued and complete. A non-posted request is a question — it occupies a tag, a table entry and a deadline until its answer arrives, and every hard problem in the transaction layer is about what happens to that answer: it comes back out of order, it comes back for a tag nobody holds, or it never comes back at all.
Call it the outstanding set. The interesting state of a transaction layer is not what it is sending; it is what it is still waiting for.
3. What This Chapter Owns
| Question | Owned by |
|---|---|
| What CXL.io carries | 7.1 |
| Register contracts and access types | 7.2 |
| Reporting events without losing evidence | 7.3 |
| Walking a hierarchy | 7.4 |
| Posted, non-posted, tags, completions, ordering | this chapter |
| PCIe posted transactions | PCIe: posted transactions |
| PCIe non-posted transactions | PCIe: non-posted transactions |
| Completion structure and types | PCIe: completions · types |
| Completion ordering rules | PCIe: completion ordering |
| Completion timeouts | PCIe: completion timeouts |
| TLP header fields | PCIe: TLP headers |
Deliberately not repeated: TLP header encoding, completion status codes and the PCIe ordering table, which the PCIe track covers field by field. This chapter is about the hardware consequences of those rules — the tag, the table, the timer, the stall — and what breaks when each is wrong.
4. The Two Paths
The third band is the entire cost of being non-posted, and a posted request skips it completely. That asymmetry is why a write-heavy path can saturate a link while a read-heavy path is limited by something that has nothing to do with bandwidth — a point §11 measures.
5. Teaching-model boundary
6. RTL 1 — Which Requests Need an Answer
always_comb begin
case (req_kind)
3'd0: is_posted = 1'b1; // memory write
3'd6: is_posted = 1'b1; // message
3'd2: is_posted = CFGWR_POSTED; // configuration write
default: is_posted = 1'b0; // all reads, I/O, cfg read
endcase
end
// The two are exact complements: every request either needs a completion
// or does not, and there is no third state.
assign needs_completion = !is_posted;=== EXP1: which requests need a completion ===
memory write posted=1 needs completion=0
message posted=1 needs completion=0
memory read posted=0 needs completion=1
config read posted=0 needs completion=1
config WRITE posted=0 needs completion=1
I/O write posted=0 needs completion=1
posted=2 non-posted=4
a configuration write is NOT fire-and-forget : ok
the variant retires a config write with no completion: okNote that "write" is not the property that decides it. A memory write is posted and a configuration write is not, and both are writes. The property is the type of the request, and the reason is that a configuration write changes device state that software must know has taken effect before it proceeds — so the protocol makes it acknowledge.
The CFGWR_POSTED variant is not a hypothetical. It is what happens when someone reasons "it's a write, so it's posted", and the consequence is a device that reports a configuration write as complete when it has not landed — after which software reads back a register it believes it wrote and gets the old value, intermittently, under load.
The both_err checker asserts that is_posted and needs_completion are never equal. That looks trivial because in this module they are complements by construction, and it is worth keeping precisely because in a larger design they usually are not — they get computed on separate paths, and drifting apart is a real failure.
7. RTL 2 — A Tag Names One Outstanding Request
A completion carries a tag, and the tag is the only thing that says which request it answers. So a tag must be unique among requests in flight — not unique forever, just unique among the live ones.
if (alloc && tag_valid) begin
if (busy_q[tag]) reuse_err <= 1'b1; // still outstanding
busy_q[tag] <= 1'b1;
n_alloc_q <= n_alloc_q + 8'd1;
end else if (alloc && !tag_valid) begin
n_denied_q <= n_denied_q + 8'd1; // back-pressure, not a drop
end=== EXP2: a tag names one outstanding request ===
10 allocations from 8 tags : allocated=8 denied=2 exhausted=1
exhaustion is back-pressure, not a dropped request: ok
no tag was handed out twice while outstanding : ok
reuse-live variant : allocated=10 reuse flagged=1
the reusing variant handed out a live tag : okExhaustion is back-pressure, not failure. Running out of tags means the issuer waits — it does not mean a request is dropped, and it is not an error condition. n_denied_q counts the stalls so the tag count can be sized against real traffic rather than guessed.
The REUSE_LIVE variant is the most dangerous bug in this chapter, and it is worth being precise about why. Suppose tag 3 is outstanding for a read of address X, the requester gives up or the completion is late, and tag 3 is handed out again for a read of address Y. The original completion then arrives, carrying tag 3, and is matched to the request for Y. The requester receives data from address X believing it came from address Y. No error is raised anywhere: the tag was valid, a request was outstanding, and the completion retired it.
That is silent data corruption produced by an allocator, and it is the reason a tag must not be recycled until the protocol guarantees no completion for its previous use can still arrive.
8. RTL 3 — Completions Return in Any Order
Three reads issued in order, completed 2, 0, 1
12 cyclesTag 2 was issued last and completed first. Nothing is wrong: completions may return in any order, because they are produced by different targets with different latencies and travel independently.
// The broken shape retires the OLDEST outstanding request whatever tag the
// completion carried -- correct only if completions never reorder.
assign eff_tag = ASSUME_INORDER ? oldest_q : cpl_tag;
...
if (cpl_data != exp_m[eff_tag]) mismatch_err <= 1'b1;=== EXP3: completions return out of order ===
3 reads issued, tags 0/1/2, outstanding=3
completion tag 2 : correct retires tag 2 data c2 | in-order model retires tag 0
after 3 completions : outstanding=0 issued=3 retired=3
every completion reached the requester that asked : ok
in-order model : mismatch flagged=1
assuming in-order delivered data to the wrong one : okThe ASSUME_INORDER instance retires the oldest outstanding request regardless of the tag. Look at what it gets right: three completions arrive, three requests retire, outstanding returns to zero, and no counter is out of balance. Every accounting check passes. It just gives tag 2's data to the requester waiting on tag 0.
The only property that catches it is the one comparing the returned data against what that specific requester expected — which is why exp_m exists. A transaction layer that only counts is blind to the entire class of misdelivery bugs, and counting is what most transaction-layer testbenches do.
The outstanding counter uses the exhaustive case for the same reason as the two previous chapters:
=== EXP4: issue and retire in the same cycle ===
one issued and one retired together : outstanding 1->1
outstanding held across simultaneous traffic : ok9. RTL 4 — A Posted Request Is Done When It Is Issued
// A posted request needs no completion, so the issuer never blocks on one.
assign accept = req_valid && (!busy_q || (is_posted && !WAIT_FOR_CPL));=== EXP5: a posted request is done when it is issued ===
6 posted writes : issued=6 done=6 stalled=0 busy=0
every posted write retired at issue, no stall : ok
wait-for-completion variant : issued=1 done=0 stalled=5
waiting for a completion that never comes stalls : okThe variant issues one request and then stalls for the rest of the test, waiting for a completion the protocol never sends. This is not a subtle performance loss; it is a hang, and it is what "just wait for the acknowledgement, it's safer" produces when the acknowledgement does not exist.
The second half of the property is the one a mutation caught:
=== EXP5b: a posted write must not queue behind a read ===
read outstanding, 4 posted writes offered : issued=5 done=4 stalled 0->0
posted writes went straight past the pending read : ok
all five requests were accepted : ok
and the read completed independently afterwards : okA read is outstanding — the issuer is waiting for its completion — and four posted writes are offered. All four are accepted immediately. They do not queue behind the read, because they have nothing to wait for.
This case escaped the first mutation run: with only posted traffic in the test, !busy_q was always true and the mutation accept = req_valid && !busy_q was indistinguishable from the correct expression. It took mixed traffic to separate them — and the scenario that separates them is the common one in real systems, where reads and writes are interleaved constantly.
10. RTL 5 — Whose Completion Is Late
Every non-posted request needs a deadline, because a target that never answers must not hold the issuer forever. The design question is whether there is one deadline or one per request.
if (GLOBAL_TIMER) begin
// One timer: it can say that SOMETHING is late, and picks arbitrarily.
if ((|live_q) && (gtimer_q >= LIMIT[7:0])) begin
to_hit = 1'b1;
for (k = NTAG-1; k >= 0; k = k - 1) if (live_q[k[2:0]]) to_tag = k[2:0];
end
end else begin
for (k = NTAG-1; k >= 0; k = k - 1)
if (live_q[k[2:0]] && (age_m[k] >= LIMIT[7:0])) begin
to_hit = 1'b1; to_tag = k[2:0];
end
endTag 7 is issued and never answered. Eight cycles later a healthy request is issued on tag 2.
=== EXP6: whose completion is late ===
tag 7 never answered : correct reported tag=7 count=1
the per-request timer named the request that hung : ok
the reported tag was the one that was actually late: ok
global-timer variant : reported tag=2 wrong-tag flagged=1
one shared timer blamed a healthy request : okThe shared timer blamed tag 2, which had been outstanding for four cycles out of a limit of twelve. It was not late. It was simply the request the arbitrary picker landed on when the one global deadline expired.
The consequence in a real system is that the error log names a healthy path, an engineer investigates it and finds nothing, and the actual hung request is abandoned without ever being identified. A timeout that cannot name the request is barely better than no timeout, because the recovery action — retry, reset, quiesce that path — depends on knowing which path.
wrong_tag_err is what makes this checkable, and it is written about the reported tag's own age rather than restating the picker:
// The reported tag must be one whose OWN age exceeded the limit.
if (age_m[to_tag] < LIMIT[7:0]) wrong_tag_err <= 1'b1;There is a second property that only appeared because a mutation survived:
a request issued and completed, then 20 idle cycles: timeouts=0
completing a request cleared it from the timer : okA request that completed must never be declared late afterwards. Obvious, and the first testbench never checked it — because it never retired anything into the timeout module at all.
11. RTL 6 — How Many Tags a Read Path Needs
The outstanding limit caps how many non-posted requests can be in flight, and that cap sets throughput on a path where each request takes a fixed time to answer.
=== EXP8: how many tags a read path needs ===
4 outstanding, latency 8 : issued=36 stalled=44 over 102 cycles
unlimited outstanding : issued=80 stalled=0
the limit was respected every cycle : ok
too few tags makes a read path latency-bound : ok
throughput matched outstanding divided by latency : okThirty-six issues in eighty cycles of demand, against eighty when the limit is removed — a factor of about two, and the ratio is not a coincidence. With a completion latency of eight cycles and at most four requests in flight, at most four requests can complete every eight cycles, so throughput is bounded at one every two cycles regardless of how much bandwidth the link has.
This is the bandwidth-delay product, and it is the answer to "how many tags do we need": enough to cover the round-trip latency at the target rate. Fewer, and the path is latency-bound — adding link bandwidth changes nothing, which is a genuinely confusing symptom the first time an engineer meets it.
And the asymmetry from §4 lands here. A posted write path has no outstanding limit because it has no outstanding set, so it is bandwidth-bound. On the same link, at the same clock, a write stream and a read stream can differ in throughput by a large factor, and no amount of link tuning closes the gap — the read path needs more tags, and if the tag space is fixed by the protocol, it needs lower latency instead.
12. RTL 7 — A Read Must Not Pass a Pending Write
The producer-consumer pattern: write the data, write the flag, and a consumer that sees the flag reads the data. It works only if the ordering rules hold.
// A write lands one cycle after acceptance: that gap is the whole problem.
assign rd_allowed = READ_PASSES_WRITE ? 1'b1 : (pending_wr_q == 4'd0);=== EXP7: a read must not pass a pending write ===
read while a write is pending : correct allowed=0 | bypass allowed=1 data=00
the read waited for the write to land : ok
the bypassing variant returned the OLD value : ok
after the write landed : allowed=1 data=ab
once it landed the consumer saw the new value : okThe bypassing variant returned 00 where the value written was ab. It answered faster and it answered wrongly.
The subtlety worth holding onto is that the write was already accepted. It was not rejected, not lost, not in error — it had been taken and had not yet landed, and that gap is where the entire producer-consumer failure lives. A design that treats "accepted" as "visible" has this bug by construction.
This is also why a posted write being fire-and-forget does not mean it is unordered. The issuer does not wait for it, but the fabric must not let a later read of the same path overtake it. No completion and no ordering are different properties, and posted requests have the first without the second.
13. RTL 8 — Nothing In Limbo
// A completion arriving in the same cycle its timer expires is a real race
// in silicon, not a testbench artefact. Exactly ONE outcome may be counted:
// counting both puts the accounting permanently out of balance by one per
// occurrence. The completion wins, because its data is valid.
assign race = retire && timed_out;
assign eff_retire = retire;
assign eff_timeout = timed_out && !retire;=== EXP9: nothing is in limbo ===
issued=50 posted=17 non-posted=33 retired=15 timed out=1 outstanding=17
conservation non-posted == retired + timed out + outstanding : 33 == 33
conservation held : ok
every count matched an independent oracle : ok
posted writes never entered the outstanding count : 17 of 50 issued
completion and timeout collided 1 time(s), counted once each
the completion won the race and the books balanced: okThe conservation law is the one that matters: every non-posted request is outstanding, retired, or timed out — there is no fourth state, and a request that falls out of all three has been forgotten while something upstream still waits for it.
The race handling arrived by accident and is the more interesting half. The first version of the testbench asserted retire and timed_out in the same cycle, which broke conservation by exactly one. The obvious response was to call the stimulus illegal and fix the test.
It is not illegal. A completion arriving in the same cycle its deadline expires is a real race, and in silicon it happens whenever a completion is marginally late. The design has to resolve it, and the resolution is that the completion wins because its data is valid and abandoning a request whose answer is in hand loses information for no reason. What must never happen is counting both, which puts the accounting permanently out of balance by one per occurrence — a slow drift that looks like a leak.
So the stimulus stayed, the design gained a race resolution and an n_race_q counter, and the test now asserts the collision occurred exactly once and the books balanced.
14. Assertions
Icarus Verilog 13.0 does not support concurrent SystemVerilog assertions, so every property below is implemented as synthesisable checker logic and verified in simulation. The assert property form states the intent.
| Property | Intent |
|---|---|
| Complementary classification | is_posted != needs_completion |
| Config write is non-posted | kind == cfgwr |-> !is_posted |
| Tag uniqueness | alloc |-> !busy[tag] |
| Exhaustion stalls | alloc && !tag_valid |-> denied++ |
| Free only what is held | free |-> busy[free_tag] |
| Completion matches by tag | retire |-> retire_tag == cpl_tag |
| Data reaches its requester | retire |-> cpl_data == expected[tag] |
| No phantom retire | cpl && !live[tag] |-> !retire |
| Outstanding stable | issue && retire |-> outstanding unchanged |
| Posted never blocks | is_posted |-> accept |
| Posted retires at issue | is_posted && accept |-> ##1 done++ |
| Timeout names the late one | timeout_valid |-> age[timeout_tag] >= LIMIT |
| Completed clears the timer | retire |-> never timeout for that tag |
| Read waits for the write | pending_wr != 0 |-> !rd_allowed |
| Outstanding limit held | outstanding <= MAX |
| Conservation | np == retired + timed_out + outstanding |
| Race resolved once | retire && timed_out |-> exactly one counted |
The one to notice is "data reaches its requester". Every other property here is satisfied by the in-order matcher that misdelivers data. Counting is not enough; something has to check identity.
15. Mutation Testing
Twenty-two mutations. Twenty-two killed.
| Mutation | Result |
|---|---|
| Configuration write treated as posted | killed |
| Memory write treated as non-posted | killed |
| Needs-completion inverted | killed |
| Allocator always returns tag zero | killed |
| Tags handed out when none are free | killed |
| Reuse of a live tag not flagged | killed |
| Denied allocation not counted | killed |
| Completions matched in order, ignoring the tag | killed |
| Completion retires a request not outstanding | killed |
| Misdelivered completion data not detected | killed |
| Simultaneous issue/retire as two increments | killed |
| Any outstanding request may be declared late | killed |
| Reported timeout tag not checked | killed |
| A retired request stays outstanding | killed |
| Posted write blocks on the previous request | killed |
| Posted write never counted as done | killed |
| Read may always pass a pending write | killed |
| Stale read counted but not flagged | killed |
| Outstanding limit not enforced | killed |
| Completion/timeout race counted twice | killed |
| Posted writes counted as outstanding | killed |
| Conservation check disabled | killed |
The first run scored nineteen of twenty-two, and all three survivors were missing stimulus rather than equivalent mutants. Each one named a scenario worth having.
A posted write blocks on the previous request. With only posted traffic in the test, busy_q was never set, so the mutation was indistinguishable from the correct expression. Separating them required a read outstanding and posted writes offered — which is the ordinary case in a real system, and is now EXP5b. A test that exercises one request type at a time cannot find bugs in the interaction between them.
A retired request stays outstanding. The timeout module was never sent a retire at all: the test issued requests and let them hang, so the retire path was dead code as far as the testbench was concerned. The new check issues a request, completes it, waits twenty idle cycles and asserts no timeout occurred.
That fix needed a second attempt. The first version checked the reported tag, but the tag-7 timeout earlier in the experiment had already latched it, so the check could never fail — a test that passed for a reason unrelated to the property. Running it on a freshly reset instance and asserting the timeout count fixed it. A check that cannot fail is not a check, and the way to find out is to make the mutation and watch.
A completion retires a request that is not outstanding. Nothing in the test ever sent a completion for a tag nobody held. Adding it required a dedicated instance, because a stray completion trips the good instance's own checker and would be scored a failure:
abuse instance flagged an unexpected completion : ok
and it retired nothing: a tag nobody holds is void: okThe second line is the property that kills the mutation. It is not enough that the stray completion is flagged; it must also retire nothing. A completion carrying a tag with no outstanding request is void, and acting on it corrupts the outstanding set.
That is the third chapter in a row where a checker needed an instance reserved for illegal stimulus. The pattern is now firmly established: any checker whose condition a correct design cannot reach needs its own DUT, or it is decoration.
16. Verification Plan
| Area | Approach |
|---|---|
| Classification | Every request kind, both directions of the complement |
| Tags | Exhaustion, uniqueness, free-of-unheld on an abuse instance |
| Matching | Out-of-order completions with distinguishable data per tag |
| Misdelivery | Data identity check, not merely retire counts |
| Stray completions | Dedicated instance, assert flagged and no retire |
| Posted path | Alone, and interleaved with an outstanding read |
| Timeouts | Per-request against shared timer, plus retire-then-idle |
| Throughput | Issue rate against the outstanding limit and latency |
| Ordering | Read during a pending write, and after it lands |
| Accounting | Independent oracle, conservation per cycle, race resolution |
The coverage model is a cross of request type against completion behaviour: posted and each non-posted kind, crossed with completes-in-order, completes-out-of-order, never-completes, and completes-as-the-timer-expires. The last column is the one that produced the race, and the posted row crossed with a live outstanding request is the one that produced EXP5b.
17. Debug Lab
A driver reads back a register it just wrote and gets the old value
CFGWR-AS-POSTED// Classification.
is_posted = (req_kind == MEM_WRITE) || (req_kind == CFG_WRITE) ||
(req_kind == MSG); // "they're all writes"A driver writes a configuration register, reads it back, and occasionally sees the previous value. The rate increases with system load. Inserting a delay between the write and the read makes it disappear, which leads the team toward a timing theory.
config WRITE posted=0 needs completion=1
the variant retires a config write with no completion: okA configuration write is non-posted: the protocol requires a completion, and the write is not known to have taken effect until that completion returns. Classifying it as posted retires it at issue, so the issuer proceeds while the write is still in flight — and a read issued immediately afterwards can reach the target first.
The instinct that misleads is "write means posted". The category is the request type, and a configuration write is grouped with reads precisely because software must know it landed.
case (req_kind)
MEM_WRITE, MSG: is_posted = 1'b1;
default: is_posted = 1'b0; // reads, I/O, and CONFIG WRITES
endcaseOnly memory writes and messages are posted. Everything else — all reads, I/O, and configuration writes — requires a completion.
Enumerate every request type in a directed test with its expected classification, and assert that is_posted and needs_completion are exact complements. The classification table is small enough to test exhaustively, so there is no excuse for sampling it.
A read returns data belonging to a different address
TAG-REUSE// Allocate the next tag.
tag <= next_tag;
next_tag <= next_tag + 1; // wraps, without checking what is still liveRare data corruption under load. A read returns plausible data for the wrong address. No error is logged anywhere — not a timeout, not a completion error, not a parity failure. It correlates with the number of outstanding requests and disappears when the queue depth is reduced, which looks like a queue bug.
reuse-live variant : allocated=10 reuse flagged=1
the reusing variant handed out a live tag : okA tag was recycled while its previous use was still outstanding. The late completion for the old request arrives carrying that tag and is matched to the new request — which is holding the same tag legitimately.
Every check passes: the tag was valid, a request was outstanding, the completion retired it. The requester receives data from the wrong address with nothing indicating a fault. This is the worst failure mode in the transaction layer because it is silent and it is data.
// Allocate only a tag that is not live, and prove it.
if (alloc && tag_valid) begin
if (busy_q[tag]) reuse_err <= 1'b1;
busy_q[tag] <= 1'b1;
end
if (free) busy_q[free_tag] <= 1'b0;A busy vector, allocation only from free entries, and back-pressure when none is available. A tag must not be reused until the protocol guarantees no completion for its previous use can still arrive — which means after the completion or after the timeout, never merely after the requester gave up.
Assert tag uniqueness among live requests as a continuous property, not an end-of-test check. And treat "requester abandoned the request" as distinct from "the tag is free": abandonment does not stop the completion from arriving.
Reads return the wrong requester's data when the fabric reorders
ASSUME-INORDER// Retire the oldest outstanding request.
if (cpl_valid) begin
retire_tag <= oldest_q;
oldest_q <= oldest_q + 1;
endCorrect against one target and wrong against another. All counters balance — requests issued equals completions retired, outstanding returns to zero, no timeouts. The data is simply wrong, and only when more than one request is in flight.
completion tag 2 : correct retires tag 2 data c2 | in-order model retires tag 0
in-order model : mismatch flagged=1The design assumes completions return in issue order. They do not — different targets have different latencies and completions travel independently, so the last request issued may be the first answered.
What makes it hard is that every accounting property still holds. Three completions retire three requests and the outstanding count is right. The only thing wrong is which requester got which data, and no counter can see that.
assign eff_tag = cpl_tag; // the tag decides, not the order
assign retire = cpl_valid && live_q[eff_tag];
if (retire && cpl_data != exp_m[eff_tag]) mismatch_err <= 1'b1;Match on the tag, and keep a per-tag record of what the requester expected so misdelivery is detectable at all.
Give every outstanding request distinguishable data, and check identity rather than counts. Then deliberately reorder completions in the testbench — a model that returns them in order will never exercise this, and in-order is the easiest model to write.
The issuer stalls forever on a memory write
WAIT-FOR-CPL// Wait for the acknowledgement before accepting another request.
if (req_valid && !busy_q) busy_q <= 1'b1;
if (busy_q && cpl_valid) busy_q <= 1'b0;The first write is issued and nothing else ever is. There is no error, no timeout on the write path, and the link is idle. It looks like a lost interrupt or a driver bug, because the hardware appears to be waiting for something.
wait-for-completion variant : issued=1 done=0 stalled=5The issuer waits for a completion that a posted request never generates. Nothing is lost or late — the protocol simply does not send one, so the wait is unbounded by construction.
It usually arrives as caution: acknowledging everything feels safer than acknowledging some things. Here it is a deadlock.
assign accept = req_valid && (!busy_q || is_posted);
if (req_valid && accept && is_posted) n_done_q <= n_done_q + 8'd1; // done nowA posted request retires at issue. It never enters the outstanding set, never occupies a tag, and never blocks the issuer.
For every wait in a design, name what ends it, and confirm that thing is actually generated. Then test the posted path interleaved with an outstanding non-posted request — a test with posted traffic alone cannot distinguish "does not block" from "nothing was blocking".
Writes queue behind a slow read and throughput collapses
POSTED-BLOCKEDassign accept = req_valid && !busy_q; // everything waits for the outstanding readMixed read/write workloads perform far worse than either alone. Write throughput collapses whenever a read is outstanding, and the collapse tracks read latency — so a slower target makes writes slower even though nothing about the writes changed.
read outstanding, 4 posted writes offered : issued=5 done=4 stalled 0->0
posted writes went straight past the pending read : okA single busy_q gate covers both request classes, so a posted write waits for a non-posted read's completion. The write has nothing to wait for — the coupling is invented by the accept expression.
The reason it survives review is that a test with one request type at a time cannot see it: with only posted traffic busy_q is never set, and with only non-posted traffic the serialisation looks correct.
assign accept = req_valid && (!busy_q || is_posted);The posted path bypasses the outstanding gate entirely. Ordering against pending writes still applies — see lab 7 — but that is a different mechanism and a different question.
Test request classes interleaved, not separately. This mutation survived a full mutation run precisely because every test used one class at a time, and the mixed case is the normal case in production traffic.
A completion timeout fires and names a healthy path
GLOBAL-TIMER// One timer for all outstanding requests.
if (|live_q) gtimer_q <= gtimer_q + 1;
if (gtimer_q >= LIMIT) begin timeout_valid <= 1'b1; timeout_tag <= any_live; endTimeout errors name a path that turns out to be healthy. Investigating it finds nothing. Meanwhile a genuinely hung request is abandoned without ever being identified, so the underlying fault is never fixed and the same error recurs against different, arbitrary paths.
tag 7 never answered : correct reported tag=7 count=1
global-timer variant : reported tag=2 wrong-tag flagged=1One shared deadline can say that something is late; it cannot say which. When it expires, the reported tag is whichever the picker happened to select — in the measured run, a request outstanding for four cycles against a limit of twelve.
Recovery depends on identity. Retrying, resetting or quiescing needs to know which path, and a timeout that reports an arbitrary tag directs every one of those actions at the wrong target.
for (k = 0; k < NTAG; k = k + 1)
if (live_q[k]) age_m[k] <= age_m[k] + 8'd1; // one age per request
...
if (live_q[k] && (age_m[k] >= LIMIT)) begin to_hit = 1'b1; to_tag = k; end
if (to_hit && age_m[to_tag] < LIMIT) wrong_tag_err <= 1'b1;An age per outstanding request. The checker is written about the reported tag's own age rather than restating the picker, so it is an independent statement of the requirement.
Test with a genuinely hung request and healthy traffic present simultaneously, and assert the identity of the reported tag rather than merely that a timeout occurred. A test with one outstanding request cannot distinguish the two designs.
A consumer sees a flag set and reads stale data
READ-PASSES-WRITEassign rd_allowed = 1'b1; // reads are served immediately, alwaysA producer writes a data buffer then sets a completion flag. A consumer polls the flag and reads the buffer. Occasionally the buffer contents are stale — the previous message, or partially updated. It is rare, load-dependent, and vanishes under a debugger.
read while a write is pending : correct allowed=0 | bypass allowed=1 data=00
after the write landed : allowed=1 data=abA read was served while an earlier write was accepted but not yet visible. The write was not lost and not in error — it had been taken and had not landed, and the read overtook it in that gap.
The producer-consumer pattern depends entirely on the ordering rule that a read may not pass a pending write. Break it and the flag becomes a lie: it says the data is ready before the data is there.
assign rd_allowed = (pending_wr_q == 4'd0); // wait for earlier writes to landReads wait for earlier writes on the same path. Note that this is separate from posted versus non-posted: a posted write needs no completion, and it still must not be overtaken.
Test a read issued while a write is pending, and assert the returned value — not merely that the read completed. Then state the distinction explicitly in review: no completion and no ordering are different properties, and posted requests have only the first.
The outstanding count drifts upward over days of uptime
RACE-COUNTED-TWICEif (retire) n_retire_q <= n_retire_q + 1;
if (timed_out) n_to_q <= n_to_q + 1; // both can be true at onceTransaction accounting is exactly right on the bench and drifts in the field. The reported outstanding count creeps upward over hours or days until it looks like a leak. Nothing is actually stuck — traffic flows normally — and a reset clears it.
completion and timeout collided 1 time(s), counted once each
conservation non-posted == retired + timed out + outstanding : 33 == 33A completion arriving in the same cycle its deadline expires is a real race, and it happens whenever a completion is marginally late. Both events are counted while the request is removed once, so conservation breaks by exactly one per occurrence.
The bench never sees it because bench latencies are deterministic and nowhere near the limit. In the field, marginal latencies are routine — which is why the drift rate correlates with load and with distance to the target.
assign race = retire && timed_out;
assign eff_retire = retire;
assign eff_timeout = timed_out && !retire; // the completion wins
if (race) n_race_q <= n_race_q + 16'd1; // and the collision is visibleResolve the race explicitly and count exactly one outcome. The completion wins because its data is valid. Exposing n_race_q turns a mysterious drift into a measurable rate that says the timeout is set too tight.
For every pair of events that retire the same object, ask whether they can occur together — and if they can, resolve it in the design rather than assuming the environment prevents it. This one was found by a testbench that produced the collision by accident; the right response was to keep the stimulus and fix the design, not to call the stimulus illegal.
18. Design Review
- Which request types are posted? Only memory writes and messages. If configuration writes are in that set, the design is wrong.
- Can a tag be reused while a completion for its previous use may still arrive? If yes, there is a silent data-corruption path.
- Does the completion matcher use the tag or the arrival order? Order is correct only if reordering is impossible, which it is not.
- Is there anything checking that data reached the requester that asked for it? Counters cannot see misdelivery.
- Does a posted request ever block on an outstanding non-posted one? It must not.
- Is there one timeout or one per request? One cannot name the late request, and recovery needs identity.
- What happens when a completion and its timeout land in the same cycle? Exactly one outcome, resolved in the design.
- How many tags, and how does that compare to the bandwidth-delay product? Too few makes the path latency-bound and link tuning will not help.
- Can a read overtake an accepted but unlanded write? That breaks every producer-consumer flow above it.
19. How This Appears in Real Engineering
Bring-up. Classification bugs surface first, as a device that appears to accept configuration and does not change behaviour. The read-back-mismatch symptom in lab 1 is one of the most common first-week findings.
Performance work. The tag count against the bandwidth-delay product is the calculation behind "why is our read bandwidth half the link rate". Engineers reach for link tuning; the answer is usually outstanding capacity or latency.
Silicon debug. Tag reuse is the bug people remember for a career, because it produces wrong data with no error indication anywhere and reproduces only under load.
Driver and firmware work. The ordering rule is the contract every producer-consumer ring depends on. When it breaks, the symptom appears in software as a corrupt message, and the investigation starts in the wrong place.
Fleet operation. n_race_q, denied-allocation counts and per-tag timeout identity are what turn intermittent field reports into a number someone can act on.
20. Common Misconceptions
| Belief | Correction |
|---|---|
| Writes are posted | Memory writes are; configuration writes are not |
| Posted means unordered | It means no completion; ordering still applies |
| Completions return in order | They return in any order; the tag decides |
| Balanced counters mean correct | The in-order matcher balances and misdelivers |
| A tag can be reused once you give up | Not until no completion for it can arrive |
| One timeout is enough | It cannot name the late request, and recovery needs identity |
| Running out of tags is an error | It is back-pressure |
| More link bandwidth fixes slow reads | Not if the path is outstanding-limited |
| A completion/timeout collision is a test artefact | It is routine when a completion is marginally late |
21. Interview Reasoning
22. Exercises
-
Analysis. A device reports
issued_np=10000,retired=9998,timed_out=0,outstanding=2, and has been idle for an hour. Nothing violates conservation. Name the two conditions consistent with this and the single measurement that distinguishes them. -
Design. Specify the tag-free rule for a requester that supports abandoning a request. State exactly when the tag may be reused, what the timeout duration must be at least as long as, and the invariant that makes reuse-after-abandonment safe.
-
RTL task. Extend the completion matcher to support completions split into multiple parts, where one request is answered by several completions. State which properties in §14 change, which new invariant is required, and what a partial completion followed by a timeout must do to the outstanding set.
-
DV task. Write the coverage cross for this layer, then explain why a testbench that only counts issues and retirements can reach full statement coverage while missing the entire misdelivery class.
-
Debug task. A mixed workload shows write throughput collapsing whenever read latency rises, and reads performing at exactly half the expected rate. Both are real. Give your investigation order and name the two independent faults, with the measurement that separates them.
-
Design review. A colleague proposes freeing a tag as soon as the requester abandons a request, arguing that the tag space is a scarce resource and the abandoned request will never be used. Give the strongest version of that argument, then name the specific failure it creates and the smallest change that keeps most of the benefit safely.
23. Summary
A transaction is a promise, and the protocol decides which promises are collected.
- Posted: memory writes and messages. Non-posted: all reads, I/O, and configuration writes. A configuration write is not fire-and-forget, which is the fact every other chapter in this module was standing on.
- Being non-posted costs a tag, a table entry, a deadline and a possible stall. A posted request costs a cycle.
- A tag must be unique among requests in flight, and reusing a live one delivers another request's data with no error logged anywhere — the worst failure in the layer.
- Completions return in any order, so the tag decides who gets the data. An in-order matcher balances every counter and still misdelivers.
- Posted does not mean unordered. No completion and no ordering are different properties, and a read overtaking a pending write breaks every producer-consumer flow above it.
- One timeout per request, not one for all of them — recovery needs to know which path hung, and a shared timer blames a healthy one.
- Tags needed equals the bandwidth-delay product. Too few makes a read path latency-bound, and adding link bandwidth changes nothing.
- A completion and its timeout can land in the same cycle. Resolve it in the design and count one outcome, or the accounting drifts by one per occurrence.
- Verification lesson: counting is not enough. Give every outstanding request distinguishable data and check identity — accounting and timeouts together still pass on a design that corrupts data.
That completes Module 7 — CXL.io. The control-plane spine is now covered end to end: what it carries, how registers behave, how events are reported, how a hierarchy is discovered, and the transaction semantics underneath all of it. What CXL.io deliberately does not provide is coherence — and that is where the rest of CXL begins.
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.
