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.

One ack passes three attemptsOne lane per request for req |-> ##[1:4] ack. Each attempt forks a thread per delay of the range.req |-> ##[1:4] ackMeasured on Verilator 5.052: one copy of the assertion per lane
attempt startspassthread dies; the attempt goes onthread dropped: the attempt has already passed
One ack passes three attemptsreq @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.edgetime15215325435545655765875clkreqackattemptsreq @15req @15: dies at edge 3 (t=25)req @15: dies at edge 4 (t=35)req @15: passes at edge 5 (t=45)req @25req @25: dies at edge 4 (t=35)req @25: passes at edge 5 (t=45)req @35req @35: passes at edge 5 (t=45)
  1. Edge 2 · t=15

    The first request starts an attempt with four threads, one for each edge from 25 to 55.

  2. Edge 4 · t=35

    Two more requests have started their own attempts. No ack yet, so the earliest threads die, but every attempt still has threads looking.

  3. Edge 5 · t=45

    One ack at 45. Every attempt has a thread looking at this edge, so all three pass here, together.

    More context

    Nothing in ack says which request it answers. The two lost responses are never missed.

  4. 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.

At the deadline, more than 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
The same traffic, with ticketsOne lane per request for the ticket rule. The capsule is the ticket the attempt captured.(req, t = issued) |-> ##4 (served + ack > t)Measured on Verilator 5.052: one copy of the assertion per lane
attempt startspassfail
The same traffic, with ticketsreq @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).edgetime15215325435545655765875clkreqackissued0123servedattemptsreq @15t=0req @15: passes at edge 6 (t=55)req @25t=1req @25: fails at edge 7 (t=65)req @35t=2req @35: fails at edge 8 (t=75)
  1. Edge 2 · t=15

    The first request takes ticket 0.

  2. Edge 4 · t=35

    The next two take tickets 1 and 2. issued has moved on; each attempt kept its own ticket.

  3. Edge 5 · t=45

    The ack serves ticket 0. served becomes 1 after this edge.

  4. Edge 6 · t=55

    Deadline of ticket 0: one response so far, more than 0. It passes.

  5. Edge 7 · t=65

    Deadline of ticket 1: still one response, not more than 1. It fails.

  6. 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

ProtocolPairingCost
Fixed latencyCapture at the request, check at ##NOne rule
In order, variable latencyTickets, a deadline per request, the companion ruleTwo counters
Out of order, with IDsOne rule per ID, a busy trackerOne rule and one bit per ID value
No order and no IDA scoreboard that matches on contentA 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.

RUNVerilator · simulator service
Edit the code, then run it
Expected: t=75 response for idle id 0 t=105 id 3 lost

Check yourself

ConceptThree requests are waiting under req |-> ##[1:8] ack when one ack arrives inside all three windows. How many attempts does it satisfy?
Predict the outputRequests at 15 and 25 are answered at 35 and 75, in order. Predict the exact output.
module tb;
  logic clk = 0;
  always #5 clk = ~clk;                  // rising edges at 5, 15, 25, ...
  logic req = 0, ack = 0;
  int unsigned issued = 0, served = 0;
  always @(posedge clk) begin
    if (req) issued <= issued + 1;
    if (ack) served <= served + 1;
  end
  int n_untagged, n_ticket;
  property ticket_served;
    int unsigned t;
    @(posedge clk) (req, t = issued) |-> ##4 (served + ack > t);
  endproperty
  untagged: assert property (@(posedge clk) req |-> ##[1:4] ack) else n_untagged++;
  ticket:   assert property (ticket_served) else n_ticket++;
  initial begin
    @(negedge clk); req = 1;              // requests at 15 and 25
    @(negedge clk);
    @(negedge clk); req = 0; ack = 1;     // first ack at 35
    @(negedge clk); ack = 0;
    repeat (3) @(negedge clk);
    ack = 1;                              // second ack at 75
    @(negedge clk); ack = 0;
    repeat (2) @(negedge clk);
    $display("untagged=%0d ticket=%0d", n_untagged, n_ticket);
    $finish;
  end
endmodule
ConceptA memory returns read data in request order, with no ID, 2 to 9 cycles after each request. Which check pairs each response with its request?
Read the waveformThe rule is (req, t = issued) |-> ##3 (served + ack > t). Which attempt fails, and where?
Two requests, two acks
012345edge123456reqackissued012served

Values sampled at each rising edge; the counters update after the edge.

Spot the bugResponses return in request order, each within four edges. In the second burst the DUT drops one response. Which rule misses it?
ConceptPer-ID rules req && id == i |-> ##[1:4] (rsp && rsp_id == i) are generated for every ID. Why also assert req |-> !busy[id]?

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