PCIe · Module 14
ACK — When Retained State May Finally Be Released
An ACK is not a Completion and it is not proof the operation happened. It is cumulative Link-local progress: it advances a retirement frontier, releasing everything up to a point and nothing beyond it. What a transmitter may conclude, and what it must not.
Chapter 14.1 established the contract and left one question deliberately open. A transmitter must retain a packet it has sent, because it may have to send it again — so when may it stop?
Retaining forever is not an option. Storage is finite, and a transmitter that cannot release anything eventually refuses to accept new work from the layer above.
What does a Data Link ACK allow the transmitting port to conclude, which retained state may it release, and what does ACK explicitly not prove about the PCIe transaction itself?
1. The Verified Behaviour
2. ACK Is Not a Completion
Both are positive news arriving from the other direction. They are otherwise unrelated.
| Data Link ACK | Transaction Completion | |
|---|---|---|
| Layer | Data Link | Transaction |
| Scope | one Link | the transaction, end to end |
| Purpose | retire Link reliability state | resolve a non-posted Request |
| Applies to | every TLP, including posted writes | non-posted Requests only |
| Carries | Link-local progress information | status, and possibly data (Chapter 13.1) |
| Proves the operation happened | no | yes — that is what its status field says (Chapter 13.2) |
| Owned by | this module | Modules 10 and 13 |
3. Identity in This Chapter
A transmitter needs a way to say this packet when it retains one and up to here when it releases them. PCIe provides a Sequence Number for that, and Chapter 14.5 owns it — its width, its wrap, and the arithmetic for comparing two of them.
This chapter uses an abstract stand-in:
seq_idin this chapter and its RTL is an internal sequence identifier — not the PCIe wire-format Sequence Number encoding. It is a locally assigned, monotonically increasing value drawn from a finite teaching range. No wrap arithmetic and no modular comparison is performed on it, deliberately, because that is exactly what 14.5 teaches.
The consequence for §7's RTL is real and worth stating up front: instead of comparing identities numerically to decide what an ACK covers, the module searches the retained entries for a match and retires from the head up to it. That is slower than the arithmetic a production design uses — and it is correct without assuming a comparison rule this chapter has not earned.
4. ACK Does Not Mean the Operation Happened
The single most consequential misreading, and it is worth being concrete about a posted write.
A Memory Write is posted. It gets no Completion, ever (Chapter 12.2). So the acknowledgement is the only positive signal a transmitter ever sees about it — which makes the temptation to read more into it almost irresistible.
Here is what an ACK for a posted Memory Write proves:
The directly connected neighbour accepted the packet under Link reliability semantics.
Here is what it does not prove:
| Not proven | Which chapter owns the real answer |
|---|---|
| the destination Function was reached | routing (Chapter 11.5) |
| a BAR decode matched | Chapter 9.6 |
| the target register was written | Chapter 12.2 §11 |
| the effect is visible to any other observer | ordering (Chapter 13.4) |
| software saw anything | outside PCIe |
And on a two-Link path, an ACK on the first hop proves nothing about the second. The Switch accepted the packet. What it does with it next is a separate relationship with a separate outcome.
The debugging consequence is §11's third scenario, and it is common enough to be worth naming here: "the ACK came back, so why did the register not change?" Because the ACK was never about the register.
5. Cumulative Means a Frontier, Not a Free List
This is the fact that determines the hardware.
Suppose five packets have been sent and none acknowledged. Using abstract identities:
retained: 10 11 12 13 14
^ ^
oldest unacked newest sentAn ACK naming 12 arrives. The rule: purge everything with an identity equal to or earlier than the one named.
retire: 10 11 12
retained: 13 14
^
oldest unackedThree entries released by one event. Not one.
Two names worth fixing now, because 14.3 and 14.4 both use them:
| Term | Meaning |
|---|---|
oldest_unacked | the earliest retained entry whose delivery is still unresolved |
| acknowledgement frontier | the point an ACK moved oldest_unacked to |
6. Three Kinds of ACK Event
Not every ACK advances anything, and a design must distinguish three cases rather than two.
| Case | What it means | What the model does |
|---|---|---|
| advancing | names a retained entry | retire the prefix through it, advance the head |
| duplicate / stale | names something already retired | retire nothing — the frontier is already past it |
| unknown | names nothing the transmitter is retaining | retire nothing, and report it |
7. A Retirement Trace
Internal teaching signals, not PCIe wire signals. seq_id is the abstract identity of §3.
step 1 2 3 4 5 6 7 8
tl_accept 1 1 1 0 0 1 0 0
alloc_seq 10 11 12 - - 13 - -
sent 10 11 12 - - 13 - -
ack_valid 0 0 0 0 1 0 1 0
ack_seq - - - - 11 - 99 -
retained 10 10,11 10..12 10..12 12 12,13 12,13 12,13
oldest_un 10 10 10 10 12 12 12 12
occupancy 1 2 3 3 1 2 2 2
retired 0 0 0 0 2 0 0 0
ack_unknown 0 0 0 0 0 0 1 1Read step 5. An ACK naming 11 retires two entries — 10 and 11 — in one event. Occupancy drops from 3 to 1. 12 is untouched, because it is newer than the frontier.
Read step 7. An ACK naming 99 matches nothing retained. Nothing is retired, occupancy is unchanged, and ack_unknown sets — and stays set, because it is sticky.
And read step 6 against step 5. A new packet is allocated after the retirement. Occupancy went 3 → 1 → 2, which is the shape a healthy link produces: retirement in bursts, allocation steadily.
8. RTL — ACK Retirement Window
// SYNTHESIZABLE. Cumulative ACK retirement over a window of retained
// transmitted packets.
// Cumulative coverage (an ACK releases everything up to and including the
// identity it names, and nothing newer): NORMATIVE (section 1).
// The identity scheme, the match search, and the error outputs:
// ILLUSTRATIVE teaching abstraction.
module ack_retire_window #(
parameter int DEPTH = 8,
parameter int SEQ_W = 8 // internal identity width, NOT PCIe
) (
input logic clk,
input logic rst_n,
// ---- A packet becomes retained (it has been handed to the Link) -------
input logic alloc_valid,
output logic alloc_ready,
input logic [SEQ_W-1:0] alloc_seq,
// ---- Abstract ACK event ----------------------------------------------
// ONE event per cycle, and it is NOT backpressurable: it is the decoded
// result of Data Link machinery this module does not implement, and there
// is nowhere upstream to push it back to. The caller must present at most
// one per cycle — asserted as an environment assumption in section 9.
input logic ack_valid,
input logic [SEQ_W-1:0] ack_seq,
// ---- State -----------------------------------------------------------
output logic [$clog2(DEPTH+1)-1:0] occupancy,
output logic [SEQ_W-1:0] oldest_unacked,
output logic oldest_valid,
output logic full,
// ---- Retirement report -----------------------------------------------
output logic ack_advanced,
output logic [$clog2(DEPTH+1)-1:0] retire_count,
// The ACK named nothing this window is retaining. Could be already-retired
// history or an identity never sent — this model cannot distinguish them
// without sequence arithmetic (section 6), so it reports the combination.
output logic ack_unknown
);
generate
if (DEPTH < 1) $error("DEPTH must be at least 1");
endgenerate
// Width-safe at DEPTH == 1, where $clog2(1) is 0 and a zero-width index is
// illegal. Occupancy is one bit wider so DEPTH itself is representable —
// a same-width counter cannot express "full" and reads it as empty.
localparam int IDX_W = (DEPTH <= 1) ? 1 : $clog2(DEPTH);
localparam int CNT_W = $clog2(DEPTH + 1);
logic [SEQ_W-1:0] seq_q [DEPTH];
logic [DEPTH-1:0] vld_q;
logic [IDX_W-1:0] head_q, tail_q;
logic [CNT_W-1:0] cnt_q;
assign occupancy = cnt_q;
assign full = (cnt_q == CNT_W'(DEPTH));
assign alloc_ready = !full;
assign oldest_valid = (cnt_q != '0);
assign oldest_unacked = seq_q[head_q];
// Explicit wrap at DEPTH-1. Natural rollover wraps at 2^IDX_W, which
// equals DEPTH only at powers of two — the bug Chapter 14.4 section 12
// develops in full.
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
// Logical position of a PHYSICAL entry, measured from the head. Used only
// by the bound assertions in section 9 — the design itself never needs the
// inverse mapping, but the per-entry properties do, and a property that
// called an undefined helper would not be a property.
function automatic logic [CNT_W-1:0] entry_logical_pos
(input int unsigned phys);
entry_logical_pos = CNT_W'((phys + DEPTH - int'(head_q)) % DEPTH);
endfunction
// ---- MATCH SEARCH ----------------------------------------------------
// How many entries, counting from the head, are covered by this ACK.
// Walk forward from the head; if the ACK's identity is found at logical
// position k, the covered count is k+1. Found nowhere -> covers nothing.
//
// A production design compares sequence numbers arithmetically and does
// not search. That comparison is Chapter 14.5's, and using it here would
// assume a rule this chapter has not taught.
logic [CNT_W-1:0] covered;
logic matched;
always_comb begin
covered = '0;
matched = 1'b0;
for (int k = 0; k < DEPTH; k++) begin
// Only positions within the current occupancy are real entries.
if (!matched && (CNT_W'(k) < cnt_q)) begin
// Logical position k maps to physical index head_q + k, wrapped.
automatic int unsigned phys = (int'(head_q) + k) % DEPTH;
if (vld_q[phys] && (seq_q[phys] == ack_seq)) begin
matched = 1'b1;
covered = CNT_W'(k) + CNT_W'(1);
end
end
end
end
wire do_retire = ack_valid && matched;
assign ack_advanced = do_retire;
assign retire_count = do_retire ? covered : '0;
// ---- RETAINED-IDENTITY UNIQUENESS ------------------------------------
// The match search above is well-defined only if no two retained entries
// carry the same identity. That is a property of the identity SOURCE, not
// of this module — Chapter 14.5's sequence scheme provides it — so this
// block does not enforce it. It EXPOSES it, so the obligation can be
// stated as a checkable assumption (section 9's A2) instead of a comment.
//
// Note this is a search over ALL entries, independent of any ACK: it asks
// "is the identity being allocated already retained?", which is the actual
// uniqueness question.
logic alloc_seq_exists;
always_comb begin
alloc_seq_exists = 1'b0;
for (int i = 0; i < DEPTH; i++)
if (vld_q[i] && (seq_q[i] == alloc_seq))
alloc_seq_exists = 1'b1;
end
logic unk_q;
assign ack_unknown = unk_q;
wire push = alloc_valid && alloc_ready;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
vld_q <= '0; head_q <= '0; tail_q <= '0; cnt_q <= '0; unk_q <= 1'b0;
for (int i = 0; i < DEPTH; i++) seq_q[i] <= '0;
end else begin
// ---- Retirement ---------------------------------------------------
// Clears exactly `covered` entries from the head. Nothing newer than
// the frontier is touched, which is the whole normative rule.
if (do_retire) begin
for (int k = 0; k < DEPTH; k++)
if (CNT_W'(k) < covered)
vld_q[(int'(head_q) + k) % DEPTH] <= 1'b0;
end
// ---- Allocation ---------------------------------------------------
if (push) begin
seq_q[tail_q] <= alloc_seq;
vld_q[tail_q] <= 1'b1;
tail_q <= next_idx(tail_q);
end
// ---- Head advance -------------------------------------------------
// Advanced by exactly the retired count, never by a fixed step. A
// head that moves one per ACK is section 5's leak.
if (do_retire) begin
automatic int unsigned nh = (int'(head_q) + int'(covered)) % DEPTH;
head_q <= IDX_W'(nh);
end
// ---- Occupancy ----------------------------------------------------
// SAME-CYCLE CONTRACT, written as one expression over both events so
// an ACK and an allocation in the same cycle cannot be resolved by
// statement order. Retirement is subtracted and allocation added
// together; the result cannot underflow because `covered` is bounded
// by cnt_q by construction.
cnt_q <= cnt_q
- (do_retire ? covered : CNT_W'(0))
+ (push ? CNT_W'(1) : CNT_W'(0));
// Reported, and it never frees anything.
if (ack_valid && !matched) unk_q <= 1'b1;
end
end
endmoduleClassification: synthesizable.
Architecture. A circular window of retained identities with an occupancy counter, and a combinational match search that converts an ACK into a count of entries to retire.
State. seq_q and vld_q per entry, head and tail pointers, an occupancy count, and a sticky unknown-ACK flag.
Why occupancy is a separate counter rather than inferred from the pointers. At full, head_q == tail_q — and at empty, also head_q == tail_q. Pointer equality cannot distinguish them, and a design that tries reads a full window as empty and allocates over unresolved entries. Chapter 14.4 §13 develops this.
Same-cycle contract, stated explicitly:
| Event pair | Resolution |
|---|---|
| ACK + allocation | both apply. Occupancy is cnt − covered + 1, computed in one expression |
| ACK on empty | matched is false — nothing retires, ack_unknown sets |
| ACK naming the entry allocated this cycle | not matched — the entry is not yet in vld_q. Retires nothing, which is the safe direction |
| allocation when full | refused by alloc_ready |
Contract. The caller presents at most one ACK event per cycle and must not withdraw it — the interface is not backpressurable, and §9's assume states that rather than leaving it in prose. Downstream relies on retire_count being the exact number of entries released.
Failure — five, and §10 maps each to a check. Advancing the head by one per ACK leaks storage until the window fills (§5). Retiring the whole window on any ACK releases unresolved packets that can then never be replayed. Letting an unmatched ACK retire the head does the same thing with a different trigger. Decrementing occupancy by one rather than by covered desynchronises the count from the valid bits. And natural pointer rollover breaks at any non-power-of-two DEPTH.
Deliberately simplified: identities only, no packet data (Chapter 14.4); a linear match search rather than sequence arithmetic (14.5); no replay (14.3); no replay timer; no distinction between stale and unknown.
Production implication: a real transmitter compares Sequence Numbers arithmetically, stores the packets themselves, participates in replay, and runs a replay timer. The cumulative-frontier rule is unchanged by all of it.
9. Assertions
// SVA over ack_retire_window. These assert the NORMATIVE cumulative
// retirement rule of section 1 and the LOCAL window contract. They assert
// nothing about ACK DLLP format or timing (Chapter 15.3), nothing about
// sequence arithmetic (Chapter 14.5), and nothing about replay (14.3).
// ---- ENVIRONMENT ASSUMPTIONS, stated rather than implied -------------
// A1: at most one ACK event per cycle. The interface is not backpressurable,
// so the caller owns this obligation.
assume property (@(posedge clk) disable iff (!rst_n)
ack_valid |-> !$isunknown(ack_seq));
// A2: RETAINED IDENTITIES ARE UNIQUE. An identity being allocated is not
// already retained. Stated over `alloc_seq_exists`, which searches the whole
// window — NOT over the current ACK, which would only forbid a coincidence
// between one allocation and one acknowledgement and would say nothing about
// uniqueness at all.
//
// This is an ASSUMPTION rather than an assertion because the obligation
// belongs to the identity source (Chapter 14.5), not to this module. A
// design whose identity source cannot guarantee it must turn this into an
// assertion and add an error output — see the note after the properties.
assume property (@(posedge clk) disable iff (!rst_n)
push |-> !alloc_seq_exists);
// ---- SAFETY ----------------------------------------------------------
// P1: THE NORMATIVE PROPERTY. An ACK retires a contiguous prefix from the
// head, and never more entries than are retained.
property p_retire_bounded_prefix;
@(posedge clk) disable iff (!rst_n)
ack_advanced |-> ((retire_count >= CNT_W'(1)) && (retire_count <= cnt_q));
endproperty
a_prefix : assert property (p_retire_bounded_prefix);
// P2: NOTHING NEWER THAN THE FRONTIER IS RELEASED. Stated per entry, so a
// design that cleared an extra valid bit cannot hide in an aggregate count.
generate for (genvar g = 0; g < DEPTH; g++) begin : g_no_overshoot
a_no_overshoot : assert property (@(posedge clk) disable iff (!rst_n)
($fell(vld_q[g]))
|-> ($past(do_retire) && ($past(entry_logical_pos(g)) < $past(covered)))
|| $past(!rst_n));
end endgenerate
// P3: an ACK that matches nothing retires NOTHING and is reported.
property p_unknown_ack_inert;
@(posedge clk) disable iff (!rst_n)
(ack_valid && !matched)
|-> (!ack_advanced && (retire_count == '0))
##1 (ack_unknown && (cnt_q == $past(cnt_q) + ($past(push) ? CNT_W'(1) : CNT_W'(0))));
endproperty
a_unknown_inert : assert property (p_unknown_ack_inert);
// P4: an ACK NEVER creates state. It cannot allocate, and it cannot make an
// invalid entry valid.
property p_ack_creates_nothing;
@(posedge clk) disable iff (!rst_n)
(ack_valid && !push) |=> (cnt_q <= $past(cnt_q));
endproperty
a_ack_no_create : assert property (p_ack_creates_nothing);
// P5: OCCUPANCY CONSERVATION across every event combination, including the
// same-cycle case. The single property that catches a decrement-by-one bug.
property p_occupancy_exact;
@(posedge clk) disable iff (!rst_n)
1'b1 |=> (cnt_q == $past(cnt_q)
- $past(ack_advanced ? retire_count : CNT_W'(0))
+ $past(push ? CNT_W'(1) : CNT_W'(0)));
endproperty
a_occupancy : assert property (p_occupancy_exact);
// P6: occupancy is bounded and never underflows.
property p_occupancy_sane;
@(posedge clk) disable iff (!rst_n)
(occupancy <= CNT_W'(DEPTH));
endproperty
a_occ_bounded : assert property (p_occupancy_sane);
// P7: the head advances by EXACTLY the retired count, and only on a
// retirement. A head that moves independently is section 5's leak or its
// overshoot, depending on direction.
property p_head_tracks_retirement;
@(posedge clk) disable iff (!rst_n)
!$stable(head_q) |-> ($past(ack_advanced) && ($past(retire_count) != '0));
endproperty
a_head : assert property (p_head_tracks_retirement);
// P8: a retired entry never becomes pending again without a new allocation.
generate for (genvar g = 0; g < DEPTH; g++) begin : g_no_resurrect
a_no_resurrect : assert property (@(posedge clk) disable iff (!rst_n)
$rose(vld_q[g]) |-> ($past(push) && ($past(tail_q) == IDX_W'(g))));
end endgenerate
// P9: a retained identity is IMMUTABLE while valid. It is the thing the ACK
// will be matched against, so it must not move underneath the comparison.
generate for (genvar g = 0; g < DEPTH; g++) begin : g_seq_stable
a_seq_stable : assert property (@(posedge clk) disable iff (!rst_n)
(vld_q[g] && !(do_retire && (entry_logical_pos(g) < covered)))
|=> $stable(seq_q[g]));
end endgenerate
// P10: allocation is refused when full — the layer can and must say no.
property p_full_refuses;
@(posedge clk) disable iff (!rst_n)
full |-> !alloc_ready;
endproperty
a_full : assert property (p_full_refuses);
// P11: pointers stay in range at every DEPTH, including non-powers of two.
property p_pointers_in_range;
@(posedge clk) disable iff (!rst_n)
(head_q < IDX_W'(DEPTH)) && (tail_q < IDX_W'(DEPTH));
endproperty
a_ptr_range : assert property (p_pointers_in_range);
// P12: THE LAYER-BOUNDARY PROPERTY. An ACK never produces a Transaction
// Layer event. Bound against the TL interface to make section 2's
// distinction checkable rather than merely explained.
property p_ack_is_not_a_completion;
@(posedge clk) disable iff (!rst_n)
ack_valid |-> !tl_completion_event;
endproperty
a_layer_boundary : assert property (p_ack_is_not_a_completion);
// P13: reset clears the LOCAL teaching state of this block.
property p_reset_clears;
@(posedge clk)
!rst_n |=> ((occupancy == '0) && (vld_q == '0) && !ack_advanced);
endproperty
a_reset : assert property (p_reset_clears);P2 and P5 are the pair that pins cumulative retirement, and they fail on opposite mutations. P5 catches a design that miscounts — decrementing by one when three entries were released. P2 catches one that clears too many valid bits, which P5 would not see if the counter was updated to match. Per-entry properties are what stop an aggregate count from covering for a per-entry bug.
P3 is written as a two-cycle sequence deliberately. It asserts that an unmatched ACK does nothing and that occupancy is unchanged except by whatever allocation happened the same cycle — so the property survives the concurrent case rather than being written for a quiet interface it will never see.
P12 is unusual: it is bound across a layer boundary. §2's distinction is normally taught and never checked. Binding it against the Transaction Layer's completion event makes "an ACK is not a Completion" a property rather than a paragraph — and it fires immediately on a design that wired the two together.
A2 is worth reading as a statement of what 14.5 buys you, and its shape matters. The match search is correct only if no two retained entries carry the same identity — so the assumption is stated over alloc_seq_exists, a search of the whole window, and not over the current ACK. An assumption relating alloc_seq to ack_seq would forbid one coincidence and say nothing about uniqueness, which is the kind of property that looks right in a review and constrains nothing in a proof.
Why it is an assumption and not an assertion. Uniqueness is a property of the identity source — PCIe's Sequence Number scheme (Chapter 14.5) — not of this module, which only consumes identities it is handed. A design whose source cannot guarantee it should convert A2 into an assertion and add an error output, because then the check belongs here; §8's alloc_seq_exists is already the expression it would need. What is not defensible is leaving the obligation implicit, since the search silently returns the oldest duplicate and the retirement prefix is then wrong by however far apart they sit.
10. Verification
Monitors observe: the allocation interface, the ACK event, occupancy, oldest_unacked, retire_count, and the reported conditions.
The scoreboard maintains its own retained list — a simple ordered queue of identities. On an ACK it finds the identity, removes the prefix through it, and computes the expected retire count itself. It must not read vld_q, cnt_q, head_q or the DUT's covered.
The independence matters most for the count. A scoreboard that read retire_count and checked occupancy against it would verify only that the DUT is consistent with itself — and the one-per-ACK mutation keeps those two consistent while leaking storage.
Retirement
- One packet, then an ACK naming it. Retire count 1, occupancy 0.
- Three packets, ACK naming the oldest. Retire count 1, two retained.
- Three packets, ACK naming the middle. Retire count 2.
- Three packets, ACK naming the newest. Retire count 3, window empty.
- Five packets, ACK naming the third, then an ACK naming the fifth. Two events, 3 then 2.
- A long run of allocations with periodic ACKs. Verify occupancy never drifts from the scoreboard's.
Negative
- A duplicate ACK — the same identity twice. Verify the second retires nothing.
- A stale ACK — an identity already retired. Verify nothing retires.
- An unknown ACK — an identity never allocated. Verify nothing retires and
ack_unknownsets (P3). - An ACK on an empty window. Verify nothing happens and no underflow.
- An ACK naming the identity allocated in the same cycle. Verify it does not match — the entry is not yet valid — and that the allocation still succeeds.
- An ACK while full. Verify it retires correctly and
alloc_readyrises.
Structural
- Fill to
DEPTH. Verifyalloc_readydrops (P10) and occupancy equalsDEPTH. DEPTH = 1. The index-width corner.- Non-power-of-two
DEPTH— 3, 5, 7. Fill, wrap, retire across the wrap, and verify pointers stay in range (P11). This is a required test, not a stress case: natural rollover passes every power-of-two depth. - Retirement spanning the physical wrap — head near
DEPTH-1, retiring several entries. Verify the modular head advance lands correctly. - ACK and allocation in the same cycle at every occupancy from 1 to
DEPTH(P5). - Reset with the window non-empty.
Which mutation which check kills
| Injected mutation | Caught by |
|---|---|
| free only the named packet, not the prefix | scoreboard retire-count mismatch; occupancy drifts upward over a long run |
| free the whole window on any ACK | P2, per-entry; and the scoreboard on the first ACK that is not the newest |
| a duplicate ACK frees again | the duplicate-ACK test; scoreboard occupancy mismatch |
| an unknown ACK frees the head | P3, immediately |
| occupancy decremented by one regardless of retire count | P5 |
| the head advances past an unacknowledged entry | P7, and P2 |
pointers wrap at 2^IDX_W | P11, at DEPTH = 5 |
| an ACK made an invalid entry valid | P8 |
| a retained identity mutated in place | P9 |
| ACK wired to a Transaction Layer event | P12 |
Coverage should include: retirement counts from 1 to DEPTH; occupancy at 0, 1, DEPTH−1, DEPTH; ACK-plus-allocation at each occupancy; the duplicate, stale and unknown paths; DEPTH = 1 and a non-power-of-two depth; and retirement spanning the physical wrap.
11. Debugging
Replay storage fills despite many ACKs arriving
The frontier is not advancing as far as the ACKs permit.
Compare retire_count against what the ACK covered. If ACKs consistently retire exactly one entry while naming identities several positions into the window, the design is treating cumulative acknowledgement as per-packet (§5) — the most common form of this bug and the one that produces a slow, steady leak.
Two others. The match search is failing, so ack_unknown is set and nothing retires — check that flag first, it is one bit and it answers the question. Or the head advances but occupancy does not, so alloc_ready stays low against a window that is actually empty.
The observation that separates them: dump occupancy alongside the count of valid entries. If they disagree, the counter is the bug; if they agree and both are high, the retirement is.
A packet newer than the ACK disappeared
Retirement overshot the frontier, and this is the dangerous direction.
The ACK named an identity; entries newer than it were released. Those packets' delivery was still unresolved, and if one is later selected for replay it is gone (Chapter 14.3).
Check the covered count against the position of the matched entry. covered must equal the matched entry's logical position plus one. If it equals the occupancy instead, the design is retiring the whole window on any match.
P2 catches it in simulation, per entry. In the field the symptom is a replay that cannot find its packet, which surfaces far from the cause.
A Memory Write's target was not updated even though an ACK was observed
The ACK proved the wrong thing, and there is no bug here yet.
An ACK says the neighbour accepted the packet (§4). It says nothing about the destination Function, the BAR decode, the target register, or any observer.
Trace forward, not backward. On a multi-hop path, ask whether the next Link carried it — the acknowledgement you saw belonged to one hop (Chapter 3.2 §2). Then walk Chapter 12.2 §14's ladder from the receiving Endpoint: BAR hit, resource handshake, the update itself.
The instinct to resist is treating the ACK as evidence that everything downstream must be fine. It is evidence about exactly one hop.
The same TLP is replayed even though an ACK for it already arrived
The ACK did not retire the corresponding retained state.
Two candidates, and one signal distinguishes them. If ack_unknown is set for that ACK, the identity did not match anything retained — an identity-mapping problem between what was allocated and what the ACK names. If it is clear and the entry is still valid afterwards, the retirement logic ran and did not clear it.
And check the ordering of the two events. If the replay was selected in the same cycle the ACK arrived, this is a same-cycle race — Chapter 14.4 §8 owns the interaction between retirement and an active replay walk, and this window model deliberately does not have one.
12. Common Misconceptions
- "An ACK means the transaction completed." It means the neighbour accepted the packet on one Link. A Completion means the operation was serviced (§2).
- "An ACK means remote software consumed the operation." It does not reach software at all (§4).
- "An ACK travels end to end through Switches." Each Link has an independent relationship. A Switch terminates one and originates another (§2).
- "One ACK acknowledges exactly one TLP." It is cumulative — it releases everything up to and including the identity it names (§1, §5).
- "An ACK frees the whole replay buffer." It frees a prefix. Anything newer than the frontier stays retained (§5).
- "ACK and Cpl are equivalent." Different layers, different scopes, different meanings, and different owners (§2).
- "An ACK for a posted write is a success status." A posted write has no status. The ACK is Link-local progress (Chapter 12.2).
- "The identity in an ACK is a Requester Tag." A Tag correlates a Completion to a Request end to end (Chapter 11.3 §6). This is a Link-local sequence identity (Chapter 14.5).
- "A stale ACK can safely free the oldest entry." It may free nothing. Freeing the head releases a packet whose delivery is unresolved (§6).
- "ACK belongs to the Transaction Layer." It is Data Link machinery and never produces a Transaction Layer event (§2, P12).
- "If ACKs are fast enough, no replay storage is needed." Storage is needed because delivery is unresolved between transmission and acknowledgement — however brief that window is (Chapter 14.1 §8).
13. Understanding Check
14. What's Next
This chapter answered the question Chapter 14.1 left open: retained state is released by a cumulative acknowledgement that advances a frontier, releasing a contiguous prefix and nothing beyond it. An ACK that matches nothing releases nothing — and none of it says anything about whether an operation happened.
That is the good path. Chapter 14.3 — NAK takes the other one: what happens when Link-local reception does not succeed, which retained traffic must be sent again, and why a retransmission must never look like a new Transaction Layer operation.
Chapter 14.4 then builds the storage both paths need — an ordered structure where reading for transmission is not dequeuing — and Chapter 14.5 replaces §3's abstract identity with PCIe's actual Sequence Number mechanism, including the comparison arithmetic §8's match search deliberately avoided.
Chapter 15.3 owns the ACK DLLP itself: its format and its timing.
The idea to carry forward: an ACK moves a frontier, not a packet — and the frontier is about a Link, not about an operation.