PCIe · Module 12
Completion Flow — The Return Path Is Its Own Pipeline
A Completer having data is not the same as a Completion having been sent. The return path has its own generation stage, its own queue, its own routing, and its own progress accounting at the Requester — and the read is not finished until that accounting says so.
Chapter 12.1 drew the return journey as three arrows: the Completer produced a Completion, the fabric routed it back, the Requester correlated it. Each of those arrows is a pipeline stage with its own buffering, its own backpressure and its own failure modes, and the read is not finished when the first one fires.
Once a Completer has serviced a non-posted Request, how does response information and returned data flow back through the fabric and resolve the correct Requester state?
1. Three Boundaries, Not One Arrow
Chapter 10.2 introduced Completions as a transaction concept. Chapter 12.1 used them. This chapter is about the machinery between the two, and the first thing to establish is that there is more of it than the transaction view suggests.
| Boundary | Owner | What "done" means here | What can stall it |
|---|---|---|---|
| target operation → response available | Completer resource | the data exists | the resource itself |
| response → Completion descriptor | Completer TL | the packet has been built | descriptor construction resources |
| descriptor → transmitted packet | Completer TX | the packet has left | queue depth, flow control, arbitration |
| packet → Requester | fabric | it arrived | congestion |
| arrival → correlated | Requester TL | the read it belongs to is known | nothing — but it can fail |
| correlated → progress updated | Requester context | how much is still expected | nothing |
| resolved → delivered | Requester result path | the client took it | the client |
Seven boundaries. Chapter 12.2 §3 made the same point for the forward path, and the discipline is identical: "the read completed" is a statement about one of these, and a design that means two of them at once has a bug.
2. The Verified Semantics
3. Data Available Is Not Packet Sent
The most common mental-model error about the return path, and it produces a specific debugging failure (§12).
target read completes
→ response data and status become available ← boundary 1
→ a Completion descriptor is constructed ← boundary 2
→ the descriptor is queued for transmission ← boundary 3
→ the packet is transmitted ← boundary 44. What the Completer Must Retain
To build the response, the Completer needs information from the Request that the Request itself is no longer around to supply.
| Retained | Why |
|---|---|
| the requester identity | it is the return route (Chapter 11.5 §7) |
| the correlation field | it is how the Requester will match the response |
| how much was requested, and how much this response covers | to know whether more Completions are owed |
| response type and status context | whether data accompanies the response |
| the transaction's handling context | attributes travel with the transaction (Chapter 11.6 §7) |
5. The Requester's Side — Progress, Not Arrival
The Requester's job on receipt has three steps, and only the third one decides anything.
Completion arrives
→ CORRELATE: which read does this belong to? (identity, not order)
→ ACCOUNT: how much of the expected extent has now arrived?
→ DECIDE: is this read finished, or is more owed?Chapter 12.1 established the first step and this chapter owns the second and third.
6. The Return Path
The two amber elements are the chapter. The descriptor that exists but has not been sent is §3. The context that must not retire on the first chunk is §5.
7. RTL — Completion Generation Queue
// SYNTHESIZABLE. Turn accepted target-response events into queued Completion
// descriptors. Decoupling the resource from the outbound link: an
// ARCHITECTURAL requirement (section 3). The descriptor fields, the depth,
// and final_for_request: ILLUSTRATIVE internal metadata.
package cpl_flow_pkg;
// NORMALIZED INTERNAL DESCRIPTOR — NOT a wire-format Completion.
typedef struct packed {
logic [15:0] requester_id; // return route, copied from the Request
logic [15:0] corr; // correlation field, copied from the Request
logic [10:0] dw; // DW carried by this response chunk
logic has_data; // status-only vs. with-data (section 2)
logic err; // ABSTRACTED status. Module 13 owns the
// actual Completion Status encodings.
logic final_for_request; // INTERNAL teaching metadata only
} cpl_desc_t;
endpackageimport cpl_flow_pkg::*;
module cpl_gen_queue #(
parameter int DEPTH = 4,
parameter int DATA_W = 128
) (
input logic clk,
input logic rst_n,
// ---- Accepted target-response event ----------------------------------
// Context and data arrive TOGETHER, for Chapter 12.2 section 4's reason:
// a response whose identity and payload can drift apart is a response that
// can be delivered to the wrong Requester.
input logic rsp_valid,
output logic rsp_ready,
input cpl_desc_t rsp_desc,
input logic [DATA_W-1:0] rsp_data,
// ---- Queued descriptor out, to the return TX path --------------------
output logic cpl_valid,
input logic cpl_ready,
output cpl_desc_t cpl_desc,
output logic [DATA_W-1:0] cpl_data
);
generate
if (DEPTH < 1) $error("DEPTH must be at least 1");
endgenerate
localparam int IDX_W = (DEPTH <= 1) ? 1 : $clog2(DEPTH);
localparam int CNT_W = $clog2(DEPTH + 1);
typedef struct packed {
cpl_desc_t desc;
logic [DATA_W-1:0] data;
} entry_t;
entry_t mem_q [DEPTH];
logic [IDX_W-1:0] wr_q, rd_q;
logic [CNT_W-1:0] cnt_q;
wire full = (cnt_q == CNT_W'(DEPTH));
wire empty = (cnt_q == '0);
assign cpl_valid = !empty;
wire pop = cpl_valid && cpl_ready;
// EFFECTIVE capacity — the slot a pop vacates is usable this cycle.
// Refusing a response at full-with-pop would stall the target resource at
// exactly the moment the queue was draining (Chapter 12.1's fixed bug,
// and the same shape).
assign rsp_ready = !full || pop;
wire push = rsp_valid && rsp_ready;
// Outputs from STORED state. There is no bypass, so a response cannot be
// presented from a live input while an earlier one is still queued.
assign cpl_desc = mem_q[rd_q].desc;
assign cpl_data = mem_q[rd_q].data;
function automatic logic [IDX_W-1:0] next_idx (input logic [IDX_W-1:0] i);
next_idx = (i == IDX_W'(DEPTH - 1)) ? '0 : (i + IDX_W'(1));
endfunction
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wr_q <= '0; rd_q <= '0; cnt_q <= '0;
end else begin
if (push) begin
mem_q[wr_q].desc <= rsp_desc;
mem_q[wr_q].data <= rsp_data;
wr_q <= next_idx(wr_q);
end
if (pop) rd_q <= next_idx(rd_q);
case ({push, pop})
2'b10: cnt_q <= cnt_q + CNT_W'(1);
2'b01: cnt_q <= cnt_q - CNT_W'(1);
default: cnt_q <= cnt_q;
endcase
end
end
endmoduleClassification: synthesizable.
Architecture. A decoupling queue between the target's response rate and the link's availability — §3's structure, made concrete. Response identity and response data are one entry, for Chapter 12.2 §4's reason.
State. Entries, pointers, occupancy.
Contract. The response producer relies on rsp_ready reflecting real capacity and must not drop a response it offered but that was refused — it must hold it. The TX path relies on the descriptor being stable while cpl_ready is low.
Failure — four. Gating rsp_ready on !full alone stalls the target at full-rate steady state. A bypass path from rsp_* to cpl_* lets a response overtake earlier queued ones, which reorders the return stream. Natural pointer rollover aliases entries at non-power-of-two DEPTH. And treating a refused response as consumed loses a Completion entirely — and a lost Completion is a read that never resolves, which is the most expensive failure available on the return path.
Deliberately simplified: one data beat per entry; no per-Traffic-Class queueing; no arbitration against other outbound traffic; no flow-control credit interaction; the split decision is upstream.
Production implication: a real return path arbitrates against other outbound traffic, respects flow control, and decides how to divide a response according to the rules Module 13 owns. The decoupling structure above is unchanged by any of that.
8. RTL — Completion Progress Tracker
The chapter's most important new block. It answers exactly one question: is this read finished?
// SYNTHESIZABLE. Per-read progress accounting on the Requester side.
// That a read may require more than one Completion: NORMATIVE consequence of
// MPS bounding Completion payloads (section 2).
// The accounting scheme, the retire condition and the error outputs:
// ILLUSTRATIVE IMPLEMENTATION ACCOUNTING — this does NOT model the PCIe rules
// governing how a Completer may legally divide a response (Module 13).
module cpl_progress_tracker #(
parameter int CTXS = 8,
parameter int LEN_W = 11,
parameter int IDX_W = (CTXS <= 1) ? 1 : $clog2(CTXS)
) (
input logic clk,
input logic rst_n,
// ---- A read becomes outstanding --------------------------------------
input logic open_valid,
input logic [IDX_W-1:0] open_id,
input logic [LEN_W-1:0] open_expected_dw,
// ---- A correlated Completion chunk arrives ---------------------------
// Correlation has ALREADY happened upstream. This block accounts; it does
// not identify (section 5).
input logic chunk_valid,
input logic [IDX_W-1:0] chunk_id,
input logic [LEN_W-1:0] chunk_dw,
// ---- Decisions -------------------------------------------------------
output logic chunk_credited, // counted toward this read
output logic read_retire, // extent complete THIS cycle
output logic [LEN_W-1:0] remaining_dw, // for the credited read
// ---- Reported conditions ---------------------------------------------
output logic unknown_ctx_error,
output logic overrun_error,
output logic reopen_error,
// An identifier outside 0..CTXS-1 was presented. Distinct from
// unknown_ctx_error, which means "in range but not active".
output logic illegal_id_error
);
logic [LEN_W-1:0] remain_q [CTXS];
logic [CTXS-1:0] active_q;
// ---- RANGE SAFETY ----------------------------------------------------
// IDX_W = $clog2(CTXS) can express values >= CTXS whenever CTXS is not a
// power of two. At CTXS = 5, IDX_W is 3 and the interface can present
// 5, 6 or 7 — indices that do not exist. Every array access below is
// therefore GUARDED, and the guard comes first.
// Compared one bit wider than the identifier, so CTXS is representable
// even when it is an exact power of two. A same-width comparison would
// truncate CTXS to zero at CTXS = 8 and reject every legal identifier.
localparam int CHK_W = IDX_W + 1;
wire open_id_legal = (CHK_W'(open_id) < CHK_W'(CTXS));
wire chunk_id_legal = (CHK_W'(chunk_id) < CHK_W'(CTXS));
// Guarded reads. The defaults are chosen so that an illegal identifier
// looks INACTIVE and EMPTY — it can therefore never satisfy `hit`, never
// be credited, and never retire anything.
logic chunk_active;
logic [LEN_W-1:0] chunk_remaining;
logic open_active;
always_comb begin
chunk_active = 1'b0;
chunk_remaining = '0;
open_active = 1'b0;
if (chunk_id_legal) begin
chunk_active = active_q[chunk_id];
chunk_remaining = remain_q[chunk_id];
end
if (open_id_legal)
open_active = active_q[open_id];
end
// Every downstream decision now reads the GUARDED values, never the
// arrays directly.
wire hit = chunk_valid && chunk_id_legal && chunk_active;
// Progress can never exceed what is still expected. An overrun is a
// reported condition, NOT a saturating subtraction — silently clamping
// would let a too-large chunk retire a read that is not complete.
wire overrun = hit && (chunk_dw > chunk_remaining);
wire credit = hit && !overrun && (chunk_dw != '0);
assign chunk_credited = credit;
assign remaining_dw = chunk_remaining;
// RETIRE only when the chunk completes the expected extent. A partial
// return NEVER retires — section 5, and the single most important line
// in this module.
assign read_retire = credit && (chunk_dw == chunk_remaining);
// Allocation is likewise gated on legality.
wire do_open = open_valid && open_id_legal && !open_active;
logic unk_q, ovr_q, reo_q, ill_q;
assign unknown_ctx_error = unk_q;
assign overrun_error = ovr_q;
assign reopen_error = reo_q;
assign illegal_id_error = ill_q;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int i = 0; i < CTXS; i++) remain_q[i] <= '0;
active_q <= '0;
unk_q <= 1'b0; ovr_q <= 1'b0; reo_q <= 1'b0; ill_q <= 1'b0;
end else begin
// Both write paths are gated on legality, so no out-of-range index
// ever reaches an array. This is STRUCTURAL prevention — the module
// does not rely on any language behaviour for illegal indices.
if (do_open) begin
remain_q[open_id] <= open_expected_dw;
active_q[open_id] <= 1'b1;
end
if (credit) begin
remain_q[chunk_id] <= chunk_remaining - chunk_dw;
if (chunk_dw == chunk_remaining)
active_q[chunk_id] <= 1'b0;
end
// Reported, never acted on. None of these writes any entry.
// Note the ordering of the two "unknown" cases: an out-of-range
// identifier is illegal_id_error, not unknown_ctx_error, because
// they call for different investigations (section 8a).
if (chunk_valid && !chunk_id_legal) ill_q <= 1'b1;
if (open_valid && !open_id_legal) ill_q <= 1'b1;
if (chunk_valid && chunk_id_legal && !chunk_active) unk_q <= 1'b1;
if (overrun) ovr_q <= 1'b1;
if (open_valid && open_id_legal && open_active) reo_q <= 1'b1;
end
end
endmoduleClassification: synthesizable.
Architecture. One remaining-DW counter and one active bit per read. Deliberately not merged with the correlation lookup — §5's two responsibilities stay in two blocks so that a correlation bug and an accounting bug cannot hide behind each other.
State. remain_q per context, an activity bitmap, four sticky error flags. The bitmap is exported-shaped rather than a per-entry struct so a reset assertion can cover every entry at once.
Range safety, and why it is not optional. IDX_W is $clog2(CTXS), which can express values ≥ CTXS whenever CTXS is not a power of two. At CTXS = 5, IDX_W is 3 and the interface can present 5, 6 or 7 — indices that do not exist.
So every array access is guarded, and the guard comes first. The module reads active_q and remain_q only inside if (*_id_legal), with defaults chosen so an illegal identifier looks inactive and empty — it therefore cannot satisfy hit, cannot be credited, and cannot retire anything. Both write paths are gated on the same legality, so no out-of-range index ever reaches an array. This is structural prevention: the module does not rely on any language behaviour for illegal indices, because that behaviour is not something a design should be trusting.
And the legality comparison is one bit wider than the identifier. A same-width compare would truncate CTXS to zero at CTXS = 8 and reject every legal identifier — a correct-looking fix that breaks the power-of-two case it was not aimed at.
Cycle behaviour. Opening a read sets its expectation. A credited chunk decrements it. read_retire pulses for exactly one cycle, on the chunk that brings the remainder to zero. An entry is reusable the following cycle.
Contract. The caller relies on read_retire meaning the whole expected extent has arrived — not the client has taken it (Chapter 12.1 §12), and not the last packet arrived. Upstream guarantees chunk_id is the result of a correlation lookup, not an arrival counter.
Failure — five, and the first is the reason the module exists. Retiring on any credited chunk rather than on the completing one frees the context while data is in flight (Chapter 12.1 §8). Using a saturating subtraction instead of the overrun check lets an oversized chunk zero the remainder and retire a read that is short. Crediting a chunk for an inactive context corrupts an entry that belongs to a different read. Reopening an already-active context silently overwrites a live read's expectation — which is why reopen_error exists rather than the write simply being allowed. And indexing the arrays before the legality check lets an out-of-range identifier read or write outside the table at any non-power-of-two CTXS.
Deliberately simplified: no Completion Status handling (Module 13); no timeout; no modelling of the PCIe rules that constrain how a response may be divided — this counts DW and nothing more; one chunk per cycle on this interface, stated explicitly in §10.
8a. Two Different Kinds of "Unknown"
An identifier that is out of range and an identifier that is in range but inactive are different faults, and merging them costs a debugging session.
illegal_id_error | unknown_ctx_error | |
|---|---|---|
| Means | the identifier is outside 0..CTXS-1 | in range, but no read is active there |
| Implies about the sender | it produced a value the table cannot represent | it produced a plausible value at the wrong time |
| Usual cause | a width or mapping bug upstream — a Tag slice, an ID translation, or a CTXS that is not a power of two | a context freed too early, or a duplicate/straggling Completion |
| Where to look | the correlation lookup and its identifier width | the retire condition, and Chapter 12.1 §8 |
Reporting them separately is what makes the distinction usable. A single "unknown context" flag would fire for both, and the first instinct — check whether a context was freed early — is the wrong one half the time.
And note that an illegal identifier is a statement about this design's table, not a protocol violation. A Completion carrying a correlation field this Requester never issued is a real and separate condition; an identifier that does not fit the table is a local mapping bug, and calling it a protocol error would send the investigation to the wrong layer entirely.
9. RTL — Return Result Router
// SYNTHESIZABLE. Deliver credited Completion data to the correct local
// destination, decoupled from the local consumer's readiness.
// The separation of protocol resolution from local delivery: ARCHITECTURAL
// (Chapter 12.1 section 12). The interface and depth: ILLUSTRATIVE.
module cpl_result_router #(
parameter int DEPTH = 4,
parameter int DEST_W = 8,
parameter int DATA_W = 128
) (
input logic clk,
input logic rst_n,
// ---- A credited chunk, with the destination from RETAINED context -----
// dest comes from the context entry, never from the arriving packet and
// never from arrival order (Chapter 12.1 section 11).
input logic in_valid,
input logic [DEST_W-1:0] in_dest,
input logic [DATA_W-1:0] in_data,
input logic [DATA_W/8-1:0] in_byte_valid,
input logic in_err,
input logic in_final, // this chunk retired the read
// ---- To the local consumer -------------------------------------------
output logic out_valid,
input logic out_ready,
output logic [DEST_W-1:0] out_dest,
output logic [DATA_W-1:0] out_data,
output logic [DATA_W/8-1:0] out_byte_valid,
output logic out_err,
output logic out_final,
output logic overflow_error
);
localparam int IDX_W = (DEPTH <= 1) ? 1 : $clog2(DEPTH);
localparam int CNT_W = $clog2(DEPTH + 1);
typedef struct packed {
logic [DEST_W-1:0] dest;
logic [DATA_W-1:0] data;
logic [DATA_W/8-1:0] bv;
logic err;
logic fin;
} res_t;
res_t mem_q [DEPTH];
logic [IDX_W-1:0] wr_q, rd_q;
logic [CNT_W-1:0] cnt_q;
logic ovf_q;
wire full = (cnt_q == CNT_W'(DEPTH));
wire pop = out_valid && out_ready;
// Same effective-capacity rule as section 7. The input here CANNOT be
// backpressured meaningfully — the data has already arrived from the
// fabric — so refusing it at full-with-pop would discard a chunk the
// buffer had room for.
wire can_accept = !full || pop;
wire push = in_valid && can_accept;
assign out_valid = (cnt_q != '0);
assign out_dest = mem_q[rd_q].dest;
assign out_data = mem_q[rd_q].data;
assign out_byte_valid = mem_q[rd_q].bv;
assign out_err = mem_q[rd_q].err;
assign out_final = mem_q[rd_q].fin;
assign overflow_error = ovf_q;
function automatic logic [IDX_W-1:0] next_idx (input logic [IDX_W-1:0] i);
next_idx = (i == IDX_W'(DEPTH - 1)) ? '0 : (i + IDX_W'(1));
endfunction
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wr_q <= '0; rd_q <= '0; cnt_q <= '0; ovf_q <= 1'b0;
end else begin
if (push) begin
mem_q[wr_q].dest <= in_dest;
mem_q[wr_q].data <= in_data;
mem_q[wr_q].bv <= in_byte_valid;
mem_q[wr_q].err <= in_err;
mem_q[wr_q].fin <= in_final;
wr_q <= next_idx(wr_q);
end
if (pop) rd_q <= next_idx(rd_q);
case ({push, pop})
2'b10: cnt_q <= cnt_q + CNT_W'(1);
2'b01: cnt_q <= cnt_q - CNT_W'(1);
default: cnt_q <= cnt_q;
endcase
// Genuinely no room: full, nothing leaving, something arriving.
if (in_valid && full && !pop) ovf_q <= 1'b1;
end
end
endmoduleClassification: synthesizable.
Architecture. A decoupling buffer carrying the local destination alongside the data, so that delivery never depends on order.
State. Entries, pointers, occupancy, a sticky overflow flag.
Contract. The consumer relies on the result being stable while it stalls. The upstream relies on in_dest having come from the context entry — this module cannot check that and does not claim to.
Failure — three. Deriving out_dest from arrival order rather than carrying it per entry delivers correctly-correlated data to the wrong client. Gating push on !full alone drops a chunk at full-rate steady state. And dropping a chunk without reporting produces a read that retired at the protocol level and delivered nothing.
Deliberately simplified: one queue rather than per-destination queues; abstracted error status; whole-chunk granularity.
10. Same-Cycle Contract on the Return Path
Stated explicitly, because the return path is where same-cycle assumptions quietly accumulate.
| Interface | Contract |
|---|---|
| §7 response in | at most one response event per cycle. A Completer whose target can produce two simultaneously needs a wider interface or an input arbiter — not modelled here. |
| §8 chunk in | at most one correlated chunk per cycle. Two Completions landing together require an arbiter upstream. |
| §8 open + chunk | may occur in the same cycle for different contexts. For the same context, open_valid on an active entry is reopen_error, not a race. |
| §8 retire + open | a retiring context is reusable the following cycle, never the same cycle. This removes the window in which a straggler could match a newly opened read. |
| §9 push + pop | legal and conserving. Full + pop + push is accepted, not overflow. |
The last row is Chapter 12.1's corrected bug, and it is repeated here because it is the same shape in a different module: can_accept = !full || pop, never !full alone. Every queue on the return path uses that form, and any that does not will drop data at exactly the moment it is busiest.
11. Assertions
// SVA over cpl_gen_queue, cpl_progress_tracker and cpl_result_router.
// LOCAL flow contracts plus the normative consequence that a read may need
// more than one Completion. NOT claims about how a Completer may legally
// divide a response (Module 13), and NOT claims about Completion packet
// legality.
// GENERATOR — P1: one accepted response event produces exactly one queued
// descriptor. (gen_in_count / gen_out_count are testbench counters.)
property p_one_descriptor_per_response;
@(posedge clk) disable iff (!rst_n)
(cpl_valid && cpl_ready) |-> (gen_out_count + 1 <= gen_in_count);
endproperty
a_one_desc : assert property (p_one_descriptor_per_response);
// GENERATOR — P2: the queued descriptor and its data are stable while the
// return TX path stalls. Identity and payload do not drift apart.
property p_desc_stable;
@(posedge clk) disable iff (!rst_n)
(cpl_valid && !cpl_ready) |=> (cpl_valid && $stable({cpl_desc, cpl_data}));
endproperty
a_desc_stable : assert property (p_desc_stable);
// GENERATOR — P3: a refused response is not consumed. The producer must be
// able to re-offer it — a lost response is a read that never resolves.
property p_refused_not_consumed;
@(posedge clk) disable iff (!rst_n)
(rsp_valid && !rsp_ready) |-> !push;
endproperty
a_not_consumed : assert property (p_refused_not_consumed);
// GENERATOR — P4: full + pop accepts. The target must not stall at the
// full-rate steady state.
property p_full_pop_accepts;
@(posedge clk) disable iff (!rst_n)
(full && pop) |-> rsp_ready;
endproperty
a_full_pop : assert property (p_full_pop_accepts);
// TRACKER — P5: THE CENTRAL PROPERTY. A partial return NEVER retires a read.
property p_partial_does_not_retire;
@(posedge clk) disable iff (!rst_n)
(chunk_credited && (chunk_dw < remaining_dw)) |-> !read_retire;
endproperty
a_no_partial_retire : assert property (p_partial_does_not_retire);
// TRACKER — P6: and the converse. The chunk that completes the extent DOES
// retire it. Together P5 and P6 pin the retire condition exactly.
property p_complete_retires;
@(posedge clk) disable iff (!rst_n)
(chunk_credited && (chunk_dw == remaining_dw)) |-> read_retire;
endproperty
a_complete_retires : assert property (p_complete_retires);
// TRACKER — P7: CONSERVATION. Progress never exceeds the expected extent.
property p_progress_bounded;
@(posedge clk) disable iff (!rst_n)
chunk_credited |-> (chunk_dw <= remaining_dw);
endproperty
a_bounded : assert property (p_progress_bounded);
// TRACKER — P8: an oversized chunk is REPORTED, not clamped. A saturating
// subtraction would zero the remainder and retire a short read.
// NOTE the guarded signals: the properties read chunk_active/chunk_remaining,
// never the arrays, for the same reason the RTL does.
property p_overrun_reported_not_absorbed;
@(posedge clk) disable iff (!rst_n)
(chunk_valid && chunk_id_legal && chunk_active
&& (chunk_dw > chunk_remaining))
|-> (!chunk_credited && !read_retire) ##1 overrun_error;
endproperty
a_overrun : assert property (p_overrun_reported_not_absorbed);
// TRACKER — P9: ISOLATION. A chunk for an in-range but inactive context
// changes nothing. (remain_snapshot / active_snapshot are testbench copies.)
property p_inactive_ctx_harmless;
@(posedge clk) disable iff (!rst_n)
(chunk_valid && chunk_id_legal && !chunk_active)
|=> ((remain_q == $past(remain_snapshot))
&& (active_q == $past(active_snapshot))
&& unknown_ctx_error);
endproperty
a_isolated : assert property (p_inactive_ctx_harmless);
// RANGE — P9a: THE STRUCTURAL PROPERTY. An out-of-range identifier changes
// NOTHING in the table. This is the one that fails for a design which
// indexes before it checks, at any non-power-of-two CTXS.
property p_illegal_id_changes_nothing;
@(posedge clk) disable iff (!rst_n)
((chunk_valid && !chunk_id_legal) || (open_valid && !open_id_legal))
|=> ((remain_q == $past(remain_snapshot))
&& (active_q == $past(active_snapshot))
&& illegal_id_error);
endproperty
a_illegal_inert : assert property (p_illegal_id_changes_nothing);
// RANGE — P9b: an illegal open never allocates.
property p_illegal_open_never_allocates;
@(posedge clk) disable iff (!rst_n)
(open_valid && !open_id_legal) |=> (active_q == $past(active_snapshot));
endproperty
a_illegal_open : assert property (p_illegal_open_never_allocates);
// RANGE — P9c: an illegal chunk is never credited and never retires.
property p_illegal_chunk_never_credited;
@(posedge clk) disable iff (!rst_n)
(chunk_valid && !chunk_id_legal) |-> (!chunk_credited && !read_retire);
endproperty
a_illegal_chunk : assert property (p_illegal_chunk_never_credited);
// RANGE — P9d: the two "unknown" conditions are DISJOINT (section 8a).
// An identifier is out of range or it is in range; it is never both, and a
// design that raised both would have made the distinction useless.
property p_error_kinds_disjoint;
@(posedge clk) disable iff (!rst_n)
chunk_valid |-> !($rose(illegal_id_error) && $rose(unknown_ctx_error));
endproperty
a_disjoint : assert property (p_error_kinds_disjoint);
// RANGE — P9e: state changes only ever occur for a LEGAL selected index.
// The generate loop makes it per-entry, so a write to a neighbouring entry
// cannot hide inside an aggregate comparison.
generate for (genvar g = 0; g < CTXS; g++) begin : g_legal_write
a_write_needs_legal : assert property (@(posedge clk) disable iff (!rst_n)
(!$stable(active_q[g]) || !$stable(remain_q[g]))
|-> ($past(do_open && (open_id == IDX_W'(g)))
|| $past(credit && (chunk_id == IDX_W'(g)))
|| $past(!rst_n)));
end endgenerate
// TRACKER — P10: retirement happens at most once per read. read_retire is a
// single-cycle pulse and cannot repeat without a reopen.
property p_retire_once;
@(posedge clk) disable iff (!rst_n)
read_retire |=> !active_q[$past(chunk_id)] until_with
(do_open && (open_id == $past(chunk_id)));
endproperty
// TRACKER — P11: an active context is never silently reopened.
property p_no_silent_reopen;
@(posedge clk) disable iff (!rst_n)
(open_valid && open_id_legal && open_active) |=> reopen_error;
endproperty
a_reopen : assert property (p_no_silent_reopen);
// ROUTER — P12: the destination is carried per entry. Data credited to one
// read never emerges against another read's destination.
// (exp_dest is a testbench map keyed by a shadow chunk ID.)
property p_dest_preserved;
@(posedge clk) disable iff (!rst_n)
(out_valid && out_ready) |-> (out_dest == exp_dest[out_seq]);
endproperty
a_dest : assert property (p_dest_preserved);
// ROUTER — P13: the result is stable while the local consumer stalls.
property p_result_stable;
@(posedge clk) disable iff (!rst_n)
(out_valid && !out_ready)
|=> (out_valid && $stable({out_dest, out_data, out_byte_valid,
out_err, out_final}));
endproperty
a_result_stable : assert property (p_result_stable);
// ROUTER — P14: full + pop + push is accepted, not overflow.
property p_router_full_pop_push;
@(posedge clk) disable iff (!rst_n)
(in_valid && full && pop) |-> (push && !$rose(overflow_error));
endproperty
a_router_full_pop : assert property (p_router_full_pop_push);
// ROUTER — P15: a chunk is never silently lost.
property p_no_silent_loss;
@(posedge clk) disable iff (!rst_n)
(in_valid && full && !pop) |=> overflow_error;
endproperty
a_no_loss : assert property (p_no_silent_loss);
// RESET — P16: reset clears local flow state in all three blocks. This is a
// LOCAL TEACHING-MODEL contract, not a statement about PCIe system reset.
property p_reset_clears_flow;
@(posedge clk)
!rst_n |=> ((cnt_q == '0) && (active_q == '0) && !out_valid && !cpl_valid);
endproperty
// And every remaining count, per entry — an aggregate check on active_q alone
// would pass a design that cleared the bitmap and left stale expectations.
generate for (genvar g = 0; g < CTXS; g++) begin : g_reset_remain
a_reset_remain : assert property (@(posedge clk)
!rst_n |=> (remain_q[g] == '0));
end endgenerate
a_reset : assert property (p_reset_clears_flow);Liveness, with assumptions:
// A1: the return TX path eventually accepts a queued Completion.
assume property (@(posedge clk) disable iff (!rst_n)
cpl_valid |-> s_eventually cpl_ready);
// A2: the local consumer eventually accepts a presented result.
assume property (@(posedge clk) disable iff (!rst_n)
out_valid |-> s_eventually out_ready);
// A3: every outstanding read eventually receives its full extent. PCIe does
// NOT guarantee this — it is precisely why Completion timeout exists.
assume property (@(posedge clk) disable iff (!rst_n)
open_valid |-> s_eventually read_retire);
// L1: under A1-A3, an opened read eventually delivers its final chunk.
property p_read_eventually_delivered;
@(posedge clk) disable iff (!rst_n)
open_valid |-> s_eventually (out_valid && out_ready && out_final);
endproperty
a_liveness : assert property (p_read_eventually_delivered);P5 and P6 are the pair that defines "finished", and neither is sufficient alone. P5 forbids early retirement — the catastrophe. P6 forbids the opposite failure, a read whose final chunk arrives and which never retires, occupying a context forever and slowly starving the design of concurrency. A design that satisfies P5 by never retiring anything is not correct, and only P6 says so.
P8 is deliberately stated as "not credited and reported" rather than as a bound on the counter. A saturating subtraction satisfies a bound — it just produces the wrong answer quietly. The property has to assert the absence of the credit, not the sanity of the result.
P9 uses whole-array snapshots for Chapter 12.1's reason: the bug it catches is a write to some entry when an unknown identifier arrives, and checking only the indexed entry would miss a decode that indexed elsewhere.
P9a–P9e are the range-safety set, and P9e is the one that cannot be faked. P9a–P9c check that an illegal identifier is inert; P9e is stated per entry and says a table entry changes only when that entry was the legal selected index. An aggregate comparison would let a write to a neighbouring entry hide — which is precisely what an unguarded out-of-range index produces in a simulator that wraps rather than errors.
P9d asserts that the two error kinds are disjoint, which is what makes §8a's diagnostic table usable. A design that raised both for the same event would have collapsed the distinction back into one flag with extra steps.
P11 makes reopening an error rather than a race. A design could plausibly treat open_valid on an active context as "just overwrite it" — and that silently discards a live read's expectation, after which its Completions overrun a remainder that belongs to a different transaction. Reporting it is the only behaviour that leaves evidence.
12. Verification
Monitors observe: the target-response interface; the generation queue's output; the correlated-chunk interface with a shadow ID; the tracker's decisions and reported conditions; and the local result interface.
The scoreboard maintains its own expected-remaining map, keyed by context, decremented by its own arithmetic. It must not read remain_q, active_q, or call the tracker's credit logic. It maintains its own destination map keyed by the shadow ID.
And it must check the negative case explicitly: for every read it opened, no read_retire may be observed before the expected total has been delivered. A scoreboard that only checks the final total cannot distinguish a correct design from one that retired early and happened to receive the rest anyway.
Generation
- One response, one Completion descriptor. The baseline.
- A burst of responses faster than the TX path drains. Verify none is lost (P3) and
rsp_readybackpressures the target. - The queue driven to full, then a response offered. Verify
rsp_readylow and the response is not consumed. - Full with a simultaneous pop. Verify the response is accepted (P4).
- The TX path stalled with a descriptor queued. Verify stability of descriptor and data together (P2).
- A status-only response (
has_datalow) interleaved with data-carrying ones. Verify both pass through intact. DEPTH = 1and a non-power-of-twoDEPTH.
Progress accounting
- A read answered by one Completion covering the whole extent. Verify retire on that chunk (P6).
- A read answered by two chunks, the first partial. Verify no retire on the first (P5) and retire on the second.
- A read answered by many small chunks. Verify the remainder decrements correctly throughout and retires exactly once.
- A final chunk smaller than the others. The residue case.
- A chunk larger than the remainder. Verify
overrun_error, no credit, no retire (P8), and that the remainder is unchanged. - A zero-DW chunk. Verify no credit.
- A chunk for an inactive context. Verify nothing changes and
unknown_ctx_errorsets (P9). - A duplicate final chunk after retirement. Verify it is treated as unknown, not as fresh progress.
open_validon an already-active context. Verifyreopen_errorand that the live expectation is not overwritten (P11).
Range safety — non-power-of-two CTXS
CTXS = 5, then presentchunk_id= 5, 6 and 7. Verifyillegal_id_error, no credit, no retire, and no entry changed (P9a, P9c). This is the required stimulus — atCTXS = 4or8these identifiers are unreachable and the bug is invisible.CTXS = 5,open_id= 5, 6, 7. Verify no allocation (P9b).CTXS = 3,6,7. Sweep the other non-power-of-two sizes and check every out-of-range identifier at each.CTXS = 8— the power-of-two case. Verify every identifier 0–7 is accepted as legal. This is the counter-test for a same-width legality compare, which would truncateCTXSto zero and reject all of them.CTXS = 1. Verify identifier 0 is legal and the index width guard holds.- An illegal identifier immediately followed by a legal one. Verify the legal one is processed normally and the illegal one left no residue.
- Verify
illegal_id_errorandunknown_ctx_errorare never both raised for one event (P9d). - Open and credit in the same cycle for different contexts. Verify both take effect.
- Retire and reopen the same index on consecutive cycles. Verify the reuse is clean.
Return ordering and interleaving
- Two reads outstanding, Completions returning in issue order.
- Two reads outstanding, returning in the opposite order. Verify each read's data reaches its own destination (P12) — the direct test that arrival order is not identity.
- Chunks from two multi-chunk reads interleaved. The strongest test in the chapter: verify both progress counters advance independently and neither retires early.
- Several reads interleaved with different extents and different chunk counts.
Local delivery
- The consumer stalled for a long period. Verify results buffer, stay stable (P13), and that the tracker's contexts still retire — protocol resolution must not wait on the client (Chapter 12.1 §12).
- The router at full with a simultaneous pop and push. Verify acceptance (P14).
- The router genuinely full. Verify
overflow_errorand no silent loss (P15). - Reset with reads outstanding and results buffered. Verify all flow state clears (P16).
Which test kills which bug
| Injected fault | What catches it |
|---|---|
| context retired on the first chunk | P5, and the two-chunk test |
| read never retires on its final chunk | P6, and the scoreboard's completion timeout |
| oversized chunk clamped instead of reported | P8 |
| chunk credited to the wrong context | P9, and the interleaved-chunk test |
| data delivered to the wrong local client | P12, with per-read distinctive payloads |
| response dropped when the generation queue is full | P3 |
| target stalled at full-with-pop | P4 |
| result chunk dropped at full+pop+push | P14 |
| active context silently reopened | P11 |
| array indexed before the legality check | P9a/P9e, at non-power-of-two CTXS |
| legality compared at the identifier's own width | the CTXS = 8 counter-test — every legal ID rejected |
| out-of-range identifier reported as unknown-context | P9d, and §8a's table |
| descriptor and data drift apart under TX stall | P2 |
Coverage should include: single-chunk and multi-chunk reads; every chunk-count from one to the modelled maximum; final chunks of full and partial size; all four reported error conditions; CTXS at 1, a power of two, and several non-powers of two, with every out-of-range identifier exercised at each; both queue depths' corner cases; every ordering permutation for two outstanding reads; and consumer stalls long enough to exercise the retire-versus-deliver separation.
13. Debugging
The target produced data but no Completion appears on the link
This is §3, and the diagnosis is a walk down four boundaries.
- Was the response event accepted?
rsp_valid && rsp_ready. Ifrsp_readywas low, the generation queue was full — and that is a downstream problem wearing this symptom. - Is a descriptor queued? If the handshake completed and occupancy did not rise, the queue's push condition is wrong.
- Did
cpl_validrise? If not, the queue's output logic is broken. - Did
cpl_readyever rise? If not, the fault is in arbitration or flow control on the outbound path — not in Completion generation at all.
The general error this scenario corrects: the target has the data is boundary 1 and a packet left is boundary 4. Three things can go wrong between them, and starting the investigation at the Requester wastes all of it.
A Completion reaches the Requester but the context never retires
Correlation succeeded — the chunk was credited — so the fault is in accounting.
Three shapes, distinguished by the remainder. If remaining_dw never reaches zero, chunks are being under-counted or one was dropped: sum the DW actually delivered and compare against the expectation. If it reached zero and read_retire never pulsed, the retire condition compares the wrong things (P6). If it wrapped to a large value, the overrun check is missing and a subtraction underflowed.
And check unknown_ctx_error. A chunk that was not credited leaves the remainder untouched, which looks identical to a chunk that was never sent.
A context retires early and later data arrives orphaned
Premature retirement — §5 and P5.
The signature is unmistakable: unknown_ctx_error sets shortly after a read completed successfully. The stragglers arrived for a context that no longer exists.
Two causes. The retire condition is chunk_credited rather than chunk_dw == remaining_dw — retiring on any chunk. Or the expectation was set wrong at open time, so the read appeared complete earlier than it was.
How to tell them apart in one observation: compare the total DW delivered against the expectation. If the total is short, it retired early on a partial chunk. If the total matches, the expectation was wrong — and the bug is at open_valid, not in the tracker.
And the downstream consequence is Chapter 12.1 §8's: a freed context is reusable, so a later read takes the identifier and the stragglers resolve against it. Data from one read is delivered as the answer to another, with both reads reporting success.
illegal_id_error sets and no read is affected
The table is intact — that is what the property guarantees — so nothing downstream is corrupted. The question is where the identifier came from.
It is a local mapping fault, not a protocol one (§8a). Something upstream produced an index the table cannot represent. Three candidates, in order. The correlation lookup's output width does not match IDX_W. A Tag-to-index translation truncated or failed to bound its result (Chapter 11.3 §6). Or CTXS was changed to a non-power-of-two value and something upstream still assumes the full 2^IDX_W range is usable.
The observation: print the offending identifier and compare against CTXS. If it is in [CTXS, 2^IDX_W), the third cause is almost certain — and it is the one that appears the day someone changes a parameter from 8 to 5 to save area.
The right data reaches the wrong local client
Correlation and accounting are both fine — the destination is wrong.
Check where in_dest came from. It must be read from the context entry, populated at open time. If it was derived from arrival order, from a queue position, or from anything in the arriving packet, that is the bug — and it works perfectly with one read outstanding, which is why it survives bring-up.
The confirming test is §12's: two reads outstanding, Completions returning in the opposite order. A design that routes by arrival delivers each read's data to the other's client, and both clients receive plausible data.
14. Common Misconceptions
- "Target data ready means the Completion has been transmitted." Three boundaries separate them, and the queue between can be full (§3).
- "One target response always equals one Completion packet." A response larger than what one payload may carry becomes several Completions (§2). The rules governing that division are Module 13's.
- "Every Completion carries data." Some non-posted operations resolve with a status-only Completion. A Memory Read's normal successful response carries data; not every Completion does (§2).
- "A returned packet can be matched by arrival order." It is matched by identity — the Requester ID and correlation field the Completer copied from the Request (§5).
- "A partial return may free the context." It may not. The context lives until the whole expected extent has arrived (§5, P5).
- "A local consumer stall can be propagated across PCIe as a ready signal." The remote transmitter does not see an application-style
readyfrom the Requester's local client. The receive architecture must buffer (Chapter 12.1 §12). - "The Completion queue cannot overflow because PCIe has flow control." Flow control governs admission between components. It says nothing about a local queue's depth relative to a target's burst rate (§3).
- "Routing and correlation are the same operation." Routing gets the packet to the right component (Chapter 11.5); correlation identifies which transaction it answers. Different mechanisms, different failure modes (§5).
- "Completion Status and a local error code are the same concept." §7's
erris an abstracted internal flag. The actual Completion Status encodings and their meanings are Module 13's. - "Request context is no longer needed once the Endpoint accepts the Request." The Completer needs it to build the response, and the Requester needs it until the read resolves (§4, §5).
- "Retirement and delivery are the same event." Retirement frees the correlation resource; delivery frees the buffer. Merging them lets a stalled client consume protocol resources (Chapter 12.1 §12).
15. Understanding Check
16. What's Next
This chapter took the return path apart. A Completer having data turned out to be three boundaries away from a packet being on the wire; a Completion arriving turned out to be two decisions away from a read being finished; and retiring a context turned out to be a different event from handing the data to whoever asked for it.
Chapter 12.4 — Examples now puts reads, writes and this return path together in worked traces — including the failure signatures that distinguish a routing problem from a decode problem from an ownership problem.
Module 13 owns everything about the Completion packet that this chapter deliberately abstracted: the Cpl and CplD forms in detail, Completion Status, Byte Count and Lower Address, the rules constraining how a response may be divided, and the ordering matrix. Chapter 12.5 takes the performance consequences of return-path depth and latency.
The idea to carry forward: a Completion arriving is not a read finishing — the accounting decides that, and it must be a separate decision from the correlation that found the read in the first place.