Lock, grab and relevance

Give one sequence exclusive use of the driver with lock or grab, keep a sequence out of arbitration until it may send, and build interrupt service on top of both.

Lesson
U5.2
Time
35 min
Runs on
Verilator with UVM
Check
5 questions

A sequence calls lock() at 25 ns and sends its burst, expecting the bus from that moment. The log shows one more item from another sequence after the lock call, then the burst. Nothing is wrong: the lock was granted at 25 ns, but the driver was in the middle of an item, and a lock only takes effect at the next arbitration.

A lock requested at 25 ns under FIFO: one clock per item
01234567done at (ns)1020304050607080itemB0C0B1A0A1A2A3C1lock_list-A-lock() at 25: granted at oncefirst arbitration under the lock

B1 was already in the driver when A locked. From the next arbitration on, only A's requests are candidates, until A unlocks after A3.

A lock is a request too

U5.1 described the arbitration queue of item requests. lock() and grab() put a second kind of request into the same queue, a lock request. Both calls block until that request is granted. Once it is, the sequence is on the sequencer's lock_list, and from the next arbitration only this sequence and its children are candidates.

The two calls differ in one line of the library. lock() appends its request to the back of the queue, behind every request already there. grab() inserts it at the front. Right after queuing, both call the same routine, which grants the first lock request in the queue whose sequence is not blocked by an existing lock.

So on a sequencer with no lock in force, lock() and grab() behave the same. The request is the only lock request, it is not blocked, and it is granted at once. The example's first two scenarios show this. A locks in one and grabs in the other, at 25 ns, under FIFO, and both print B0 C0 B1 A0 A1 A2 A3 C1 B2 C2 B3 C3.

The difference appears when a lock is already held and more than one sequence is queuing for the next one. B holds a lock from the start. A requests a lock at 15 ns and C at 25 ns. If C uses lock(), C's request sits behind A's, and after B releases the order is B, A, C. If C uses grab(), its request goes to the front and the order is B, C, A. The example measures both: B0 B1 B2 B3 A0 A1 A2 A3 C0 C1 C2 C3 and B0 B1 B2 B3 C0 C1 C2 C3 A0 A1 A2 A3.

Neither call pre-empts the driver. Whatever item the driver holds when the lock is granted completes normally; that is the B1 in the hook. If the burst must start on a clean bus, the locking sequence should wait for the driver to go idle after lock() returns. A signal-wait helper like the ones below does that.

Who a lock blocks

While lock_list is not empty, the arbiter asks is_blocked(seq) for every item request. A sequence is blocked if any entry on the list is neither the sequence itself nor one of its ancestors, found by walking get_parent_sequence(). Two consequences follow.

First, locks nest. A sequence that holds a lock can start a child with child.start(m_sequencer, this), and the child gets grants while everyone else waits. The example's sixth scenario has A lock, start child a with two items, then send its own: a0 a1 A0 A1 B0 C0 B1 C1. A child may even take its own lock while the parent holds one; then the parent cannot send until the child unlocks, a trap the Cookbook calls out.

Second, a lock beats every priority. Under STRICT_FIFO with A at 100, B at 200 and C at 300, A's lock() at 25 ns still wins the sequencer: C0 C1 C2 A0 A1 A2 A3 C3 B0 B1 B2 B3. The lock request itself is not arbitrated by priority; it is granted as soon as no other lock blocks it. Priorities only rank item requests.

The sequencer answers questions about this state. is_grabbed() is 1 while any lock is held, current_grabber() returns the most recent holder, has_lock(seq) and is_blocked(seq) ask about one sequence. In a hang, sqr.print() shows the lock list next to the arbitration queue.

Releasing

unlock() and ungrab() are one function with two names. Either removes the caller's entry from the lock list and immediately grants the next queued lock request, if there is one. Calling it from a sequence that holds no lock is a warning, SQRUNL, and changes nothing. Pairing lock() with ungrab() works, though a reader will trust you less.

A sequence that finishes while still holding a lock is a different matter. The sequencer notices as the sequence exits, reports a UVM_ERROR with the id SEQFINERR and removes the lock, so the other sequences continue. The Cookbook, written against earlier releases, describes a deadlock instead. Either way it is a bug: release in every path out of body().

Release depends on the path

task body();
  grab();
  if (!check_status()) return;   // leaves the grab
  send(0); send(1);
  ungrab();
endtask

One early return and the sequence ends holding the sequencer. The sequencer reports SEQFINERR and frees it, and the run fails on the error count.

One exit, one release

