USB · Module 28
USB vs PCIe
USB holds one transaction outstanding per endpoint so its throughput is exactly 1/(latency+1) whatever the wire carries; PCIe tags many at once and needs exactly latency+1 tags to saturate — both measured as closed forms over 64 points.
The last of four comparisons, and the last chapter of this module. The first three were all about naming a peer. This one is not: PCIe usually has exactly one peer and it is soldered down. PCIe's problem is naming a transaction.
1. The Comparison That Is Not About Bandwidth
"PCIe is faster" is true and explains nothing, because it treats the gap as a matter of degree. It is not. USB and PCIe differ in how many things they allow to be happening at once, and that difference produces the bandwidth gap rather than resulting from it.
The headline result:
| outstanding | cycles for 24 transactions at latency 8 | measured as | |
|---|---|---|---|
USB (usb_xact_slot) | 1 per endpoint | 216 | exactly 24 × (8+1) |
PCIe (pcie_tag_tracker) | 8 tags | 34 | approaching 24 + 8 |
Same fabric, same latency, same clock. 6.4×, and not one bit of it is because the wire is faster.
2. Two Identities, At Two Different Layers
PCIe's layering is worth one diagram, because it contains a trap that catches people who have read about it but not built it.
PCIe identifies a transaction twice, at two layers, for two different purposes
3. Two Designs, One Question
pcie_tag_tracker | usb_xact_slot | |
|---|---|---|
| identifier | a tag per request | none |
| outstanding | up to N, settable at run time | exactly 1 per endpoint |
| completion order | any | the only one possible |
| split completions | tracked by byte count | cannot occur |
| what a request needs | a free tag | an idle endpoint |
| writes | posted — no tag, never stalled | no distinction |
| the bug it can have | a tag reused too early | none of this shape |
tag_limit is an input, not a parameter. That is what makes section 5's
surface a single exhaustive sweep instead of eight separate elaborations — and
it is also how the USB case is reached inside the PCIe design: tag_limit = 1.
4. The Designs (Verilog-2005)
// =====================================================================
// OUTSTANDING TRANSACTIONS -- THE MECHANISM PCIe NEEDS AND USB DOES NOT.
//
// CLASSIFICATION: simplified synthesisable teaching RTL.
// Two modules. Neither is a controller: there is no TLP encoder, no
// link layer, no credits, no USB packet engine and no scheduler. Each
// is the part that answers one question.
//
// "This response just arrived. Which request was it for?"
//
// USB answers it by CONSTRUCTION. The host issues one transaction to an
// endpoint and waits for it. The response is the next thing on the wire,
// so there is nothing to match -- and USB packets carry no transaction
// identifier at all, because none is needed.
//
// PCIe answers it with a TAG. A requester may have many non-posted
// requests in flight at once; completions travel independently, come
// back OUT OF ORDER, and may arrive split into several pieces. So every
// request carries a tag, and the requester must track each one until the
// last byte of its completion has arrived.
//
// The difference is not bandwidth, it is CONCURRENCY:
//
// USB: one outstanding transaction, so throughput is bounded by
// 1 / latency, whatever the wire can carry.
// PCIe: N outstanding transactions, so throughput is bounded by
// min(1, N / latency) -- and with enough tags, not by latency
// at all.
//
// That is a formula, so the chapter measures it. Section 5's table is
// throughput against tag count and latency, and the USB case is exactly
// its first row.
//
// What the tags cost is a class of bug that cannot exist on USB: a tag
// reused before its completion arrives silently attributes one
// transaction's data to another.
// =====================================================================
// ---------------------------------------------------------------------
// pcie_tag_tracker -- many in flight, matched by tag.
//
// `tag_limit` is an INPUT rather than a parameter so the testbench can
// sweep how many tags the requester is allowed to use without
// re-elaborating. That is what makes the throughput curve in section 5
// a single exhaustive sweep instead of eight separate runs -- and it is
// also how the USB case is reached: tag_limit = 1.
// ---------------------------------------------------------------------
module pcie_tag_tracker #(
parameter integer N_TAG = 8,
// Bytes are tracked so a SPLIT completion can be modelled: a single
// read may be answered by several completions, and the tag is not free
// until the last byte arrives.
parameter integer MAX_BYTES = 256
) (
input wire clk,
input wire rst_n,
// How many tags the requester may use, 1..N_TAG. Zero is treated as
// one: a requester that can have nothing outstanding cannot make
// progress, and silently deadlocking is worse than clamping.
input wire [$clog2(N_TAG+1)-1:0] tag_limit,
// ---- a request ----
input wire req_valid,
// POSTED requests (writes) expect no completion and consume no tag.
// That is why a PCIe write is fast and a PCIe read is not: the write is
// finished when it is sent, and the read is not finished until it comes
// back.
input wire req_posted,
input wire [15:0] req_bytes,
output wire req_ready, // a tag was available
output wire [$clog2(N_TAG)-1:0] req_tag,
output wire req_stall, // no tag available: this is back-pressure
// ---- a completion, arriving whenever the fabric feels like it ----
input wire cpl_valid,
input wire [$clog2(N_TAG)-1:0] cpl_tag,
input wire [15:0] cpl_bytes,
output wire cpl_retire, // this completion finished its request
// ---- observability ----
output wire [31:0] n_alloc,
output wire [31:0] n_posted,
output wire [31:0] n_stall,
output wire [31:0] n_retire,
output wire [31:0] n_partial, // a completion that did not finish a tag
output wire [31:0] n_bad_tag, // a completion for a tag nobody owns
output wire [31:0] n_over, // more bytes returned than requested
output wire [31:0] n_outstanding // how many tags are in flight NOW
);
localparam integer TW = $clog2(N_TAG);
localparam integer LW = $clog2(N_TAG+1);
reg t_busy [0:N_TAG-1];
reg [15:0] t_rem [0:N_TAG-1];
integer i;
// Clamp to at least one usable tag. A requester allowed zero
// outstanding transactions can never make progress, and a design that
// deadlocks silently is harder to debug than one that refuses to.
wire [LW-1:0] lim = (tag_limit == 0) ? {{(LW-1){1'b0}}, 1'b1} : tag_limit;
// -------------------------------------------------------------------
// ALLOCATE -- the lowest free tag strictly below the limit.
//
// Walking downwards so the lowest index wins. Real requesters often
// allocate round-robin to spread wear on completion buffers; lowest-
// free is chosen here because it is deterministic, which is what makes
// the tag-reuse property checkable at all.
// -------------------------------------------------------------------
reg free_any;
reg [TW-1:0] free_tag;
always @(*) begin
free_any = 1'b0;
free_tag = {TW{1'b0}};
for (i = N_TAG - 1; i >= 0; i = i - 1) begin
if (!t_busy[i] && (i < lim)) begin
free_any = 1'b1;
free_tag = i[TW-1:0];
end
end
end
// A posted request needs no tag, so it is never stalled by tag
// exhaustion. This asymmetry is the whole reason PCIe separates the two
// classes.
assign req_ready = req_valid && (req_posted || free_any);
assign req_tag = free_tag;
assign req_stall = req_valid && !req_posted && !free_any;
// -------------------------------------------------------------------
// COMPLETE -- match by tag, subtract bytes, free on the last one.
// -------------------------------------------------------------------
wire cpl_known = cpl_valid && t_busy[cpl_tag];
wire [15:0] rem_now = t_rem[cpl_tag];
// More bytes than were asked for. On a real link this is a malformed
// completion; here it is flagged rather than allowed to wrap the
// counter, because a wrapped counter would keep the tag outstanding
// forever and turn a protocol error into a hang.
wire cpl_overrun = cpl_known && (cpl_bytes > rem_now);
wire cpl_last = cpl_known && (cpl_bytes >= rem_now);
assign cpl_retire = cpl_last;
reg [31:0] alloc_c, posted_c, stall_c, retire_c, partial_c, bad_c, over_c;
assign n_alloc = alloc_c;
assign n_posted = posted_c;
assign n_stall = stall_c;
assign n_retire = retire_c;
assign n_partial = partial_c;
assign n_bad_tag = bad_c;
assign n_over = over_c;
// Combinational, so it reports the tracker as it stands rather than as
// it stood a cycle ago.
reg [31:0] out_now;
always @(*) begin
out_now = 32'd0;
for (i = 0; i < N_TAG; i = i + 1) if (t_busy[i]) out_now = out_now + 32'd1;
end
assign n_outstanding = out_now;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (i = 0; i < N_TAG; i = i + 1) begin
t_busy[i] <= 1'b0;
t_rem[i] <= 16'd0;
end
alloc_c <= 32'd0;
posted_c <= 32'd0;
stall_c <= 32'd0;
retire_c <= 32'd0;
partial_c <= 32'd0;
bad_c <= 32'd0;
over_c <= 32'd0;
end else begin
// ---- completions, then requests ----
//
// The order of these two blocks is immaterial to correctness, and it
// is worth saying why rather than implying otherwise. `free_any` and
// `free_tag` are combinational over the REGISTERED t_busy, so they
// describe the table as it stood at the START of this cycle. A tag
// retired by a completion in this cycle therefore becomes
// allocatable in the NEXT one -- not this one.
//
// That is ONE CYCLE OF TURNAROUND per tag, and it is a real cost with
// a visible consequence: saturating a fabric of latency L needs L+1
// tags, not L. Section 5's table shows exactly that boundary.
//
// The two blocks cannot collide, because an index being retired is
// busy and is therefore never the index free_any selects.
if (cpl_valid) begin
if (!t_busy[cpl_tag]) begin
// A completion for a tag nobody owns. Either the fabric
// invented it or this requester retired the tag early -- and
// the second is exactly what a tag-reuse bug looks like from
// here.
bad_c <= bad_c + 32'd1;
end else begin
if (cpl_bytes >= rem_now) begin
t_busy[cpl_tag] <= 1'b0;
t_rem[cpl_tag] <= 16'd0;
retire_c <= retire_c + 32'd1;
if (cpl_bytes > rem_now) over_c <= over_c + 32'd1;
end else begin
// A split completion: the request is not finished, so the tag
// stays outstanding. Freeing it here would allow the tag to be
// reused while the rest of the data was still in flight.
t_rem[cpl_tag] <= rem_now - cpl_bytes;
partial_c <= partial_c + 32'd1;
end
end
end
// ---- then the request ----
if (req_valid) begin
if (req_posted) begin
posted_c <= posted_c + 32'd1;
end else if (free_any) begin
// free_tag was chosen from the table as it stood at the start of
// the cycle, so it is not an index any completion is retiring
// right now.
t_busy[free_tag] <= 1'b1;
t_rem[free_tag] <= req_bytes;
alloc_c <= alloc_c + 32'd1;
end else begin
stall_c <= stall_c + 32'd1;
end
end
end
end
endmodule
// ---------------------------------------------------------------------
// usb_xact_slot -- one in flight, matched by nothing.
//
// The same question, answered by construction. A USB host issues one
// transaction to an endpoint and waits for the response; the response is
// the next thing on the wire. So:
//
// * There is no tag. There is no field in any USB packet that
// identifies which transaction a response belongs to, because the
// question never arises.
//
// * There is no reordering. One outstanding transaction cannot be
// overtaken.
//
// * There is no split-completion reassembly. A transaction's data
// arrives in one transaction.
//
// * And there is no tag-reuse bug to have.
//
// What it costs is in the port list too, by omission: there is no way to
// have a second transaction outstanding, so throughput is 1 / latency
// and no amount of link bandwidth changes that. Section 5 measures it.
// ---------------------------------------------------------------------
module usb_xact_slot #(
parameter integer N_EP = 4
) (
input wire clk,
input wire rst_n,
// ---- issue a transaction to an endpoint ----
input wire iss_valid,
input wire [$clog2(N_EP)-1:0] iss_ep,
output wire iss_ready, // that endpoint was idle
output wire iss_busy, // that endpoint already has one in flight
// ---- the response, which can only belong to that endpoint's ----
input wire rsp_valid,
input wire [$clog2(N_EP)-1:0] rsp_ep,
output wire rsp_match, // there was a transaction to answer
output wire rsp_spurious,// there was not
output wire [31:0] n_issued,
output wire [31:0] n_refused,
output wire [31:0] n_answered,
output wire [31:0] n_spurious,
output wire [31:0] n_outstanding
);
localparam integer EW = $clog2(N_EP);
// One bit per endpoint. That is the entire mechanism -- compare it with
// the tag array above, which needs a byte counter per entry because a
// completion can be partial.
reg ep_busy [0:N_EP-1];
integer i;
assign iss_ready = iss_valid && !ep_busy[iss_ep];
assign iss_busy = iss_valid && ep_busy[iss_ep];
// A response with nothing outstanding on that endpoint. On a real bus
// this is a device talking when it was not asked, which USB treats as a
// protocol error -- and which is detectable precisely because there is
// only ever one thing it could have been answering.
assign rsp_match = rsp_valid && ep_busy[rsp_ep];
assign rsp_spurious = rsp_valid && !ep_busy[rsp_ep];
reg [31:0] iss_c, ref_c, ans_c, spur_c;
assign n_issued = iss_c;
assign n_refused = ref_c;
assign n_answered = ans_c;
assign n_spurious = spur_c;
reg [31:0] out_now;
always @(*) begin
out_now = 32'd0;
for (i = 0; i < N_EP; i = i + 1) if (ep_busy[i]) out_now = out_now + 32'd1;
end
assign n_outstanding = out_now;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (i = 0; i < N_EP; i = i + 1) ep_busy[i] <= 1'b0;
iss_c <= 32'd0;
ref_c <= 32'd0;
ans_c <= 32'd0;
spur_c <= 32'd0;
end else begin
// Responses first, for the same reason as the tracker: an endpoint
// answered this cycle can be reissued in this cycle.
if (rsp_valid) begin
if (ep_busy[rsp_ep]) begin
ep_busy[rsp_ep] <= 1'b0;
ans_c <= ans_c + 32'd1;
end else begin
spur_c <= spur_c + 32'd1;
end
end
if (iss_valid) begin
if (!ep_busy[iss_ep]) begin
ep_busy[iss_ep] <= 1'b1;
iss_c <= iss_c + 32'd1;
end else begin
ref_c <= ref_c + 32'd1;
end
end
end
end
endmodule5. The Measurement
A fabric model with a settable latency, requests offered as fast as the tracker accepts them, and a count of cycles to retire 24 transactions. Sweeping tag limit 1..8 against latency 1..8 gives 64 points, every one reachable, both dimensions independent inputs.
Cycles to move 24 transactions. Identical in Verilog, SystemVerilog and VHDL:
| tags | L=1 | L=2 | L=3 | L=4 | L=5 | L=6 | L=7 | L=8 |
|---|---|---|---|---|---|---|---|---|
| 1 | 48 | 72 | 96 | 120 | 144 | 168 | 192 | 216 |
| 2 | 25 | 37 | 49 | 61 | 73 | 85 | 97 | 109 |
| 3 | 25 | 26 | 34 | 42 | 50 | 58 | 66 | 74 |
| 4 | 25 | 26 | 27 | 33 | 39 | 45 | 51 | 57 |
| 5 | 25 | 26 | 27 | 28 | 33 | 38 | 43 | 48 |
| 6 | 25 | 26 | 27 | 28 | 29 | 33 | 37 | 41 |
| 7 | 25 | 26 | 27 | 28 | 29 | 30 | 34 | 38 |
| 8 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 34 |
Row 1 is the USB case. The bold diagonal is where the link first saturates.
Two closed forms, asserted as equalities
The table is not an empirical curiosity. Both edges of it are exact, and the
bench asserts them with == rather than >= — an inequality would also pass
for a design that was slower still, and the point of a closed form is to pin
the number from both sides.
One tag costs the full latency, every time:
cycles(tags = 1, L) == K * (L + 1) K = 2424×2 = 48, 24×9 = 216. Every cell in row 1 is exactly that. Nothing overlaps, so each transaction costs its latency plus the cycle that issued it, and no amount of link bandwidth changes this number.
Latency+1 tags saturate the link, exactly:
cycles(tags >= L + 1, L) == K + LRow 2 at L=1 is 25 = 24+1. Row 3 at L=2 is 26 = 24+2. Row 8 at L=7 is 31 = 24+7. One issue per cycle, and the only cost left is draining the last transaction.
And fewer tags than that provably cannot saturate:
cycles(tags <= L, L) > K + LThat negative half matters. Without it, the saturation property would be satisfied by a design that was always saturated regardless of tag count — and the chapter would have no result at all.
What that means for the two protocols
USB's 125 µs frame and PCIe's ~2 µs round trip are usually quoted as a latency comparison. Row 1 of the table says something stronger: USB's throughput is its latency, because its concurrency is fixed at one. Halving USB's latency doubles its throughput; widening its wire does nothing.
PCIe decoupled the two. With enough tags its throughput stops depending on latency at all — which is why PCIe could keep scaling bandwidth by adding lanes while its round-trip latency barely moved, and why USB could not.
Four tags in flight, completing in the wrong order
Out-of-order is not one case, it is every permutation — so the bench sweeps all 3! = 6 orderings of three tags rather than demonstrating reverse order once. A tracker that happened to work for reverse order is not thereby correct for the rest.
6. Posted Versus Non-Posted
The other half of why PCIe is fast, and the half people skip.
A posted request — a write — expects no completion. It is finished when it is sent. So it consumes no tag, and tag exhaustion cannot block it.
A non-posted request — a read — is not finished until its data comes back. It consumes a tag for the whole round trip.
a PCIe write is fast because it is over when it leaves
a PCIe read is slow because it is not over until it returnsThat asymmetry is why mixing reads and writes changes a PCIe link's behaviour so much, and why a design that made writes wait for read tags would serialise a workload PCIe exists to overlap. It is mutation N3, and the property is only observable when no tag is free — so the bench fills the table to capacity at each of the eight limits and checks it there.
7. Split Completions And The Bug USB Cannot Have
One PCIe read may be answered by several completions. The tag must stay outstanding until the last byte arrives.
Free it earlier and the tag is reused while data is still in flight — and the next transaction collects the remainder as though it were its own. That is the tag-reuse bug, it corrupts data silently, and it is mutation N1.
The bench sweeps every split of 256 bytes into equal pieces of 256, 128, 64, 32 and 16 — every shape a power-of-two payload can take — and after each non-final piece asserts both that the tag is still busy and that the design did not claim to retire.
8. The Testbench (Verilog)
// =====================================================================
// Testbench for pcie_tag_tracker and usb_xact_slot.
//
// THE HEADLINE IS A THROUGHPUT SURFACE, NOT A PASS/FAIL.
//
// Both designs answer "which request was this response for?". The
// interesting question is what each one's answer COSTS, and the cost is
// concurrency: how many transactions can be in flight at once.
//
// So the bench contains a FABRIC MODEL with a settable latency, issues
// requests as fast as the tracker will take them, and measures how many
// cycles it takes to retire a fixed number. Sweeping (tag_limit,
// latency) gives a surface, and the tag_limit = 1 row of that surface IS
// the USB behaviour -- because a USB host has exactly one transaction
// outstanding per endpoint, by construction.
//
// THE SHADOW MODEL IS FORMULATED IN THE OPPOSITE DIRECTION. The tracker
// allocates by walking its tags DOWNWARDS so the lowest free index wins;
// the model walks UPWARDS and stops at the first free one. Same answer,
// different derivation.
// =====================================================================
`timescale 1ns/1ps
module tb_tg_v;
localparam integer N_TAG = 8;
localparam integer N_EP = 4;
localparam integer MAXF = 64; // fabric depth: in-flight completions
reg clk = 1'b0, rst_n = 1'b0;
always #5 clk = ~clk;
// ---- PCIe side ----
reg [3:0] tag_limit = 4'd8;
reg req_valid = 1'b0, req_posted = 1'b0;
reg [15:0] req_bytes = 16'd0;
wire req_ready, req_stall;
wire [2:0] req_tag;
reg cpl_valid = 1'b0;
reg [2:0] cpl_tag = 3'd0;
reg [15:0] cpl_bytes = 16'd0;
wire cpl_retire;
wire [31:0] n_alloc, n_posted, n_stall, n_retire, n_partial,
n_bad_tag, n_over, n_outstanding;
pcie_tag_tracker #(.N_TAG(N_TAG)) dut (
.clk(clk), .rst_n(rst_n), .tag_limit(tag_limit),
.req_valid(req_valid), .req_posted(req_posted), .req_bytes(req_bytes),
.req_ready(req_ready), .req_tag(req_tag), .req_stall(req_stall),
.cpl_valid(cpl_valid), .cpl_tag(cpl_tag), .cpl_bytes(cpl_bytes),
.cpl_retire(cpl_retire),
.n_alloc(n_alloc), .n_posted(n_posted), .n_stall(n_stall),
.n_retire(n_retire), .n_partial(n_partial), .n_bad_tag(n_bad_tag),
.n_over(n_over), .n_outstanding(n_outstanding)
);
// ---- USB side ----
reg iss_valid = 1'b0;
reg [1:0] iss_ep = 2'd0;
wire iss_ready, iss_busy;
reg rsp_valid = 1'b0;
reg [1:0] rsp_ep = 2'd0;
wire rsp_match, rsp_spurious;
wire [31:0] u_n_issued, u_n_refused, u_n_answered, u_n_spurious, u_n_out;
usb_xact_slot #(.N_EP(N_EP)) udut (
.clk(clk), .rst_n(rst_n),
.iss_valid(iss_valid), .iss_ep(iss_ep),
.iss_ready(iss_ready), .iss_busy(iss_busy),
.rsp_valid(rsp_valid), .rsp_ep(rsp_ep),
.rsp_match(rsp_match), .rsp_spurious(rsp_spurious),
.n_issued(u_n_issued), .n_refused(u_n_refused),
.n_answered(u_n_answered), .n_spurious(u_n_spurious),
.n_outstanding(u_n_out)
);
integer errors = 0, checks = 0, steps = 0;
integer seed;
// ---- cumulative across resets ----
//
// The DUTs' own counters are zeroed by every reset_all, so reading them in
// the final summary would report only whatever happened after the last
// one. Per-step checks still use the DUT counters directly; these are for
// the totals.
integer c_alloc = 0, c_posted = 0, c_stall = 0, c_retire = 0,
c_partial = 0, c_bad = 0, c_over = 0;
integer c_iss = 0, c_ref = 0, c_ans = 0, c_spur = 0;
// $random is SIGNED: mask the sign bit before any modulo.
function [31:0] urand;
input dummy;
begin urand = $random(seed) & 32'h3FFF_FFFF; end
endfunction
task ck(input cond, input [255:0] what);
begin
checks = checks + 1;
if (!cond) begin
errors = errors + 1;
if (errors <= 20)
$display(" ERROR @%0t step#%0d: %0s", $time, steps, what);
end
end
endtask
// ---- what the design said, sampled at a DEFINED instant ----
//
// cpl_retire is only meaningful while cpl_valid is asserted. A check
// placed after do_cpl returns reads it after the deassert, where the
// answer is a delta-cycle artefact -- which produced 4 failures against a
// design that was entirely correct. Capture once, assert on the capture.
reg obs_retire;
// ---- the shadow tracker, maintained by the bench ----
reg m_busy [0:N_TAG-1];
reg [15:0] m_rem [0:N_TAG-1];
reg um_busy [0:N_EP-1];
// Upward-and-stop, the opposite of the design's downward walk.
function m_free_any(input [3:0] lim);
integer j; reg f;
begin
f = 1'b0;
for (j = 0; j < N_TAG; j = j + 1)
if (!m_busy[j] && (j < lim) && !f) f = 1'b1;
m_free_any = f;
end
endfunction
function [2:0] m_free_tag(input [3:0] lim);
integer j; reg f; reg [2:0] t;
begin
f = 1'b0; t = 3'd0;
for (j = 0; j < N_TAG; j = j + 1)
if (!m_busy[j] && (j < lim) && !f) begin t = j[2:0]; f = 1'b1; end
m_free_tag = t;
end
endfunction
function [31:0] m_out;
input dummy;
integer j; reg [31:0] c;
begin
c = 32'd0;
for (j = 0; j < N_TAG; j = j + 1) if (m_busy[j]) c = c + 32'd1;
m_out = c;
end
endfunction
function [31:0] um_out;
input dummy;
integer j; reg [31:0] c;
begin
c = 32'd0;
for (j = 0; j < N_EP; j = j + 1) if (um_busy[j]) c = c + 32'd1;
um_out = c;
end
endfunction
// ---- the fabric: completions in flight, each with a due cycle ----
//
// A model of the thing PCIe has and USB does not: a transport that holds
// several requests at once and returns them WHENEVER, not in order.
reg f_act [0:MAXF-1];
reg [2:0] f_tag [0:MAXF-1];
reg [15:0] f_bytes [0:MAXF-1];
integer f_due [0:MAXF-1];
integer now_c;
task fabric_clear;
integer j;
begin
for (j = 0; j < MAXF; j = j + 1) f_act[j] = 1'b0;
now_c = 0;
end
endtask
task fabric_push(input [2:0] t, input [15:0] b, input integer due);
integer j; reg placed;
begin
placed = 1'b0;
for (j = 0; j < MAXF; j = j + 1)
if (!f_act[j] && !placed) begin
f_act[j] = 1'b1; f_tag[j] = t; f_bytes[j] = b; f_due[j] = due;
placed = 1'b1;
end
// A full fabric would silently drop a completion and the tag would
// stay outstanding forever, which reads as a design hang. Bound it.
ck(placed, "TEST BUG: the fabric model overflowed");
end
endtask
// Pick the earliest-due active completion at or before `now`. Only ONE
// per cycle, because completions are serialised on a real link.
task fabric_pop(input integer now, output found, output [2:0] t,
output [15:0] b);
integer j, best, bestdue;
begin
best = -1; bestdue = 0; found = 1'b0; t = 3'd0; b = 16'd0;
for (j = 0; j < MAXF; j = j + 1)
if (f_act[j] && (f_due[j] <= now))
if ((best < 0) || (f_due[j] < bestdue)) begin
best = j; bestdue = f_due[j];
end
if (best >= 0) begin
found = 1'b1; t = f_tag[best]; b = f_bytes[best];
f_act[best] = 1'b0;
end
end
endtask
// ---------------------------------------------------------------
// THE MEASUREMENT.
//
// Issue K non-posted requests as fast as the tracker accepts them,
// against a fabric of fixed latency, and count the cycles until the
// last one retires. Everything about the result is decided by how many
// tags the requester is allowed to hold.
// ---------------------------------------------------------------
integer K_XACT = 24;
task measure(input integer lim, input integer lat, output integer cycles);
integer issued, retired, guard;
reg pop_found;
reg [2:0] pop_tag;
reg [15:0] pop_bytes;
reg [2:0] got_tag;
reg pre_free;
reg [2:0] pre_tag;
integer j;
begin
// reset both the DUT and the model
rst_n = 1'b0;
req_valid = 1'b0; cpl_valid = 1'b0; req_posted = 1'b0;
@(posedge clk); @(posedge clk);
rst_n = 1'b1;
@(posedge clk); #1;
for (j = 0; j < N_TAG; j = j + 1) begin m_busy[j] = 1'b0; m_rem[j] = 16'd0; end
fabric_clear;
tag_limit = lim[3:0];
issued = 0; retired = 0; guard = 0;
// Every wait loop is bounded. An unbounded one turns a design hang
// into a test that never finishes, which is strictly worse.
while ((retired < K_XACT) && (guard < 20000)) begin
// present at most one due completion
fabric_pop(now_c, pop_found, pop_tag, pop_bytes);
cpl_valid = pop_found;
cpl_tag = pop_tag;
cpl_bytes = pop_bytes;
// offer a request whenever there are any left
req_valid = (issued < K_XACT);
req_posted = 1'b0;
req_bytes = 16'd64;
#1;
got_tag = req_tag;
// The allocation decision is taken from the table as it stood at the
// START of this cycle, because the design's free_any is
// combinational over the REGISTERED tag array. A tag retired by the
// completion being presented right now is not allocatable until next
// cycle -- one cycle of turnaround per tag, which is why saturating
// a fabric of latency L needs L+1 tags.
//
// Applying the completion to the model first, and only then judging
// the allocation, made the model one cycle ahead of the design. The
// sweep then hung at every point where latency was less than the tag
// limit, and reported 20000 -- the guard value -- for 54 of its 64
// cells. Ordering, not arithmetic.
pre_free = m_free_any(lim[3:0]);
pre_tag = m_free_tag(lim[3:0]);
// ---- PROPERTY 1: the tracker and the model pick the same tag ----
if (req_valid && pre_free) begin
ck(req_ready === 1'b1, "a request was refused while a tag was free");
ck(got_tag === pre_tag, "the tracker allocated a different tag than the model");
end else if (req_valid) begin
// ---- PROPERTY 2: no free tag means a stall, not a silent drop ----
ck(req_stall === 1'b1, "the tracker neither accepted nor stalled a request");
ck(req_ready === 1'b0, "the tracker accepted a request with no tag free");
end
// ---- now advance the model, completions first, exactly as the
// design's clocked process does ----
if (cpl_valid && m_busy[pop_tag]) begin
if (pop_bytes >= m_rem[pop_tag]) begin
m_busy[pop_tag] = 1'b0; m_rem[pop_tag] = 16'd0;
retired = retired + 1;
end else begin
m_rem[pop_tag] = m_rem[pop_tag] - pop_bytes;
end
end
if (req_valid && pre_free) begin
m_busy[pre_tag] = 1'b1;
m_rem[pre_tag] = 16'd64;
fabric_push(pre_tag, 16'd64, now_c + lat);
issued = issued + 1;
end
@(posedge clk); #1;
// ---- PROPERTY 3: outstanding count is exact, every cycle ----
//
// Checked AFTER the edge. Before it, the design's combinational
// count still describes the previous cycle while the model has
// already advanced -- comparing across that boundary produced 1122
// failures against a design and a model that agreed perfectly.
ck(n_outstanding === m_out(0),
"the tracker and the model disagree about how many tags are in flight");
now_c = now_c + 1;
guard = guard + 1;
steps = steps + 1;
end
req_valid = 1'b0; cpl_valid = 1'b0;
ck(retired == K_XACT, "the measurement did not retire every transaction");
cycles = now_c;
end
endtask
// ---------------------------------------------------------------
// Single-step helpers for the directed phases.
// ---------------------------------------------------------------
task do_req(input posted, input [15:0] bytes, output [2:0] tg,
output accepted);
begin
req_valid = 1'b1; req_posted = posted; req_bytes = bytes;
#1;
tg = req_tag;
accepted = req_ready;
if (!posted) begin
if (m_free_any(tag_limit)) begin
ck(req_ready === 1'b1, "a request was refused while a tag was free");
ck(req_tag === m_free_tag(tag_limit), "wrong tag allocated");
end else begin
ck(req_stall === 1'b1, "no stall was raised with every tag busy");
end
end else begin
// ---- PROPERTY 4: a posted request never consumes a tag ----
//
// This is why a PCIe write is fast and a PCIe read is not: the
// write is finished when it is sent.
ck(req_ready === 1'b1, "a posted request was refused");
ck(req_stall === 1'b0, "a posted request was stalled by tag exhaustion");
end
@(posedge clk); #1;
req_valid = 1'b0;
if (!posted && accepted) begin
m_busy[tg] = 1'b1; m_rem[tg] = bytes;
c_alloc = c_alloc + 1;
end
if (posted) c_posted = c_posted + 1;
if (!posted && !accepted) c_stall = c_stall + 1;
ck(n_outstanding === m_out(0), "outstanding count wrong after a request");
steps = steps + 1;
end
endtask
task do_cpl(input [2:0] t, input [15:0] bytes);
reg e_known, e_last, e_over;
reg [31:0] r0, p0, b0, o0;
begin
e_known = m_busy[t];
e_last = e_known && (bytes >= m_rem[t]);
e_over = e_known && (bytes > m_rem[t]);
r0 = n_retire; p0 = n_partial; b0 = n_bad_tag; o0 = n_over;
cpl_valid = 1'b1; cpl_tag = t; cpl_bytes = bytes;
#1;
obs_retire = cpl_retire;
// ---- PROPERTY 5: retire means the LAST byte arrived ----
//
// A tag freed on a partial completion could be reused while the rest
// of the data was still in flight, and the next transaction would
// then collect it.
ck(cpl_retire === e_last,
"retire does not mean the request was completely satisfied");
@(posedge clk); #1;
cpl_valid = 1'b0;
if (!e_known) c_bad = c_bad + 1;
else if (e_last) c_retire = c_retire + 1;
else c_partial = c_partial + 1;
if (e_over) c_over = c_over + 1;
if (!e_known) begin
// ---- PROPERTY 6: a completion for a free tag is reported ----
ck(n_bad_tag == b0 + 32'd1, "a completion for an unowned tag was not reported");
ck(n_retire == r0, "an unowned completion retired something");
end else if (e_last) begin
m_busy[t] = 1'b0; m_rem[t] = 16'd0;
ck(n_retire == r0 + 32'd1, "a finishing completion did not retire");
ck(n_over == o0 + (e_over ? 32'd1 : 32'd0), "overrun miscounted");
end else begin
m_rem[t] = m_rem[t] - bytes;
// ---- PROPERTY 7: a partial completion keeps the tag ----
ck(n_partial == p0 + 32'd1, "a partial completion was not counted");
ck(n_retire == r0, "a partial completion retired the tag");
end
ck(n_outstanding === m_out(0), "outstanding count wrong after a completion");
steps = steps + 1;
end
endtask
task reset_all;
integer j;
begin
rst_n = 1'b0;
req_valid = 1'b0; cpl_valid = 1'b0; req_posted = 1'b0;
iss_valid = 1'b0; rsp_valid = 1'b0;
@(posedge clk); @(posedge clk);
rst_n = 1'b1;
@(posedge clk); #1;
for (j = 0; j < N_TAG; j = j + 1) begin m_busy[j] = 1'b0; m_rem[j] = 16'd0; end
for (j = 0; j < N_EP; j = j + 1) um_busy[j] = 1'b0;
tag_limit = 4'd8;
end
endtask
// ---- the USB side ----
task do_iss(input [1:0] ep);
reg e_busy;
reg [31:0] i0, f0;
begin
e_busy = um_busy[ep];
i0 = u_n_issued; f0 = u_n_refused;
iss_valid = 1'b1; iss_ep = ep;
#1;
// ---- PROPERTY 8: one outstanding transaction per endpoint ----
//
// The entire mechanism. There is no second slot to have, which is
// why no USB packet carries a transaction identifier.
ck(iss_ready === !e_busy, "the slot accepted a second transaction on one endpoint");
ck(iss_busy === e_busy, "busy was not reported for an occupied endpoint");
ck(!(iss_ready && iss_busy), "ready and busy were both asserted");
@(posedge clk); #1;
iss_valid = 1'b0;
if (!e_busy) begin
um_busy[ep] = 1'b1;
c_iss = c_iss + 1;
ck(u_n_issued == i0 + 32'd1, "an accepted transaction was not counted");
end else begin
c_ref = c_ref + 1;
ck(u_n_refused == f0 + 32'd1, "a refused transaction was not counted");
end
ck(u_n_out === um_out(0), "usb outstanding count wrong after an issue");
steps = steps + 1;
end
endtask
task do_rsp(input [1:0] ep);
reg e_busy;
reg [31:0] a0, s0;
begin
e_busy = um_busy[ep];
a0 = u_n_answered; s0 = u_n_spurious;
rsp_valid = 1'b1; rsp_ep = ep;
#1;
// ---- PROPERTY 9: a response with nothing outstanding is spurious ----
//
// Detectable precisely BECAUSE there is only one thing it could have
// been answering. With N tags in flight the same question needs a
// tag field to answer at all.
ck(rsp_match === e_busy, "a response was not matched to its transaction");
ck(rsp_spurious === !e_busy, "an unsolicited response was not flagged");
@(posedge clk); #1;
rsp_valid = 1'b0;
if (e_busy) begin
um_busy[ep] = 1'b0;
c_ans = c_ans + 1;
ck(u_n_answered == a0 + 32'd1, "an answered transaction was not counted");
end else begin
c_spur = c_spur + 1;
ck(u_n_spurious == s0 + 32'd1, "a spurious response was not counted");
end
ck(u_n_out === um_out(0), "usb outstanding count wrong after a response");
steps = steps + 1;
end
endtask
// ---- results ----
integer thr_cyc [0:8][0:8]; // cycles for (limit, latency)
integer lim, lat, k, j, c;
reg [2:0] tg;
reg acc;
reg [31:0] snap;
// exhaustive reach over (tag_limit 1..8) x (latency 1..8) = 64
reg reach [0:63];
integer nr, ri;
// and over the directed tracker state space, below
reg reach_t [0:255];
integer nrt;
initial begin
for (ri = 0; ri < 64; ri = ri + 1) reach[ri] = 1'b0;
for (ri = 0; ri < 256; ri = ri + 1) reach_t[ri] = 1'b0;
seed = 32'd28004;
reset_all;
// =============================================================
// PHASE 1 (DIRECTED, EXHAUSTIVE) -- THE THROUGHPUT SURFACE.
//
// Every tag limit from 1 to 8 against every fabric latency from 1 to
// 8: 64 points, all reachable, both dimensions independent inputs.
//
// The tag_limit = 1 row is the USB case. A USB host has exactly one
// transaction outstanding per endpoint, so whatever that row says
// about throughput is what USB can do regardless of link speed.
// =============================================================
for (lim = 1; lim <= 8; lim = lim + 1)
for (lat = 1; lat <= 8; lat = lat + 1) begin
measure(lim, lat, c);
thr_cyc[lim][lat] = c;
reach[(lim - 1) * 8 + (lat - 1)] = 1'b1;
end
// ---- PROPERTY 10: more tags never make it slower ----
//
// Monotonicity in the tag count. Stated as a property rather than
// eyeballed off the table, because a tracker that leaked tags would
// produce a table that still looked plausible.
for (lat = 1; lat <= 8; lat = lat + 1)
for (lim = 2; lim <= 8; lim = lim + 1)
ck(thr_cyc[lim][lat] <= thr_cyc[lim-1][lat],
"adding a tag made the transfer slower");
// ---- PROPERTY 11: more latency never makes it faster ----
for (lim = 1; lim <= 8; lim = lim + 1)
for (lat = 2; lat <= 8; lat = lat + 1)
ck(thr_cyc[lim][lat] >= thr_cyc[lim][lat-1],
"adding latency made the transfer faster");
// ---- PROPERTY 12: one tag costs the full latency, EXACTLY ----
//
// THE USB RESULT, as a closed form rather than an observation. With one
// outstanding transaction nothing overlaps, so each takes (latency + 1)
// cycles -- latency to come back, one to issue the next -- and the total
// is exactly K*(latency+1). No amount of link bandwidth changes it.
//
// Asserted with == rather than >=. An inequality would also pass for a
// design that was slower still, and the point of a closed form is that
// it pins the number from both sides.
for (lat = 1; lat <= 8; lat = lat + 1)
ck(thr_cyc[1][lat] == K_XACT * (lat + 1),
"one outstanding transaction did not cost exactly latency+1 cycles each");
// ---- PROPERTY 13: latency+1 tags saturate the link, EXACTLY ----
//
// The other closed form, and the one that answers "how many tags do I
// need?". With L+1 tags the requester issues one per cycle and the only
// cost left is draining the last one, so the total is exactly K + L.
//
// It is latency+1 and not latency because of the one-cycle tag
// turnaround: free_any reads the REGISTERED tag array, so a tag retired
// this cycle is allocatable next cycle. That single cycle is the
// difference between needing L tags and needing L+1.
for (lat = 1; lat <= 8; lat = lat + 1)
for (lim = lat + 1; lim <= 8; lim = lim + 1)
ck(thr_cyc[lim][lat] == K_XACT + lat,
"latency+1 tags did not saturate the link");
// ---- PROPERTY 14: fewer than latency+1 tags CANNOT saturate ----
//
// The negative half, without which the property above would be
// satisfied by a design that was always saturated regardless of tags --
// and the whole chapter would have no result.
for (lat = 2; lat <= 8; lat = lat + 1)
for (lim = 1; lim <= lat; lim = lim + 1)
ck(thr_cyc[lim][lat] > K_XACT + lat,
"fewer tags than latency+1 saturated the link anyway");
// =============================================================
// PHASE 2 (DIRECTED) -- COMPLETIONS OUT OF ORDER.
//
// The case USB cannot produce. Four tags are allocated in order and
// completed in REVERSE, and every one must retire correctly.
// =============================================================
reset_all;
do_req(1'b0, 16'd64, tg, acc); ck(tg === 3'd0, "first tag should be 0");
do_req(1'b0, 16'd64, tg, acc); ck(tg === 3'd1, "second tag should be 1");
do_req(1'b0, 16'd64, tg, acc); ck(tg === 3'd2, "third tag should be 2");
do_req(1'b0, 16'd64, tg, acc); ck(tg === 3'd3, "fourth tag should be 3");
ck(n_outstanding === 32'd4, "four requests should leave four tags in flight");
do_cpl(3'd3, 16'd64);
do_cpl(3'd1, 16'd64);
do_cpl(3'd2, 16'd64);
do_cpl(3'd0, 16'd64);
ck(n_outstanding === 32'd0, "completing every tag should empty the tracker");
ck(n_retire === 32'd4, "four completions should retire four requests");
ck(n_bad_tag === 32'd0, "out-of-order completion reported a bad tag");
// =============================================================
// PHASE 3 (DIRECTED, EXHAUSTIVE over every completion order)
//
// Three tags, all 3! = 6 completion orders. Out-of-order is not one
// case, it is every permutation, and a tracker that happened to work
// for reverse order is not thereby correct for the rest.
// =============================================================
for (k = 0; k < 6; k = k + 1) begin : perm
integer a, b2, c2, t0;
reset_all;
do_req(1'b0, 16'd64, tg, acc);
do_req(1'b0, 16'd64, tg, acc);
do_req(1'b0, 16'd64, tg, acc);
// the six orderings of {0,1,2}
case (k)
0: begin a = 0; b2 = 1; c2 = 2; end
1: begin a = 0; b2 = 2; c2 = 1; end
2: begin a = 1; b2 = 0; c2 = 2; end
3: begin a = 1; b2 = 2; c2 = 0; end
4: begin a = 2; b2 = 0; c2 = 1; end
default: begin a = 2; b2 = 1; c2 = 0; end
endcase
do_cpl(a[2:0], 16'd64);
do_cpl(b2[2:0], 16'd64);
do_cpl(c2[2:0], 16'd64);
ck(n_outstanding === 32'd0, "some completion order left a tag outstanding");
ck(n_retire === 32'd3, "some completion order lost a retirement");
ck(n_bad_tag === 32'd0, "some completion order was mistaken for a bad tag");
end
// =============================================================
// PHASE 4 (DIRECTED, EXHAUSTIVE over split shapes) -- SPLIT
// COMPLETIONS.
//
// One request may be answered by several completions. The tag must
// stay outstanding until the LAST byte arrives -- freeing it early is
// the tag-reuse bug, and it would attribute the remaining data to
// whatever transaction took the tag next.
//
// Every split of 256 bytes into equal pieces of 256, 128, 64, 32 and
// 16 is swept, which is every shape a power-of-two payload can take.
// =============================================================
for (k = 0; k < 5; k = k + 1) begin : splits
integer piece, pieces, n;
reset_all;
piece = 256 >> k;
pieces = 256 / piece;
do_req(1'b0, 16'd256, tg, acc);
for (n = 0; n < pieces; n = n + 1) begin
do_cpl(tg, piece[15:0]);
if (n < pieces - 1) begin
// ---- PROPERTY 13: a partially completed tag stays busy ----
ck(n_outstanding === 32'd1,
"a tag was freed before its last completion arrived");
ck(obs_retire === 1'b0, "a partial completion claimed to retire");
end
end
ck(n_outstanding === 32'd0, "the tag was not freed by its last completion");
ck(n_partial === (pieces - 1),
"the number of partial completions does not match the split");
end
// =============================================================
// PHASE 5 (DIRECTED) -- TAG EXHAUSTION IS BACK-PRESSURE.
//
// With two tags, a third request must STALL rather than be dropped or
// accepted. That stall is why PCIe read throughput depends on tag
// count, and it is the mechanism phase 1 measures.
// =============================================================
reset_all;
tag_limit = 4'd2;
do_req(1'b0, 16'd64, tg, acc); ck(acc === 1'b1, "first of two tags refused");
do_req(1'b0, 16'd64, tg, acc); ck(acc === 1'b1, "second of two tags refused");
snap = n_stall;
do_req(1'b0, 16'd64, tg, acc);
ck(acc === 1'b0, "a third request was accepted with only two tags");
ck(n_stall == snap + 32'd1, "tag exhaustion did not raise a stall");
ck(n_outstanding === 32'd2, "a stalled request consumed a tag anyway");
// ---- PROPERTY 14: a POSTED request is never stalled ----
//
// Even with every tag busy. A write needs no completion, so tag
// exhaustion cannot block it -- which is exactly why mixing posted and
// non-posted traffic changes a PCIe link's behaviour so much.
snap = n_posted;
do_req(1'b1, 16'd64, tg, acc);
ck(acc === 1'b1, "a posted request was blocked by tag exhaustion");
ck(n_posted == snap + 32'd1, "a posted request was not counted");
ck(n_outstanding === 32'd2, "a posted request consumed a tag");
// and freeing one tag lets the next non-posted request through
do_cpl(3'd0, 16'd64);
do_req(1'b0, 16'd64, tg, acc);
ck(acc === 1'b1, "freeing a tag did not admit the next request");
ck(tg === 3'd0, "the freed tag was not the one reused");
// =============================================================
// PHASE 5b (DIRECTED, EXHAUSTIVE over every tag limit)
//
// The posted-request guarantee is only OBSERVABLE when no tag is free,
// because with a tag free a posted request would be accepted either way.
// So the table is filled to capacity at each of the eight limits and the
// guarantee is checked there.
//
// That is the difference between a property being stated and being
// tested: phase 5 demonstrated it once, which gave the mutation that
// breaks it a domain of one.
// =============================================================
for (k = 1; k <= 8; k = k + 1) begin : postedsweep
integer b;
reg [31:0] psnap, osnap2;
reset_all;
tag_limit = k[3:0];
for (b = 0; b < k; b = b + 1) begin
do_req(1'b0, 16'd64, tg, acc);
ck(acc === 1'b1, "a request was refused below the tag limit");
end
ck(n_outstanding === k, "filling to the limit did not use every tag");
// a non-posted request must now stall
do_req(1'b0, 16'd64, tg, acc);
ck(acc === 1'b0, "a request was accepted above the tag limit");
// ---- and a POSTED request must NOT ----
//
// This is why a PCIe write never blocks on read credit: it needs no
// completion, so tag exhaustion cannot apply to it. A design that made
// writes wait for tags would serialise a workload PCIe is specifically
// built to overlap.
psnap = n_posted; osnap2 = n_outstanding;
do_req(1'b1, 16'd64, tg, acc);
ck(acc === 1'b1, "a posted request was blocked by tag exhaustion");
ck(n_posted == psnap + 32'd1, "a posted request was not counted");
ck(n_outstanding === osnap2, "a posted request consumed a tag");
end
// =============================================================
// PHASE 6 (DIRECTED) -- MALFORMED COMPLETIONS.
//
// A completion for a tag nobody owns, and a completion carrying more
// bytes than were asked for. Both are protocol errors and both must
// be REPORTED rather than absorbed -- an absorbed overrun would wrap
// the byte counter and leave the tag outstanding forever, turning a
// protocol error into a hang.
// =============================================================
reset_all;
snap = n_bad_tag;
do_cpl(3'd5, 16'd64); // nobody owns tag 5
ck(n_bad_tag == snap + 32'd1, "a completion for a free tag was accepted");
ck(n_outstanding === 32'd0, "an unowned completion changed the tracker state");
do_req(1'b0, 16'd64, tg, acc);
snap = n_over;
do_cpl(tg, 16'd128); // twice what was asked for
ck(n_over == snap + 32'd1, "an over-long completion was not reported");
ck(n_outstanding === 32'd0, "an over-long completion left the tag outstanding");
// =============================================================
// PHASE 6b (DIRECTED, EXHAUSTIVE over overrun shapes)
//
// A completion carrying more bytes than were requested, for every
// request size and both interesting overrun amounts: one byte too many,
// and twice as many as asked for. Eight cases rather than the single
// one phase 6 had, because a mutation that stops reporting overruns
// should not be able to score 2.
//
// In every case the tag must still RETIRE. Absorbing the overrun by
// wrapping the byte counter would leave the tag outstanding forever and
// turn a protocol error into a hang, which is strictly worse.
// =============================================================
for (k = 0; k < 8; k = k + 1) begin : overruns
integer want, extra;
reg [31:0] osnap;
reset_all;
want = 16 << (k % 4);
extra = (k < 4) ? 1 : want; // one byte too many, or double
do_req(1'b0, want[15:0], tg, acc);
osnap = n_over;
do_cpl(tg, (want + extra));
ck(n_over == osnap + 32'd1, "an over-long completion was not reported");
ck(n_outstanding === 32'd0,
"an over-long completion left the tag outstanding");
ck(n_retire > 32'd0, "an over-long completion did not retire the tag");
end
// =============================================================
// PHASE 7 (DIRECTED, EXHAUSTIVE over the tracker's busy-mask)
//
// Every one of the 2**8 = 256 combinations of which tags are busy,
// reached by allocating and completing rather than by forcing state.
// For each, the allocation decision is checked against the model.
// =============================================================
for (k = 0; k < 256; k = k + 1) begin : masks
integer b;
reset_all;
// allocate all eight, then complete the ones the mask says are free
for (b = 0; b < 8; b = b + 1) do_req(1'b0, 16'd64, tg, acc);
ck(n_outstanding === 32'd8, "eight requests did not fill eight tags");
for (b = 0; b < 8; b = b + 1)
if (!k[b]) do_cpl(b[2:0], 16'd64);
// ---- a POSTED request, against every table state ----
//
// It must be accepted and must consume no tag, whatever else is in
// flight -- including when every tag is busy. Swept here rather than
// demonstrated once, because a property exercised a single time has a
// mutation domain of one and tells you almost nothing.
snap = n_outstanding;
do_req(1'b1, 16'd64, tg, acc);
ck(acc === 1'b1, "a posted request was refused");
ck(n_outstanding === snap, "a posted request consumed a tag");
// ---- a completion for a tag NOBODY OWNS, against every state ----
//
// Whichever tag is free, a completion for it is a protocol error and
// must be reported rather than absorbed. With a full table this case
// does not exist, so it is skipped rather than faked.
if (m_free_any(4'd8)) begin : badcpl
reg [2:0] ft;
reg [31:0] bsnap, osnap;
ft = m_free_tag(4'd8);
bsnap = n_bad_tag; osnap = n_outstanding;
do_cpl(ft, 16'd64);
ck(n_bad_tag == bsnap + 32'd1,
"a completion for an unowned tag was not reported");
ck(n_outstanding === osnap,
"a completion for an unowned tag changed the tracker state");
end
// Now exactly the tags set in k are busy. The next allocation must
// pick the lowest free one, which the model computes independently.
if (m_free_any(4'd8)) begin
do_req(1'b0, 16'd64, tg, acc);
ck(acc === 1'b1, "a request was refused with a free tag");
end else begin
do_req(1'b0, 16'd64, tg, acc);
ck(acc === 1'b0, "a request was accepted with every tag busy");
end
reach_t[k] = 1'b1;
end
// =============================================================
// PHASE 8 (DIRECTED) -- THE USB SLOT.
//
// One outstanding transaction per endpoint. The endpoints are
// independent, a second issue to a busy endpoint is refused, and a
// response with nothing outstanding is flagged.
// =============================================================
reset_all;
for (k = 0; k < 4; k = k + 1) begin
do_iss(k[1:0]);
ck(u_n_out === (k + 1), "each endpoint should hold one transaction");
end
// every endpoint busy: a second issue to each must be refused
for (k = 0; k < 4; k = k + 1) do_iss(k[1:0]);
ck(u_n_refused === 32'd4, "four second-issues should all be refused");
ck(u_n_out === 32'd4, "a refused issue changed the outstanding count");
// answer them all
for (k = 0; k < 4; k = k + 1) do_rsp(k[1:0]);
ck(u_n_out === 32'd0, "answering every endpoint should empty the slots");
// an unsolicited response
snap = u_n_spurious;
do_rsp(2'd2);
ck(u_n_spurious == snap + 32'd1, "an unsolicited response was not flagged");
// =============================================================
// PHASE 9 (DIRECTED, EXHAUSTIVE over the endpoint busy-mask)
//
// All 2**4 = 16 combinations of which endpoints are busy, crossed
// with an issue and a response to each of the 4 endpoints. 16 x 4 x 2
// decisions, every one checked against the model.
// =============================================================
for (k = 0; k < 16; k = k + 1) begin : epmask
integer b;
reset_all;
for (b = 0; b < 4; b = b + 1) if (k[b]) do_iss(b[1:0]);
for (b = 0; b < 4; b = b + 1) do_iss(b[1:0]);
reset_all;
for (b = 0; b < 4; b = b + 1) if (k[b]) do_iss(b[1:0]);
for (b = 0; b < 4; b = b + 1) do_rsp(b[1:0]);
end
// =============================================================
// PHASE 10 (RANDOM) -- mixed traffic with a reordering fabric.
// =============================================================
`ifndef DIRECTED_ONLY
reset_all;
fabric_clear;
for (k = 0; k < 800; k = k + 1) begin : rnd
reg pf; reg [2:0] pt; reg [15:0] pb;
// a due completion, if any
fabric_pop(now_c, pf, pt, pb);
if (pf) do_cpl(pt, pb);
// a request, sometimes posted
if ((urand(0) % 3) != 0) begin
if ((urand(0) % 5) == 0) begin
do_req(1'b1, 16'd64, tg, acc);
end else begin
do_req(1'b0, 16'd64, tg, acc);
// A random latency, so completions come back out of order --
// which is the property the whole tracker exists for.
if (acc) fabric_push(tg, 16'd64, now_c + 1 + (urand(0) % 9));
end
end
// and some USB traffic on the side
if ((urand(0) % 2) == 0) do_iss((urand(0) % 4));
else do_rsp((urand(0) % 4));
now_c = now_c + 1;
end
// drain whatever is still in flight, bounded
for (k = 0; k < 400; k = k + 1) begin : drain
reg pf2; reg [2:0] pt2; reg [15:0] pb2;
fabric_pop(now_c + 100, pf2, pt2, pb2);
if (pf2) do_cpl(pt2, pb2);
now_c = now_c + 1;
end
`endif
nr = 0; for (ri = 0; ri < 64; ri = ri + 1) if (reach[ri]) nr = nr + 1;
nrt = 0; for (ri = 0; ri < 256; ri = ri + 1) if (reach_t[ri]) nrt = nrt + 1;
$display("steps=%0d checks=%0d reach_thr=%0d/64 reach_tag=%0d/256 errors=%0d",
steps, checks, nr, nrt, errors);
$display("[pcie] alloc=%0d posted=%0d stalls=%0d retire=%0d partial=%0d bad_tag=%0d over=%0d",
c_alloc, c_posted, c_stall, c_retire, c_partial, c_bad, c_over);
$display("[usb] issued=%0d refused=%0d answered=%0d spurious=%0d",
c_iss, c_ref, c_ans, c_spur);
$display("--- cycles to move %0d transactions, by tags outstanding x latency ---", K_XACT);
$display(" tags L=1 L=2 L=3 L=4 L=5 L=6 L=7 L=8");
for (lim = 1; lim <= 8; lim = lim + 1)
$display(" %4d %8d%8d%8d%8d%8d%8d%8d%8d", lim,
thr_cyc[lim][1], thr_cyc[lim][2], thr_cyc[lim][3], thr_cyc[lim][4],
thr_cyc[lim][5], thr_cyc[lim][6], thr_cyc[lim][7], thr_cyc[lim][8]);
$display(" (row tags=1 IS the USB case: one transaction outstanding)");
if (nr != 64 || nrt != 256) begin
$display("FAIL: exhaustive sweep incomplete"); errors = errors + 1;
end
if (errors == 0) $display("PASS: 0 errors in %0d checks", checks);
else $display("FAIL: %0d errors in %0d checks", errors, checks);
$finish;
end
endmodule9. SystemVerilog
// =====================================================================
// OUTSTANDING TRANSACTIONS -- SystemVerilog.
//
// Same hardware contract as the Verilog file: same ports, same widths,
// same reset values, same cycle-by-cycle behaviour. What changes is
// `always_comb` / `always_ff`, loop variables declared in the loop, and
// every continuous assignment written as `logic` + `assign` on separate
// lines rather than as an initialised declaration.
//
// THAT LAST POINT IS NOT COSMETIC. `logic x = expr;` is a one-shot
// VARIABLE INITIALISER in SystemVerilog -- evaluated once at time zero and
// never again -- while the Verilog `wire x = expr;` it came from is a
// continuous assignment. Translating the five such lines in the previous
// chapter mechanically produced 29,580 phantom failures against a design
// that was entirely correct, and the symptom was structural: nothing ever
// matched, anywhere.
//
// OUTSTANDING TRANSACTIONS -- THE MECHANISM PCIe NEEDS AND USB DOES NOT.
//
// CLASSIFICATION: simplified synthesisable teaching RTL.
// Two modules. Neither is a controller: there is no TLP encoder, no
// link layer, no credits, no USB packet engine and no scheduler. Each
// is the part that answers one question.
//
// "This response just arrived. Which request was it for?"
//
// USB answers it by CONSTRUCTION. The host issues one transaction to an
// endpoint and waits for it. The response is the next thing on the wire,
// so there is nothing to match -- and USB packets carry no transaction
// identifier at all, because none is needed.
//
// PCIe answers it with a TAG. A requester may have many non-posted
// requests in flight at once; completions travel independently, come
// back OUT OF ORDER, and may arrive split into several pieces. So every
// request carries a tag, and the requester must track each one until the
// last byte of its completion has arrived.
//
// The difference is not bandwidth, it is CONCURRENCY:
//
// USB: one outstanding transaction, so throughput is bounded by
// 1 / latency, whatever the wire can carry.
// PCIe: N outstanding transactions, so throughput is bounded by
// min(1, N / latency) -- and with enough tags, not by latency
// at all.
//
// That is a formula, so the chapter measures it. Section 5's table is
// throughput against tag count and latency, and the USB case is exactly
// its first row.
//
// What the tags cost is a class of bug that cannot exist on USB: a tag
// reused before its completion arrives silently attributes one
// transaction's data to another.
// =====================================================================
// ---------------------------------------------------------------------
// pcie_tag_tracker -- many in flight, matched by tag.
//
// `tag_limit` is an INPUT rather than a parameter so the testbench can
// sweep how many tags the requester is allowed to use without
// re-elaborating. That is what makes the throughput curve in section 5
// a single exhaustive sweep instead of eight separate runs -- and it is
// also how the USB case is reached: tag_limit = 1.
// ---------------------------------------------------------------------
module pcie_tag_tracker #(
parameter int N_TAG = 8,
// Bytes are tracked so a SPLIT completion can be modelled: a single
// read may be answered by several completions, and the tag is not free
// until the last byte arrives.
parameter int MAX_BYTES = 256
) (
input logic clk,
input logic rst_n,
// How many tags the requester may use, 1..N_TAG. Zero is treated as
// one: a requester that can have nothing outstanding cannot make
// progress, and silently deadlocking is worse than clamping.
input logic [$clog2(N_TAG+1)-1:0] tag_limit,
// ---- a request ----
input logic req_valid,
// POSTED requests (writes) expect no completion and consume no tag.
// That is why a PCIe write is fast and a PCIe read is not: the write is
// finished when it is sent, and the read is not finished until it comes
// back.
input logic req_posted,
input logic [15:0] req_bytes,
output logic req_ready, // a tag was available
output logic [$clog2(N_TAG)-1:0] req_tag,
output logic req_stall, // no tag available: this is back-pressure
// ---- a completion, arriving whenever the fabric feels like it ----
input logic cpl_valid,
input logic [$clog2(N_TAG)-1:0] cpl_tag,
input logic [15:0] cpl_bytes,
output logic cpl_retire, // this completion finished its request
// ---- observability ----
output logic [31:0] n_alloc,
output logic [31:0] n_posted,
output logic [31:0] n_stall,
output logic [31:0] n_retire,
output logic [31:0] n_partial, // a completion that did not finish a tag
output logic [31:0] n_bad_tag, // a completion for a tag nobody owns
output logic [31:0] n_over, // more bytes returned than requested
output logic [31:0] n_outstanding // how many tags are in flight NOW
);
localparam int TW = $clog2(N_TAG);
localparam int LW = $clog2(N_TAG+1);
logic t_busy [N_TAG];
logic [15:0] t_rem [N_TAG];
// Clamp to at least one usable tag. A requester allowed zero
// outstanding transactions can never make progress, and a design that
// deadlocks silently is harder to debug than one that refuses to.
logic [LW-1:0] lim;
assign lim = (tag_limit == 0) ? {{(LW-1){1'b0}}, 1'b1} : tag_limit;
// -------------------------------------------------------------------
// ALLOCATE -- the lowest free tag strictly below the limit.
//
// Walking downwards so the lowest index wins. Real requesters often
// allocate round-robin to spread wear on completion buffers; lowest-
// free is chosen here because it is deterministic, which is what makes
// the tag-reuse property checkable at all.
// -------------------------------------------------------------------
logic free_any;
logic [TW-1:0] free_tag;
always_comb begin
free_any = 1'b0;
free_tag = '0;
for (int i = N_TAG - 1; i >= 0; i--) begin
if (!t_busy[i] && (i < lim)) begin
free_any = 1'b1;
free_tag = TW'(i);
end
end
end
// A posted request needs no tag, so it is never stalled by tag
// exhaustion. This asymmetry is the whole reason PCIe separates the two
// classes.
assign req_ready = req_valid && (req_posted || free_any);
assign req_tag = free_tag;
assign req_stall = req_valid && !req_posted && !free_any;
// -------------------------------------------------------------------
// COMPLETE -- match by tag, subtract bytes, free on the last one.
// -------------------------------------------------------------------
logic cpl_known;
assign cpl_known = cpl_valid && t_busy[cpl_tag];
logic [15:0] rem_now;
assign rem_now = t_rem[cpl_tag];
// More bytes than were asked for. On a real link this is a malformed
// completion; here it is flagged rather than allowed to wrap the
// counter, because a wrapped counter would keep the tag outstanding
// forever and turn a protocol error into a hang.
logic cpl_overrun;
assign cpl_overrun = cpl_known && (cpl_bytes > rem_now);
logic cpl_last;
assign cpl_last = cpl_known && (cpl_bytes >= rem_now);
assign cpl_retire = cpl_last;
logic [31:0] alloc_c, posted_c, stall_c, retire_c, partial_c, bad_c, over_c;
assign n_alloc = alloc_c;
assign n_posted = posted_c;
assign n_stall = stall_c;
assign n_retire = retire_c;
assign n_partial = partial_c;
assign n_bad_tag = bad_c;
assign n_over = over_c;
// Combinational, so it reports the tracker as it stands rather than as
// it stood a cycle ago.
logic [31:0] out_now;
always_comb begin
out_now = 32'd0;
for (int i = 0; i < N_TAG; i++) if (t_busy[i]) out_now = out_now + 32'd1;
end
assign n_outstanding = out_now;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int i = 0; i < N_TAG; i++) begin
t_busy[i] <= 1'b0;
t_rem[i] <= 16'd0;
end
alloc_c <= 32'd0;
posted_c <= 32'd0;
stall_c <= 32'd0;
retire_c <= 32'd0;
partial_c <= 32'd0;
bad_c <= 32'd0;
over_c <= 32'd0;
end else begin
// ---- completions, then requests ----
//
// The order of these two blocks is immaterial to correctness, and it
// is worth saying why rather than implying otherwise. `free_any` and
// `free_tag` are combinational over the REGISTERED t_busy, so they
// describe the table as it stood at the START of this cycle. A tag
// retired by a completion in this cycle therefore becomes
// allocatable in the NEXT one -- not this one.
//
// That is ONE CYCLE OF TURNAROUND per tag, and it is a real cost with
// a visible consequence: saturating a fabric of latency L needs L+1
// tags, not L. Section 5's table shows exactly that boundary.
//
// The two blocks cannot collide, because an index being retired is
// busy and is therefore never the index free_any selects.
if (cpl_valid) begin
if (!t_busy[cpl_tag]) begin
// A completion for a tag nobody owns. Either the fabric
// invented it or this requester retired the tag early -- and
// the second is exactly what a tag-reuse bug looks like from
// here.
bad_c <= bad_c + 32'd1;
end else begin
if (cpl_bytes >= rem_now) begin
t_busy[cpl_tag] <= 1'b0;
t_rem[cpl_tag] <= 16'd0;
retire_c <= retire_c + 32'd1;
if (cpl_bytes > rem_now) over_c <= over_c + 32'd1;
end else begin
// A split completion: the request is not finished, so the tag
// stays outstanding. Freeing it here would allow the tag to be
// reused while the rest of the data was still in flight.
t_rem[cpl_tag] <= rem_now - cpl_bytes;
partial_c <= partial_c + 32'd1;
end
end
end
// ---- then the request ----
if (req_valid) begin
if (req_posted) begin
posted_c <= posted_c + 32'd1;
end else if (free_any) begin
// free_tag was chosen from the table as it stood at the start of
// the cycle, so it is not an index any completion is retiring
// right now.
t_busy[free_tag] <= 1'b1;
t_rem[free_tag] <= req_bytes;
alloc_c <= alloc_c + 32'd1;
end else begin
stall_c <= stall_c + 32'd1;
end
end
end
end
endmodule
// ---------------------------------------------------------------------
// usb_xact_slot -- one in flight, matched by nothing.
//
// The same question, answered by construction. A USB host issues one
// transaction to an endpoint and waits for the response; the response is
// the next thing on the wire. So:
//
// * There is no tag. There is no field in any USB packet that
// identifies which transaction a response belongs to, because the
// question never arises.
//
// * There is no reordering. One outstanding transaction cannot be
// overtaken.
//
// * There is no split-completion reassembly. A transaction's data
// arrives in one transaction.
//
// * And there is no tag-reuse bug to have.
//
// What it costs is in the port list too, by omission: there is no way to
// have a second transaction outstanding, so throughput is 1 / latency
// and no amount of link bandwidth changes that. Section 5 measures it.
// ---------------------------------------------------------------------
module usb_xact_slot #(
parameter int N_EP = 4
) (
input logic clk,
input logic rst_n,
// ---- issue a transaction to an endpoint ----
input logic iss_valid,
input logic [$clog2(N_EP)-1:0] iss_ep,
output logic iss_ready, // that endpoint was idle
output logic iss_busy, // that endpoint already has one in flight
// ---- the response, which can only belong to that endpoint's ----
input logic rsp_valid,
input logic [$clog2(N_EP)-1:0] rsp_ep,
output logic rsp_match, // there was a transaction to answer
output logic rsp_spurious,// there was not
output logic [31:0] n_issued,
output logic [31:0] n_refused,
output logic [31:0] n_answered,
output logic [31:0] n_spurious,
output logic [31:0] n_outstanding
);
localparam int EW = $clog2(N_EP);
// One bit per endpoint. That is the entire mechanism -- compare it with
// the tag array above, which needs a byte counter per entry because a
// completion can be partial.
logic ep_busy [N_EP];
assign iss_ready = iss_valid && !ep_busy[iss_ep];
assign iss_busy = iss_valid && ep_busy[iss_ep];
// A response with nothing outstanding on that endpoint. On a real bus
// this is a device talking when it was not asked, which USB treats as a
// protocol error -- and which is detectable precisely because there is
// only ever one thing it could have been answering.
assign rsp_match = rsp_valid && ep_busy[rsp_ep];
assign rsp_spurious = rsp_valid && !ep_busy[rsp_ep];
logic [31:0] iss_c, ref_c, ans_c, spur_c;
assign n_issued = iss_c;
assign n_refused = ref_c;
assign n_answered = ans_c;
assign n_spurious = spur_c;
logic [31:0] out_now;
always_comb begin
out_now = 32'd0;
for (int i = 0; i < N_EP; i++) if (ep_busy[i]) out_now = out_now + 32'd1;
end
assign n_outstanding = out_now;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int i = 0; i < N_EP; i++) ep_busy[i] <= 1'b0;
iss_c <= 32'd0;
ref_c <= 32'd0;
ans_c <= 32'd0;
spur_c <= 32'd0;
end else begin
// Responses first, for the same reason as the tracker: an endpoint
// answered this cycle can be reissued in this cycle.
if (rsp_valid) begin
if (ep_busy[rsp_ep]) begin
ep_busy[rsp_ep] <= 1'b0;
ans_c <= ans_c + 32'd1;
end else begin
spur_c <= spur_c + 32'd1;
end
end
if (iss_valid) begin
if (!ep_busy[iss_ep]) begin
ep_busy[iss_ep] <= 1'b1;
iss_c <= iss_c + 32'd1;
end else begin
ref_c <= ref_c + 32'd1;
end
end
end
end
endmoduleThe SystemVerilog testbench
// =====================================================================
// Testbench for pcie_tag_tracker and usb_xact_slot -- SystemVerilog.
//
// SAME SEED AND SAME PHASE ORDER AS THE VERILOG BENCH, deliberately.
// Icarus seeds $random identically, so both drive identical stimulus and
// any difference between the two mutation columns is a real difference
// between the two DESIGNS. The independent-stimulus role is VHDL's.
//
// THE HEADLINE IS A THROUGHPUT SURFACE, NOT A PASS/FAIL.
//
// Both designs answer "which request was this response for?". The
// interesting question is what each one's answer COSTS, and the cost is
// concurrency: how many transactions can be in flight at once.
//
// So the bench contains a FABRIC MODEL with a settable latency, issues
// requests as fast as the tracker will take them, and measures how many
// cycles it takes to retire a fixed number. Sweeping (tag_limit,
// latency) gives a surface, and the tag_limit = 1 row of that surface IS
// the USB behaviour -- because a USB host has exactly one transaction
// outstanding per endpoint, by construction.
//
// THE SHADOW MODEL IS FORMULATED IN THE OPPOSITE DIRECTION. The tracker
// allocates by walking its tags DOWNWARDS so the lowest free index wins;
// the model walks UPWARDS and stops at the first free one. Same answer,
// different derivation.
// =====================================================================
`timescale 1ns/1ps
module tb_tg_sv;
localparam int N_TAG = 8;
localparam int N_EP = 4;
localparam int MAXF = 64; // fabric depth: in-flight completions
logic clk = 1'b0, rst_n = 1'b0;
always #5 clk = ~clk;
// ---- PCIe side ----
logic [3:0] tag_limit = 4'd8;
logic req_valid = 1'b0, req_posted = 1'b0;
logic [15:0] req_bytes = 16'd0;
logic req_ready, req_stall;
logic [2:0] req_tag;
logic cpl_valid = 1'b0;
logic [2:0] cpl_tag = 3'd0;
logic [15:0] cpl_bytes = 16'd0;
logic cpl_retire;
logic [31:0] n_alloc, n_posted, n_stall, n_retire, n_partial,
n_bad_tag, n_over, n_outstanding;
pcie_tag_tracker #(.N_TAG(N_TAG)) dut (
.clk(clk), .rst_n(rst_n), .tag_limit(tag_limit),
.req_valid(req_valid), .req_posted(req_posted), .req_bytes(req_bytes),
.req_ready(req_ready), .req_tag(req_tag), .req_stall(req_stall),
.cpl_valid(cpl_valid), .cpl_tag(cpl_tag), .cpl_bytes(cpl_bytes),
.cpl_retire(cpl_retire),
.n_alloc(n_alloc), .n_posted(n_posted), .n_stall(n_stall),
.n_retire(n_retire), .n_partial(n_partial), .n_bad_tag(n_bad_tag),
.n_over(n_over), .n_outstanding(n_outstanding)
);
// ---- USB side ----
logic iss_valid = 1'b0;
logic [1:0] iss_ep = 2'd0;
logic iss_ready, iss_busy;
logic rsp_valid = 1'b0;
logic [1:0] rsp_ep = 2'd0;
logic rsp_match, rsp_spurious;
logic [31:0] u_n_issued, u_n_refused, u_n_answered, u_n_spurious, u_n_out;
usb_xact_slot #(.N_EP(N_EP)) udut (
.clk(clk), .rst_n(rst_n),
.iss_valid(iss_valid), .iss_ep(iss_ep),
.iss_ready(iss_ready), .iss_busy(iss_busy),
.rsp_valid(rsp_valid), .rsp_ep(rsp_ep),
.rsp_match(rsp_match), .rsp_spurious(rsp_spurious),
.n_issued(u_n_issued), .n_refused(u_n_refused),
.n_answered(u_n_answered), .n_spurious(u_n_spurious),
.n_outstanding(u_n_out)
);
int errors = 0, checks = 0, steps = 0;
int seed;
// ---- cumulative across resets ----
//
// The DUTs' own counters are zeroed by every reset_all, so reading them in
// the final summary would report only whatever happened after the last
// one. Per-step checks still use the DUT counters directly; these are for
// the totals.
int c_alloc = 0, c_posted = 0, c_stall = 0, c_retire = 0,
c_partial = 0, c_bad = 0, c_over = 0;
int c_iss = 0, c_ref = 0, c_ans = 0, c_spur = 0;
// $random is SIGNED: mask the sign bit before any modulo.
function automatic logic [31:0] urand();
return $random(seed) & 32'h3FFF_FFFF;
endfunction
task automatic ck(input logic cond, input string what);
begin
checks = checks + 1;
if (!cond) begin
errors = errors + 1;
if (errors <= 20)
$display(" ERROR @%0t step#%0d: %s", $time, steps, what);
end
end
endtask
// ---- what the design said, sampled at a DEFINED instant ----
//
// cpl_retire is only meaningful while cpl_valid is asserted. A check
// placed after do_cpl returns reads it after the deassert, where the
// answer is a delta-cycle artefact -- which produced 4 failures against a
// design that was entirely correct. Capture once, assert on the capture.
logic obs_retire;
// ---- the shadow tracker, maintained by the bench ----
logic m_busy [0:N_TAG-1];
logic [15:0] m_rem [0:N_TAG-1];
logic um_busy [0:N_EP-1];
// Upward-and-stop, the opposite of the design's downward walk.
function automatic logic m_free_any(input [3:0] lim);
int j; logic f;
begin
f = 1'b0;
for (j = 0; j < N_TAG; j = j + 1)
if (!m_busy[j] && (j < lim) && !f) f = 1'b1;
m_free_any = f;
end
endfunction
function automatic logic [2:0] m_free_tag(input [3:0] lim);
int j; logic f; logic [2:0] t;
begin
f = 1'b0; t = 3'd0;
for (j = 0; j < N_TAG; j = j + 1)
if (!m_busy[j] && (j < lim) && !f) begin t = j[2:0]; f = 1'b1; end
m_free_tag = t;
end
endfunction
function automatic logic [31:0] m_out(input logic dummy);
int j; logic [31:0] c;
begin
c = 32'd0;
for (j = 0; j < N_TAG; j = j + 1) if (m_busy[j]) c = c + 32'd1;
m_out = c;
end
endfunction
function automatic logic [31:0] um_out(input logic dummy);
int j; logic [31:0] c;
begin
c = 32'd0;
for (j = 0; j < N_EP; j = j + 1) if (um_busy[j]) c = c + 32'd1;
um_out = c;
end
endfunction
// ---- the fabric: completions in flight, each with a due cycle ----
//
// A model of the thing PCIe has and USB does not: a transport that holds
// several requests at once and returns them WHENEVER, not in order.
logic f_act [0:MAXF-1];
logic [2:0] f_tag [0:MAXF-1];
logic [15:0] f_bytes [0:MAXF-1];
integer f_due [0:MAXF-1];
int now_c;
task automatic fabric_clear;
int j;
begin
for (j = 0; j < MAXF; j = j + 1) f_act[j] = 1'b0;
now_c = 0;
end
endtask
task automatic fabric_push(input [2:0] t, input [15:0] b, input integer due);
int j; logic placed;
begin
placed = 1'b0;
for (j = 0; j < MAXF; j = j + 1)
if (!f_act[j] && !placed) begin
f_act[j] = 1'b1; f_tag[j] = t; f_bytes[j] = b; f_due[j] = due;
placed = 1'b1;
end
// A full fabric would silently drop a completion and the tag would
// stay outstanding forever, which reads as a design hang. Bound it.
ck(placed, "TEST BUG: the fabric model overflowed");
end
endtask
// Pick the earliest-due active completion at or before `now`. Only ONE
// per cycle, because completions are serialised on a real link.
task automatic fabric_pop(input integer now, output found, output [2:0] t,
output [15:0] b);
int j, best, bestdue;
begin
best = -1; bestdue = 0; found = 1'b0; t = 3'd0; b = 16'd0;
for (j = 0; j < MAXF; j = j + 1)
if (f_act[j] && (f_due[j] <= now))
if ((best < 0) || (f_due[j] < bestdue)) begin
best = j; bestdue = f_due[j];
end
if (best >= 0) begin
found = 1'b1; t = f_tag[best]; b = f_bytes[best];
f_act[best] = 1'b0;
end
end
endtask
// ---------------------------------------------------------------
// THE MEASUREMENT.
//
// Issue K non-posted requests as fast as the tracker accepts them,
// against a fabric of fixed latency, and count the cycles until the
// last one retires. Everything about the result is decided by how many
// tags the requester is allowed to hold.
// ---------------------------------------------------------------
int K_XACT = 24;
task automatic measure(input integer lim, input integer lat, output integer cycles);
int issued, retired, guard;
logic pop_found;
logic [2:0] pop_tag;
logic [15:0] pop_bytes;
logic [2:0] got_tag;
logic pre_free;
logic [2:0] pre_tag;
int j;
begin
// reset both the DUT and the model
rst_n = 1'b0;
req_valid = 1'b0; cpl_valid = 1'b0; req_posted = 1'b0;
@(posedge clk); @(posedge clk);
rst_n = 1'b1;
@(posedge clk); #1;
for (j = 0; j < N_TAG; j = j + 1) begin m_busy[j] = 1'b0; m_rem[j] = 16'd0; end
fabric_clear;
tag_limit = lim[3:0];
issued = 0; retired = 0; guard = 0;
// Every wait loop is bounded. An unbounded one turns a design hang
// into a test that never finishes, which is strictly worse.
while ((retired < K_XACT) && (guard < 20000)) begin
// present at most one due completion
fabric_pop(now_c, pop_found, pop_tag, pop_bytes);
cpl_valid = pop_found;
cpl_tag = pop_tag;
cpl_bytes = pop_bytes;
// offer a request whenever there are any left
req_valid = (issued < K_XACT);
req_posted = 1'b0;
req_bytes = 16'd64;
#1;
got_tag = req_tag;
// The allocation decision is taken from the table as it stood at the
// START of this cycle, because the design's free_any is
// combinational over the REGISTERED tag array. A tag retired by the
// completion being presented right now is not allocatable until next
// cycle -- one cycle of turnaround per tag, which is why saturating
// a fabric of latency L needs L+1 tags.
//
// Applying the completion to the model first, and only then judging
// the allocation, made the model one cycle ahead of the design. The
// sweep then hung at every point where latency was less than the tag
// limit, and reported 20000 -- the guard value -- for 54 of its 64
// cells. Ordering, not arithmetic.
pre_free = m_free_any(lim[3:0]);
pre_tag = m_free_tag(lim[3:0]);
// ---- PROPERTY 1: the tracker and the model pick the same tag ----
if (req_valid && pre_free) begin
ck(req_ready === 1'b1, "a request was refused while a tag was free");
ck(got_tag === pre_tag, "the tracker allocated a different tag than the model");
end else if (req_valid) begin
// ---- PROPERTY 2: no free tag means a stall, not a silent drop ----
ck(req_stall === 1'b1, "the tracker neither accepted nor stalled a request");
ck(req_ready === 1'b0, "the tracker accepted a request with no tag free");
end
// ---- now advance the model, completions first, exactly as the
// design's clocked process does ----
if (cpl_valid && m_busy[pop_tag]) begin
if (pop_bytes >= m_rem[pop_tag]) begin
m_busy[pop_tag] = 1'b0; m_rem[pop_tag] = 16'd0;
retired = retired + 1;
end else begin
m_rem[pop_tag] = m_rem[pop_tag] - pop_bytes;
end
end
if (req_valid && pre_free) begin
m_busy[pre_tag] = 1'b1;
m_rem[pre_tag] = 16'd64;
fabric_push(pre_tag, 16'd64, now_c + lat);
issued = issued + 1;
end
@(posedge clk); #1;
// ---- PROPERTY 3: outstanding count is exact, every cycle ----
//
// Checked AFTER the edge. Before it, the design's combinational
// count still describes the previous cycle while the model has
// already advanced -- comparing across that boundary produced 1122
// failures against a design and a model that agreed perfectly.
ck(n_outstanding === m_out(0),
"the tracker and the model disagree about how many tags are in flight");
now_c = now_c + 1;
guard = guard + 1;
steps = steps + 1;
end
req_valid = 1'b0; cpl_valid = 1'b0;
ck(retired == K_XACT, "the measurement did not retire every transaction");
cycles = now_c;
end
endtask
// ---------------------------------------------------------------
// Single-step helpers for the directed phases.
// ---------------------------------------------------------------
task automatic do_req(input posted, input [15:0] bytes, output [2:0] tg,
output accepted);
begin
req_valid = 1'b1; req_posted = posted; req_bytes = bytes;
#1;
tg = req_tag;
accepted = req_ready;
if (!posted) begin
if (m_free_any(tag_limit)) begin
ck(req_ready === 1'b1, "a request was refused while a tag was free");
ck(req_tag === m_free_tag(tag_limit), "wrong tag allocated");
end else begin
ck(req_stall === 1'b1, "no stall was raised with every tag busy");
end
end else begin
// ---- PROPERTY 4: a posted request never consumes a tag ----
//
// This is why a PCIe write is fast and a PCIe read is not: the
// write is finished when it is sent.
ck(req_ready === 1'b1, "a posted request was refused");
ck(req_stall === 1'b0, "a posted request was stalled by tag exhaustion");
end
@(posedge clk); #1;
req_valid = 1'b0;
if (!posted && accepted) begin
m_busy[tg] = 1'b1; m_rem[tg] = bytes;
c_alloc = c_alloc + 1;
end
if (posted) c_posted = c_posted + 1;
if (!posted && !accepted) c_stall = c_stall + 1;
ck(n_outstanding === m_out(0), "outstanding count wrong after a request");
steps = steps + 1;
end
endtask
task automatic do_cpl(input [2:0] t, input [15:0] bytes);
logic e_known, e_last, e_over;
logic [31:0] r0, p0, b0, o0;
begin
e_known = m_busy[t];
e_last = e_known && (bytes >= m_rem[t]);
e_over = e_known && (bytes > m_rem[t]);
r0 = n_retire; p0 = n_partial; b0 = n_bad_tag; o0 = n_over;
cpl_valid = 1'b1; cpl_tag = t; cpl_bytes = bytes;
#1;
obs_retire = cpl_retire;
// ---- PROPERTY 5: retire means the LAST byte arrived ----
//
// A tag freed on a partial completion could be reused while the rest
// of the data was still in flight, and the next transaction would
// then collect it.
ck(cpl_retire === e_last,
"retire does not mean the request was completely satisfied");
@(posedge clk); #1;
cpl_valid = 1'b0;
if (!e_known) c_bad = c_bad + 1;
else if (e_last) c_retire = c_retire + 1;
else c_partial = c_partial + 1;
if (e_over) c_over = c_over + 1;
if (!e_known) begin
// ---- PROPERTY 6: a completion for a free tag is reported ----
ck(n_bad_tag == b0 + 32'd1, "a completion for an unowned tag was not reported");
ck(n_retire == r0, "an unowned completion retired something");
end else if (e_last) begin
m_busy[t] = 1'b0; m_rem[t] = 16'd0;
ck(n_retire == r0 + 32'd1, "a finishing completion did not retire");
ck(n_over == o0 + (e_over ? 32'd1 : 32'd0), "overrun miscounted");
end else begin
m_rem[t] = m_rem[t] - bytes;
// ---- PROPERTY 7: a partial completion keeps the tag ----
ck(n_partial == p0 + 32'd1, "a partial completion was not counted");
ck(n_retire == r0, "a partial completion retired the tag");
end
ck(n_outstanding === m_out(0), "outstanding count wrong after a completion");
steps = steps + 1;
end
endtask
task automatic reset_all;
int j;
begin
rst_n = 1'b0;
req_valid = 1'b0; cpl_valid = 1'b0; req_posted = 1'b0;
iss_valid = 1'b0; rsp_valid = 1'b0;
@(posedge clk); @(posedge clk);
rst_n = 1'b1;
@(posedge clk); #1;
for (j = 0; j < N_TAG; j = j + 1) begin m_busy[j] = 1'b0; m_rem[j] = 16'd0; end
for (j = 0; j < N_EP; j = j + 1) um_busy[j] = 1'b0;
tag_limit = 4'd8;
end
endtask
// ---- the USB side ----
task automatic do_iss(input [1:0] ep);
logic e_busy;
logic [31:0] i0, f0;
begin
e_busy = um_busy[ep];
i0 = u_n_issued; f0 = u_n_refused;
iss_valid = 1'b1; iss_ep = ep;
#1;
// ---- PROPERTY 8: one outstanding transaction per endpoint ----
//
// The entire mechanism. There is no second slot to have, which is
// why no USB packet carries a transaction identifier.
ck(iss_ready === !e_busy, "the slot accepted a second transaction on one endpoint");
ck(iss_busy === e_busy, "busy was not reported for an occupied endpoint");
ck(!(iss_ready && iss_busy), "ready and busy were both asserted");
@(posedge clk); #1;
iss_valid = 1'b0;
if (!e_busy) begin
um_busy[ep] = 1'b1;
c_iss = c_iss + 1;
ck(u_n_issued == i0 + 32'd1, "an accepted transaction was not counted");
end else begin
c_ref = c_ref + 1;
ck(u_n_refused == f0 + 32'd1, "a refused transaction was not counted");
end
ck(u_n_out === um_out(0), "usb outstanding count wrong after an issue");
steps = steps + 1;
end
endtask
task automatic do_rsp(input [1:0] ep);
logic e_busy;
logic [31:0] a0, s0;
begin
e_busy = um_busy[ep];
a0 = u_n_answered; s0 = u_n_spurious;
rsp_valid = 1'b1; rsp_ep = ep;
#1;
// ---- PROPERTY 9: a response with nothing outstanding is spurious ----
//
// Detectable precisely BECAUSE there is only one thing it could have
// been answering. With N tags in flight the same question needs a
// tag field to answer at all.
ck(rsp_match === e_busy, "a response was not matched to its transaction");
ck(rsp_spurious === !e_busy, "an unsolicited response was not flagged");
@(posedge clk); #1;
rsp_valid = 1'b0;
if (e_busy) begin
um_busy[ep] = 1'b0;
c_ans = c_ans + 1;
ck(u_n_answered == a0 + 32'd1, "an answered transaction was not counted");
end else begin
c_spur = c_spur + 1;
ck(u_n_spurious == s0 + 32'd1, "a spurious response was not counted");
end
ck(u_n_out === um_out(0), "usb outstanding count wrong after a response");
steps = steps + 1;
end
endtask
// ---- results ----
integer thr_cyc [0:8][0:8]; // cycles for (limit, latency)
int lim, lat, k, j, c;
logic [2:0] tg;
logic acc;
logic [31:0] snap;
// exhaustive reach over (tag_limit 1..8) x (latency 1..8) = 64
logic reach [0:63];
int nr, ri;
// and over the directed tracker state space, below
logic reach_t [0:255];
int nrt;
initial begin
for (ri = 0; ri < 64; ri = ri + 1) reach[ri] = 1'b0;
for (ri = 0; ri < 256; ri = ri + 1) reach_t[ri] = 1'b0;
seed = 32'd28004;
reset_all;
// =============================================================
// PHASE 1 (DIRECTED, EXHAUSTIVE) -- THE THROUGHPUT SURFACE.
//
// Every tag limit from 1 to 8 against every fabric latency from 1 to
// 8: 64 points, all reachable, both dimensions independent inputs.
//
// The tag_limit = 1 row is the USB case. A USB host has exactly one
// transaction outstanding per endpoint, so whatever that row says
// about throughput is what USB can do regardless of link speed.
// =============================================================
for (lim = 1; lim <= 8; lim = lim + 1)
for (lat = 1; lat <= 8; lat = lat + 1) begin
measure(lim, lat, c);
thr_cyc[lim][lat] = c;
reach[(lim - 1) * 8 + (lat - 1)] = 1'b1;
end
// ---- PROPERTY 10: more tags never make it slower ----
//
// Monotonicity in the tag count. Stated as a property rather than
// eyeballed off the table, because a tracker that leaked tags would
// produce a table that still looked plausible.
for (lat = 1; lat <= 8; lat = lat + 1)
for (lim = 2; lim <= 8; lim = lim + 1)
ck(thr_cyc[lim][lat] <= thr_cyc[lim-1][lat],
"adding a tag made the transfer slower");
// ---- PROPERTY 11: more latency never makes it faster ----
for (lim = 1; lim <= 8; lim = lim + 1)
for (lat = 2; lat <= 8; lat = lat + 1)
ck(thr_cyc[lim][lat] >= thr_cyc[lim][lat-1],
"adding latency made the transfer faster");
// ---- PROPERTY 12: one tag costs the full latency, EXACTLY ----
//
// THE USB RESULT, as a closed form rather than an observation. With one
// outstanding transaction nothing overlaps, so each takes (latency + 1)
// cycles -- latency to come back, one to issue the next -- and the total
// is exactly K*(latency+1). No amount of link bandwidth changes it.
//
// Asserted with == rather than >=. An inequality would also pass for a
// design that was slower still, and the point of a closed form is that
// it pins the number from both sides.
for (lat = 1; lat <= 8; lat = lat + 1)
ck(thr_cyc[1][lat] == K_XACT * (lat + 1),
"one outstanding transaction did not cost exactly latency+1 cycles each");
// ---- PROPERTY 13: latency+1 tags saturate the link, EXACTLY ----
//
// The other closed form, and the one that answers "how many tags do I
// need?". With L+1 tags the requester issues one per cycle and the only
// cost left is draining the last one, so the total is exactly K + L.
//
// It is latency+1 and not latency because of the one-cycle tag
// turnaround: free_any reads the REGISTERED tag array, so a tag retired
// this cycle is allocatable next cycle. That single cycle is the
// difference between needing L tags and needing L+1.
for (lat = 1; lat <= 8; lat = lat + 1)
for (lim = lat + 1; lim <= 8; lim = lim + 1)
ck(thr_cyc[lim][lat] == K_XACT + lat,
"latency+1 tags did not saturate the link");
// ---- PROPERTY 14: fewer than latency+1 tags CANNOT saturate ----
//
// The negative half, without which the property above would be
// satisfied by a design that was always saturated regardless of tags --
// and the whole chapter would have no result.
for (lat = 2; lat <= 8; lat = lat + 1)
for (lim = 1; lim <= lat; lim = lim + 1)
ck(thr_cyc[lim][lat] > K_XACT + lat,
"fewer tags than latency+1 saturated the link anyway");
// =============================================================
// PHASE 2 (DIRECTED) -- COMPLETIONS OUT OF ORDER.
//
// The case USB cannot produce. Four tags are allocated in order and
// completed in REVERSE, and every one must retire correctly.
// =============================================================
reset_all;
do_req(1'b0, 16'd64, tg, acc); ck(tg === 3'd0, "first tag should be 0");
do_req(1'b0, 16'd64, tg, acc); ck(tg === 3'd1, "second tag should be 1");
do_req(1'b0, 16'd64, tg, acc); ck(tg === 3'd2, "third tag should be 2");
do_req(1'b0, 16'd64, tg, acc); ck(tg === 3'd3, "fourth tag should be 3");
ck(n_outstanding === 32'd4, "four requests should leave four tags in flight");
do_cpl(3'd3, 16'd64);
do_cpl(3'd1, 16'd64);
do_cpl(3'd2, 16'd64);
do_cpl(3'd0, 16'd64);
ck(n_outstanding === 32'd0, "completing every tag should empty the tracker");
ck(n_retire === 32'd4, "four completions should retire four requests");
ck(n_bad_tag === 32'd0, "out-of-order completion reported a bad tag");
// =============================================================
// PHASE 3 (DIRECTED, EXHAUSTIVE over every completion order)
//
// Three tags, all 3! = 6 completion orders. Out-of-order is not one
// case, it is every permutation, and a tracker that happened to work
// for reverse order is not thereby correct for the rest.
// =============================================================
for (k = 0; k < 6; k = k + 1) begin : perm
int a, b2, c2, t0;
reset_all;
do_req(1'b0, 16'd64, tg, acc);
do_req(1'b0, 16'd64, tg, acc);
do_req(1'b0, 16'd64, tg, acc);
// the six orderings of {0,1,2}
case (k)
0: begin a = 0; b2 = 1; c2 = 2; end
1: begin a = 0; b2 = 2; c2 = 1; end
2: begin a = 1; b2 = 0; c2 = 2; end
3: begin a = 1; b2 = 2; c2 = 0; end
4: begin a = 2; b2 = 0; c2 = 1; end
default: begin a = 2; b2 = 1; c2 = 0; end
endcase
do_cpl(a[2:0], 16'd64);
do_cpl(b2[2:0], 16'd64);
do_cpl(c2[2:0], 16'd64);
ck(n_outstanding === 32'd0, "some completion order left a tag outstanding");
ck(n_retire === 32'd3, "some completion order lost a retirement");
ck(n_bad_tag === 32'd0, "some completion order was mistaken for a bad tag");
end
// =============================================================
// PHASE 4 (DIRECTED, EXHAUSTIVE over split shapes) -- SPLIT
// COMPLETIONS.
//
// One request may be answered by several completions. The tag must
// stay outstanding until the LAST byte arrives -- freeing it early is
// the tag-reuse bug, and it would attribute the remaining data to
// whatever transaction took the tag next.
//
// Every split of 256 bytes into equal pieces of 256, 128, 64, 32 and
// 16 is swept, which is every shape a power-of-two payload can take.
// =============================================================
for (k = 0; k < 5; k = k + 1) begin : splits
int piece, pieces, n;
reset_all;
piece = 256 >> k;
pieces = 256 / piece;
do_req(1'b0, 16'd256, tg, acc);
for (n = 0; n < pieces; n = n + 1) begin
do_cpl(tg, piece[15:0]);
if (n < pieces - 1) begin
// ---- PROPERTY 13: a partially completed tag stays busy ----
ck(n_outstanding === 32'd1,
"a tag was freed before its last completion arrived");
ck(obs_retire === 1'b0, "a partial completion claimed to retire");
end
end
ck(n_outstanding === 32'd0, "the tag was not freed by its last completion");
ck(n_partial === (pieces - 1),
"the number of partial completions does not match the split");
end
// =============================================================
// PHASE 5 (DIRECTED) -- TAG EXHAUSTION IS BACK-PRESSURE.
//
// With two tags, a third request must STALL rather than be dropped or
// accepted. That stall is why PCIe read throughput depends on tag
// count, and it is the mechanism phase 1 measures.
// =============================================================
reset_all;
tag_limit = 4'd2;
do_req(1'b0, 16'd64, tg, acc); ck(acc === 1'b1, "first of two tags refused");
do_req(1'b0, 16'd64, tg, acc); ck(acc === 1'b1, "second of two tags refused");
snap = n_stall;
do_req(1'b0, 16'd64, tg, acc);
ck(acc === 1'b0, "a third request was accepted with only two tags");
ck(n_stall == snap + 32'd1, "tag exhaustion did not raise a stall");
ck(n_outstanding === 32'd2, "a stalled request consumed a tag anyway");
// ---- PROPERTY 14: a POSTED request is never stalled ----
//
// Even with every tag busy. A write needs no completion, so tag
// exhaustion cannot block it -- which is exactly why mixing posted and
// non-posted traffic changes a PCIe link's behaviour so much.
snap = n_posted;
do_req(1'b1, 16'd64, tg, acc);
ck(acc === 1'b1, "a posted request was blocked by tag exhaustion");
ck(n_posted == snap + 32'd1, "a posted request was not counted");
ck(n_outstanding === 32'd2, "a posted request consumed a tag");
// and freeing one tag lets the next non-posted request through
do_cpl(3'd0, 16'd64);
do_req(1'b0, 16'd64, tg, acc);
ck(acc === 1'b1, "freeing a tag did not admit the next request");
ck(tg === 3'd0, "the freed tag was not the one reused");
// =============================================================
// PHASE 5b (DIRECTED, EXHAUSTIVE over every tag limit)
//
// The posted-request guarantee is only OBSERVABLE when no tag is free,
// because with a tag free a posted request would be accepted either way.
// So the table is filled to capacity at each of the eight limits and the
// guarantee is checked there.
//
// That is the difference between a property being stated and being
// tested: phase 5 demonstrated it once, which gave the mutation that
// breaks it a domain of one.
// =============================================================
for (k = 1; k <= 8; k = k + 1) begin : postedsweep
int b;
logic [31:0] psnap, osnap2;
reset_all;
tag_limit = k[3:0];
for (b = 0; b < k; b = b + 1) begin
do_req(1'b0, 16'd64, tg, acc);
ck(acc === 1'b1, "a request was refused below the tag limit");
end
ck(n_outstanding === k, "filling to the limit did not use every tag");
// a non-posted request must now stall
do_req(1'b0, 16'd64, tg, acc);
ck(acc === 1'b0, "a request was accepted above the tag limit");
// ---- and a POSTED request must NOT ----
//
// This is why a PCIe write never blocks on read credit: it needs no
// completion, so tag exhaustion cannot apply to it. A design that made
// writes wait for tags would serialise a workload PCIe is specifically
// built to overlap.
psnap = n_posted; osnap2 = n_outstanding;
do_req(1'b1, 16'd64, tg, acc);
ck(acc === 1'b1, "a posted request was blocked by tag exhaustion");
ck(n_posted == psnap + 32'd1, "a posted request was not counted");
ck(n_outstanding === osnap2, "a posted request consumed a tag");
end
// =============================================================
// PHASE 6 (DIRECTED) -- MALFORMED COMPLETIONS.
//
// A completion for a tag nobody owns, and a completion carrying more
// bytes than were asked for. Both are protocol errors and both must
// be REPORTED rather than absorbed -- an absorbed overrun would wrap
// the byte counter and leave the tag outstanding forever, turning a
// protocol error into a hang.
// =============================================================
reset_all;
snap = n_bad_tag;
do_cpl(3'd5, 16'd64); // nobody owns tag 5
ck(n_bad_tag == snap + 32'd1, "a completion for a free tag was accepted");
ck(n_outstanding === 32'd0, "an unowned completion changed the tracker state");
do_req(1'b0, 16'd64, tg, acc);
snap = n_over;
do_cpl(tg, 16'd128); // twice what was asked for
ck(n_over == snap + 32'd1, "an over-long completion was not reported");
ck(n_outstanding === 32'd0, "an over-long completion left the tag outstanding");
// =============================================================
// PHASE 6b (DIRECTED, EXHAUSTIVE over overrun shapes)
//
// A completion carrying more bytes than were requested, for every
// request size and both interesting overrun amounts: one byte too many,
// and twice as many as asked for. Eight cases rather than the single
// one phase 6 had, because a mutation that stops reporting overruns
// should not be able to score 2.
//
// In every case the tag must still RETIRE. Absorbing the overrun by
// wrapping the byte counter would leave the tag outstanding forever and
// turn a protocol error into a hang, which is strictly worse.
// =============================================================
for (k = 0; k < 8; k = k + 1) begin : overruns
int want, extra;
logic [31:0] osnap;
reset_all;
want = 16 << (k % 4);
extra = (k < 4) ? 1 : want; // one byte too many, or double
do_req(1'b0, want[15:0], tg, acc);
osnap = n_over;
do_cpl(tg, (want + extra));
ck(n_over == osnap + 32'd1, "an over-long completion was not reported");
ck(n_outstanding === 32'd0,
"an over-long completion left the tag outstanding");
ck(n_retire > 32'd0, "an over-long completion did not retire the tag");
end
// =============================================================
// PHASE 7 (DIRECTED, EXHAUSTIVE over the tracker's busy-mask)
//
// Every one of the 2**8 = 256 combinations of which tags are busy,
// reached by allocating and completing rather than by forcing state.
// For each, the allocation decision is checked against the model.
// =============================================================
for (k = 0; k < 256; k = k + 1) begin : masks
int b;
reset_all;
// allocate all eight, then complete the ones the mask says are free
for (b = 0; b < 8; b = b + 1) do_req(1'b0, 16'd64, tg, acc);
ck(n_outstanding === 32'd8, "eight requests did not fill eight tags");
for (b = 0; b < 8; b = b + 1)
if (!k[b]) do_cpl(b[2:0], 16'd64);
// ---- a POSTED request, against every table state ----
//
// It must be accepted and must consume no tag, whatever else is in
// flight -- including when every tag is busy. Swept here rather than
// demonstrated once, because a property exercised a single time has a
// mutation domain of one and tells you almost nothing.
snap = n_outstanding;
do_req(1'b1, 16'd64, tg, acc);
ck(acc === 1'b1, "a posted request was refused");
ck(n_outstanding === snap, "a posted request consumed a tag");
// ---- a completion for a tag NOBODY OWNS, against every state ----
//
// Whichever tag is free, a completion for it is a protocol error and
// must be reported rather than absorbed. With a full table this case
// does not exist, so it is skipped rather than faked.
if (m_free_any(4'd8)) begin : badcpl
logic [2:0] ft;
logic [31:0] bsnap, osnap;
ft = m_free_tag(4'd8);
bsnap = n_bad_tag; osnap = n_outstanding;
do_cpl(ft, 16'd64);
ck(n_bad_tag == bsnap + 32'd1,
"a completion for an unowned tag was not reported");
ck(n_outstanding === osnap,
"a completion for an unowned tag changed the tracker state");
end
// Now exactly the tags set in k are busy. The next allocation must
// pick the lowest free one, which the model computes independently.
if (m_free_any(4'd8)) begin
do_req(1'b0, 16'd64, tg, acc);
ck(acc === 1'b1, "a request was refused with a free tag");
end else begin
do_req(1'b0, 16'd64, tg, acc);
ck(acc === 1'b0, "a request was accepted with every tag busy");
end
reach_t[k] = 1'b1;
end
// =============================================================
// PHASE 8 (DIRECTED) -- THE USB SLOT.
//
// One outstanding transaction per endpoint. The endpoints are
// independent, a second issue to a busy endpoint is refused, and a
// response with nothing outstanding is flagged.
// =============================================================
reset_all;
for (k = 0; k < 4; k = k + 1) begin
do_iss(k[1:0]);
ck(u_n_out === (k + 1), "each endpoint should hold one transaction");
end
// every endpoint busy: a second issue to each must be refused
for (k = 0; k < 4; k = k + 1) do_iss(k[1:0]);
ck(u_n_refused === 32'd4, "four second-issues should all be refused");
ck(u_n_out === 32'd4, "a refused issue changed the outstanding count");
// answer them all
for (k = 0; k < 4; k = k + 1) do_rsp(k[1:0]);
ck(u_n_out === 32'd0, "answering every endpoint should empty the slots");
// an unsolicited response
snap = u_n_spurious;
do_rsp(2'd2);
ck(u_n_spurious == snap + 32'd1, "an unsolicited response was not flagged");
// =============================================================
// PHASE 9 (DIRECTED, EXHAUSTIVE over the endpoint busy-mask)
//
// All 2**4 = 16 combinations of which endpoints are busy, crossed
// with an issue and a response to each of the 4 endpoints. 16 x 4 x 2
// decisions, every one checked against the model.
// =============================================================
for (k = 0; k < 16; k = k + 1) begin : epmask
int b;
reset_all;
for (b = 0; b < 4; b = b + 1) if (k[b]) do_iss(b[1:0]);
for (b = 0; b < 4; b = b + 1) do_iss(b[1:0]);
reset_all;
for (b = 0; b < 4; b = b + 1) if (k[b]) do_iss(b[1:0]);
for (b = 0; b < 4; b = b + 1) do_rsp(b[1:0]);
end
// =============================================================
// PHASE 10 (RANDOM) -- mixed traffic with a reordering fabric.
// =============================================================
`ifndef DIRECTED_ONLY
reset_all;
fabric_clear;
for (k = 0; k < 800; k = k + 1) begin : rnd
logic pf; reg [2:0] pt; reg [15:0] pb;
// a due completion, if any
fabric_pop(now_c, pf, pt, pb);
if (pf) do_cpl(pt, pb);
// a request, sometimes posted
if ((urand() % 3) != 0) begin
if ((urand() % 5) == 0) begin
do_req(1'b1, 16'd64, tg, acc);
end else begin
do_req(1'b0, 16'd64, tg, acc);
// A random latency, so completions come back out of order --
// which is the property the whole tracker exists for.
if (acc) fabric_push(tg, 16'd64, now_c + 1 + (urand() % 9));
end
end
// and some USB traffic on the side
if ((urand() % 2) == 0) do_iss((urand() % 4));
else do_rsp((urand() % 4));
now_c = now_c + 1;
end
// drain whatever is still in flight, bounded
for (k = 0; k < 400; k = k + 1) begin : drain
logic pf2; reg [2:0] pt2; reg [15:0] pb2;
fabric_pop(now_c + 100, pf2, pt2, pb2);
if (pf2) do_cpl(pt2, pb2);
now_c = now_c + 1;
end
`endif
nr = 0; for (ri = 0; ri < 64; ri = ri + 1) if (reach[ri]) nr = nr + 1;
nrt = 0; for (ri = 0; ri < 256; ri = ri + 1) if (reach_t[ri]) nrt = nrt + 1;
$display("steps=%0d checks=%0d reach_thr=%0d/64 reach_tag=%0d/256 errors=%0d",
steps, checks, nr, nrt, errors);
$display("[pcie] alloc=%0d posted=%0d stalls=%0d retire=%0d partial=%0d bad_tag=%0d over=%0d",
c_alloc, c_posted, c_stall, c_retire, c_partial, c_bad, c_over);
$display("[usb] issued=%0d refused=%0d answered=%0d spurious=%0d",
c_iss, c_ref, c_ans, c_spur);
$display("--- cycles to move %0d transactions, by tags outstanding x latency ---", K_XACT);
$display(" tags L=1 L=2 L=3 L=4 L=5 L=6 L=7 L=8");
for (lim = 1; lim <= 8; lim = lim + 1)
$display(" %4d %8d%8d%8d%8d%8d%8d%8d%8d", lim,
thr_cyc[lim][1], thr_cyc[lim][2], thr_cyc[lim][3], thr_cyc[lim][4],
thr_cyc[lim][5], thr_cyc[lim][6], thr_cyc[lim][7], thr_cyc[lim][8]);
$display(" (row tags=1 IS the USB case: one transaction outstanding)");
if (nr != 64 || nrt != 256) begin
$display("FAIL: exhaustive sweep incomplete"); errors = errors + 1;
end
if (errors == 0) $display("PASS: 0 errors in %0d checks", checks);
else $display("FAIL: %0d errors in %0d checks", errors, checks);
$finish;
end
endmoduleSame seed and phase order as the Verilog bench, so any difference between those two mutation columns is a real difference between the designs. Their outputs are byte-identical, including the whole throughput surface.
10. VHDL-2008
-- =====================================================================
-- OUTSTANDING TRANSACTIONS -- VHDL-2008.
--
-- CLASSIFICATION: simplified synthesisable teaching RTL.
-- Two entities, same hardware contract as the Verilog and SystemVerilog
-- files: same ports, same widths, same reset values, same cycle-by-cycle
-- behaviour.
--
-- "This response just arrived. Which request was it for?"
--
-- USB answers it by CONSTRUCTION: one transaction per endpoint, so the
-- response is the next thing on the wire and no USB packet carries a
-- transaction identifier at all.
--
-- PCIe answers it with a TAG. Many non-posted requests may be in flight,
-- completions come back OUT OF ORDER and may arrive SPLIT, so every
-- request carries a tag the requester must track until the last byte.
--
-- The difference is CONCURRENCY, not bandwidth:
--
-- USB: one outstanding transaction -> throughput <= 1 / latency
-- PCIe: N outstanding transactions -> throughput <= min(1, N/latency)
--
-- Both combinational processes use `process (all)`. In a file that gets
-- mutated nine times that is not a convenience: a mutation adding a
-- branch that reads a new signal would otherwise need the sensitivity
-- list extended by hand, and forgetting produces a mutant that fails for
-- the wrong reason.
-- =====================================================================
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
package tg_pkg is
-- Ceiling log2, for index widths. Prefixed so it cannot collide with a
-- port name: a VHDL port shadows a same-named package object, and VHDL
-- is case-insensitive, so the collision would be silent.
function tg_clog2 (n : natural) return natural;
end package;
package body tg_pkg is
function tg_clog2 (n : natural) return natural is
variable r : natural := 0;
variable v : natural := 1;
begin
while v < n loop
v := v * 2;
r := r + 1;
end loop;
if r = 0 then
return 1;
else
return r;
end if;
end function;
end package body;
-- ---------------------------------------------------------------------
-- pcie_tag_tracker -- many in flight, matched by tag.
--
-- `tag_limit` is an INPUT rather than a generic so the testbench can
-- sweep how many tags the requester may use without re-elaborating. That
-- is what makes the throughput surface a single exhaustive sweep, and it
-- is also how the USB case is reached: tag_limit = 1.
-- ---------------------------------------------------------------------
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use work.tg_pkg.all;
entity pcie_tag_tracker is
generic (
N_TAG : natural := 8;
MAX_BYTES : natural := 256
);
port (
clk : in std_logic;
rst_n : in std_logic;
-- How many tags the requester may use, 1..N_TAG. Zero is clamped to
-- one: a requester allowed nothing outstanding can never make
-- progress, and deadlocking silently is worse than refusing to.
tag_limit : in std_logic_vector(tg_clog2(N_TAG + 1) - 1 downto 0);
req_valid : in std_logic;
-- POSTED requests (writes) expect no completion and consume no tag.
-- That is why a PCIe write is fast and a PCIe read is not.
req_posted : in std_logic;
req_bytes : in std_logic_vector(15 downto 0);
req_ready : out std_logic;
req_tag : out std_logic_vector(tg_clog2(N_TAG) - 1 downto 0);
req_stall : out std_logic;
cpl_valid : in std_logic;
cpl_tag : in std_logic_vector(tg_clog2(N_TAG) - 1 downto 0);
cpl_bytes : in std_logic_vector(15 downto 0);
cpl_retire : out std_logic;
n_alloc : out std_logic_vector(31 downto 0);
n_posted : out std_logic_vector(31 downto 0);
n_stall : out std_logic_vector(31 downto 0);
n_retire : out std_logic_vector(31 downto 0);
n_partial : out std_logic_vector(31 downto 0);
n_bad_tag : out std_logic_vector(31 downto 0);
n_over : out std_logic_vector(31 downto 0);
n_outstanding : out std_logic_vector(31 downto 0)
);
end entity;
architecture rtl of pcie_tag_tracker is
constant TW : natural := tg_clog2(N_TAG);
constant LW : natural := tg_clog2(N_TAG + 1);
type busy_arr_t is array (0 to N_TAG - 1) of std_logic;
type rem_arr_t is array (0 to N_TAG - 1) of unsigned(15 downto 0);
signal t_busy : busy_arr_t := (others => '0');
signal t_rem : rem_arr_t := (others => (others => '0'));
signal lim : unsigned(LW - 1 downto 0) := (others => '0');
signal free_any : std_logic := '0';
signal free_tag : unsigned(TW - 1 downto 0) := (others => '0');
signal cpl_known : std_logic := '0';
signal rem_now : unsigned(15 downto 0) := (others => '0');
signal cpl_overrun : std_logic := '0';
signal cpl_last : std_logic := '0';
signal alloc_c : unsigned(31 downto 0) := (others => '0');
signal posted_c : unsigned(31 downto 0) := (others => '0');
signal stall_c : unsigned(31 downto 0) := (others => '0');
signal retire_c : unsigned(31 downto 0) := (others => '0');
signal partial_c : unsigned(31 downto 0) := (others => '0');
signal bad_c : unsigned(31 downto 0) := (others => '0');
signal over_c : unsigned(31 downto 0) := (others => '0');
signal out_now : unsigned(31 downto 0) := (others => '0');
begin
lim <= to_unsigned(1, LW) when unsigned(tag_limit) = 0
else unsigned(tag_limit);
-- -------------------------------------------------------------------
-- ALLOCATE -- the lowest free tag strictly below the limit.
--
-- Walking downwards so the lowest index wins. Real requesters often
-- allocate round-robin; lowest-free is chosen here because it is
-- deterministic, which is what makes the tag-reuse property checkable.
-- -------------------------------------------------------------------
alloc : process (all)
variable fa : std_logic;
variable ft : unsigned(TW - 1 downto 0);
begin
fa := '0';
ft := (others => '0');
for i in N_TAG - 1 downto 0 loop
if t_busy(i) = '0' and to_unsigned(i, LW) < lim then
fa := '1';
ft := to_unsigned(i, TW);
end if;
end loop;
free_any <= fa;
free_tag <= ft;
end process;
-- A posted request needs no tag, so tag exhaustion never stalls it. This
-- asymmetry is the whole reason PCIe separates the two classes.
req_ready <= '1' when (req_valid = '1' and (req_posted = '1' or free_any = '1'))
else '0';
req_tag <= std_logic_vector(free_tag);
req_stall <= '1' when (req_valid = '1' and req_posted = '0' and free_any = '0')
else '0';
-- -------------------------------------------------------------------
-- COMPLETE -- match by tag, subtract bytes, free on the last one.
-- -------------------------------------------------------------------
cpl_known <= '1' when (cpl_valid = '1' and t_busy(to_integer(unsigned(cpl_tag))) = '1')
else '0';
rem_now <= t_rem(to_integer(unsigned(cpl_tag)));
-- More bytes than were asked for. On a real link a malformed completion;
-- flagged rather than absorbed, because absorbing it would wrap the byte
-- counter, keep the tag outstanding forever, and turn a protocol error
-- into a hang.
cpl_overrun <= '1' when (cpl_known = '1' and unsigned(cpl_bytes) > rem_now) else '0';
cpl_last <= '1' when (cpl_known = '1' and unsigned(cpl_bytes) >= rem_now) else '0';
cpl_retire <= cpl_last;
n_alloc <= std_logic_vector(alloc_c);
n_posted <= std_logic_vector(posted_c);
n_stall <= std_logic_vector(stall_c);
n_retire <= std_logic_vector(retire_c);
n_partial <= std_logic_vector(partial_c);
n_bad_tag <= std_logic_vector(bad_c);
n_over <= std_logic_vector(over_c);
-- Combinational, so it reports the tracker as it stands rather than as it
-- stood a cycle ago.
occupancy : process (all)
variable c : unsigned(31 downto 0);
begin
c := (others => '0');
for i in 0 to N_TAG - 1 loop
if t_busy(i) = '1' then c := c + 1; end if;
end loop;
out_now <= c;
end process;
n_outstanding <= std_logic_vector(out_now);
process (clk, rst_n)
begin
if rst_n = '0' then
t_busy <= (others => '0');
t_rem <= (others => (others => '0'));
alloc_c <= (others => '0');
posted_c <= (others => '0');
stall_c <= (others => '0');
retire_c <= (others => '0');
partial_c <= (others => '0');
bad_c <= (others => '0');
over_c <= (others => '0');
elsif rising_edge(clk) then
-- ---- completions, then requests ----
--
-- The order of these two blocks is immaterial to correctness, and it
-- is worth saying why. free_any and free_tag are combinational over
-- the REGISTERED t_busy, so they describe the table as it stood at the
-- START of this cycle. A tag retired by a completion in this cycle
-- becomes allocatable in the NEXT one, not this one.
--
-- That is ONE CYCLE OF TURNAROUND per tag, with a visible consequence:
-- saturating a fabric of latency L needs L+1 tags, not L.
--
-- The blocks cannot collide, because an index being retired is busy
-- and is therefore never the index free_any selects.
if cpl_valid = '1' then
if t_busy(to_integer(unsigned(cpl_tag))) = '0' then
-- A completion for a tag nobody owns. Either the fabric invented
-- it or this requester retired the tag early -- and the second is
-- exactly what a tag-reuse bug looks like from here.
bad_c <= bad_c + 1;
else
if unsigned(cpl_bytes) >= rem_now then
t_busy(to_integer(unsigned(cpl_tag))) <= '0';
t_rem(to_integer(unsigned(cpl_tag))) <= (others => '0');
retire_c <= retire_c + 1;
if unsigned(cpl_bytes) > rem_now then
over_c <= over_c + 1;
end if;
else
-- A split completion: the request is not finished, so the tag
-- stays outstanding. Freeing it here would let the tag be reused
-- while the rest of the data was still in flight.
t_rem(to_integer(unsigned(cpl_tag))) <= rem_now - unsigned(cpl_bytes);
partial_c <= partial_c + 1;
end if;
end if;
end if;
if req_valid = '1' then
if req_posted = '1' then
posted_c <= posted_c + 1;
elsif free_any = '1' then
-- free_tag was chosen from the table as it stood at the start of
-- the cycle, so it is not an index any completion is retiring now.
t_busy(to_integer(free_tag)) <= '1';
t_rem(to_integer(free_tag)) <= unsigned(req_bytes);
alloc_c <= alloc_c + 1;
else
stall_c <= stall_c + 1;
end if;
end if;
end if;
end process;
end architecture;
-- ---------------------------------------------------------------------
-- usb_xact_slot -- one in flight, matched by nothing.
--
-- The same question, answered by construction. A USB host issues one
-- transaction to an endpoint and waits; the response is the next thing on
-- the wire. So there is no tag, no reordering, no split-completion
-- reassembly -- and no tag-reuse bug to have.
--
-- What it costs is visible in the port list by OMISSION: there is no way
-- to have a second transaction outstanding, so throughput is 1 / latency
-- and no amount of link bandwidth changes that.
-- ---------------------------------------------------------------------
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use work.tg_pkg.all;
entity usb_xact_slot is
generic (
N_EP : natural := 4
);
port (
clk : in std_logic;
rst_n : in std_logic;
iss_valid : in std_logic;
iss_ep : in std_logic_vector(tg_clog2(N_EP) - 1 downto 0);
iss_ready : out std_logic;
iss_busy : out std_logic;
rsp_valid : in std_logic;
rsp_ep : in std_logic_vector(tg_clog2(N_EP) - 1 downto 0);
rsp_match : out std_logic;
rsp_spurious : out std_logic;
n_issued : out std_logic_vector(31 downto 0);
n_refused : out std_logic_vector(31 downto 0);
n_answered : out std_logic_vector(31 downto 0);
n_spurious : out std_logic_vector(31 downto 0);
n_outstanding : out std_logic_vector(31 downto 0)
);
end entity;
architecture rtl of usb_xact_slot is
type ep_arr_t is array (0 to N_EP - 1) of std_logic;
-- One bit per endpoint. That is the entire mechanism -- compare it with
-- the tag array above, which needs a byte counter per entry because a
-- completion can be partial.
signal ep_busy : ep_arr_t := (others => '0');
signal iss_c : unsigned(31 downto 0) := (others => '0');
signal ref_c : unsigned(31 downto 0) := (others => '0');
signal ans_c : unsigned(31 downto 0) := (others => '0');
signal spur_c : unsigned(31 downto 0) := (others => '0');
signal out_now : unsigned(31 downto 0) := (others => '0');
begin
iss_ready <= '1' when (iss_valid = '1' and ep_busy(to_integer(unsigned(iss_ep))) = '0')
else '0';
iss_busy <= '1' when (iss_valid = '1' and ep_busy(to_integer(unsigned(iss_ep))) = '1')
else '0';
-- A response with nothing outstanding on that endpoint. On a real bus this
-- is a device talking when it was not asked, and it is detectable
-- precisely because there is only one thing it could have been answering.
rsp_match <= '1' when (rsp_valid = '1' and ep_busy(to_integer(unsigned(rsp_ep))) = '1')
else '0';
rsp_spurious <= '1' when (rsp_valid = '1' and ep_busy(to_integer(unsigned(rsp_ep))) = '0')
else '0';
n_issued <= std_logic_vector(iss_c);
n_refused <= std_logic_vector(ref_c);
n_answered <= std_logic_vector(ans_c);
n_spurious <= std_logic_vector(spur_c);
occupancy : process (all)
variable c : unsigned(31 downto 0);
begin
c := (others => '0');
for i in 0 to N_EP - 1 loop
if ep_busy(i) = '1' then c := c + 1; end if;
end loop;
out_now <= c;
end process;
n_outstanding <= std_logic_vector(out_now);
process (clk, rst_n)
begin
if rst_n = '0' then
ep_busy <= (others => '0');
iss_c <= (others => '0');
ref_c <= (others => '0');
ans_c <= (others => '0');
spur_c <= (others => '0');
elsif rising_edge(clk) then
-- Responses first, for the same reason as the tracker: the decision
-- signals are combinational over the REGISTERED busy bits, so an
-- endpoint answered this cycle is reissuable next cycle.
if rsp_valid = '1' then
if ep_busy(to_integer(unsigned(rsp_ep))) = '1' then
ep_busy(to_integer(unsigned(rsp_ep))) <= '0';
ans_c <= ans_c + 1;
else
spur_c <= spur_c + 1;
end if;
end if;
if iss_valid = '1' then
if ep_busy(to_integer(unsigned(iss_ep))) = '0' then
ep_busy(to_integer(unsigned(iss_ep))) <= '1';
iss_c <= iss_c + 1;
else
ref_c <= ref_c + 1;
end if;
end if;
end if;
end process;
end architecture;The VHDL testbench
-- =====================================================================
-- Testbench for pcie_tag_tracker and usb_xact_slot -- VHDL-2008.
--
-- THE HEADLINE IS A THROUGHPUT SURFACE, NOT A PASS/FAIL.
--
-- Both designs answer "which response belongs to which request?". The
-- interesting question is what each answer COSTS, and the cost is
-- concurrency: how many transactions can be in flight at once.
--
-- So the bench contains a FABRIC MODEL with a settable latency, issues
-- requests as fast as the tracker will take them, and measures how many
-- cycles it takes to retire a fixed number. Sweeping (tag_limit, latency)
-- gives a surface, and the tag_limit = 1 row of that surface IS the USB
-- behaviour, because a USB host has exactly one transaction outstanding
-- per endpoint by construction.
--
-- THE SHADOW MODEL IS FORMULATED IN THE OPPOSITE DIRECTION. The tracker
-- allocates by walking its tags DOWNWARDS so the lowest free index wins;
-- the model walks UPWARDS and stops at the first free one.
--
-- THIS IS THE INDEPENDENT BENCH. The directed phases are structurally
-- identical to the Verilog and SystemVerilog benches, so the DIRECTED
-- mutation columns must agree EXACTLY and any disagreement is a real
-- finding. The random phase uses a VHDL-native generator.
-- =====================================================================
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use std.textio.all;
entity tb_tg_vhdl is
generic (
DIRECTED_ONLY : boolean := false
);
end entity;
architecture sim of tb_tg_vhdl is
constant N_TAG : natural := 8;
constant N_EP : natural := 4;
constant MAXF : natural := 64;
signal clk : std_logic := '0';
signal rst_n : std_logic := '0';
signal done : boolean := false;
signal tag_limit : std_logic_vector(3 downto 0) := "1000";
signal req_valid : std_logic := '0';
signal req_posted : std_logic := '0';
signal req_bytes : std_logic_vector(15 downto 0) := (others => '0');
signal req_ready : std_logic;
signal req_tag : std_logic_vector(2 downto 0);
signal req_stall : std_logic;
signal cpl_valid : std_logic := '0';
signal cpl_tag : std_logic_vector(2 downto 0) := "000";
signal cpl_bytes : std_logic_vector(15 downto 0) := (others => '0');
signal cpl_retire : std_logic;
signal n_alloc, n_posted, n_stall, n_retire, n_partial,
n_bad_tag, n_over, n_outstanding : std_logic_vector(31 downto 0);
signal iss_valid : std_logic := '0';
signal iss_ep : std_logic_vector(1 downto 0) := "00";
signal iss_ready : std_logic;
signal iss_busy : std_logic;
signal rsp_valid : std_logic := '0';
signal rsp_ep : std_logic_vector(1 downto 0) := "00";
signal rsp_match : std_logic;
signal rsp_spurious : std_logic;
signal u_n_issued, u_n_refused, u_n_answered, u_n_spurious, u_n_out
: std_logic_vector(31 downto 0);
begin
dut : entity work.pcie_tag_tracker
generic map (N_TAG => N_TAG)
port map (
clk => clk, rst_n => rst_n, tag_limit => tag_limit,
req_valid => req_valid, req_posted => req_posted, req_bytes => req_bytes,
req_ready => req_ready, req_tag => req_tag, req_stall => req_stall,
cpl_valid => cpl_valid, cpl_tag => cpl_tag, cpl_bytes => cpl_bytes,
cpl_retire => cpl_retire,
n_alloc => n_alloc, n_posted => n_posted, n_stall => n_stall,
n_retire => n_retire, n_partial => n_partial, n_bad_tag => n_bad_tag,
n_over => n_over, n_outstanding => n_outstanding
);
udut : entity work.usb_xact_slot
generic map (N_EP => N_EP)
port map (
clk => clk, rst_n => rst_n,
iss_valid => iss_valid, iss_ep => iss_ep,
iss_ready => iss_ready, iss_busy => iss_busy,
rsp_valid => rsp_valid, rsp_ep => rsp_ep,
rsp_match => rsp_match, rsp_spurious => rsp_spurious,
n_issued => u_n_issued, n_refused => u_n_refused,
n_answered => u_n_answered, n_spurious => u_n_spurious,
n_outstanding => u_n_out
);
clkgen : process
begin
while not done loop
clk <= '0'; wait for 5 ns;
clk <= '1'; wait for 5 ns;
end loop;
wait;
end process;
main : process
variable errors : integer := 0;
variable checks : integer := 0;
variable steps : integer := 0;
variable lo : line;
constant K_XACT : integer := 24;
-- ---- what the design said, sampled at a DEFINED instant ----
--
-- cpl_retire is only meaningful while cpl_valid is asserted. A check
-- placed after the procedure returns reads it where the answer is a
-- delta-cycle artefact.
variable obs_retire : std_logic := '0';
-- ---- the shadow tracker, maintained by the bench ----
type mbusy_t is array (0 to N_TAG - 1) of std_logic;
type mrem_t is array (0 to N_TAG - 1) of integer;
variable m_busy : mbusy_t := (others => '0');
variable m_rem : mrem_t := (others => 0);
type umbusy_t is array (0 to N_EP - 1) of std_logic;
variable um_busy : umbusy_t := (others => '0');
-- ---- cumulative across resets ----
--
-- The DUTs' own counters are zeroed by every reset_all, so reading them
-- in the final summary would report only the last phase.
variable c_alloc, c_posted, c_stall, c_retire, c_partial, c_bad, c_over
: integer := 0;
variable c_iss, c_ref, c_ans, c_spur : integer := 0;
-- ---- the fabric: completions in flight, each with a due cycle ----
type fact_t is array (0 to MAXF - 1) of boolean;
type ftag_t is array (0 to MAXF - 1) of integer;
variable f_act : fact_t := (others => false);
variable f_tag : ftag_t := (others => 0);
variable f_bytes : ftag_t := (others => 0);
variable f_due : ftag_t := (others => 0);
variable now_c : integer := 0;
type thr_t is array (0 to 8, 0 to 8) of integer;
variable thr_cyc : thr_t := (others => (others => 0));
type reach_t is array (0 to 63) of boolean;
type reacht_t is array (0 to 255) of boolean;
variable reach : reach_t := (others => false);
variable reach_tag : reacht_t := (others => false);
variable nr, nrt : integer := 0;
variable rnd_state : unsigned(31 downto 0) := x"000C06D4";
impure function urand return integer is
begin
rnd_state := resize(rnd_state * to_unsigned(1103515245, 32), 32)
+ to_unsigned(12345, 32);
-- The HIGH bits. In an LCG with a power-of-two modulus bit i has
-- period 2**(i+1), so `urand mod 4` off the low bits cycles
-- 3,0,1,2,... in lockstep while its histogram stays perfectly uniform.
return to_integer(rnd_state(30 downto 15));
end function;
procedure ck (cond : boolean; what : string) is
begin
checks := checks + 1;
if not cond then
errors := errors + 1;
if errors <= 20 then
write(lo, string'(" ERROR @") & time'image(now) &
string'(" step#") & integer'image(steps) &
string'(": ") & what);
writeline(output, lo);
end if;
end if;
end procedure;
-- Upward-and-stop, the opposite of the design's downward walk.
impure function m_free_any (lim : integer) return boolean is
variable f : boolean := false;
begin
for j in 0 to N_TAG - 1 loop
if m_busy(j) = '0' and j < lim and not f then f := true; end if;
end loop;
return f;
end function;
impure function m_free_tag (lim : integer) return integer is
variable f : boolean := false;
variable t : integer := 0;
begin
for j in 0 to N_TAG - 1 loop
if m_busy(j) = '0' and j < lim and not f then t := j; f := true; end if;
end loop;
return t;
end function;
impure function m_out return integer is
variable c : integer := 0;
begin
for j in 0 to N_TAG - 1 loop
if m_busy(j) = '1' then c := c + 1; end if;
end loop;
return c;
end function;
impure function um_out return integer is
variable c : integer := 0;
begin
for j in 0 to N_EP - 1 loop
if um_busy(j) = '1' then c := c + 1; end if;
end loop;
return c;
end function;
procedure fabric_clear is
begin
f_act := (others => false);
now_c := 0;
end procedure;
procedure fabric_push (t, b, due : integer) is
variable placed : boolean := false;
begin
for j in 0 to MAXF - 1 loop
if not f_act(j) and not placed then
f_act(j) := true; f_tag(j) := t; f_bytes(j) := b; f_due(j) := due;
placed := true;
end if;
end loop;
-- A full fabric would silently drop a completion and the tag would stay
-- outstanding forever, which reads as a design hang. Bound it.
ck(placed, "TEST BUG: the fabric model overflowed");
end procedure;
-- Pick the earliest-due active completion at or before `nw`. Only ONE per
-- cycle, because completions are serialised on a real link.
procedure fabric_pop (nw : integer; found : out boolean;
t : out integer; b : out integer) is
variable best, bestdue : integer;
begin
best := -1; bestdue := 0; found := false; t := 0; b := 0;
for j in 0 to MAXF - 1 loop
if f_act(j) and f_due(j) <= nw then
if best < 0 or f_due(j) < bestdue then
best := j; bestdue := f_due(j);
end if;
end if;
end loop;
if best >= 0 then
found := true; t := f_tag(best); b := f_bytes(best);
f_act(best) := false;
end if;
end procedure;
procedure reset_all is
begin
rst_n <= '0';
req_valid <= '0'; cpl_valid <= '0'; req_posted <= '0';
iss_valid <= '0'; rsp_valid <= '0';
wait until rising_edge(clk);
wait until rising_edge(clk);
rst_n <= '1';
wait until rising_edge(clk);
wait for 1 ns;
m_busy := (others => '0');
m_rem := (others => 0);
um_busy := (others => '0');
tag_limit <= "1000";
end procedure;
-- ---------------------------------------------------------------
-- THE MEASUREMENT.
-- ---------------------------------------------------------------
procedure measure (lim, lat : integer; cycles : out integer) is
variable issued, retired, guard : integer;
variable pop_found : boolean;
variable pop_tag, pop_bytes : integer;
variable got_tag : integer;
variable pre_free : boolean;
variable pre_tag : integer;
begin
rst_n <= '0';
req_valid <= '0'; cpl_valid <= '0'; req_posted <= '0';
wait until rising_edge(clk);
wait until rising_edge(clk);
rst_n <= '1';
wait until rising_edge(clk);
wait for 1 ns;
m_busy := (others => '0');
m_rem := (others => 0);
fabric_clear;
tag_limit <= std_logic_vector(to_unsigned(lim, 4));
issued := 0; retired := 0; guard := 0;
-- Every wait loop is bounded. An unbounded one turns a design hang
-- into a test that never finishes, which is strictly worse.
while retired < K_XACT and guard < 20000 loop
fabric_pop(now_c, pop_found, pop_tag, pop_bytes);
if pop_found then cpl_valid <= '1'; else cpl_valid <= '0'; end if;
cpl_tag <= std_logic_vector(to_unsigned(pop_tag, 3));
cpl_bytes <= std_logic_vector(to_unsigned(pop_bytes, 16));
if issued < K_XACT then req_valid <= '1'; else req_valid <= '0'; end if;
req_posted <= '0';
req_bytes <= std_logic_vector(to_unsigned(64, 16));
wait for 1 ns;
got_tag := to_integer(unsigned(req_tag));
-- The allocation decision is taken from the table as it stood at the
-- START of this cycle, because the design's free_any is combinational
-- over the REGISTERED tag array. A tag retired by the completion
-- being presented right now is not allocatable until next cycle --
-- one cycle of turnaround per tag, which is why saturating a fabric
-- of latency L needs L+1 tags.
pre_free := m_free_any(lim);
pre_tag := m_free_tag(lim);
-- ---- PROPERTY 1: the tracker and the model pick the same tag ----
if issued < K_XACT and pre_free then
ck(req_ready = '1', "a request was refused while a tag was free");
ck(got_tag = pre_tag, "the tracker allocated a different tag than the model");
elsif issued < K_XACT then
-- ---- PROPERTY 2: no free tag means a stall, not a silent drop ----
ck(req_stall = '1', "the tracker neither accepted nor stalled a request");
ck(req_ready = '0', "the tracker accepted a request with no tag free");
end if;
-- ---- advance the model, completions first, as the design does ----
if pop_found and m_busy(pop_tag) = '1' then
if pop_bytes >= m_rem(pop_tag) then
m_busy(pop_tag) := '0'; m_rem(pop_tag) := 0;
retired := retired + 1;
else
m_rem(pop_tag) := m_rem(pop_tag) - pop_bytes;
end if;
end if;
if issued < K_XACT and pre_free then
m_busy(pre_tag) := '1';
m_rem(pre_tag) := 64;
fabric_push(pre_tag, 64, now_c + lat);
issued := issued + 1;
end if;
wait until rising_edge(clk);
wait for 1 ns;
-- ---- PROPERTY 3: outstanding count is exact, every cycle ----
--
-- Checked AFTER the edge. Before it, the design's combinational count
-- still describes the previous cycle while the model has already
-- advanced, and comparing across that boundary compares two
-- different instants.
ck(to_integer(unsigned(n_outstanding)) = m_out,
"the tracker and the model disagree about how many tags are in flight");
now_c := now_c + 1;
guard := guard + 1;
steps := steps + 1;
end loop;
req_valid <= '0'; cpl_valid <= '0';
ck(retired = K_XACT, "the measurement did not retire every transaction");
cycles := now_c;
end procedure;
procedure do_req (posted : boolean; bytes : integer;
tg : out integer; accepted : out boolean) is
begin
req_valid <= '1';
if posted then req_posted <= '1'; else req_posted <= '0'; end if;
req_bytes <= std_logic_vector(to_unsigned(bytes, 16));
wait for 1 ns;
tg := to_integer(unsigned(req_tag));
accepted := (req_ready = '1');
if not posted then
if m_free_any(to_integer(unsigned(tag_limit))) then
ck(req_ready = '1', "a request was refused while a tag was free");
ck(to_integer(unsigned(req_tag)) = m_free_tag(to_integer(unsigned(tag_limit))),
"wrong tag allocated");
else
ck(req_stall = '1', "no stall was raised with every tag busy");
end if;
else
-- ---- PROPERTY 4: a posted request never consumes a tag ----
ck(req_ready = '1', "a posted request was refused");
ck(req_stall = '0', "a posted request was stalled by tag exhaustion");
end if;
wait until rising_edge(clk);
wait for 1 ns;
req_valid <= '0';
if (not posted) and accepted then
m_busy(tg) := '1'; m_rem(tg) := bytes;
c_alloc := c_alloc + 1;
end if;
if posted then c_posted := c_posted + 1; end if;
if (not posted) and (not accepted) then c_stall := c_stall + 1; end if;
ck(to_integer(unsigned(n_outstanding)) = m_out,
"outstanding count wrong after a request");
steps := steps + 1;
end procedure;
procedure do_cpl (t : integer; bytes : integer) is
variable e_known, e_last, e_over : boolean;
variable r0, p0, b0, o0 : integer;
begin
e_known := (m_busy(t) = '1');
e_last := e_known and (bytes >= m_rem(t));
e_over := e_known and (bytes > m_rem(t));
r0 := to_integer(unsigned(n_retire));
p0 := to_integer(unsigned(n_partial));
b0 := to_integer(unsigned(n_bad_tag));
o0 := to_integer(unsigned(n_over));
cpl_valid <= '1';
cpl_tag <= std_logic_vector(to_unsigned(t, 3));
cpl_bytes <= std_logic_vector(to_unsigned(bytes, 16));
wait for 1 ns;
obs_retire := cpl_retire;
-- ---- PROPERTY 5: retire means the LAST byte arrived ----
ck((cpl_retire = '1') = e_last,
"retire does not mean the request was completely satisfied");
wait until rising_edge(clk);
wait for 1 ns;
cpl_valid <= '0';
if not e_known then c_bad := c_bad + 1;
elsif e_last then c_retire := c_retire + 1;
else c_partial := c_partial + 1; end if;
if e_over then c_over := c_over + 1; end if;
if not e_known then
-- ---- PROPERTY 6: a completion for a free tag is reported ----
ck(to_integer(unsigned(n_bad_tag)) = b0 + 1,
"a completion for an unowned tag was not reported");
ck(to_integer(unsigned(n_retire)) = r0, "an unowned completion retired something");
elsif e_last then
m_busy(t) := '0'; m_rem(t) := 0;
ck(to_integer(unsigned(n_retire)) = r0 + 1, "a finishing completion did not retire");
if e_over then
ck(to_integer(unsigned(n_over)) = o0 + 1, "overrun miscounted");
else
ck(to_integer(unsigned(n_over)) = o0, "overrun miscounted");
end if;
else
m_rem(t) := m_rem(t) - bytes;
-- ---- PROPERTY 7: a partial completion keeps the tag ----
ck(to_integer(unsigned(n_partial)) = p0 + 1, "a partial completion was not counted");
ck(to_integer(unsigned(n_retire)) = r0, "a partial completion retired the tag");
end if;
ck(to_integer(unsigned(n_outstanding)) = m_out,
"outstanding count wrong after a completion");
steps := steps + 1;
end procedure;
procedure do_iss (ep : integer) is
variable e_busy : boolean;
variable i0, f0 : integer;
begin
e_busy := (um_busy(ep) = '1');
i0 := to_integer(unsigned(u_n_issued));
f0 := to_integer(unsigned(u_n_refused));
iss_valid <= '1';
iss_ep <= std_logic_vector(to_unsigned(ep, 2));
wait for 1 ns;
-- ---- PROPERTY 8: one outstanding transaction per endpoint ----
ck((iss_ready = '1') = (not e_busy),
"the slot accepted a second transaction on one endpoint");
ck((iss_busy = '1') = e_busy, "busy was not reported for an occupied endpoint");
ck(not (iss_ready = '1' and iss_busy = '1'), "ready and busy were both asserted");
wait until rising_edge(clk);
wait for 1 ns;
iss_valid <= '0';
if not e_busy then
um_busy(ep) := '1';
c_iss := c_iss + 1;
ck(to_integer(unsigned(u_n_issued)) = i0 + 1,
"an accepted transaction was not counted");
else
c_ref := c_ref + 1;
ck(to_integer(unsigned(u_n_refused)) = f0 + 1,
"a refused transaction was not counted");
end if;
ck(to_integer(unsigned(u_n_out)) = um_out,
"usb outstanding count wrong after an issue");
steps := steps + 1;
end procedure;
procedure do_rsp (ep : integer) is
variable e_busy : boolean;
variable a0, s0 : integer;
begin
e_busy := (um_busy(ep) = '1');
a0 := to_integer(unsigned(u_n_answered));
s0 := to_integer(unsigned(u_n_spurious));
rsp_valid <= '1';
rsp_ep <= std_logic_vector(to_unsigned(ep, 2));
wait for 1 ns;
-- ---- PROPERTY 9: a response with nothing outstanding is spurious ----
ck((rsp_match = '1') = e_busy, "a response was not matched to its transaction");
ck((rsp_spurious = '1') = (not e_busy), "an unsolicited response was not flagged");
wait until rising_edge(clk);
wait for 1 ns;
rsp_valid <= '0';
if e_busy then
um_busy(ep) := '0';
c_ans := c_ans + 1;
ck(to_integer(unsigned(u_n_answered)) = a0 + 1,
"an answered transaction was not counted");
else
c_spur := c_spur + 1;
ck(to_integer(unsigned(u_n_spurious)) = s0 + 1,
"a spurious response was not counted");
end if;
ck(to_integer(unsigned(u_n_out)) = um_out,
"usb outstanding count wrong after a response");
steps := steps + 1;
end procedure;
variable c, tg : integer;
variable acc : boolean;
variable snap : integer;
-- Hoisted to the process declarative region. VHDL has no inline
-- `declare` block inside a process body, so per-iteration locals live
-- here with distinct names -- distinct on purpose: two phases sharing one
-- index variable is exactly the bug that made an earlier chapter in this
-- track report 1 babble cutoff where there were 4.
variable pa, pb2v, pc2 : integer; -- phase 3, permutation
variable piece, pieces : integer; -- phase 4, split shapes
variable psnap, osnap2 : integer; -- phase 5b, posted sweep
variable want, extra, osnap : integer; -- phase 6b, overrun shapes
variable ft, bsnap, osnap3 : integer; -- phase 7, unowned completion
variable rpf, rpf2 : boolean; -- phase 10, fabric
variable rpt, rpb, rpt2, rpb2 : integer;
variable kmask : std_logic_vector(7 downto 0);
variable emask : std_logic_vector(3 downto 0);
begin
reset_all;
-- ===============================================================
-- PHASE 1 (DIRECTED, EXHAUSTIVE) -- THE THROUGHPUT SURFACE.
--
-- Every tag limit 1..8 against every fabric latency 1..8: 64 points,
-- all reachable, both dimensions independent inputs.
--
-- The tag_limit = 1 row is the USB case.
-- ===============================================================
for lim in 1 to 8 loop
for lat in 1 to 8 loop
measure(lim, lat, c);
thr_cyc(lim, lat) := c;
reach((lim - 1) * 8 + (lat - 1)) := true;
end loop;
end loop;
-- ---- PROPERTY 10: more tags never make it slower ----
for lat in 1 to 8 loop
for lim in 2 to 8 loop
ck(thr_cyc(lim, lat) <= thr_cyc(lim - 1, lat),
"adding a tag made the transfer slower");
end loop;
end loop;
-- ---- PROPERTY 11: more latency never makes it faster ----
for lim in 1 to 8 loop
for lat in 2 to 8 loop
ck(thr_cyc(lim, lat) >= thr_cyc(lim, lat - 1),
"adding latency made the transfer faster");
end loop;
end loop;
-- ---- PROPERTY 12: one tag costs the full latency, EXACTLY ----
--
-- THE USB RESULT as a closed form. With one outstanding transaction
-- nothing overlaps, so each costs (latency + 1) cycles and the total is
-- exactly K*(latency+1). Asserted with = rather than >=, because an
-- inequality would also pass for a design that was slower still.
for lat in 1 to 8 loop
ck(thr_cyc(1, lat) = K_XACT * (lat + 1),
"one outstanding transaction did not cost exactly latency+1 cycles each");
end loop;
-- ---- PROPERTY 13: latency+1 tags saturate the link, EXACTLY ----
--
-- It is latency+1 and not latency because of the one-cycle tag
-- turnaround: free_any reads the REGISTERED tag array, so a tag retired
-- this cycle is allocatable next cycle.
for lat in 1 to 8 loop
for lim in lat + 1 to 8 loop
ck(thr_cyc(lim, lat) = K_XACT + lat, "latency+1 tags did not saturate the link");
end loop;
end loop;
-- ---- PROPERTY 14: fewer than latency+1 tags CANNOT saturate ----
--
-- The negative half. Without it, property 13 would be satisfied by a
-- design that was always saturated regardless of tags, and the chapter
-- would have no result.
for lat in 2 to 8 loop
for lim in 1 to lat loop
ck(thr_cyc(lim, lat) > K_XACT + lat,
"fewer tags than latency+1 saturated the link anyway");
end loop;
end loop;
-- ===============================================================
-- PHASE 2 (DIRECTED) -- COMPLETIONS OUT OF ORDER.
-- ===============================================================
reset_all;
do_req(false, 64, tg, acc); ck(tg = 0, "first tag should be 0");
do_req(false, 64, tg, acc); ck(tg = 1, "second tag should be 1");
do_req(false, 64, tg, acc); ck(tg = 2, "third tag should be 2");
do_req(false, 64, tg, acc); ck(tg = 3, "fourth tag should be 3");
ck(to_integer(unsigned(n_outstanding)) = 4,
"four requests should leave four tags in flight");
do_cpl(3, 64);
do_cpl(1, 64);
do_cpl(2, 64);
do_cpl(0, 64);
ck(to_integer(unsigned(n_outstanding)) = 0,
"completing every tag should empty the tracker");
ck(to_integer(unsigned(n_retire)) = 4, "four completions should retire four requests");
ck(to_integer(unsigned(n_bad_tag)) = 0, "out-of-order completion reported a bad tag");
-- ===============================================================
-- PHASE 3 (DIRECTED, EXHAUSTIVE over every completion order)
--
-- Three tags, all 3! = 6 orders. Out-of-order is not one case, it is
-- every permutation.
-- ===============================================================
for k in 0 to 5 loop
reset_all;
do_req(false, 64, tg, acc);
do_req(false, 64, tg, acc);
do_req(false, 64, tg, acc);
case k is
when 0 => pa := 0; pb2v := 1; pc2 := 2;
when 1 => pa := 0; pb2v := 2; pc2 := 1;
when 2 => pa := 1; pb2v := 0; pc2 := 2;
when 3 => pa := 1; pb2v := 2; pc2 := 0;
when 4 => pa := 2; pb2v := 0; pc2 := 1;
when others => pa := 2; pb2v := 1; pc2 := 0;
end case;
do_cpl(pa, 64);
do_cpl(pb2v, 64);
do_cpl(pc2, 64);
ck(to_integer(unsigned(n_outstanding)) = 0,
"some completion order left a tag outstanding");
ck(to_integer(unsigned(n_retire)) = 3, "some completion order lost a retirement");
ck(to_integer(unsigned(n_bad_tag)) = 0,
"some completion order was mistaken for a bad tag");
end loop;
-- ===============================================================
-- PHASE 4 (DIRECTED, EXHAUSTIVE over split shapes)
--
-- The tag must stay outstanding until the LAST byte arrives. Freeing it
-- early is the tag-reuse bug, and it would attribute the remainder to
-- whatever transaction took the tag next.
-- ===============================================================
for k in 0 to 4 loop
reset_all;
piece := 256 / (2 ** k);
pieces := 256 / piece;
do_req(false, 256, tg, acc);
for n in 0 to pieces - 1 loop
do_cpl(tg, piece);
if n < pieces - 1 then
-- ---- PROPERTY 15: a partially completed tag stays busy ----
ck(to_integer(unsigned(n_outstanding)) = 1,
"a tag was freed before its last completion arrived");
ck(obs_retire = '0', "a partial completion claimed to retire");
end if;
end loop;
ck(to_integer(unsigned(n_outstanding)) = 0,
"the tag was not freed by its last completion");
ck(to_integer(unsigned(n_partial)) = pieces - 1,
"the number of partial completions does not match the split");
end loop;
-- ===============================================================
-- PHASE 5 (DIRECTED) -- TAG EXHAUSTION IS BACK-PRESSURE.
-- ===============================================================
reset_all;
tag_limit <= "0010";
wait for 1 ns;
do_req(false, 64, tg, acc); ck(acc, "first of two tags refused");
do_req(false, 64, tg, acc); ck(acc, "second of two tags refused");
snap := to_integer(unsigned(n_stall));
do_req(false, 64, tg, acc);
ck(not acc, "a third request was accepted with only two tags");
ck(to_integer(unsigned(n_stall)) = snap + 1, "tag exhaustion did not raise a stall");
ck(to_integer(unsigned(n_outstanding)) = 2, "a stalled request consumed a tag anyway");
snap := to_integer(unsigned(n_posted));
do_req(true, 64, tg, acc);
ck(acc, "a posted request was blocked by tag exhaustion");
ck(to_integer(unsigned(n_posted)) = snap + 1, "a posted request was not counted");
ck(to_integer(unsigned(n_outstanding)) = 2, "a posted request consumed a tag");
do_cpl(0, 64);
do_req(false, 64, tg, acc);
ck(acc, "freeing a tag did not admit the next request");
ck(tg = 0, "the freed tag was not the one reused");
-- ===============================================================
-- PHASE 5b (DIRECTED, EXHAUSTIVE over every tag limit)
--
-- The posted-request guarantee is only OBSERVABLE when no tag is free,
-- so the table is filled to capacity at each of the eight limits.
-- ===============================================================
for k in 1 to 8 loop
reset_all;
tag_limit <= std_logic_vector(to_unsigned(k, 4));
wait for 1 ns;
for b in 0 to k - 1 loop
do_req(false, 64, tg, acc);
ck(acc, "a request was refused below the tag limit");
end loop;
ck(to_integer(unsigned(n_outstanding)) = k,
"filling to the limit did not use every tag");
do_req(false, 64, tg, acc);
ck(not acc, "a request was accepted above the tag limit");
psnap := to_integer(unsigned(n_posted));
osnap2 := to_integer(unsigned(n_outstanding));
do_req(true, 64, tg, acc);
ck(acc, "a posted request was blocked by tag exhaustion");
ck(to_integer(unsigned(n_posted)) = psnap + 1, "a posted request was not counted");
ck(to_integer(unsigned(n_outstanding)) = osnap2, "a posted request consumed a tag");
end loop;
-- ===============================================================
-- PHASE 6 (DIRECTED) -- MALFORMED COMPLETIONS.
-- ===============================================================
reset_all;
snap := to_integer(unsigned(n_bad_tag));
do_cpl(5, 64);
ck(to_integer(unsigned(n_bad_tag)) = snap + 1,
"a completion for a free tag was accepted");
ck(to_integer(unsigned(n_outstanding)) = 0,
"an unowned completion changed the tracker state");
do_req(false, 64, tg, acc);
snap := to_integer(unsigned(n_over));
do_cpl(tg, 128);
ck(to_integer(unsigned(n_over)) = snap + 1, "an over-long completion was not reported");
ck(to_integer(unsigned(n_outstanding)) = 0,
"an over-long completion left the tag outstanding");
-- ===============================================================
-- PHASE 6b (DIRECTED, EXHAUSTIVE over overrun shapes)
-- ===============================================================
for k in 0 to 7 loop
reset_all;
want := 16 * (2 ** (k mod 4));
if k < 4 then extra := 1; else extra := want; end if;
do_req(false, want, tg, acc);
osnap := to_integer(unsigned(n_over));
do_cpl(tg, want + extra);
ck(to_integer(unsigned(n_over)) = osnap + 1,
"an over-long completion was not reported");
ck(to_integer(unsigned(n_outstanding)) = 0,
"an over-long completion left the tag outstanding");
ck(to_integer(unsigned(n_retire)) > 0,
"an over-long completion did not retire the tag");
end loop;
-- ===============================================================
-- PHASE 7 (DIRECTED, EXHAUSTIVE over the tracker's busy-mask)
--
-- All 2**8 = 256 combinations of which tags are busy, reached by
-- allocating and completing rather than by forcing state.
-- ===============================================================
for k in 0 to 255 loop
kmask := std_logic_vector(to_unsigned(k, 8));
reset_all;
for b in 0 to 7 loop do_req(false, 64, tg, acc); end loop;
ck(to_integer(unsigned(n_outstanding)) = 8,
"eight requests did not fill eight tags");
for b in 0 to 7 loop
if kmask(b) = '0' then do_cpl(b, 64); end if;
end loop;
-- ---- a POSTED request, against every table state ----
snap := to_integer(unsigned(n_outstanding));
do_req(true, 64, tg, acc);
ck(acc, "a posted request was refused");
ck(to_integer(unsigned(n_outstanding)) = snap, "a posted request consumed a tag");
-- ---- a completion for a tag NOBODY OWNS, against every state ----
if m_free_any(8) then
ft := m_free_tag(8);
bsnap := to_integer(unsigned(n_bad_tag));
osnap3 := to_integer(unsigned(n_outstanding));
do_cpl(ft, 64);
ck(to_integer(unsigned(n_bad_tag)) = bsnap + 1,
"a completion for an unowned tag was not reported");
ck(to_integer(unsigned(n_outstanding)) = osnap3,
"a completion for an unowned tag changed the tracker state");
end if;
if m_free_any(8) then
do_req(false, 64, tg, acc);
ck(acc, "a request was refused with a free tag");
else
do_req(false, 64, tg, acc);
ck(not acc, "a request was accepted with every tag busy");
end if;
reach_tag(k) := true;
end loop;
-- ===============================================================
-- PHASE 8 (DIRECTED) -- THE USB SLOT.
-- ===============================================================
reset_all;
for k in 0 to 3 loop
do_iss(k);
ck(to_integer(unsigned(u_n_out)) = k + 1,
"each endpoint should hold one transaction");
end loop;
for k in 0 to 3 loop do_iss(k); end loop;
ck(to_integer(unsigned(u_n_refused)) = 4,
"four second-issues should all be refused");
ck(to_integer(unsigned(u_n_out)) = 4,
"a refused issue changed the outstanding count");
for k in 0 to 3 loop do_rsp(k); end loop;
ck(to_integer(unsigned(u_n_out)) = 0,
"answering every endpoint should empty the slots");
snap := to_integer(unsigned(u_n_spurious));
do_rsp(2);
ck(to_integer(unsigned(u_n_spurious)) = snap + 1,
"an unsolicited response was not flagged");
-- ===============================================================
-- PHASE 9 (DIRECTED, EXHAUSTIVE over the endpoint busy-mask)
-- ===============================================================
for k in 0 to 15 loop
emask := std_logic_vector(to_unsigned(k, 4));
reset_all;
for b in 0 to 3 loop
if emask(b) = '1' then do_iss(b); end if;
end loop;
for b in 0 to 3 loop do_iss(b); end loop;
reset_all;
for b in 0 to 3 loop
if emask(b) = '1' then do_iss(b); end if;
end loop;
for b in 0 to 3 loop do_rsp(b); end loop;
end loop;
-- ===============================================================
-- PHASE 10 (RANDOM) -- mixed traffic with a reordering fabric.
-- ===============================================================
if not DIRECTED_ONLY then
reset_all;
fabric_clear;
for k in 0 to 799 loop
fabric_pop(now_c, rpf, rpt, rpb);
if rpf then do_cpl(rpt, rpb); end if;
if (urand mod 3) /= 0 then
if (urand mod 5) = 0 then
do_req(true, 64, tg, acc);
else
do_req(false, 64, tg, acc);
-- A random latency, so completions come back out of order -- the
-- property the whole tracker exists for.
if acc then fabric_push(tg, 64, now_c + 1 + (urand mod 9)); end if;
end if;
end if;
if (urand mod 2) = 0 then do_iss(urand mod 4);
else do_rsp(urand mod 4); end if;
now_c := now_c + 1;
end loop;
for k in 0 to 399 loop
fabric_pop(now_c + 100, rpf2, rpt2, rpb2);
if rpf2 then do_cpl(rpt2, rpb2); end if;
now_c := now_c + 1;
end loop;
end if;
nr := 0;
for i in 0 to 63 loop
if reach(i) then nr := nr + 1; end if;
end loop;
nrt := 0;
for i in 0 to 255 loop
if reach_tag(i) then nrt := nrt + 1; end if;
end loop;
write(lo, string'("steps=") & integer'image(steps) &
string'(" checks=") & integer'image(checks) &
string'(" reach_thr=") & integer'image(nr) &
string'("/64 reach_tag=") & integer'image(nrt) &
string'("/256 errors=") & integer'image(errors));
writeline(output, lo);
write(lo, string'("[pcie] alloc=") & integer'image(c_alloc) &
string'(" posted=") & integer'image(c_posted) &
string'(" stalls=") & integer'image(c_stall) &
string'(" retire=") & integer'image(c_retire) &
string'(" partial=") & integer'image(c_partial) &
string'(" bad_tag=") & integer'image(c_bad) &
string'(" over=") & integer'image(c_over));
writeline(output, lo);
write(lo, string'("[usb] issued=") & integer'image(c_iss) &
string'(" refused=") & integer'image(c_ref) &
string'(" answered=") & integer'image(c_ans) &
string'(" spurious=") & integer'image(c_spur));
writeline(output, lo);
write(lo, string'("--- cycles to move ") & integer'image(K_XACT) &
string'(" transactions, by tags outstanding x latency ---"));
writeline(output, lo);
write(lo, string'(" tags L=1 L=2 L=3 L=4 L=5 L=6 L=7 L=8"));
writeline(output, lo);
for lim in 1 to 8 loop
write(lo, string'(" "));
write(lo, lim, right, 4);
for lat in 1 to 8 loop
write(lo, thr_cyc(lim, lat), right, 8);
end loop;
writeline(output, lo);
end loop;
write(lo, string'(" (row tags=1 IS the USB case: one transaction outstanding)"));
writeline(output, lo);
if nr /= 64 or nrt /= 256 then
write(lo, string'("FAIL: exhaustive sweep incomplete")); writeline(output, lo);
errors := errors + 1;
end if;
if errors = 0 then
write(lo, string'("PASS: 0 errors in ") & integer'image(checks) & string'(" checks"));
else
write(lo, string'("FAIL: ") & integer'image(errors) &
string'(" errors in ") & integer'image(checks) & string'(" checks"));
end if;
writeline(output, lo);
done <= true;
wait;
end process;
end architecture;11. Assertions
// ---------------------------------------------------------------------
// Properties for the tag tracker and the transaction slot.
//
// NOT SIMULATED IN THIS CHAPTER. Icarus Verilog does not support
// concurrent assertions, so every number published here comes from the
// procedural checks in the testbenches. These are the same obligations in
// the form a commercial simulator or a formal tool would take.
//
// As in 28.2 and 28.3, read the SHAPE of the two groups. The tracker needs
// properties about IDENTITY -- which tag, held how long, matched to what --
// because it has many transactions to confuse. The slot needs none of
// those, and the reason is not that it is simpler: it is that it has
// nothing to confuse.
// ---------------------------------------------------------------------
module tg_sva #(parameter int N_TAG = 8, parameter int N_EP = 4) (
input logic clk,
input logic rst_n,
// tracker
input logic [3:0] tag_limit,
input logic req_valid,
input logic req_posted,
input logic req_ready,
input logic [2:0] req_tag,
input logic req_stall,
input logic cpl_valid,
input logic [2:0] cpl_tag,
input logic cpl_retire,
input logic [31:0] n_outstanding,
input logic [31:0] n_alloc,
input logic [31:0] n_retire,
// slot
input logic iss_valid,
input logic [1:0] iss_ep,
input logic iss_ready,
input logic iss_busy,
input logic rsp_valid,
input logic rsp_match,
input logic rsp_spurious,
input logic [31:0] u_n_outstanding
);
default clocking cb @(posedge clk); endclocking
default disable iff (!rst_n);
// ---- 1. ready and stall are exact complements, for a read ----
//
// A request must be either accepted or refused. Neither means the
// requester has no idea whether the transaction exists.
a_req_decided : assert property
((req_valid && !req_posted) |-> (req_ready ^ req_stall));
// ---- 2. a POSTED request is never stalled ----
//
// THE property that makes a PCIe write fast. It needs no completion, so
// tag exhaustion cannot apply to it.
a_posted_never_stalls : assert property
((req_valid && req_posted) |-> (req_ready && !req_stall));
// ---- 3. the allocated tag is within the limit ----
//
// A tag above the limit would be one the fabric was never told to expect,
// and the completion for it would come back addressed to nobody.
a_tag_in_range : assert property
((req_ready && !req_posted) |-> (req_tag < tag_limit));
// ---- 4. the outstanding count never exceeds the limit ----
a_within_limit : assert property (n_outstanding <= tag_limit);
// ---- 5. a retire always follows a completion ----
a_retire_needs_cpl : assert property (cpl_retire |-> cpl_valid);
// ---- 6. allocation and retirement balance ----
//
// The invariant that makes a tag leak visible. A tracker that allocated
// more than it retired and more than it holds has lost a tag, and a lost
// tag is a permanent loss of one unit of concurrency.
a_balance : assert property (n_alloc == n_retire + n_outstanding);
// ---- 7. THE TAG-REUSE PROPERTY ----
//
// A tag must not be allocated again until it has been retired. This is
// the obligation N1 breaks, it is the one that cannot be checked at all
// without tracking bytes, and it is the reason the tracker holds a byte
// count per tag rather than a single busy bit.
//
// Written per-tag with a generate, because the obligation is about one
// tag's history rather than about any cycle.
generate
for (genvar t = 0; t < N_TAG; t++) begin : g_reuse
property p_no_reuse;
(req_ready && !req_posted && (req_tag == t))
|=> (!(req_ready && !req_posted && (req_tag == t)))
until_with (cpl_retire && (cpl_tag == t));
endproperty
a_no_reuse : assert property (p_no_reuse);
end
endgenerate
// ---- 8. the slot's two outputs are exact complements ----
a_iss_decided : assert property (iss_valid |-> (iss_ready ^ iss_busy));
// ---- 9. the slot holds at most one per endpoint ----
//
// Stated as a bound on the total, which for N_EP endpoints each holding at
// most one is the strongest form available without naming endpoints.
a_slot_bound : assert property (u_n_outstanding <= N_EP);
// ---- 10. a response is matched or spurious, never both nor neither ----
//
// USB's whole matching guarantee in one line. It is this simple ONLY
// because there is one outstanding transaction; property 7 is what the
// same guarantee costs when there are eight.
a_rsp_decided : assert property (rsp_valid |-> (rsp_match ^ rsp_spurious));
// ---- COVER: the interesting states are reached ----
// Assertions over stimulus that never fills the tracker, never splits a
// completion and never reorders one prove nothing.
c_full : cover property (n_outstanding == tag_limit);
c_stall : cover property (req_stall);
c_partial : cover property (cpl_valid && !cpl_retire);
c_reorder : cover property ((cpl_tag != 0) ##[1:8] (cpl_tag == 0));
c_posted : cover property (req_valid && req_posted && (n_outstanding == tag_limit));
c_spurious : cover property (rsp_spurious);
endmodule12. Where UVM Fits
// ---------------------------------------------------------------------
// UVM structure for the tag tracker and the transaction slot.
//
// NOT SIMULATED IN THIS CHAPTER. Icarus cannot compile UVM -- it breaks on
// virtual method dispatch -- so every number comes from the procedural
// benches. This is the structure a production environment would use.
//
// THE DESIGN DECISION: the fabric is an AGENT, not a scoreboard helper.
// It owns the reordering, the latency distribution and the splitting, and
// it is the only component that knows when a completion is due. That
// separation is what lets the scoreboard be a pure matcher -- and a pure
// matcher is the only kind that can detect a tag-reuse bug, because it has
// no opinion about what SHOULD have been in flight.
// ---------------------------------------------------------------------
// ---- a request, and the fabric's freedom to answer it however ----
class pcie_req extends uvm_sequence_item;
`uvm_object_utils(pcie_req)
rand bit posted;
rand int bytes;
// How the fabric will answer: after how long, and in how many pieces.
// Randomised HERE rather than in the driver, so the reordering is
// reproducible from the seed and a failing case can be replayed.
rand int latency;
rand int n_pieces;
constraint c_sane {
bytes inside {64, 128, 256};
latency inside {[1:12]};
n_pieces inside {[1:4]};
// A posted request is never answered, so its answer shape is
// meaningless -- pinned rather than left random, because a randomised
// don't-care is a coverage hole that looks like coverage.
posted -> n_pieces == 1;
}
// Reads dominate. A workload of pure writes never allocates a tag and
// would measure nothing the chapter is about.
constraint c_mix { posted dist { 0 := 7, 1 := 3 }; }
function new(string name = "pcie_req");
super.new(name);
endfunction
endclass
// ---- the fabric agent: latency, reordering and splitting live here ----
//
// It holds completions in a queue sorted by due time, which is what makes
// them come back out of order without the sequence having to describe the
// reordering explicitly. A sequence that had to specify the order would be
// testing the order it thought of.
class fabric_agent extends uvm_component;
`uvm_component_utils(fabric_agent)
typedef struct {
int tag;
int bytes_left;
int piece;
int due;
} inflight_t;
inflight_t q[$];
int unsigned now;
virtual pcie_if vif;
task run_phase(uvm_phase phase);
forever begin
@(posedge vif.clk);
now++;
deliver_one();
end
endtask
// ONE completion per cycle. A real link serialises them, and a fabric that
// delivered several at once would hide every ordering bug in the tracker.
task deliver_one();
int best = -1;
foreach (q[i])
if (q[i].due <= now && (best < 0 || q[i].due < q[best].due)) best = i;
if (best < 0) begin
vif.cpl_valid <= 1'b0;
return;
end
vif.cpl_valid <= 1'b1;
vif.cpl_tag <= q[best].tag;
vif.cpl_bytes <= q[best].piece;
q[best].bytes_left -= q[best].piece;
if (q[best].bytes_left <= 0) q.delete(best);
else q[best].due = now + 1;
endtask
endclass
// ---- the scoreboard is a pure MATCHER ----
//
// It holds what it believes is outstanding, and it holds it from observing
// the interface -- not from the sequence's intent. That is deliberate: a
// scoreboard fed the intended tag would agree with a tag-reuse bug, because
// the intent and the reuse are the same tag.
class tag_scoreboard extends uvm_scoreboard;
`uvm_component_utils(tag_scoreboard)
int unsigned bytes_left [int]; // tag -> bytes still expected
int unsigned n_reuse, n_orphan, n_matched;
function void saw_alloc(int tag, int bytes);
// ---- THE CHECK ----
// A tag that is already outstanding has been reused. Nothing else in
// the environment can see this, because the DUT's own busy bit is what
// the bug corrupts.
if (bytes_left.exists(tag) && bytes_left[tag] > 0) begin
n_reuse++;
`uvm_error("TAG", $sformatf("tag %0d reused with %0d bytes outstanding",
tag, bytes_left[tag]))
end
bytes_left[tag] = bytes;
endfunction
function void saw_completion(int tag, int bytes);
if (!bytes_left.exists(tag) || bytes_left[tag] == 0) begin
// A completion nobody was waiting for. On a real link a fabric error;
// in a simulation it is usually this environment's own book-keeping,
// which is why it is counted rather than asserted immediately.
n_orphan++;
return;
end
if (bytes > bytes_left[tag]) begin
`uvm_error("TAG", $sformatf("tag %0d over-completed by %0d bytes",
tag, bytes - bytes_left[tag]))
bytes_left[tag] = 0;
end else begin
bytes_left[tag] -= bytes;
end
if (bytes_left[tag] == 0) n_matched++;
endfunction
function void report_phase(uvm_phase phase);
`uvm_info("TAG", $sformatf("matched %0d, reused %0d, orphaned %0d",
n_matched, n_reuse, n_orphan), UVM_LOW)
// Every tag must be settled at the end. A tag left outstanding is a leak
// and costs one unit of concurrency permanently.
foreach (bytes_left[t])
if (bytes_left[t] != 0)
`uvm_error("TAG", $sformatf("tag %0d left outstanding with %0d bytes",
t, bytes_left[t]))
endfunction
endclass
// ---- coverage: the CONCURRENCY, which is the whole subject ----
class tag_coverage extends uvm_subscriber #(pcie_req);
`uvm_component_utils(tag_coverage)
int unsigned observed_outstanding;
covergroup cg with function sample(pcie_req r, int outstanding);
cp_posted : coverpoint r.posted;
cp_bytes : coverpoint r.bytes { bins b[] = {64, 128, 256}; }
cp_pieces : coverpoint r.n_pieces { bins p[] = {[1:4]}; }
// How many were in flight when this request was issued. THE coverage
// point of the chapter: a regression that never exceeded two outstanding
// has not tested the mechanism at all, whatever its line coverage says.
cp_conc : coverpoint outstanding { bins c[] = {[0:8]}; }
// Splitting crossed with concurrency, because a split completion while
// several tags are in flight is where a reuse bug actually bites.
x_split : cross cp_pieces, cp_conc;
// And a posted request while the tracker is FULL, which is the only
// condition under which the posted guarantee is observable.
x_posted_full : cross cp_posted, cp_conc {
ignore_bins uninteresting = binsof(cp_conc) with (cp_conc < 8);
}
endgroup
function new(string name, uvm_component parent);
super.new(name, parent);
cg = new();
endfunction
function void write(pcie_req t);
cg.sample(t, observed_outstanding);
endfunction
endclass13. Mutation Testing
Nine defects, six in the tag tracker and three in the transaction slot, injected one at a time into all three languages. Every replacement asserted; each mutation generated as its own file.
| # | the injected defect | V-all | V-dir | SV-all | SV-dir | VHDL-all | VHDL-dir |
|---|---|---|---|---|---|---|---|
| BASE | unmodified designs | 0 | 0 | 0 | 0 | 0 | 0 |
| N1 | a tag is freed by any completion, not the last | 94 | 94 | 94 | 94 | 94 | 94 |
| N2 | a completion is applied without checking ownership | 768 | 768 | 768 | 768 | 768 | 768 |
| N3 | a posted request consumes a tag | 20 | 20 | 20 | 20 | 20 | 20 |
| N4 | the highest free tag is allocated, not the lowest | 3297 | 2890 | 3297 | 2890 | 3308 | 2890 |
| N5 | tag_limit is ignored | 2704 | 2704 | 2704 | 2704 | 2704 | 2704 |
| N6 | an over-long completion is absorbed silently | 18 | 18 | 18 | 18 | 18 | 18 |
| N7 | a second transaction accepted on a busy endpoint | 554 | 72 | 554 | 72 | 482 | 72 |
| N8 | an unsolicited response treated as a match | 490 | 66 | 490 | 66 | 444 | 66 |
| N9 | a response frees endpoint 0 whatever it answered | 1631 | 43 | 1631 | 43 | 1744 | 43 |
Every mutation is killed, and every one by directed stimulus alone. The directed column is identical across all three languages at all nine rows — 94, 768, 20, 2890, 2704, 18, 72, 66, 43.
N5 is the mutation that would have invalidated the chapter
N5 makes the tracker ignore tag_limit and use every tag it has. The design
still works: every transaction completes, every tag is matched, no data is
corrupted. Nothing a functional test would look for is wrong.
What breaks is the measurement. With the limit ignored, every row of the throughput surface becomes the saturated row, and the closed forms in section 5 — the USB result and the saturation boundary — both evaporate.
It scores 2704, all directed, and almost all of that is properties 12 and
14: the exact K*(L+1) equality and the negative half that says fewer tags
cannot saturate.
Run totals
| steps | checks | throughput reach | tag-state reach | errors | |
|---|---|---|---|---|---|
| Verilog, full | 9166 | 34,155 | 64 / 64 | 256 / 256 | 0 |
| Verilog, directed only | 7452 | 26,981 | 64 / 64 | 256 / 256 | 0 |
| SystemVerilog, full | 9166 | 34,155 | 64 / 64 | 256 / 256 | 0 |
| SystemVerilog, directed only | 7452 | 26,981 | 64 / 64 | 256 / 256 | 0 |
| VHDL, full | 9202 | 34,276 | 64 / 64 | 256 / 256 | 0 |
| VHDL, directed only | 7452 | 26,981 | 64 / 64 | 256 / 256 | 0 |
The directed-only rows are identical across all three languages in every column, and so is the entire 64-cell throughput surface. The full rows differ only in VHDL's check count, by 121, from its independent random stream.
14. What This Does Not Cover
No ordering rules between posted and completion traffic. Real PCIe has a table of which transaction classes may pass which, and it is a substantial subject. This chapter models the tag accounting only; a posted request here consumes no tag and is otherwise independent.
No credits. PCIe's link layer uses credit-based flow control, which is a second and separate back-pressure mechanism. The only back-pressure modelled here is tag exhaustion.
No link layer at all. No sequence numbers, no ACK/NAK, no replay buffer. Section 2 argues that the link-layer identity and the transaction-layer identity are different mechanisms; only the second is built.
Eight tags. Real PCIe allows 32 by default and 256 with extended tags. The sweep is exhaustive at 8, and the closed forms are stated in terms of L and are not specific to 8 — but the table is for 8.
Latency is a fixed integer per measurement. Real fabric latency has a distribution. Fixing it is what makes the closed forms exact rather than approximate; the random phase uses a variable latency precisely to check that the tracker does not depend on it being fixed.
One requester. Multiple requesters sharing a completion path is where real tag management gets hard, and it is out of scope.
USB's scheduling is not modelled. usb_xact_slot models one transaction per
endpoint. The frame structure, the periodic budget and the transaction
scheduling that sit above it are modules 14 through 18 of this track.
The 6.4× figure is cycles, not seconds. It compares the two mechanisms on one clock and one latency. Real USB and real PCIe also differ in clock rate and width, and those differences are additional rather than included.
15. The Interview Answer
Three sentences, and do not start with the bandwidth numbers.
1. Name the mechanism. "USB has one transaction outstanding per endpoint — the host issues it and waits — so no USB packet needs a transaction identifier. PCIe has many in flight at once, so every non-posted request carries a tag and the requester tracks it until the last byte of its completion arrives."
2. Name the consequence with the formula. "That makes USB's throughput exactly one transaction per latency-plus-one cycles, whatever the wire can carry. PCIe's is min(1, tags/(latency+1)) — so with enough tags its throughput stops depending on latency at all. That is why PCIe kept scaling bandwidth by adding lanes while its round-trip latency barely moved."
3. Name the cost, because the tags are not free. "The tags buy a bug USB cannot have: reuse a tag before its completion arrives and you attribute one transaction's data to another, silently. That is why a tracker has to hold a byte count per tag and not just a busy bit — a split completion must not free it."
If there is time, the best follow-up detail is the one from section 5: you need latency+1 tags to saturate, not latency, because a tag retired this cycle is not allocatable until the next. Knowing that is the difference between having read about outstanding transactions and having counted cycles on one.
16. What This Module Measured
Four comparisons, four mechanisms, four measured costs:
| chapter | the mechanism the other protocol lacks | the measured cost |
|---|---|---|
| 28.1, UART | synchronisation from a single edge | a tolerance budget shrinking as 1/N |
| 28.2, SPI | asking a device who it is | 0 of 11 failures detectable by any slave |
| 28.3, Ethernet | an authority to assign addresses | 294 of 1065 mis-delivered, vs 0 of 130 |
| 28.4, PCIe | naming a transaction | 216 cycles vs 34, at one tag vs eight |
Not one of those is a bandwidth number, and not one of them appears on a comparison table.
And the method, stated once, because it is reusable: find the one mechanism the two protocols do not share, build it in hardware, verify it exhaustively over a reachable domain, and mutate it to prove the verification would have noticed. Then the comparison is a number with a derivation, and the conversation is about engineering rather than about preference.
Continue learning
Related tutorials
- Related topic
USB vs UART
UART spends zero wires on synchronisation and pays a tolerance budget that shrinks as the frame grows; USB spends a SYNC field, an encoding rule and a PLL to buy that budget away — measured across 5376 exhaustive points, not quoted.
- Related topic
USB vs SPI
SPI selects a peripheral with a wire routed at layout time and USB with an address the host assigned — so a chip-select contention is invisible to every slave (0 of 11) while a duplicate USB address is detected every time (274 of 274).
- Related topic
USB vs Ethernet
USB has one authority that assigns every address; Ethernet has none, so a switch infers the topology from traffic — and an inferred table is wrong 294 times out of 1065 where an assigned one is wrong 0 times out of 130.
- Related topic
USB Flash Drives
Every flash drive speaks Bulk-Only Transport — CBW out, data, CSW in. The spec enumerates thirteen cases of host-versus-device disagreement, six of them fatal, and the two rarest are the ones that ship broken.
Standards & specifications
- Governing standard
- USB-IF (Universal Serial Bus Specification)(opens USB Implementers Forum (USB-IF) in a new tab)
Defines the USB bus — its electrical signalling, connectors, packet and transaction model, device framework and the descriptors a device must expose — together with the device-class specifications layered on it. It does not define host-controller register interfaces (xHCI and EHCI are separate documents) nor any operating system's driver architecture.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the USB curriculum.
