Pipelined drivers
Drive a bus that carries two transfers at once: two driver workers, a semaphore around get() and the address phase, a response sent later with put(), and a sequence that collects responses on a second thread.
- Lesson
- U6.1
- Time
- 30 min
- Runs on
- Verilator with UVM
- Check
- 5 questions
A memory controller accepts a new address every clock while it is still returning the data of the previous access. Its bench passes a thousand-item regression. In the lab the first back-to-back write and read to the same address returns stale data. The bench never saw it: its driver waited for each item's data phase before asking for the next item, so no two transfers were ever on the bus together.
Each item has an address phase and, one clock later, a data phase. The serial driver leaves every other clock empty; the pipelined driver keeps both phases busy and finishes four items at 45 ns instead of 75 ns.
Two phases, two items in flight
The bus in this lesson has two phases. In the address phase the master drives avalid, addr, we and wdata until the memory accepts them at a clock edge. In the data phase, the next clock, the memory returns rdata. So item 2's address phase can run while item 1's data phase completes. AHB and OCP work this way, and some buses have more phases.
A driver that waits for the data phase before asking for the next item drives the bus at half speed. It also never creates the overlap the design must handle, such as a write whose data phase meets the next read's address phase. Filling the pipeline takes three changes. The driver works on two items at once, the handshake releases the sequence early, and the sequence stops waiting for each result.
U4 built the single-item handshake and U5 the choice among sequences. Here one sequence and one sequencer stay; only the driver and the shape of the sequence change. The example assumes a fixed latency, no reset and one producing sequence.
Handing off a request, completing it later
- Handoff of item 1 (0 ns)
Worker A takes the semaphore key and calls
get(). The sequence'sfinish_itemfor item 1 returns at once, so it starts preparing item 2.More context
Worker A holds the only key, so worker B is blocked on the semaphore. Item 1 reaches worker A through get(), and the sequence's finish_item returns in the same time step.
Follow the calls, values and waits
- call get(1): Worker A → Address semaphore
- data key: Address semaphore → Worker A
- call get(): Worker A → Sequencer
- data item 1: Sequence: send thread → Sequencer
- data item 1: Sequencer → Worker A
- Waiting: Worker B — waiting for the key
- Released: Sequence: send thread
- Item 1's address accepted (5 ns)
The memory accepts item 1's address phase at the 5 ns edge. Worker A clears
avalid, then returns the key.More context
Worker A drove item 1's address from 0 ns; the memory takes it at the 5 ns edge. Clearing avalid before returning the key keeps the bus from repeating the address.
Follow the calls, values and waits
- data addr 1: Worker A → Memory pins
- return key: Worker A → Address semaphore
- Released: Worker B
- Handoff of item 2 (5 ns)
Worker B takes the key and item 2 and starts its address phase while worker A is in item 1's data phase.
More context
The log shows "sent item 2" at 5 ns: handoff happens the moment worker B takes the item, before any of its pins move. Worker A is busy with item 1's data phase until 15 ns.
Follow the calls, values and waits
- call get(1): Worker B → Address semaphore
- data key: Address semaphore → Worker B
- call get(): Worker B → Sequencer
- data item 2: Sequence: send thread → Sequencer
- data item 2: Sequencer → Worker B
- Waiting: Worker A — data phase of item 1
- Released: Sequence: send thread
- Completion of item 1 (15 ns)
Worker A reads
rdataafter the data edge, builds a response withset_id_infoand callsput. At the same edge the memory accepts item 2's address.More context
Two things happen at the 15 ns edge: item 1's data phase ends, so its response reaches the collect thread, and item 2's address phase is accepted. This is the overlap the serial driver never produces.
Follow the calls, values and waits
- data rdata 1: Memory pins → Worker A
- return rsp 1: Worker A → Sequence: collect thread
- data addr 2: Worker B → Memory pins
- Released: Sequence: collect thread
- Released: Worker A
- Handoff of item 3 (15 ns)
Worker B returns the key; worker A, free again, takes it and item 3.
More context
Item 3's start_item has been waiting since 5 ns because both workers were busy; it is handed off at 15 ns, matching "sent item 3" in the log. That wait is the pipeline's back-pressure on the sequence.
Follow the calls, values and waits
- return key: Worker B → Address semaphore
- call get(1): Worker A → Address semaphore
- data key: Address semaphore → Worker A
- call get(): Worker A → Sequencer
- data item 3: Sequence: send thread → Sequencer
- data item 3: Sequencer → Worker A
- Waiting: Worker B — data phase of item 2
- Released: Sequence: send thread
- Serial before completionbefore 15 ns (serial) (illustrative)
In the serial alternative, finish_item stays blocked until the data phase completes at 15 ns.
More context
A separate, derived scenario: before 15 ns the serial sequence is still in finish_item for item 1. It returns at 15 ns, then offers item 2. The pipelined run already handed item 2 off at 5 ns.
Follow the calls, values and waits
- data item 1: Sequence: send thread → Sequencer
- Waiting: Sequence: send thread — in finish_item until 15 ns
- Released: Worker A
- Released: Worker B
Read the diagram as text
- Sequence: send thread: start_item, finish_item per item
- Sequencer: One request handshake at a time
- Address semaphore: One key
- Worker A: Item 1: address 0-5 ns, data 5-15 ns
- Worker B: Item 2: address 5-15 ns, data 15-25 ns
- Sequence: collect thread: get_response loop
- Memory pins: Address phase and data phase overlap
- Sequence: send thread → Sequencer: finish_item(item)
- Worker A → Sequencer: get(): handoff of item 1
- Worker B → Sequencer: get(): handoff of item 2
- Worker A → Address semaphore: holds key 0-5 ns
- Worker B → Address semaphore: holds key 5-15 ns
- Worker A → Memory pins: drives item 1
- Worker B → Memory pins: drives item 2
- Worker A → Sequence: collect thread: put(rsp): completion
Handoff (the worker's get) and completion (its put) are separate events. The semaphore covers request selection and the address phase only.
Every item passes two milestones. Handoff is the moment the driver takes responsibility for the request: the sequence's finish_item returns, and it may prepare the next request. Completion is the moment the result exists: the data phase has ended and the read data is known. A serial driver calls item_done after the data phase, so both milestones coincide.
The pipelined driver separates them. seq_item_port.get(req) takes the item and completes the request handshake at once; in uvm-core it is the item selection followed by item_done(). When the data phase ends, the driver builds a response, copies the request's IDs into it with rsp.set_id_info(req), and calls seq_item_port.put(rsp). put takes no time, not even a delta cycle; the sequencer routes the response to its sequence by sequence ID.
This is the same handshake U4 taught, with one more event. U4.3 tied item_done to bus acceptance. Here get releases the request handshake at handoff, and put returns a separate response after the data phase. The design decision is when the sequence may issue its next request: at handoff, which fills the pipeline, or at completion, which keeps one transfer at a time.
Handoff and completion times for the four items
| Item | Serial driver: finish_item returns | Pipelined: finish_item returns (handoff) | Pipelined: response arrives (completion) |
|---|---|---|---|
| 1 write 0x10 | 15 ns | 0 ns | 15 ns |
| 2 read 0x10 | 35 ns | 5 ns | 25 ns |
| 3 read 0x20 | 55 ns | 15 ns | 35 ns |
| 4 read 0x30 | 75 ns | 25 ns | 45 ns |
Pipelined times are from the example below; serial times follow from the same two-edge timing with one item in flight.
Two workers and one semaphore
The driver forks two identical workers. Each worker carries its own item through both stages: it takes the item, drives the address phase, waits through the data phase and returns the response. Neither is permanently the address thread. Two workers are enough for this two-stage, fixed-latency bus; a deeper pipeline or variable latency needs more.
task worker(string id);
mem_item tr, rsp; // this worker's own handles
forever begin
addr_phase.get(); // one key: request selection and address phase
seq_item_port.get(tr); // handoff: finish_item returns now
accept_tr(tr, $time);
vif.avalid <= 1; vif.we <= tr.we; vif.addr <= tr.addr; vif.wdata <= tr.wdata;
@(posedge vif.clk); // the memory accepts the address phase
void'(begin_tr(tr)); // here: the acceptance edge
vif.avalid <= 0; // clear first ...
addr_phase.put(); // ... then let the other worker drive
@(posedge vif.clk); // data phase ends at this edge
rsp = mem_item::type_id::create("rsp");
rsp.set_id_info(tr);
rsp.we = tr.we; rsp.addr = tr.addr; rsp.rdata = vif.rdata;
end_tr(tr);
seq_item_port.put(rsp); // completion
end
endtaskThe semaphore serialises two things. First, request selection: only one worker is inside get at a time. The sequencer serves one request handshake through a one-entry FIFO, and a second request it cannot place is a fatal error whose text suggests a semaphore. Second, the address pins: only one worker drives them.
The worker clears avalid before it releases the key. The other worker's avalid <= 1 comes later in the same time step and wins; with no item waiting, the bus goes idle instead of repeating the last address. The worker reads rdata right after the data edge, before the memory's nonblocking update for the next transfer lands, so it reads its own item's data. U4.3 showed the same pre-edge read on ready.
Each worker keeps its own tr handle. The req field that uvm_driver provides is a single variable; if both workers used it, worker B's get would replace worker A's item during A's data phase. The quiz shows that variant failing.
A sequence with two threads
Serial: waits for each result
for (int i = 0; i < 4; i++) begin
mem_item it = mem_item::type_id::create("it");
start_item(it);
fill(it, i);
finish_item(it);
get_response(rsp); // pipeline drains here
end
Correct for register-style accesses that need each result before the next request, but only one transfer is ever in flight.
Pipelined: sends and collects in parallel
fork
for (int i = 0; i < 4; i++) begin
mem_item it = mem_item::type_id::create("it");
start_item(it);
fill(it, i);
finish_item(it);
end
repeat (4) get_response(rsp);
join
The send loop is paced by the workers' get() calls; responses are read as they arrive.
With handoff early, a get_response right after finish_item would stall the send loop until the data phase ends. Create a new item for every send: the driver still reads a handed-off item during its data phase, and a reused object would also repeat one transaction ID.
A sequence that needs no results can call use_response_handler(1) and count responses in response_handler, which keeps its response queue empty. Its body() must still stay alive until the count reaches the number sent. Once a sequence has ended, the sequencer no longer finds it and drops later responses with a warning.
Timestamps on the item
With several items in flight, "when did this item happen" has three answers. accept_tr records when the driver took the item and end_tr when it completed. begin_tr records whatever the driver chooses as the start; this example calls it at the edge where the memory accepts the address phase. Without transaction recording the calls still set the three times, which get_accept_time(), get_begin_time() and get_end_time() read back.
The sequencer would record the item too. Automatic item recording is on by default. start_item begins the item at the grant, and finish_item ends it when it returns, which here is the handoff, long before the data phase ends. So the driver calls seq_item_port.disable_auto_item_recording() at the start of its run_phase, and the item's begin and end are only the driver's. U2.4 introduced the switch.
Item 3 shows each mark. Worker A takes it at 15 ns and drives its address during 15-25 ns; the memory accepts it at 25 ns and the data phase ends at 35 ns. The pipeline's back-pressure is on the sequence side: item 3's start_item began at 5 ns and waited until 15 ns, when a worker and the key were free.
When completion is out of order
This memory answers in order. A two-bank memory does not: a fast bank finishes a later read before an earlier read from the slow bank. The model below keeps the handoff, takes one item every 10 ns and gives each outstanding item its own completion thread. Address 0x80 sits in the slow bank (40 ns); 0x01 and 0x02 take 10 ns.
a, then strategy B as sequence b. Arrows are returned responses.- Read 1 to the slow bank (0 ns)
The driver takes read 1 (0x80) and gives it its own completion thread; the slow bank needs 40 ns.
More context
One item every 10 ns: read 1 goes to the slow bank and will not answer until 40 ns. Times are relative to the start of each sequence.
Follow the calls, values and waits
- data read 1 (0x80): Driver → Slow bank
- Waiting: Slow bank — busy until 40 ns
- Read 2 to the fast bank (10 ns)
The driver takes read 2 (0x01); the fast bank needs 10 ns.
More context
Read 2 is issued after read 1 but will finish 20 ns earlier.
Follow the calls, values and waits
- data read 2 (0x01): Driver → Fast bank
- Alternative A at 20 ns
Response 2 arrives first. Collector A pops it, looks up request 2 by its transaction ID and retires the entry. Read 3 is taken at the same moment.
More context
In sequence a, read 2 completes at 20 ns: the first entry of "done by arrival 20 30 40". The queue never holds more than one response.
Follow the calls, values and waits
- data read 3 (0x02): Driver → Fast bank
- return tid 2: Fast bank → Response queue
- data tid 2: Response queue → A: take and look up
- Released: A: take and look up
- Alternative A at 30 ns
Response 3 arrives and is handled at once.
More context
Read 3 completes at 30 ns. Request 1 is still outstanding in the lookup table.
Follow the calls, values and waits
- return tid 3: Fast bank → Response queue
- data tid 3: Response queue → A: take and look up
- Released: A: take and look up
- Alternative A at 40 ns
Response 1 finally leaves the slow bank and completes read 1.
More context
Arrival order was 2, 3, 1, and every response matched its own request: "matched wrong 0". A naive k-th pairing would have been wrong all three times.
Follow the calls, values and waits
- return tid 1: Slow bank → Response queue
- data tid 1: Response queue → A: take and look up
- Released: A: take and look up
- Released: Slow bank
- Alternative B at 20 ns
In the second run, collector B asks for ID 1 first. Response 2 arrives and stays queued.
More context
Sequence b runs the same traffic after sequence a ends; times are relative to its start. Its collector waits for transaction ID 1, so response 2 waits in the queue (occupancy 1).
Follow the calls, values and waits
- return tid 2: Fast bank → Response queue
- Waiting: B: wait for chosen ID — get_response(tid 1)
- Waiting: Slow bank — busy until 40 ns (alternative B)
- Alternative B at 30 ns
Response 3 arrives and joins response 2 in the queue.
More context
Queue occupancy is now 2. Behind a slower response, more than 8 queued entries would be dropped silently in this library.
Follow the calls, values and waits
- return tid 3: Fast bank → Response queue
- Waiting: B: wait for chosen ID — get_response(tid 1)
- Alternative B at 40 ns
Response 1 lands; collector B takes 1, then 2, then 3.
More context
All three reads finish at 40 ns: "by id 40 40 40". Strategy B needed no table but every read waited behind the slow one.
Follow the calls, values and waits
- return tid 1: Slow bank → Response queue
- data tid 1: Response queue → B: wait for chosen ID
- data tid 2: Response queue → B: wait for chosen ID
- data tid 3: Response queue → B: wait for chosen ID
- Released: B: wait for chosen ID
- Released: Slow bank
Read the diagram as text
- Driver: Takes items 1, 2, 3 at 0, 10, 20 ns
- Slow bank: 0x80: 40 ns
- Fast bank: 0x01, 0x02: 10 ns
- Response queue: Per sequence, depth 8
- A: take and look up: Done at 20, 30, 40 ns
- B: wait for chosen ID: Done at 40, 40, 40 ns
- Driver → Slow bank: read 1
- Driver → Fast bank: reads 2, 3
- Fast bank → Response queue: tid 2 at 20, tid 3 at 30
- Slow bank → Response queue: tid 1 at 40
- Response queue → A: take and look up: oldest first
- Response queue → B: wait for chosen ID: tid 1, then 2, then 3
Both alternatives pair every response with the right request. A finishes each read when its data exists; B needs no table but waits behind the slowest response.
// driver: one thread per outstanding item
seq_item_port.get(tr);
fork complete(tr); join_none // complete(): bank delay, set_id_info, put(rsp)
#10;
// collector A: take whatever arrives, look up its request, retire the entry
get_response(rsp);
req_of = by_tid[rsp.get_transaction_id()];
by_tid.delete(rsp.get_transaction_id());
// collector B: wait for a chosen ID, in send order
get_response(rsp, sent[k].get_transaction_id());The naive loop pairs the k-th response with the k-th request. Measured here, the arrival order is 2, 3, 1 and all three pairings are wrong, with no warning: the sequencer routes by sequence ID only and never reorders.
Alternative A handles each result the moment it exists, at the cost of a table of outstanding requests keyed by ID. Alternative B needs no table, but a slow response holds up the ones behind it. Behind a very slow one, the queue can pass its depth of 8 and drop entries silently. On real pins the bus carries a tag with each data beat, and the driver keys its outstanding items by that tag. U8.2 builds the matching out-of-order scoreboard.
Try it
Read the log by time. Item 2's finish_item returns at 5 ns, at the same edge that accepts item 1's address phase, and item 2's address phase is on the bus during item 1's data phase. The read of 0x10 at item 2 returns 0x5a, the value item 1 wrote one clock earlier.
Each completion line carries the item's three timestamps. Accept is when a worker took the item and begin is the edge where the memory accepted its address phase, after one clock of driving. Four items complete at 45 ns; a serial driver that holds the semaphore through the data phase (the second option of the quiz) finishes at 75 ns.
Try deleting the line vif.avalid <= 0, and watch the memory accept item 4's address a second time at 45 ns. Then move get_response into the send loop and see the completions spread to every other clock.
0 ns sequence sent item 1: write 0x10 <= 0x5a 5 ns driver A address phase of item 1 accepted 5 ns sequence sent item 2: read 0x10 15 ns driver A item 1 done accept 0 begin 5 end 15 15 ns driver B address phase of item 2 accepted 15 ns sequence response for item 1 15 ns sequence sent item 3: read 0x20 25 ns driver B item 2 done: rdata=0x5a accept 5 begin 15 end 25 25 ns driver A address phase of item 3 accepted 25 ns sequence response for item 2: rdata=0x5a 25 ns sequence sent item 4: read 0x30 35 ns driver A item 3 done: rdata=0xa0 accept 15 begin 25 end 35 35 ns driver B address phase of item 4 accepted 35 ns sequence response for item 3: rdata=0xa0 45 ns driver B item 4 done: rdata=0xb0 accept 25 begin 35 end 45 45 ns sequence response for item 4: rdata=0xb0 45 ns test all four responses are in UVM_ERROR : 0Check yourself
Remember
- Every item has two milestones: handoff, when
finish_itemreturns, and completion, when the result exists. A pipelined driver separates them so the sequence can issue the next request at handoff. get()hands the request off at once;put(rsp)returns the result later, in zero time, routed by the IDsset_id_infocopied.- Two identical workers each carry their own item, with their own handle, through both phases; a one-key semaphore serialises request selection and the address phase.
- A pipelined sequence sends on one thread and collects on another, with a new item per send, and stays alive until every response has arrived.
- When completion reorders, match by transaction ID: look up each arriving response, or wait for a chosen ID and accept the head-of-line wait.
Practice
- Keep a two-clock memory busy Implement · Hard