task body();
  grab();
  if (check_status()) begin
    send(0); send(1);
  end
  ungrab();
endtask

Every path reaches ungrab, and the error never appears.

If the process that requested a lock is killed before the grant, the sequencer removes the stale request with error SEQLCKZMB rather than deadlock. These three reports, SQRUNL, SEQFINERR and SEQLCKZMB, are the ones to search for when a locked sequencer misbehaves.

Stepping out of arbitration

Sometimes a sequence has items ready but may not send them yet: a flow-control window is closed, a credit count is zero, the design is in the wrong state. Blocking inside body() before start_item works, but then the sequence holds no request and nobody knows it is waiting. UVM offers a cleaner way: the sequence stays in the queue and tells the arbiter to skip it.

is_relevant() is a function the arbiter calls for each request of yours; return 0 and the request is not a candidate at this arbitration. wait_for_relevant() is a task the sequencer calls when every queued request is irrelevant; it must block until your condition could have changed. Returning from it in zero time again and again is a fatal error, SEQRELEVANTLOOP, once the zero-time returns exceed a limit of 10 by default. Wait for a real event there.

A sequence that sends only while the configuration object holds credits. post_do runs after each item is done; U5.3 covers the hooks.
class credit_seq extends src_seq;
  incr_cfg cfg;
  function bit is_relevant(); return cfg.credits > 0; endfunction
  task wait_for_relevant(); cfg.wait_for_credit(); endtask
  function void post_do(uvm_sequence_item this_item); cfg.credits--; endfunction
endclass

The example gives C two credits and adds two more at 75 ns, while B sends freely under FIFO. The measured line is B0 C0 B1 C1 B2 B3 C2 C3 (done at +100 ns). C's first two items interleave with B's. With no credits left, C's request stays queued but is skipped, so B2 and B3 go out. At 60 ns the driver has only C's irrelevant request, and it waits; the credits arrive at 75 ns, and C2 and C3 follow.

The alternative, blocking in body(), would give the same grant order here. The difference shows when you debug: a relevant-but-waiting sequence is visible in sqr.print(), and the zero-time guard catches a wait_for_relevant that does not really wait.

Interrupt service

Interrupts are the classic use of all this. The Cookbook names three patterns. An exclusive service routine grabs the sequencer, so no other stimulus runs until it is done, like a non-maskable interrupt. A prioritised one, in the Cookbook's description, uses lock() with a high priority, so that it waits its turn and then keeps the bus. In this library the lock request itself ignores priority, as the STRICT_FIFO scenario above showed, so that recipe does not rank service routines. To rank them here, give their items a high priority under STRICT_FIFO and skip the lock, or lock and accept that lock requests are served first come, first served. And hardware-triggered sequences simply wait for a signal and send, taking their chances in normal arbitration.

All three need a way for a sequence to see a pin. A sequence should not hold a virtual interface of its own; instead the agent's configuration object holds it, with small signal-wait helpers: wait_for_irq() is @(posedge vif.irq), wait_for_credit() waits for an event. The sequence fetches the object through m_sequencer from the configuration database, as the example's credit_seq does in pre_start.

The example's last scenario wires an exclusive service routine. C's third value is 255, so the incrementer raises irq when it accepts that item. A forked watcher waits for the interrupt and starts sequence I, which grabs, sends two items and ungrabs. Measured: B0 C0 B1 C1 B2 C2 B3 I0 I1 C3. B3 had already been taken by the driver in the same time step as the interrupt, so it completes first. From the next arbitration I owns the bus, and C3 waits.

In a real bench the watcher lives in the control sequence, usually a virtual sequence, in a fork next to the main traffic. It loops: wait for the interrupt, run the service sequence, wait again. Keep the service sequence short, release at its end, and remember that the item in flight when the interrupt arrives is never cut off.

Try it

Eight scenarios on one bench, one grant log each. The first two are identical because no lock was held when A asked. The fourth and fifth differ only in C's call, and that is the whole difference between lock and grab: where the request is queued relative to other lock requests.

Try moving A's delay in the first scenario from 25 to 35 ns and predict which item of B or C slips in before A0. Then delete the ungrab() from the service routine: the run still completes, with one SEQFINERR error from the sequencer.

