Tags: one response must not satisfy every request
See why a ranged response rule passes every waiting request on one response, and pair responses with requests using tickets, IDs or a scoreboard.
- Lesson
- A4.2
- Time
- 25 min
- Runs on
- Verilator
- Check
- 6 questions
A cache sent three refill requests in a row and got one response back. The other two were lost inside the fabric. req |-> ##[1:4] ack passed all three attempts, at the same edge, and the hang appeared a million cycles later.
req |-> ##[1:4] ack. Each attempt forks a thread per delay of the range.- Edge 2 · t=15
The first request starts an attempt with four threads, one for each edge from 25 to 55.
- Edge 4 · t=35
Two more requests have started their own attempts. No
ackyet, so the earliest threads die, but every attempt still has threads looking. - Edge 5 · t=45
One
ackat 45. Every attempt has a thread looking at this edge, so all three pass here, together.More context
Nothing in
acksays which request it answers. The two lost responses are never missed. - Edge 8 · t=75
The windows close with no further
ack, and nothing fails.
Read the lanes as text
- req @15, from edge 2 (t=15): thread 1 (to edge 3 (t=25)) dies at edge 3 (t=25); thread 2 (to edge 4 (t=35)) dies at edge 4 (t=35); thread 3 (to edge 5 (t=45)) passes at edge 5 (t=45); thread 4 (to edge 6 (t=55)) is dropped at edge 5 (t=45), because the attempt has already passed.
- req @25, from edge 3 (t=25): thread 1 (to edge 4 (t=35)) dies at edge 4 (t=35); thread 2 (to edge 5 (t=45)) passes at edge 5 (t=45); thread 3 (to edge 6 (t=55)) is dropped at edge 5 (t=45), because the attempt has already passed; thread 4 (to edge 7 (t=65)) is dropped at edge 5 (t=45), because the attempt has already passed.
- req @35, from edge 4 (t=35): thread 1 (to edge 5 (t=45)) passes at edge 5 (t=45); thread 2 (to edge 6 (t=55)) is dropped at edge 5 (t=45), because the attempt has already passed; thread 3 (to edge 7 (t=65)) is dropped at edge 5 (t=45), because the attempt has already passed; thread 4 (to edge 8 (t=75)) is dropped at edge 5 (t=45), because the attempt has already passed.
Sampled values, edge 1 to 8: req 0 1 1 1 0 0 0 0; ack 0 0 0 0 1 0 0 0.
The program behind the lanes
The lanes come from running this program on Verilator 5.052. Each lane is a separate copy of acked that can start only at that lane's edge, so every verdict and its edge are read from that copy's own output.
module tb;
logic clk = 0;
always #5 clk = ~clk; // rising edges at 5, 15, 25, ...
logic req = 0, ack = 0;
// Ticket counters: a request takes ticket number issued, the next ack serves ticket served.
int unsigned issued = 0, served = 0;
always @(posedge clk) begin
if (req) issued <= issued + 1;
if (ack) served <= served + 1;
end
acked: assert property (@(posedge clk) req |-> ##[1:4] ack);
initial begin
@(negedge clk); req = 1; // requests at 15, 25, 35
repeat (2) @(negedge clk);
@(negedge clk); req = 0; ack = 1; // one ack, at 45
@(negedge clk); ack = 0;
repeat (3) @(negedge clk); // edges at 55 ... 75
$finish;
end
endmodule
The lanes converge: one response, three passes.
Why the lanes merge
A range gives every waiting attempt a thread at each edge of its window. When windows overlap, one ack falls inside all of them, and each attempt counts it as its own. The rule checks "some response came", never "my response came".
An edge-sensitive antecedent such as $rose(req) starts fewer attempts, but back-to-back requests still share a response. Making each attempt stop at its first ack does not help either: the first ack is the same one for all three. Each response needs a tag that says which request it answers.
Tickets for in-order responses
When responses return in request order, the tag can live in the checker. Two counters hand out tickets: issued counts requests and served counts responses. A request takes ticket issued; the next ack serves ticket served.
t responses, counting this edge, means ticket t was served.int unsigned issued = 0, served = 0;
always @(posedge clk) begin
if (req) issued <= issued + 1;
if (ack) served <= served + 1;
end
property ticket_served; // ticket t is served within 4 edges
int unsigned t;
@(posedge clk) (req, t = issued) |-> ##4 (served + ack > t);
endproperty- Edge 2 · t=15
The first request takes ticket 0.
- Edge 4 · t=35
The next two take tickets 1 and 2.
issuedhas moved on; each attempt kept its own ticket. - Edge 5 · t=45
The
ackserves ticket 0.servedbecomes 1 after this edge. - Edge 6 · t=55
Deadline of ticket 0: one response so far, more than 0. It passes.
- Edge 7 · t=65
Deadline of ticket 1: still one response, not more than 1. It fails.
- Edge 8 · t=75
Ticket 2 fails the same way. The lanes now separate: one pass, two failures.
Read the lanes as text
- req @15, from edge 2 (t=15), captures t=0: passes at edge 6 (t=55).
- req @25, from edge 3 (t=25), captures t=1: fails at edge 7 (t=65).
- req @35, from edge 4 (t=35), captures t=2: fails at edge 8 (t=75).
Sampled values, edge 1 to 8: req 0 1 1 1 0 0 0 0; ack 0 0 0 0 1 0 0 0; issued 0 0 1 2 3 3 3 3; served 0 0 0 0 0 1 1 1.
The program behind the lanes
The lanes come from running this program on Verilator 5.052. Each lane is a separate copy of ticket_served that can start only at that lane's edge, so every verdict and its edge are read from that copy's own output.
module tb;
logic clk = 0;
always #5 clk = ~clk; // rising edges at 5, 15, 25, ...
logic req = 0, ack = 0;
// Ticket counters: a request takes ticket number issued, the next ack serves ticket served.
int unsigned issued = 0, served = 0;
always @(posedge clk) begin
if (req) issued <= issued + 1;
if (ack) served <= served + 1;
end
ticket_served: assert property (@(posedge clk) (req, t = issued) |-> ##4 (served + ack > t));
initial begin
@(negedge clk); req = 1; // requests at 15, 25, 35
repeat (2) @(negedge clk);
@(negedge clk); req = 0; ack = 1; // one ack, at 45
@(negedge clk); ack = 0;
repeat (3) @(negedge clk); // edges at 55 ... 75
$finish;
end
endmodule
Each attempt waits for its own ticket, so the lost responses fail at their deadlines.
The verdict is checked at the deadline, so a pass is reported there, not at the ack. A failure is reported at the end of the window, as a range rule would. Add the companion ack |-> served < issued: a response with no ticket outstanding is a duplicate or an invention.
Reading a ticket failure
A ticket failure proves that a response is missing, but it does not always point at the right one. If the second of three responses is lost, the third is counted as the second, and the deadline that fails is the last one. Counting alone cannot do better: from the outside, losing the second response looks the same as losing the third. Lesson A8.2 measures exactly this case.
Take the deadline from the specification's latency bound, not from what the design happens to do today. Too short, and the rule fails legal traffic. Too long, and a lost response is still caught, only later. Remember that in-order responses queue: a response waits behind the ones ahead of it, so the bound must cover the longest legal queue, not one request in isolation.
IDs for out-of-order responses
When responses can overtake each other, the tag must travel with them, as an ID. A generate loop writes one rule per ID value, with the ID as a constant, so no local variable is needed.
Per-ID rules bring back the original problem if an ID is reused while outstanding: two requests with ID 2 would both pass on one response. A small tracker keeps a busy bit per ID, and two rules guard it: no request reuses a busy ID, and no response carries an idle one. The example runs all three.
The two tracker rules also split the blame. A reused ID is the requester's fault, so no_reuse points at the master. A response for an idle ID, whether duplicated, invented or carrying a corrupted tag, is the responder's fault, so known points at the slave. The per-ID rule then reports the request that the corrupted response should have answered, at the end of its window.
Which pairing fits the protocol
| Protocol | Pairing | Cost |
|---|---|---|
| Fixed latency | Capture at the request, check at ##N | One rule |
| In order, variable latency | Tickets, a deadline per request, the companion rule | Two counters |
| Out of order, with IDs | One rule per ID, a busy tracker | One rule and one bit per ID value |
| No order and no ID | A scoreboard that matches on content | A model in the testbench |
Choose the cheapest pairing the protocol guarantees.
A protocol that keeps order and also carries IDs can use either pairing. Tickets cost less to write, and IDs are faster to debug. Counts tell you that a response is missing; IDs tell you which. With 256 IDs, per-ID rules become slow and hard to read, and a scoreboard keyed by ID is usually the better tool. Keep the assertions for the timing and handshake rules, which the scoreboard cannot see edge by edge.
Try it
IDs 1 and 2 came back in the opposite order and both passed. The response at 75 carried ID 0, which nothing had requested, so known fired at once. The rule for ID 3 then waited out its window and failed at 105.
Change rsp_id = 0 to rsp_id = 3 and predict the output. Then set the second request's ID to 1 as well, and see which rule complains.
t=75 response for idle id 0 t=105 id 3 lostCheck yourself
Remember
- With overlapping windows, one response satisfies every waiting attempt of a range rule.
- In order: number requests and responses with two counters and check each ticket at its deadline.
- Out of order: one rule per ID, plus a busy tracker so no ID is outstanding twice.
- Add a companion rule: no response without an outstanding request.
- Without order or ID, pair responses in a scoreboard.
Practice
- Tickets for a lost response Verify · Medium