Mailboxes and backpressure

Pass items between processes through a mailbox, and give it a size that holds a fast producer back instead of letting it run ahead of the DUT.

Lesson
3.5
Time
25 min
Runs on
Verilator
Check
5 questions

A generator puts 1,000 transactions into an unbounded mailbox in zero simulation time and reports that it is done. The test then allows 50 cycles for the driver to finish, but the driver needs one cycle per transaction, so only 50 transactions reach the DUT. The coverage report still shows all 1,000, because the generator sampled coverage as it created them.

The generator finishes at once; the backlog sits in the mailbox
01234567generated1000in mailbox999998997996995994993992driven to DUT12345678generator reports done

One column per clock cycle. Fifty cycles later, 950 transactions are still waiting when the test ends.

A channel that makes processes wait

A mailbox is a first-in, first-out channel between processes. put() adds a message at the back, and get() removes the oldest one. If the mailbox is empty, get() blocks until a message arrives, so the consumer needs no polling loop.

Declare it with the type it carries, as in mailbox #(packet) gen2drv = new(4);, and the compiler checks every put and get against that type. A mailbox is an object, so the variable holds no mailbox until new() creates one.

MethodBlocks whileWhat it doesReturns
put(m)the mailbox is full (bounded only)adds m at the backnothing
get(m)the mailbox is emptyremoves the oldest message into mnothing
peek(m)the mailbox is emptycopies the oldest message into m and leaves it therenothing
try_put(m)neveradds m if there is roomnonzero if added, 0 if full
try_get(m), try_peek(m)neveras get or peek, if there is a messagenonzero on success, 0 if empty
num()nevercounts the waiting messagesthe count at this moment

Mailbox methods: the first three block, the try_ forms never do.

Backpressure from a bounded mailbox

new() or new(0) creates an unbounded mailbox, where put() never blocks. That is the opening story: the generator can create every transaction at time 0, however slowly the driver works.

An unbounded mailbox also hides a driver that stalls. If the driver stops taking items because of a DUT hang, the generator keeps going, and the backlog grows without any message in the log.

new(N) with N greater than 0 creates a bounded mailbox that holds at most N messages. When it is full, put() blocks until the consumer takes one. This holding back of a producer by a slower consumer is called backpressure.

Bounded mailbox, new(2); the driver takes one item every other cycle
01234567producerput 0, 1blockedput 2blockedput 3blockedput 4donenum()21consumer-get 0-get 1-get 2get 3a get makes room for put 2

One column per clock cycle. The producer can never be more than two items ahead.

With a bound, the generator runs at the driver's pace. When it reports done, at most N items are still waiting, so the end of the test only has to wait for a short, known drain. Choose N from the design: 1 makes the generator hand over one item at a time, and a larger N models a master with a request queue of that depth.

Nonblocking calls return a status

try_get() and try_put() never wait, so they need another way to say that nothing happened. Each returns a nonzero value on success and 0 on failure. A failed try_get(t) delivers no message, so t still holds an old value, which looks exactly like a real message.

Status ignored

void'(mb.try_get(t));
check(t);   // may check the old t again

When the mailbox is empty, the previous transaction is checked a second time.

Status tested

if (mb.try_get(t))
  check(t);

Only a message that was really taken is checked.

Use the try_ forms when a process must not stall, for example a model that services several channels in one loop. Everywhere else, the blocking forms are simpler and harder to get wrong.

num() is a snapshot. Another process can put or get right after you read it, so do not use num() to decide whether a later get() will block. It is reliable at the end of the test, once every producer and consumer has stopped, for checking that nothing was left behind.

A mailbox carries handles

A mailbox of class objects stores handles, references to objects, not copies of the objects (Module 4 covers classes in depth). Suppose a generator creates one object, then changes its fields and puts it again for every transaction. All the messages then point at the same object, and the driver sees only the latest values.

Create a new object for every message with new(), and treat an object as the receiver's once you have put it. The producer must not change it afterwards.

Knowing when the stream has ended

A consumer that loops on get() forever never learns that the producer has finished. Give it a way to know. The simplest is a fixed count agreed with the producer, as in repeat (n) mb.get(t);. Another is a counter of items sent, which the end of the test can wait on with wait (n_taken == n_sent).

A special end message also works, such as a null handle in a mailbox of objects, provided it can never be confused with a real item. The consumer stops when it gets that message, and the producer sends it only after its last real item.

Then finish with a check that the mailbox is empty. A leftover message means an item was generated but never driven, which is exactly the bug in the opening story.

Try it

The first two puts return at once. From then on each put() returns only when a get() makes room, so the producer is held to the consumer's pace. Both processes only record times; the parent prints everything after join.

Change new(2) to new() and predict the put times before you run it. Then try new(1).

RUNVerilator · simulator service
Edit the code, then run it
Expected: item 0: put returned at t=0, got at t=10 item 10: put returned at t=0, got at t=20 item 20: put returned at t=10, got at t=30 item 30: put returned at t=20, got at t=40 item 40: put returned at t=30, got at t=50 left in mailbox: 0

Check yourself

Predict the outputPredict the exact output. Each > 0 turns a status into 1 or 0.
module tb;
  mailbox #(int) mb = new(1);
  int ok1, ok2, v;
  initial begin
    ok1 = mb.try_put(5);
    ok2 = mb.try_put(6);
    mb.get(v);
    $display("%0d %0d %0d %0d", ok1 > 0, ok2 > 0, v, mb.num());
  end
endmodule
Spot the bugA consumer polls a mailbox that holds 10 and 20, once per time unit for 4 time units. It must count and sum exactly the items it took. Each option is the loop body. Which one gets the count and sum wrong?
ConceptA mailbox is created with new(2). A producer calls put() five times in a row, and nothing ever calls get(). How many put() calls return?
ConceptWhy is a queue int q[$] a poor channel between a generator process and a driver process?
Read the waveformA mailbox created with new(2) is full in cycle 2, and the producer is blocked in put(3). The consumer calls get() in cycle 3. When does put(3) complete?
A blocked put and a get
01234producerput 1put 2put 3: blocked?num()12?consumer-get-get

One column per clock cycle.

Remember

  • A mailbox is a FIFO channel: get() blocks when it is empty, and put() blocks when a bounded mailbox is full.
  • new(N) with N greater than 0 bounds the mailbox and holds a fast producer to the consumer's pace.
  • try_put() and try_get() never block; always test the status they return.
  • A mailbox of objects carries handles, so create a new object for every message.
  • Give the consumer a count or another end signal, and check at the end that the mailbox is empty.

Practice