RUNVerilator + UVM 2020.3.1 ยท simulator service
Edit the code, then run it
Expected among the UVM messages: FIFO, A lock() at 25 ns -> B0 C0 B1 A0 A1 A2 A3 C1 B2 C2 B3 C3 FIFO, A grab() at 25 ns -> B0 C0 B1 A0 A1 A2 A3 C1 B2 C2 B3 C3 STRICT_FIFO, A(100) lock() at 25 ns -> C0 C1 C2 A0 A1 A2 A3 C3 B0 B1 B2 B3 B holds lock; A lock 15, C lock 25 -> B0 B1 B2 B3 A0 A1 A2 A3 C0 C1 C2 C3 B holds lock; A lock 15, C grab 25 -> B0 B1 B2 B3 C0 C1 C2 C3 A0 A1 A2 A3 A lock() then child a -> a0 a1 A0 A1 B0 C0 B1 C1 FIFO, C needs credits -> B0 C0 B1 C1 B2 B3 C2 C3 (done at +100 ns) FIFO, ISR I grabs on irq -> B0 C0 B1 C1 B2 C2 B3 I0 I1 C3 UVM_ERROR : 0

Check yourself

Predict the outputWhat line does the $display print? UVM's own messages are not part of the answer.
`timescale 1ns/1ns
`include "uvm_macros.svh"
import uvm_pkg::*;
class item_c extends uvm_sequence_item;
  `uvm_object_utils(item_c)
  int idx;
  function new(string name = "item_c"); super.new(name); endfunction
endclass
class drv_c extends uvm_driver #(item_c);
  `uvm_component_utils(drv_c)
  string grants = "";
  int done;
  function new(string name, uvm_component parent); super.new(name, parent); endfunction
  task run_phase(uvm_phase phase);
    forever begin
      seq_item_port.get_next_item(req);
      grants = {grants, " ", req.get_parent_sequence().get_name(), $sformatf("%0d", req.idx)};
      #10;
      seq_item_port.item_done();
      done++;
    end
  endtask
endclass
class seq_c extends uvm_sequence #(item_c);
  `uvm_object_utils(seq_c)
  int n = 3, delay = 0;
  bit use_grab = 0;
  function new(string name = "seq_c"); super.new(name); endfunction
  task body();
    #(delay);
    if (use_grab) grab();
    for (int i = 0; i < n; i++) begin
      item_c it = item_c::type_id::create("it");
      it.idx = i;
      start_item(it);
      finish_item(it);
    end
    if (use_grab) ungrab();
  endtask
endclass
class my_test extends uvm_test;
  `uvm_component_utils(my_test)
  drv_c drv;
  uvm_sequencer #(item_c) sqr;
  function new(string name, uvm_component parent); super.new(name, parent); endfunction
  function void build_phase(uvm_phase phase);
    drv = drv_c::type_id::create("drv", this);
    sqr = uvm_sequencer #(item_c)::type_id::create("sqr", this);
  endfunction
  function void connect_phase(uvm_phase phase);
    drv.seq_item_port.connect(sqr.seq_item_export);
  endfunction
  task run_phase(uvm_phase phase);
    seq_c a = seq_c::type_id::create("A");
    seq_c b = seq_c::type_id::create("B");
    a.n = 2; a.delay = 15; a.use_grab = 1;
    phase.raise_objection(this);
    fork
      a.start(sqr);
      b.start(sqr);
    join
    $display("order:%s", drv.grants);
    phase.drop_objection(this);
  endtask
endclass
module tb;
  initial run_test("my_test");
endmodule
Spot the bugBackground sequence B sends four items; service sequence I starts at 15 ns and must send its two items back to back, giving the order B0 B1 I0 I1 B2 B3. Each option is the body of I. Which one fails?
ConceptSequence B holds a lock. A calls lock() at 15 ns and C calls grab() at 25 ns. B releases at 40 ns. Who gets the sequencer next, and why?
ConceptA virtual sequence locks a bus sequencer and then starts a child sequence on that sequencer with child.start(sqr, this). Meanwhile another test sequence has items queued there. What happens?
ConceptA sequence overrides is_relevant() to return 0 while a credit counter is zero, and it is the only sequence with a request queued. Its wait_for_relevant() is written as #0;. Credits arrive at 75 ns. What does uvm-core 2020.3.1 do?

Remember

  • lock() and grab() queue a lock request and block until it is granted; the lock takes effect at the next arbitration, so the item in flight completes.
  • lock queues at the back and grab at the front; with no lock held they behave the same, and only other lock requests see the difference.
  • A lock blocks every sequence except the holder and its descendants, whatever their priorities.
  • unlock and ungrab are the same function; a sequence that ends holding a lock gets a SEQFINERR error and loses the lock.
  • is_relevant() keeps a request queued but out of arbitration; wait_for_relevant() must really wait, or the zero-time guard stops the run.