Protocols with transaction IDs promise a partial order. AXI is the common example: read responses with the same ID return in request order, while responses with different IDs may return in any order. Many other protocols use the same rule under other names.
A checker must encode that partial order exactly. The Part 5 scoreboard used one queue, which encodes a total order. Here we need one queue per ID.
Checker shape | Legal cross-ID reorder | Illegal within-ID swap |
|---|---|---|
One global FIFO | False error | Caught |
Search anywhere for a match | Accepted | Missed |
One FIFO per ID | Accepted | Caught |
The any-order search is the dangerous one. It never raises a false error, so it looks robust, but it cannot see an ordering bug at all.
The natural structure is T exp_q[int][$]: an associative array keyed by ID, where each entry is a queue in arrival order. An ID gets storage only when it has something outstanding, so a 16-bit ID space costs nothing until used.
add_expected(id, item) pushes to the back of that ID's queue.
check_actual(id, item) pops the front of that ID's queue and compares. A response for an ID with an empty or missing queue is its own error: the DUT answered something nobody asked for.
outstanding() sums the queue sizes. The bench uses it to cap traffic in flight and to prove the test drained.
Delete an ID's entry when its queue empties. Reading a key that does not exist returns the default value (an empty queue here), and the LRM allows a tool to warn; checking exists() first keeps the intent clear.
The class is parameterized on the item type, so the capstone and other benches can reuse it with a struct or a packed vector. For a class-handle item, override same() to compare fields; the default === would compare handles.
The DUT keeps one FIFO per ID and answers one ID every other cycle. Three copies run on the same request stream:
Instance | Arbitration | Expected verdict |
|---|---|---|
u_rr (MODE 0) | Round-robin across IDs | Clean: cross-ID reorder is legal |
u_prio (MODE 1) | Highest non-empty ID first | Clean: a different legal reorder |
u_bad (MODE 2) | Round-robin, but swaps two queued ID-2 responses once | Errors: within-ID order broken |
Each request carries its sequence number as payload, so every item is unique and a swap is visible. The bench also counts responses that were overtaken by a younger request. If that count were zero, the clean result on u_rr would prove nothing.
// Responder: one FIFO per ID, answers one ID every other cycle.
// MODE 0: round-robin across IDs (legal reorder)
// MODE 1: highest non-empty ID first (a different legal reorder)
// MODE 2: like MODE 0, but swaps two queued ID-2 responses (illegal)
module ooo_responder #(parameter int MODE = 0) (
input logic clk, rst_n,
input logic req_valid,
input logic [1:0] req_id,
input logic [7:0] req_data,
output logic rsp_valid,
output logic [1:0] rsp_id,
output logic [7:0] rsp_data
);
logic [7:0] mem [4][8];
logic [2:0] wr [4], rd [4];
logic [3:0] cnt [4];
logic slot, swapped;
logic [1:0] rr, pick;
logic any;
always_comb begin
any = 0; pick = 0;
if (MODE == 1) begin
for (int i = 0; i < 4; i++) if (cnt[i] != 0) begin any = 1; pick = 2'(i); end
end else begin
for (int k = 3; k >= 0; k--) begin
automatic logic [1:0] i = rr + 2'(k);
if (cnt[i] != 0) begin any = 1; pick = i; end
end
end
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int i = 0; i < 4; i++) begin wr[i] <= 0; rd[i] <= 0; cnt[i] <= 0; end
slot <= 0; rr <= 0; swapped <= 0; rsp_valid <= 0; rsp_id <= 0; rsp_data <= 0;
end else begin
automatic logic pop = slot && any;
slot <= ~slot;
rsp_valid <= pop;
if (req_valid) begin
mem[req_id][wr[req_id]] <= req_data;
wr[req_id] <= wr[req_id] + 1;
end
if (pop) begin
rsp_id <= pick;
rsp_data <= mem[pick][rd[pick]];
if (MODE == 2 && pick == 2 && cnt[2] >= 2 && !swapped) begin
rsp_data <= mem[2][rd[2] + 1]; // newer one goes first
mem[2][rd[2] + 1] <= mem[2][rd[2]]; // older one is next
swapped <= 1;
end
rd[pick] <= rd[pick] + 1;
rr <= pick + 1;
end
for (int i = 0; i < 4; i++)
cnt[i] <= cnt[i] + 4'(req_valid && req_id == 2'(i)) - 4'(pop && pick == 2'(i));
end
end
endmodule
// In order within an ID, any order across IDs.
class id_scoreboard #(type T = logic [7:0]);
string name;
protected T exp_q[int][$]; // ID -> expected items, oldest first
int n_added, n_matched, n_errors;
function new(string name);
this.name = name;
endfunction
function void add_expected(int id, T item);
exp_q[id].push_back(item);
n_added++;
endfunction
// Override for class-handle items: === would compare handles.
virtual function bit same(T a, T b);
return a === b;
endfunction
function void check_actual(int id, T item);
T exp;
if (!exp_q.exists(id) || exp_q[id].size() == 0) begin
n_errors++;
$display("%s: UNEXPECTED response for ID %0d with nothing outstanding (got %h)", name, id, item);
return;
end
exp = exp_q[id].pop_front();
if (exp_q[id].size() == 0) exp_q.delete(id);
if (!same(item, exp)) begin
n_errors++;
$display("%s: MISMATCH ID %0d expected %h got %h", name, id, exp, item);
end else n_matched++;
endfunction
function int outstanding();
int n = 0;
foreach (exp_q[id]) n += exp_q[id].size();
return n;
endfunction
function void report();
foreach (exp_q[id])
$display("%s: ID %0d still has %0d outstanding", name, id, exp_q[id].size());
endfunction
endclass
The bench passes with 19 cross-ID reorders accepted and two mismatches on sb_bad (MISMATCH ID 2 expected 03 got 04, then the reverse). Two changes make it fail, and both were run:
Collapse the key: force every ID to key 0 in the scoreboard. It becomes a global FIFO and sb_rr reports false errors.
Match anywhere: search the whole ID queue for the item instead of comparing the head. sb_bad then reports nothing and the bench fails on the missed swap.
This is the checker-checks-checker pattern: the bench asserts that the bad instance is flagged, so a scoreboard that silently stopped checking order cannot pass. In UVM the same class sits inside a uvm_scoreboard fed by analysis exports; the standard library has in-order comparators but no out-of-order one.
Cap traffic in flight from the scoreboard's own count, as the bench does with outstanding() >= 6. Then the stimulus cannot overrun the DUT and the cap is visible in one place.
Burst on one ID on purpose. With random IDs and a fast responder, the per-ID queues rarely held two items, and the within-ID bug was not reachable. Bursts of one to three requests on the same ID fixed that.
Make payloads unique (a sequence number or a tag field) when the data path allows it. Identical payloads hide swaps.
reordered > 0?Without real reordering, a global-FIFO scoreboard would also pass on u_rr. The counter proves the stimulus exercised the case the per-ID structure exists for.
Something else: an unexpected response (the DUT answered nothing, or answered twice). Report it separately from data mismatches, because it points at duplication or a corrupted ID rather than wrong data.