PCIe · Module 20
High-Speed Data Movement — Keeping the Link Busy Without Breaking Anything
Throughput is not bigger packets. It is having legal work available every cycle — which means Tags, credits, buffers and descriptors must all be productive at once, and a faster engine is still the same ownership machine.
Chapters 20.2–20.4 built a DMA engine that is correct. It owns descriptors properly, tracks Tags, matches Completions, and walks segment lists without losing or duplicating work.
It is also slow, deliberately. Chapter 20.3 used a small Tag pool; Chapter 20.4 fetched one descriptor at a time. Both were the right teaching choice, and both leave the Link idle most of the time.
The instinct is to reach for bigger packets. That helps, and §8 quantifies exactly how much — but it is not where the bandwidth is.
The bandwidth is in having legal work ready every single cycle, and that turns out to be a multi-resource problem: Tags, flow-control credits, buffer space, and descriptors must all be simultaneously non-empty.
How does a DMA engine expose enough independent work to keep a high-speed Link busy — without relaxing a single thing Module 20 has established?
1. Sources and What Is Derived
2. What Actually Limits Throughput
Four terms, and the smallest one wins:
available Link bandwidth
× transaction efficiency payload / (payload + overhead) §8
× ability to keep work in flight Tags, credits, descriptors §§6-7
× ability to absorb what returns completion buffering, sink rate §12Most DMA engines that underperform are not limited by the first term. They are limited by the third or fourth — and §20's debugging ladder exists to identify which.
And the terms are multiplicative, which has a practical consequence: fixing a term that is not the bottleneck changes nothing measurable. Doubling the Tag pool on a credit-limited engine produces exactly zero improvement — which is §20's second scenario and one of the most common wasted efforts in DMA tuning.
3. Performance Is Not Permission to Relax Correctness
4. Bandwidth and Latency Are Different Problems
Bandwidth is useful bytes per second. Latency is time from request to useful response.
Writes are posted (Chapter 20.3 §3), so host-memory latency does not bound write bandwidth. The engine issues and moves on; only credits and transmit opportunity limit it.
Reads are non-posted, so every request waits a round trip. A read engine's bandwidth is bounded by how much it can have outstanding, which is §5.
The derived relationship, and the single most useful formula in the chapter:
bandwidth ≈ bytes_in_flight / round_trip_latencyThis is an upper-bound intuition, not a PCIe rule (§1). It says: to go faster, either put more bytes in flight or reduce latency — and the engine only controls the first.
5. Bandwidth-Delay Product
Rearranged, the formula tells you what to build:
required bytes in flight ≈ target_bandwidth × round_trip_latency
required requests ≈ ceil(bytes_in_flight / request_size)Computed (§17) — derived model, illustrative latencies:
| Target | Round trip | Bytes in flight | Requests @512 B | Requests @4 KiB |
|---|---|---|---|---|
| 1 GB/s | 0.5 µs | 500 | 1 | 1 |
| 4 GB/s | 1.0 µs | 4,000 | 8 | 1 |
| 8 GB/s | 2.0 µs | 16,000 | 32 | 4 |
| 16 GB/s | 1.0 µs | 16,000 | 32 | 4 |
| 16 GB/s | 4.0 µs | 64,000 | 125 | 16 |
Three things to read out of this table.
Requirements grow with latency, not only with bandwidth. The 16 GB/s rows differ only in round trip, and the request requirement differs by 4×.
Request size trades directly against request count. At 4 KiB per request, 16 GB/s over a 4 µs round trip needs 16 outstanding — comfortably inside a 5-bit Tag field (Chapter 20.3 §1). At 512 B it needs 125, which exceeds 32 Tags and forces Extended Tags.
And the last row is why Extended Tag exists. A high-bandwidth, high-latency path with modest request sizes genuinely cannot be served by 32 Tags — the arithmetic, not the protocol, is what demands them.
6. Why One Outstanding Read Is Slow
Chapter 20.3 §15's single-outstanding tracker was correct and deliberately bounded. Here is its ceiling.
Derived simulation (§17): 512-byte requests, a 250-cycle round trip, 200,000 cycles:
| Outstanding | Bytes/cycle | Relative |
|---|---|---|
| 1 | 2.05 | 1.0× |
| 2 | 4.09 | 2.0× |
| 4 | 8.18 | 4.0× |
| 8 | 16.36 | 8.0× |
| 16 | 32.73 | 16.0× |
| 32 | 65.45 | 32.0× |
| 64 | 130.91 | 64.0× |
Perfectly linear across the whole range, because in this model nothing else binds. The engine is doing 1/64th of what the same hardware could do, purely from lack of concurrency.
And that is the point of the model, not the numbers. Real scaling stops being linear as soon as another resource becomes the limit — which is §7, and which is exactly what §19's waveform shows.
7. More Tags Is Not More Bandwidth
The Tag-limited ceiling, computed (§17) — derived:
ceiling ≈ tags × request_size / round_trip_latency| Tags | Request size | Round trip | Ceiling |
|---|---|---|---|
| 8 | 512 B | 1 µs | 4.10 GB/s |
| 32 | 512 B | 1 µs | 16.38 GB/s |
| 32 | 4096 B | 1 µs | 131.07 GB/s |
| 256 | 512 B | 1 µs | 131.07 GB/s |
Read rows 3 and 4. The same ceiling reached two ways — 32 Tags with large requests, or 256 Tags with small ones. Request size and Tag count are interchangeable in this model, which is why "add Tags" is not automatically the answer.
8. Payload Size and Collapsing Returns
Larger payloads amortize per-TLP overhead — the one effect that genuinely is about packet size.
Computed (§17) with a normalized, illustrative overhead of 24 bytes per TLP:
| Payload | Efficiency | Δ from previous |
|---|---|---|
| 64 B | 72.7% | — |
| 128 B | 84.2% | +11.5 pts |
| 256 B | 91.4% | +7.2 pts |
| 512 B | 95.5% | +4.1 pts |
| 1024 B | 97.7% | +2.2 pts |
| 2048 B | 98.8% | +1.1 pts |
| 4096 B | 99.4% | +0.6 pts |
The shape is the lesson. Each doubling gains roughly half what the previous one did. 128→256 is worth 7.2 points; 2048→4096 is worth 0.6 — and the second costs 16× the buffering, 16× the latency to assemble a payload, and 16× the granularity penalty on short transfers.
Three qualifications that keep this honest.
The overhead value is illustrative (§1). A different overhead shifts the numbers; it does not change the shape, which is what the section is for.
MPS and MRRS are different fields (Chapter 20.3 §8) bounding different things. Raising one does not raise the other, and mutation 22 is the design that conflates them.
And a large MRRS does not imply a single large Completion (Chapter 20.3 §10). One request may still return several — so §12's buffering must be sized for the request, not for an assumed single response.
9. The Issue Window
Every cycle, the engine asks one question:
can_issue = tag_available
&& credit_available
&& buffer_space_reserved
&& link_request_ready
&& work_availableAll five, every time. §17 measured what happens when two are dropped: 18.6% of states issue illegally.
And each term must be a grant, not an observation — which is §11, and the most subtle failure in this chapter.
10. Reservation, Not Observation
11. Descriptor Prefetch, and the Stale-Descriptor Trap
Chapter 20.4 §5 established that descriptor fetch is DMA and sits on the critical path. Prefetching hides that latency: fetch descriptor N+1 while segment N executes.
And it introduces a failure that fetch-one-at-a-time cannot have.
12. Sizing the Sink
Request-side optimization without sink-side capacity produces Completions with nowhere to go.
The invariant (§13's RTL):
reserved_bytes ≤ capacity alwaysReservation happens before the request is issued, not when data returns — because by the time it returns it is too late to decline.
And buffer sizing has an honest limit. The derived model is:
required buffering ≥ arrival_rate × maximum_expected_stallIf no bounded maximum stall exists, no finite buffer is sufficient. That is not a sizing problem; it is an architecture problem, and the answer is backpressure rather than depth — which §18's decision table makes concrete.
13. RTL — Admission, Tags and Reservation
// SYNTHESIZABLE. Multi-resource admission control (section 9).
// EVERY TERM IS REQUIRED. Section 16 measured an admission check that used
// only tags and work: it issues without credit or buffer in 18.6% of
// resource states -- which is a flow-control violation, not a stall.
//
// THIS BLOCK DOES NOT IMPLEMENT PCIe FLOW CONTROL. It consumes a
// normalized grant from the credit manager (Module 16).
module dma_admission (
input logic tag_available, // section 14's allocator
input logic credit_granted, // NORMALIZED grant, not a raw counter (§10)
input logic buffer_reserved, // section 15
input logic tx_request_ready,
input logic work_available,
output logic issue_allowed
);
// A conjunction, deliberately flat and readable: any future edit that
// drops a term is visible in review rather than buried in an FSM.
assign issue_allowed = tag_available
&& credit_granted
&& buffer_reserved
&& tx_request_ready
&& work_available;
endmodule// SYNTHESIZABLE. Multi-Tag allocator with an optional rotating search.
// DEEPENS Chapter 20.3 section 15's allocator: same lease semantics, plus
// same-cycle free-and-allocate and a fairness option.
//
// Section 16: 600,000 random operations across TAGS in {1,2,3,8,32},
// including simultaneous free+alloc -- 0 disagreements with an
// independent set model.
module tag_pool #(
parameter int TAGS = 32,
parameter bit ROTATE = 1, // 1 = round-robin search (§ fairness)
parameter int TW = (TAGS <= 1) ? 1 : $clog2(TAGS)
) (
input logic clk,
input logic rst_n,
input logic alloc_req,
output logic alloc_valid,
output logic [TW-1:0] alloc_tag,
input logic free_req,
input logic [TW-1:0] free_tag,
output logic [TAGS-1:0] busy_map,
output logic [TW:0] free_count, // one bit wider: 0..TAGS
output logic err_bad_free
);
generate if (TAGS < 1) $error("TAGS must be at least 1"); endgenerate
logic [TAGS-1:0] busy_q;
logic [TW-1:0] rr_q;
logic bad_q;
assign busy_map = busy_q;
assign err_bad_free = bad_q;
// Free count is a real count, not a popcount recomputed downstream --
// section 15's reservation logic needs it every cycle.
always_comb begin
free_count = '0;
for (int i = 0; i < TAGS; i++) if (!busy_q[i]) free_count = free_count + 1;
end
// ==================================================================
// SEARCH ORDER IS A FAIRNESS POLICY, NOT A CORRECTNESS ONE (§ below).
// A fixed low-index-first search is functionally correct and repeatedly
// favours low Tag IDs -- which is harmless protocol-wise and can skew
// lab observation and latency distribution. ROTATE makes it round-robin.
// PCIe REQUIRES NO TAG FAIRNESS; this is implementation policy.
// ==================================================================
logic [TW-1:0] pick;
logic found;
always_comb begin
pick = '0; found = 1'b0;
for (int k = TAGS-1; k >= 0; k--) begin
int idx = ROTATE ? ((int'(rr_q) + k) % TAGS) : k;
if (!busy_q[idx]) begin pick = TW'(idx); found = 1'b1; end
end
end
assign alloc_valid = alloc_req && found;
assign alloc_tag = pick;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin busy_q <= '0; rr_q <= '0; bad_q <= 1'b0; end
else begin
// ==============================================================
// SAME-CYCLE FREE AND ALLOCATE IS THE STEADY STATE of a busy
// engine: a context retiring while a new request issues. Free is
// applied first so the released Tag is immediately re-allocatable.
// ==============================================================
if (free_req) begin
if (free_tag < TW'(TAGS) && busy_q[free_tag]) busy_q[free_tag] <= 1'b0;
else bad_q <= 1'b1; // double or out-of-range free
end
if (alloc_valid) begin
busy_q[alloc_tag] <= 1'b1;
rr_q <= (alloc_tag == TW'(TAGS-1)) ? '0 : (alloc_tag + TW'(1));
end
end
end
endmodule// SYNTHESIZABLE. Reserve sink capacity BEFORE issuing (section 12).
// RESERVE-THEN-ISSUE, RELEASE-ON-COMMIT. Reserving after the request is
// issued is too late: the Completion is already coming.
module completion_reservation #(
parameter int CAPACITY_BYTES = 16384,
parameter int CW = $clog2(CAPACITY_BYTES + 1)
) (
input logic clk,
input logic rst_n,
input logic [CW-1:0] req_bytes,
input logic reserve_req,
output logic reserve_ok,
input logic release_valid,
input logic [CW-1:0] release_bytes,
output logic [CW-1:0] reserved_bytes,
output logic [CW-1:0] free_bytes,
output logic almost_full,
output logic err_over_release
);
logic [CW-1:0] res_q;
logic over_q;
assign reserved_bytes = res_q;
assign free_bytes = CW'(CAPACITY_BYTES) - res_q;
assign almost_full = (free_bytes < CW'(CAPACITY_BYTES/8));
assign err_over_release = over_q;
// ADMISSION USES FREE CAPACITY, not a full bit -- a request needs a
// specific number of bytes, and "not full" does not mean "enough".
assign reserve_ok = reserve_req && (req_bytes <= free_bytes);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin res_q <= '0; over_q <= 1'b0; end
else begin
unique case ({reserve_ok, release_valid})
2'b10: res_q <= res_q + req_bytes;
2'b01: if (release_bytes <= res_q) res_q <= res_q - release_bytes;
else over_q <= 1'b1; // released more than held
2'b11: begin
// Same cycle: net the two so the invariant never breaks
// transiently (section 20's audit).
if (release_bytes <= res_q) res_q <= res_q - release_bytes + req_bytes;
else over_q <= 1'b1;
end
default: ;
endcase
end
end
// INVARIANT: reserved_bytes <= CAPACITY_BYTES, always (P8).
endmoduleClassification: all three synthesizable.
The allocator was verified (§17): 600,000 operations across TAGS = 1, 2, 3, 8, 32 including same-cycle free-and-allocate — 0 disagreements with an independent set model.
ROTATE is fairness policy, not correctness — PCIe requires no Tag fairness, and a fixed search is functionally fine. It skews latency distribution and lab observation, which is worth a parameter and not worth a claim.
And reserve_ok uses free_bytes, not almost_full (§12): a request needs a specific number of bytes.
Failure — five. Dropping a term from issue_allowed — 18.6% illegal issues. Observing a credit counter instead of taking a grant (§10). Reserving after issuing. Admission on a full bit rather than free capacity. And TAGS = 1, where the guarded width keeps the index one bit rather than zero.
14. RTL — Prefetch Epoch, Packer and Shared Arbiter
// SYNTHESIZABLE. Job epoch -- the stale-prefetch defence (section 11).
// IMPLEMENTATION POLICY, and the epoch is LOCAL: it is never transmitted
// over PCIe and has no protocol meaning.
//
// Section 16: without this, 161,182 stale descriptors executed across
// 40,000 randomized abort sequences. With it, 0.
module job_epoch #(parameter int EW = 4) (
input logic clk,
input logic rst_n,
input logic job_start,
input logic job_abort,
input logic [EW-1:0] item_epoch, // carried with each prefetched item
input logic item_valid,
output logic item_is_stale,
output logic [EW-1:0] current_epoch
);
logic [EW-1:0] ep_q;
assign current_epoch = ep_q;
// Wrap is harmless: a stale item would need exactly 2^EW epoch changes
// to alias, and the FIFO is far shallower than that.
assign item_is_stale = item_valid && (item_epoch != ep_q);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) ep_q <= '0;
else if (job_abort || job_start) ep_q <= ep_q + EW'(1);
end
endmodule// SYNTHESIZABLE. Assemble small source beats into efficient payloads (§8).
// BOUNDED THREE WAYS so it never waits forever: the target size, the end
// of a descriptor, and an explicit flush. Waiting only for the target
// would stall a transfer whose final bytes never reach it.
//
// Section 16: 60,000 random (beat, target, ready) cases -- byte
// conservation `accepted == emitted + buffered` held in every one.
module payload_packer #(
parameter int TARGET_BYTES = 256,
parameter int BUF_BYTES = 1024,
parameter int BW = $clog2(BUF_BYTES + 1)
) (
input logic clk,
input logic rst_n,
input logic in_valid,
output logic in_ready,
input logic [7:0] in_bytes, // beat size, normalized
input logic in_last, // end of descriptor -> flush
output logic pkt_valid,
input logic pkt_ready,
output logic [BW-1:0] pkt_bytes,
output logic pkt_last,
output logic [BW-1:0] buffered_bytes
);
logic [BW-1:0] buf_q;
logic last_q;
assign buffered_bytes = buf_q;
// Accept while there is room for a maximum beat -- never overflow.
assign in_ready = (buf_q + BW'(255) <= BW'(BUF_BYTES));
wire have_target = (buf_q >= BW'(TARGET_BYTES));
// EMIT ON TARGET **OR** ON FLUSH. The second condition is what makes a
// final short payload emit at all (section 20, mutation 11).
assign pkt_valid = have_target || (last_q && (buf_q != '0));
assign pkt_bytes = have_target ? BW'(TARGET_BYTES) : buf_q;
assign pkt_last = last_q && !have_target;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin buf_q <= '0; last_q <= 1'b0; end
else begin
// Both directions on their own handshake. Same-cycle in and out is
// the normal case at speed, and the arithmetic must net correctly.
unique case ({in_valid && in_ready, pkt_valid && pkt_ready})
2'b10: buf_q <= buf_q + BW'(in_bytes);
2'b01: buf_q <= buf_q - pkt_bytes;
2'b11: buf_q <= buf_q + BW'(in_bytes) - pkt_bytes;
default: ;
endcase
if (in_valid && in_ready && in_last) last_q <= 1'b1;
if (pkt_valid && pkt_ready && pkt_last) last_q <= 1'b0;
end
end
endmodule// SYNTHESIZABLE. One shared PCIe transmit opportunity, several producers.
// GRANT IS HELD UNTIL THE TRANSFER COMPLETES (section 20's counterexample).
// A combinational priority encoder re-evaluated every cycle changes the
// selected producer while `valid && !ready`, so the request that finally
// transfers is not the one that was offered.
module tx_arbiter #(
parameter int N = 4,
parameter int IW = (N <= 1) ? 1 : $clog2(N)
) (
input logic clk,
input logic rst_n,
input logic [N-1:0] req,
input logic down_ready,
output logic [N-1:0] grant,
output logic grant_valid,
output logic [IW-1:0] grant_idx
);
logic [IW-1:0] owner_q, rr_q;
logic held_q;
assign grant_valid = held_q;
assign grant_idx = owner_q;
always_comb begin
grant = '0;
if (held_q) grant[owner_q] = 1'b1;
end
// Round-robin from the last owner: no producer is starved while it keeps
// requesting. FAIRNESS IS IMPLEMENTATION POLICY, not a PCIe requirement.
logic [IW-1:0] pick; logic found;
always_comb begin
pick='0; found=1'b0;
for (int k = N-1; k >= 0; k--) begin
int idx = (int'(rr_q) + k) % N;
if (req[idx]) begin pick = IW'(idx); found = 1'b1; end
end
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin held_q <= 1'b0; owner_q <= '0; rr_q <= '0; end
else begin
if (!held_q) begin
if (found) begin owner_q <= pick; held_q <= 1'b1; end
end else if (down_ready) begin
// ==========================================================
// OWNERSHIP RELEASED ONLY ON AN ACTUAL TRANSFER. Not when the
// requester deasserts, not on a timer. Section 16 verified
// conservation across 60,000 runs: transfers never exceeded
// offers.
// ==========================================================
held_q <= 1'b0;
rr_q <= (owner_q == IW'(N-1)) ? '0 : (owner_q + IW'(1));
end
end
end
endmoduleClassification: all three synthesizable.
The packer's flush condition is what makes it terminate. Emitting only at TARGET_BYTES would strand a descriptor's final short payload forever — mutation 11, and §17's conservation equation is what catches it.
And the arbiter releases only on transfer, which is Chapter 18.2 §11's ownership rule applied to a shared resource.
Failure — five. Emitting only on the target size strands the tail. A combinational grant mutates the request under stall (§21). Releasing the grant when the requester deasserts hands the slot away mid-transfer. Accepting a beat without room overflows. And no epoch on prefetched descriptors — 161,182 stale executions.
15. Performance Counters and the Bottleneck Classifier
// SYNTHESIZABLE. Bottleneck attribution -- the chapter's debugging tool.
// EVERY COUNTER IS DIAGNOSTIC ONLY and drives no functional signal (P12).
// Priority is DECLARED so simultaneous causes are attributed consistently
// rather than double-counted.
module dma_bottleneck #(parameter int CW = 32) (
input logic clk,
input logic rst_n,
input logic tx_valid, tx_ready,
input logic work_available, tag_available, credit_granted, buffer_reserved,
input logic clear,
output logic [CW-1:0] tx_busy_cycles,
output logic [CW-1:0] tx_stall_cycles,
output logic [CW-1:0] no_work_cycles,
output logic [CW-1:0] no_tag_cycles,
output logic [CW-1:0] no_credit_cycles,
output logic [CW-1:0] no_buffer_cycles
);
logic [CW-1:0] busy_q, stall_q, work_q, tag_q, cred_q, buf_q;
assign {tx_busy_cycles, tx_stall_cycles} = {busy_q, stall_q};
assign {no_work_cycles, no_tag_cycles} = {work_q, tag_q};
assign {no_credit_cycles, no_buffer_cycles} = {cred_q, buf_q};
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n || clear) begin
busy_q<='0; stall_q<='0; work_q<='0; tag_q<='0; cred_q<='0; buf_q<='0;
end else begin
// ==============================================================
// DECLARED PRIORITY (section 19's ladder reads these in this order):
// a transfer happened -> busy
// offered but not accepted -> downstream stall
// otherwise, the FIRST missing resource, in a fixed order
//
// Without a declared order, a cycle missing two resources is
// counted twice and the percentages stop summing.
// ==============================================================
if (tx_valid && tx_ready) begin if(!(&busy_q)) busy_q <= busy_q + CW'(1); end
else if (tx_valid && !tx_ready) begin if(!(&stall_q)) stall_q <= stall_q + CW'(1); end
else if (!work_available) begin if(!(&work_q)) work_q <= work_q + CW'(1); end
else if (!tag_available) begin if(!(&tag_q)) tag_q <= tag_q + CW'(1); end
else if (!credit_granted) begin if(!(&cred_q)) cred_q <= cred_q + CW'(1); end
else if (!buffer_reserved) begin if(!(&buf_q)) buf_q <= buf_q + CW'(1); end
end
end
endmoduleClassification: synthesizable (instrumentation).
The declared priority is what makes the counters sum. A cycle missing both a Tag and a credit is attributed once, to the first in the order — so the six counters partition every cycle and their proportions are meaningful.
Without an order they double-count, and §20's first question — "which resource is the bottleneck" — becomes unanswerable from the very numbers built to answer it.
16. Assertions
// SVA over the high-speed blocks. LOCAL contract only. Nothing asserts
// that downstream becomes ready, that credits are returned, that
// Completions arrive, or that any bandwidth is achieved.
// ---- ADMISSION --------------------------------------------------------
// P1: ISSUE REQUIRES EVERY RESOURCE. Section 16 measured a two-term check:
// it issues without credit or buffer in 18.6% of resource states.
property p_admission_complete;
@(posedge clk) disable iff (!rst_n)
issue_allowed |-> (tag_available && credit_granted && buffer_reserved
&& tx_request_ready && work_available);
endproperty
a_adm : assert property (p_admission_complete);
// ---- TAG POOL ---------------------------------------------------------
// P2: AN ALLOCATED TAG WAS PREVIOUSLY FREE -- no double allocation.
property p_alloc_was_free;
@(posedge clk) disable iff (!rst_n)
alloc_valid |-> !busy_map[alloc_tag];
endproperty
a_alloc : assert property (p_alloc_was_free);
// P3: A TAG STAYS BUSY UNTIL EXPLICITLY FREED -- the lease from 20.3 §9.
property p_tag_lease;
@(posedge clk) disable iff (!rst_n)
(busy_map[t] && !(free_req && (free_tag == TW'(t)))) |=> busy_map[t];
endproperty
a_lease : assert property (p_tag_lease);
// P3b: freeing a non-busy or out-of-range Tag is REPORTED.
property p_bad_free;
@(posedge clk) disable iff (!rst_n)
(free_req && ((free_tag >= TW'(TAGS)) || !busy_map[free_tag])) |=> err_bad_free;
endproperty
a_badfree : assert property (p_bad_free);
// P4: THE OUTSTANDING COUNT EQUALS THE LIVE CONTEXT POPULATION. A drift
// here is a leak that stays invisible until Tags exhaust (mutation 18).
property p_outstanding_matches;
@(posedge clk) disable iff (!rst_n)
($countones(busy_map) + free_count) == (TW+1)'(TAGS);
endproperty
a_pop : assert property (p_outstanding_matches);
// ---- SHARED RESOURCES -------------------------------------------------
// P5: A GRANT IS CONSUMED BY AT MOST ONE PRODUCER. The property section
// 16's first counterexample violates -- two engines spending one credit.
property p_single_consumer;
@(posedge clk) disable iff (!rst_n)
$onehot0(grant);
endproperty
a_one : assert property (p_single_consumer);
// ---- COMPLETION RESERVATION -------------------------------------------
// P8: RESERVED BYTES NEVER EXCEED CAPACITY. The core sink invariant.
property p_reservation_bounded;
@(posedge clk) disable iff (!rst_n)
reserved_bytes <= CW'(CAPACITY_BYTES);
endproperty
a_res : assert property (p_reservation_bounded);
// P8b: reservation happens BEFORE issue, never after (section 12).
property p_reserve_before_issue;
@(posedge clk) disable iff (!rst_n)
issue_allowed |-> buffer_reserved;
endproperty
a_pre : assert property (p_reserve_before_issue);
// P9: RELEASE IS BOUNDED AND EXACTLY ONCE -- releasing more than is held
// is reported, not silently wrapped (mutation 19).
property p_release_bounded;
@(posedge clk) disable iff (!rst_n)
(release_valid && (release_bytes > reserved_bytes)) |=> err_over_release;
endproperty
a_rel : assert property (p_release_bounded);
// ---- PAYLOAD PACKER ---------------------------------------------------
// P10: BYTE CONSERVATION. Section 16 verified this across 60,000 random
// (beat, target, ready) cases with zero violations.
property p_packer_conserves;
@(posedge clk) disable iff (!rst_n)
(buffered_bytes <= BW'(BUF_BYTES))
&& ((pkt_valid && pkt_ready) |-> (pkt_bytes <= buffered_bytes));
endproperty
a_pack : assert property (p_packer_conserves);
// P10b: THE FINAL PARTIAL PAYLOAD IS EMITTED. Without the flush condition
// a descriptor's tail is stranded forever (mutation 11).
property p_final_flush;
@(posedge clk) disable iff (!rst_n)
(last_q && (buffered_bytes != '0)) |-> pkt_valid;
endproperty
a_flush : assert property (p_final_flush);
// P11: THROUGHPUT ACCOUNTING COUNTS TRANSFERS, NOT OFFERS. The rule from
// 18.9 §15, 19.5 §15, 20.1 §19 -- fourth module, same law.
property p_count_on_fire;
@(posedge clk) disable iff (!rst_n)
(tx_busy_cycles > $past(tx_busy_cycles)) |-> ($past(tx_valid) && $past(tx_ready));
endproperty
a_cnt : assert property (p_count_on_fire);
// P12: DIAGNOSTICS DO NOT AFFECT FUNCTION. A counter that gates behaviour
// is a functional block wearing a diagnostic label (section 15).
property p_counters_inert;
@(posedge clk) disable iff (!rst_n)
clear |-> ($stable(issue_allowed) || $past(tx_ready) || $past(work_available));
endproperty
a_inert : assert property (p_counters_inert);
// ---- ARBITER ----------------------------------------------------------
// P13: THE GRANT IS STABLE UNDER STALL. Section 16's second counterexample
// is the combinational encoder that moves it mid-transaction.
property p_grant_held;
@(posedge clk) disable iff (!rst_n)
(grant_valid && !down_ready) |=> (grant_valid && $stable(grant_idx));
endproperty
a_hold : assert property (p_grant_held);
// P13b: and it is released ONLY on an actual transfer, not when the
// requester deasserts.
property p_release_on_transfer;
@(posedge clk) disable iff (!rst_n)
($fell(grant_valid)) |-> $past(down_ready);
endproperty
a_reltx : assert property (p_release_on_transfer);
// ---- PREFETCH ---------------------------------------------------------
// P14: THE PREFETCH FIFO NEVER OVERFLOWS OR UNDERFLOWS.
property p_fifo_bounded;
@(posedge clk) disable iff (!rst_n)
(prefetch_count <= PF_W'(PREFETCH_DEPTH));
endproperty
a_fifo : assert property (p_fifo_bounded);
// P15: A STALE-EPOCH ITEM CANNOT EXECUTE. Section 16: 161,182 stale
// descriptors executed without this check, 0 with it.
property p_no_stale_execute;
@(posedge clk) disable iff (!rst_n)
item_is_stale |-> !descriptor_executes;
endproperty
a_stale : assert property (p_no_stale_execute);
// P15b: an abort or a new job changes the epoch, so everything already in
// the FIFO becomes stale by construction.
property p_epoch_advances;
@(posedge clk) disable iff (!rst_n)
(job_abort || job_start) |=> (current_epoch != $past(current_epoch));
endproperty
a_epoch : assert property (p_epoch_advances);
// P16: reset clears every ownership -- no Tag, reservation or grant
// survives.
property p_reset;
@(posedge clk)
!rst_n |=> ((busy_map == '0) && (reserved_bytes == '0) && !grant_valid);
endproperty
a_reset : assert property (p_reset);P1 is the chapter's admission property, and it is deliberately a flat conjunction so a dropped term fails immediately rather than degrading quietly.
P5 and P13 are the shared-resource pair. P5 forbids two consumers of one grant (§16's first counterexample); P13 forbids the grant moving while a transaction is pending (§16's second).
P10 with P10b is the packer's conservation pair — bytes are never lost, and the tail is never stranded.
And P15 is the optimization-specific one. Prefetch is the only thing in this chapter that creates work which can outlive its reason, and P15 is what keeps §3's claim true: the faster engine is still the same ownership machine.
No liveness. "Downstream eventually ready", "credits are returned" and "Completions arrive" are environment properties. P13's held grant is the bounded form — ownership persists until the transfer, however long that takes.
17. Verification, Fault Injection, and Model Verification
Executed before publication. All performance numbers in this chapter are script-generated (§1).
Tag pool — 600,000 operations
Across TAGS = 1, 2, 3, 8, 32, including same-cycle free-and-allocate, compared against an independent set model: 0 disagreements. No double allocation, no invalid free accepted, exhaustion reported exactly when full.
Admission control — 200,000 resource vectors
| Admission rule | Issues without a required resource |
|---|---|
| all five terms (§13) | 0 |
| Tags and work only | 37,291 — 18.6% |
Outstanding reads — derived scaling model
512-byte requests, 250-cycle round trip, 200,000 cycles: linear from 1 to 64 outstanding — 2.05 to 130.91 bytes/cycle, 64× (§6). Derived model, illustrative inputs (§1).
Payload packer — 60,000 cases
accepted == emitted + buffered across random beat patterns, target sizes and ready patterns: 0 violations, including the final short payload.
Prefetch epoch — 40,000 abort sequences
| Design | Stale descriptors executed |
|---|---|
| epoch-checked | 0 |
| no epoch check | 161,182 |
Arbiter — 60,000 randomized runs
Three producers with random request and ready patterns: transfers never exceeded offers — no duplication, and the grant is structurally held because ownership clears only on transfer.
Directed tests
TAGS= 1, 2, 32 — allocation, exhaustion, same-cycle free+alloc. Required.ROTATE= 0 and 1 — verify both are functionally correct and only distribution differs.- Each admission term removed in turn — verify
issue_allowedfalls (P1). Required. - Reservation exceeding capacity — verify refusal, not truncation (P8).
- Release larger than reserved — verify
err_over_release(P9). - Simultaneous reserve and release — verify the net is correct (§21).
- Packer with beats smaller than target, larger, and exactly equal; final short payload (P10). Required.
in_lastmid-buffer — verify flush emits the remainder exactly once.- Arbiter: all producers requesting, downstream stalled 50 cycles — verify the grant does not move (P13). Required.
- A producer deasserting mid-grant — verify it keeps the grant until transfer.
- Prefetch with abort — verify stale descriptors are discarded (P15). Required.
- Counters — verify they change no functional signal (P12).
The scoreboard maintains independent Tag, reservation and byte models and never reads busy_map, reserved_bytes or the packer's buffer.
Mutations
| # | Mutation | Caught by | Symptom |
|---|---|---|---|
| 1 | same Tag allocated twice | P2 | Completions unattributable (20.3 §19) |
| 2 | Tag freed before the final Completion | P3 | same, and it only appears with concurrency |
| 3 | outstanding count decremented on the first fragment | P4 | Tag freed while data still returning |
| 4 | admission ignores credits | P1 | flow-control violation — 18.6% of states |
| 5 | admission ignores completion buffer | P1 | Completion data with nowhere to go |
| 6 | two engines spend the same credit | P5 | over-subscription; one credit used twice (§10) |
| 7 | arbiter grant changes under stall | P13 | the request that transfers is not the one offered |
| 8 | request counter advances on valid | P11 | throughput reported that did not happen |
| 9 | prefetch FIFO overflows | P14 | descriptors silently dropped |
| 10 | prefetched descriptor executes after abort | P15 | 161,182 stale executions (measured) |
| 11 | packer never flushes the final partial payload | P10 | last bytes of every descriptor stranded |
| 12 | packer duplicates a beat under stall | P10 | byte conservation broken |
| 13 | write progress advances before TX ownership | 20.1 P3b | 79.5% overshoot (measured there) |
| 14 | larger MRRS without larger reservation | P8 | Completion overrun |
| 15 | fixed-priority arbiter starves descriptor fetch | review + P13 | throughput collapses when data is busy |
| 16 | TAGS = 1 produces a zero-width index | elaboration | build failure at the simplest configuration |
| 17 | non-power-of-two FIFO pointer aliases | 20.2 P9 | 19.9% out of range (measured there) |
| 18 | outstanding count disagrees with valid contexts | P4 | leak invisible until Tags exhaust |
| 19 | reservation released twice | P9 | reservation underflows; over-admission follows |
| 20 | source FIFO assumes a bounded stall that does not exist | review + §12 | overflow under sustained backpressure |
| 21 | throughput counter counts offers | P11 | measured bandwidth exceeds the Link's |
| 22 | MPS and MRRS treated as one knob | review + 20.3 §8 | over-large writes or fragmented reads |
| 23 | large MRRS assumed to give one Completion | 20.3 P12b | retires on the first fragment |
| 24 | Tags assumed the only limit | P1 | adding Tags changes nothing; §20's scenario 2 |
| 25 | prefetch treated as ownership transfer | P15 | aborted job's descriptor executes in the next job |
| 26 | credit observed rather than granted | P5 | §10's double-spend |
Same-cycle audit
| Case | Declared resolution |
|---|---|
| Tag freed + allocated | free first; the Tag is immediately re-allocatable (§13) |
| reserve + release same cycle | netted in one arithmetic step, so the invariant never breaks transiently |
| packer input beat + output payload | both on their own handshake; the 2'b11 arm nets them |
| grant transfer + a higher-priority request | the transfer completes; the new request wins the next arbitration |
| abort + a prefetched descriptor already popped | the epoch check is on the consumer side, so a popped stale item is still discarded (P15) |
all resources available + tx_ready low | counted as a downstream stall, not a resource shortage (§15's priority) |
18. Backpressure and the Limits of Buffering
A high-speed producer cannot assume the transmit path is always ready. What to do about it depends entirely on whether the source can be stalled.
| Source | Can it stall? | Required architecture |
|---|---|---|
stream with ready | yes | propagate backpressure; a FIFO only smooths bursts |
| fixed-rate sampler | no | sized elastic buffer and a defined overflow behaviour |
| memory reader under our control | controlled | schedule reads against buffer occupancy |
19. The Throughput Waveform
Three phases, showing that fixing one bottleneck reveals the next. Internal teaching signals.
PHASE A PHASE B PHASE C
1 outstanding 32 outstanding 32 outstanding
cycle 1 2 3 4 | 5 6 7 8 | 9 10 11 12
desc_ready 1 1 1 1 | 1 1 1 1 | 1 1 1 1
request_valid 1 0 0 0 | 1 1 1 1 | 1 1 0 0
request_ready 1 0 0 0 | 1 1 1 1 | 1 1 0 0
outstanding 1 1 1 1 | 12 20 28 32 | 32 32 32 32
tag_free_count 31 31 31 31 | 20 12 4 0 | 4 2 0 0
np_credit_ok 1 1 1 1 | 1 1 1 1 | 1 1 0 0
cpl_buf_free 16k 16k 16k 16k | 12k 8k 4k 2k | 1k 0 0 0
cpl_valid 0 0 0 1 | 0 1 1 1 | 1 1 1 1
tx_utilized 1 0 0 0 | 1 1 1 1 | 1 1 0 0
stall_reason - RTT RTT RTT | - - - - | - - CRD BUFPhase A — one outstanding read. request_valid fires once, then three idle cycles waiting for the round trip. tag_free_count sits at 31 — 31 Tags unused while the Link idles. This is §6's 1× row.
Phase B — 32 outstanding. Requests issue every cycle, outstanding climbs to 32, and tx_utilized stays high. The latency is now hidden by concurrency, not removed.
Phase C — the next bottleneck appears. tag_free_count reaches 0 and, one cycle later, np_credit_ok drops and cpl_buf_free hits zero. stall_reason changes from CRD to BUF.
That transition is the whole lesson. Optimizing the request side moved the constraint to the return side. Adding more Tags in Phase C would do nothing — §20's second scenario, and §15's counters are what tell you so.
20. Debugging
Symptom → which resource → counter → experiment.
The Link is fast and DMA achieves a fraction of it
Do not start at the PHY. Read §15's counters and compute proportions — the declared priority makes them partition every cycle.
| Dominant counter | Bottleneck | Fix |
|---|---|---|
tx_stall_cycles | downstream — the PCIe path is not accepting | arbitration, credits at the core, Link state |
no_work_cycles | descriptor starvation | prefetch (§11), ring refill, software |
no_tag_cycles | Tag-limited | Extended Tag, or larger requests (§7) |
no_credit_cycles | credit-limited | the partner is not returning credits (16.6) |
no_buffer_cycles | sink-limited | completion buffering, downstream drain rate |
One reading identifies the class, which is the point of building them.
Throughput stops improving when Tags are added
Expected (§7), and §19's Phase C is the picture. Another resource became the constraint.
The distinguishing experiment: add Tags and re-read the counters. If no_tag_cycles fell to near zero and no_credit_cycles or no_buffer_cycles rose to take its place, the Tag limit is gone and the next one has bound. Adding still more Tags is now provably useless.
And check request size instead (§7's table): 32 Tags at 4 KiB reaches the same modelled ceiling as 256 Tags at 512 B.
It works at small transfers and fails under sustained load
A leak, and the counters localize it. Watch tag_free_count and reserved_bytes over a long run: a monotonic drift toward zero that never recovers is a leak, not congestion.
Three candidates: a Tag not freed on an error path (Chapter 20.6 §14 measures this at 99.9%), a reservation released on success but not on error, or a grant never released because the arbiter waits for a producer that withdrew.
The distinguishing experiment: inject errors deliberately and watch whether the free counts return to their initial values. They must.
Throughput oscillates
Look at occupancy over time rather than at averages. Periodic collapse usually means a supply-side refill cycle: the prefetch FIFO drains, descriptors run out, the engine idles while the next batch is fetched, then bursts.
no_work_cycles rising in bursts confirms it, and the fix is prefetch depth (Chapter 20.5 §11) or ring management — not more Tags.
21. Common Misconceptions
- "Maximum Link bandwidth is what the application gets." Four multiplicative terms, smallest wins (§2).
- "Larger MPS always improves throughput." Collapsing returns: +7.2 points at 128→256, +0.6 at 2048→4096 (§8).
- "Larger MRRS always improves throughput." It trades against request count and buffering (§7, §12).
- "More Tags always improves throughput." Only if Tags are the binding resource — §19's Phase C.
- "One outstanding read can saturate a fast Link." 64× below the same hardware's capability in the derived model (§6).
- "Credits are a correctness concern, not a performance one." They are a first-class throughput resource (§9).
- "Descriptor prefetch transfers ownership early." Fetched ≠ executing (§11); 161,182 stale executions without an epoch.
- "A deeper FIFO fixes backpressure." Not if the stall is unbounded (§18).
- "An arbiter may re-evaluate every cycle." The grant must be held (§17's counterexample).
- "Writes are unlimited because they are posted." They consume posted credits and transmit opportunity (§4).
- "Completion ordering makes Tag context unnecessary." Chapter 20.3 §19's third counterexample.
- "Performance work means wider datapaths." It means occupancy — every stage productive at once (§2).
22. Understanding Check
23. What's Next
Throughput is occupancy, not packet size.
Four multiplicative terms (§2), and the smallest wins — so optimizing a term that is not binding produces exactly zero improvement, which §19's waveform shows and §20's counters diagnose.
Reads need concurrency (§6): 64× between one outstanding request and 64 in the derived model. And Tags are one resource of four (§9) — an admission check that forgets credits or buffering issues illegally in 18.6% of states.
Every optimization here added concurrency and relaxed nothing (§3). Prefetch needed an epoch (161,182 stale executions without one); shared resources needed grants rather than observations; the arbiter needed to hold its choice.
Chapter 20.6 — FPGA Examples closes Module 20 by composing everything: the descriptor engine, the read and write engines, the shared transmit scheduler, the Completion path with its context RAM, the buffers, the error cleanup, and the interrupt hand-off — with the clock-domain, memory-latency and reset boundaries that only appear when the blocks become one design.
The idea to carry forward: availability is not permission.