UVM
Sequencer Architecture
The sequencer's internal structure — a component that brokers sequences and the driver through a seq_item pull channel, with an arbitration queue, a lock list, and the TLM pull methods underneath.
Sequencers · Module 11 · Page 11.1
The Engineering Problem
The sequences modules (Modules 9–10) built everything that runs on a sequencer — sequences, items, the handshake, arbitration, virtual/layered/nested/parallel patterns. Throughout, the sequencer was a black box: a thing you start sequences on, that arbitrates and feeds the driver. This module opens the box. To reason about subtle behavior — why a non-blocking pull returns null, how arbitration and locks are actually queued, what the driver connects to — you need the sequencer's internal architecture: its class hierarchy, the TLM channel it exposes, and the queues underneath.
A sequencer is a uvm_component (permanent, in the hierarchy — unlike transient sequences) whose job is to broker between sequences (producers) and the driver (consumer). Its architecture has three parts. First, a TLM pull channel: the sequencer's seq_item_export (a pull implementation) that the driver's seq_item_port connects to, exposing the methods get_next_item, try_next_item, item_done, get, peek, put. Second, internal queues: an arbitration queue of sequences waiting for a grant, and a lock/grab list of sequences holding exclusivity. Third, a class hierarchy (uvm_sequencer → uvm_sequencer_param_base → uvm_sequencer_base → uvm_component) that layers the typed item/response handling over the arbitration machinery. This chapter is that anatomy: the component, the pull channel, the queues, the methods — and why the non-blocking try_next_item returning null is the architecture detail that bites.
What is the sequencer's internal architecture — the component class hierarchy, the seq_item pull channel (export/port and its TLM methods) it exposes to the driver, and the internal queues (arbitration, lock) underneath — and why does the non-blocking
try_next_itemreturn null?
Motivation — why the architecture is shaped this way
The sequencer's internal structure isn't arbitrary; each part answers a structural need of brokering producers and a consumer:
- It's permanent infrastructure, so it's a component. Sequences come and go (transient), but the channel between them and the driver must persist for the whole simulation — built in
build_phase, connected inconnect_phase. So the sequencer is auvm_component, part of the hierarchy, not a transient object. - The driver consumes by pulling, so the channel is a pull export. The driver takes items when it's ready (back-pressure, Module 8.3), so the channel must let the driver pull. The sequencer exposes a pull export (
seq_item_export) implementing the pull methods; the driver's pull port connects to it. The export is the consumer-facing interface. - Many sequences compete, so there's an arbitration queue. Multiple sequences
start_itemconcurrently; the sequencer must hold them and choose. So internally it maintains an arbitration queue of waiting sequences, on which it runs the arbitration policy (Module 10.4) at each grant. - Exclusivity must be tracked, so there's a lock list.
lock/grab(Modules 9.2, 10.3) suspend arbitration for a sequence's subtree; the sequencer needs to record who holds exclusivity, so it keeps a lock/grab list that overrides the normal arbitration. - Typed items layer over generic arbitration, so the class hierarchy is layered. The arbitration/lock machinery is type-agnostic (it lives in
uvm_sequencer_base); the typed item and response handling adds on top (uvm_sequencer_param_base#(REQ,RSP), thenuvm_sequencer). Layering keeps the generic and typed concerns separate.
The motivation, in one line: brokering transient producers and a pulling consumer needs persistent infrastructure (a component), a pull channel (the export the driver pulls from), and internal queues (arbitration + locks) to choose among waiting sequences — with a layered class hierarchy separating generic arbitration from typed item handling.
Mental Model
Hold the sequencer's architecture as a ticketed service counter with a back office:
The sequencer is a service counter: a front window where the server pulls the next customer, and a back office of queues that decide who's next — a permanent fixture, staffed all day. The front window is the pull channel (
seq_item_export): the server (the driver) steps up when it's ready and says "next" (get_next_item), and the window hands over one customer (item). The window has a few service modes: "next, and I'll wait if there's a line" (get_next_item, blocking), "next if anyone's here right now, otherwise I'll do something else" (try_next_item, non-blocking — returns nobody if the queue's empty), and "I'm done with this one" (item_done). The back office is the internal queues: an intake queue of customers who've taken a number and are waiting (the arbitration queue), and a reservations list of customers who booked the counter exclusively (the lock/grab list), which jumps the normal queue. The counter itself is a permanent fixture (a component) — it's built once and staffed for the whole day, unlike the customers (sequences) who come and go. And the one thing that trips people: if the server uses "next if anyone's here right now" and nobody is, the window hands over nobody (null) — the server must notice and not try to serve an empty chair.
So the sequencer's architecture is front window + back office + permanent fixture: a pull channel the driver pulls from, internal queues (arbitration + locks) that order who's served, and a component that persists. The non-blocking "next if anyone's here" returning nobody is the detail to respect.
Visual Explanation — the sequencer's internal anatomy
The defining picture is the inside of the sequencer: producers feeding an arbitration queue, a lock list overriding it, and a pull export the driver pulls from.
The figure opens the sequencer to show the three internal parts working together. Producers — the sequences — register in the arbitration queue when they start_item (each waiting for a grant). The lock/grab list records sequences holding exclusivity, and it overrides normal arbitration — a locked sequence's subtree is selected regardless of the queue. At each grant (when the driver pulls), the select step applies the arbitration policy (Module 10.4) to the queue, unless a lock is held (then the holder is selected), and produces the granted item. That item is handed out through the seq_item_export — the pull channel, the sequencer's consumer-facing TLM interface — which the driver's seq_item_port pulls from (get_next_item). Responses flow back through the same channel (item_done(rsp) → put). The whole assembly lives inside the sequencer component, which holds the queues and the export and persists for the simulation. The key architectural reading is the flow: producers → queues (arbitration + lock) → select → export → driver. Everything the sequences modules did from the outside (start_item registers in the queue; lock/grab populates the lock list; the driver's get_next_item pulls from the export) now has an inside: the queues that hold and order, and the export that delivers. The sequencer is the queues plus the channel, packaged as a component.
RTL / Simulation Perspective — the class hierarchy and the channel
The sequencer's architecture shows up as a class hierarchy (layering typed handling over arbitration) and a connection (the pull export to the driver's port). The code makes both concrete.
// ── CLASS HIERARCHY: typed handling layered over arbitration over component ──
// uvm_sequencer #(REQ,RSP) ← your sequencer (typed items/responses)
// extends uvm_sequencer_param_base #(REQ,RSP) ← typed item/response handling
// extends uvm_sequencer_base ← arbitration queue, lock list, policy
// extends uvm_component ← permanent, in the hierarchy
class bus_sequencer extends uvm_sequencer #(bus_item); // a typed sequencer
`uvm_component_utils(bus_sequencer)
function new(string name, uvm_component parent); super.new(name, parent); endfunction
endclass
// ── THE PULL CHANNEL: driver's port connects to the sequencer's export ──
class my_agent extends uvm_agent;
function void connect_phase(uvm_phase phase);
driver.seq_item_port.connect(sequencer.seq_item_export); // pull port → pull export
endfunction
endclass
// ── THE TLM PULL METHODS the export implements (the driver calls these) ──
// get_next_item(req) : BLOCKING — wait for and return the next granted item
// try_next_item(req) : NON-BLOCKING — return the next item, or NULL if none is grantable now
// item_done(rsp = null): signal completion (optionally return a response)
// get(req) : get_next_item + item_done combined (blocking)
// peek(req) : return the next item WITHOUT consuming it (blocking)
// put(rsp) : return a response
task my_driver::run_phase(uvm_phase phase);
forever begin
if (cfg.use_try) begin
seq_item_port.try_next_item(req); // NON-BLOCKING
if (req == null) begin do_other_work(); continue; end // ← MUST check null (no item ready)
end else
seq_item_port.get_next_item(req); // BLOCKING (always returns a valid item)
drive_on_pins(req);
seq_item_port.item_done();
end
endtaskThe code shows the two structural facts of the architecture. The class hierarchy (the comment chain): your bus_sequencer extends uvm_sequencer #(bus_item), which extends uvm_sequencer_param_base #(REQ,RSP) (the typed item/response handling), which extends uvm_sequencer_base (the arbitration queue, lock list, and policy — the type-agnostic machinery), which extends uvm_component (making it permanent, in the hierarchy). So the typed concerns layer over the arbitration concerns over the component — the layering from the Motivation. The pull channel is the one connect_phase line: driver.seq_item_port.connect(sequencer.seq_item_export) wires the driver's pull port to the sequencer's pull export. And the TLM pull methods the export implements (the comment block, then the driver code): get_next_item is blocking (waits for and returns a valid item), while try_next_item is non-blocking — it returns the next item or null if none is grantable now, so a driver using it must check for null (if (req == null)) before driving, or it crashes (the DebugLab). The others — item_done, get, peek, put — round out the pull interface. The shape to carry: the sequencer's architecture is a layered class hierarchy exposing a pull export with these TLM methods, and the blocking-vs-non-blocking distinction (get_next_item vs try_next_item) is the one with a null trap.
Verification Perspective — the class hierarchy layers
The sequencer's class hierarchy is not incidental — it layers three distinct concerns (component, arbitration, typed handling), and knowing which layer does what explains where behavior comes from.
The hierarchy separates concerns by layer, and knowing the layers tells you where each behavior lives. At the bottom, uvm_component makes the sequencer permanent infrastructure: it has a place in the hierarchy, a parent, and phases (build_phase, connect_phase, run_phase) — this is why the sequencer is built and connected like any component and persists for the simulation (the contrast with transient sequences). Above it, uvm_sequencer_base holds the type-agnostic arbitration machinery: the arbitration queue (waiting sequences), the lock/grab list (exclusivity), and the arbitration policy (Module 10.4) — all the arbitration and locking behavior lives here, independent of the item type, which is why arbitration works the same regardless of what items flow. Above that, uvm_sequencer_param_base #(REQ,RSP) adds the typed concerns: handling the specific request and response types — the REQ/RSP parameterization that ties the sequencer to its item type. At the top, uvm_sequencer #(REQ,RSP) is your sequencer, exposing the seq_item_export the driver connects to. The verification value of knowing the layers is attribution: when you reason about arbitration, it's uvm_sequencer_base; when about typed items/responses, it's uvm_sequencer_param_base; when about phases/lifetime, it's uvm_component. The layering is why the same arbitration machinery serves every sequencer type — it's in the type-agnostic base — and why the typed handling is a separate concern on top. So the class hierarchy is the architecture's organizing principle: generic arbitration in the middle, typed handling on top, component at the base.
Runtime / Execution Flow — the pull channel in operation
At run time, the sequencer's architecture operates as a pull channel: the driver calls the export's methods, which consult the internal queues and return items. The flow shows the methods driving the internal machinery.
The pull channel is the operational view of the architecture: the TLM methods are the interface to the internal queues. Step 1, the driver calls get_next_item (or try_next_item) on its seq_item_port, which reaches the sequencer's seq_item_export — the call enters the sequencer. Step 2, the export consults the internal queues: it runs the arbitration policy on the arbitration queue to pick a winner among waiting sequences (Module 10.4), unless a lock is held (then the lock holder is selected) — this is where the queues from Figure 1 are used. Step 3, it returns the granted item to the driver — and here the blocking distinction matters: get_next_item blocks until an item is grantable (so it always returns a valid item), while try_next_item returns immediately with the item or null if nothing is grantable right now (the queue is empty or all sequences are between items). Step 4, the driver drives the item and calls item_done, which completes the transaction (unblocking the sequence's finish_item) and accepts any response (put) to route back. So the architecture in operation is: the driver's pull methods drive the internal arbitration/lock queues, which select and return items. The methods aren't separate from the queues — they're the door to them. And the non-blocking try_next_item's null return is the visible consequence of the architecture: it reports "the queues have nothing grantable now," which a driver must handle (the DebugLab), unlike get_next_item which simply waits until they do.
Waveform Perspective — get_next_item vs try_next_item
The architectural distinction between the blocking and non-blocking pull methods is visible on a timeline: get_next_item waits for an item; try_next_item returns immediately, with null when nothing is ready.
get_next_item (blocks until an item) vs try_next_item (returns now, null if none)
12 cyclesThe waveform contrasts the two pull behaviors the sequencer's export provides. item_avail marks when the internal arbitration queue has a grantable item. With get_next_item, the driver blocks — gni_wait is high — until an item is available (item_avail rises around cycle 3), then it returns the item (R); get_next_item always returns a valid item because it waits for one. With try_next_item, the driver calls and returns immediately: when no item is available (e.g., cycle 1, cycle 6), it returns null (tni_null pulses) and the driver goes off to do other work; when one is available (cycle 7), it returns the item. The visual difference is blocking vs not: get_next_item waits (a held gni_wait), while try_next_item returns instantly with either an item or null. The tni_null pulses are the architectural detail that bites — they're the export reporting "nothing grantable now," and a driver using try_next_item must handle them (do other work, retry) rather than dereferencing the null. The picture to carry: the sequencer's pull export offers two ways to ask for an item — wait until there's one (get_next_item, always valid) or take one if there is, else nothing (try_next_item, possibly null) — and the choice between them, and handling the null, is the most consequential bit of the architecture for a driver.
DebugLab — the driver that crashed on a null try_next_item
A driver that crashed because it didn't null-check try_next_item
A driver used try_next_item (so it could do other work — drive idle cycles, check a side channel — when no transaction was pending) and crashed intermittently with a null-handle dereference: it tried to drive req on the pins and req was null. The crash happened only sometimes — under heavy traffic it ran fine, but during idle periods (no sequence had an item ready) it faulted. The same driver using get_next_item never crashed.
try_next_item is non-blocking and returns null when no item is grantable — and the driver used the returned handle without checking for null:
driver run loop (using try_next_item):
seq_item_port.try_next_item(req); // NON-BLOCKING: returns the item, or NULL if none grantable now
// ✗ MISSING: if (req == null) ... ← no null check
drive_on_pins(req); // ✗ during idle, req is NULL → null-handle crash
seq_item_port.item_done();
why intermittent:
heavy traffic → an item is usually available → try_next_item returns a valid item → works
idle period → no sequence has an item ready → try_next_item returns NULL → drive_on_pins(null) → CRASH
fix — null-check try_next_item; only drive when non-null, and only item_done when you got one:
seq_item_port.try_next_item(req);
if (req == null) begin
drive_idle(); // ✓ no item — do other work, don't drive a null
continue;
end
drive_on_pins(req);
seq_item_port.item_done(); // ✓ only after actually getting an itemThis is the try_next_item-null-deref bug, the architecture's most common trap. The sequencer's pull export offers two ways to get an item: get_next_item, which blocks until one is grantable (so it always returns a valid item), and try_next_item, which is non-blocking — it returns the next item or null if nothing is grantable right now. A driver uses try_next_item precisely because it wants to not block — to do other work when no transaction is pending. But that means it will sometimes get null, and it must check for it. This driver didn't: it called try_next_item and then immediately drove req on the pins, so during idle periods — when no sequence had an item ready and try_next_item returned null — drive_on_pins(null) crashed. The intermittency is the giveaway: it worked under heavy traffic (an item was usually available, so try_next_item returned a valid one) and crashed during idle (no item → null). The contrast with get_next_item (which never crashes here) confirms it: get_next_item blocks through the idle period and only returns when there's a valid item, so there's no null to mishandle. The fix is to null-check try_next_item: only drive_on_pins and item_done when req is non-null; when it's null, do the other work the driver chose try_next_item for. The general lesson, and the architecture point: get_next_item is blocking and always valid; try_next_item is non-blocking and possibly null — so any driver using try_next_item must handle the null return, because the null is the export's way of saying "the queues have nothing for you right now."
The tell is an intermittent null crash in a driver during idle periods. Diagnose try_next_item bugs:
- Check whether the driver uses
try_next_item. A null-handle crash on the pulled item points attry_next_item(non-blocking, can return null), notget_next_item(blocking, always valid). - Correlate the crash with idle/low traffic. A crash that happens during idle (no item ready) but not under heavy traffic is the
try_next_item-null signature. - Confirm the null check is missing. Find the
try_next_itemcall and verify there's anif (req == null)guard before driving; its absence is the bug. - Check
item_doneplacement.item_doneshould be called only when an item was actually obtained — calling it on a null path is a related error.
Handle the non-blocking pull correctly:
- Null-check every
try_next_item. Guard withif (req == null)and take the no-item path (idle, other work, retry); only drive when non-null. - Call
item_doneonly after obtaining an item. Pair it with a successful (non-null)try_next_itemor aget_next_item, never on the null path. - Use
get_next_itemif you don't need non-blocking. If the driver has nothing else to do when idle,get_next_item(blocking, always valid) avoids the null entirely — reach fortry_next_itemonly when you genuinely want to do other work while waiting. - Test under idle and contention. The null path appears only when no item is ready, so test idle periods, not just heavy traffic, to surface the missing null check.
The one-sentence lesson: the sequencer's get_next_item is blocking and always returns a valid item, while try_next_item is non-blocking and returns null when nothing is grantable now — so a driver using try_next_item must null-check the result (driving a null crashes during idle periods), and use get_next_item instead when it has nothing else to do while waiting.
Common Mistakes
- Not null-checking
try_next_item. It's non-blocking and returns null when no item is grantable; driving a null crashes during idle. Guard withif (req == null). - Calling
item_doneon a null path.item_donebelongs only after actually obtaining an item; pairing it with a nulltry_next_itemis an error. - Treating the sequencer as transient. It's a
uvm_component— built inbuild_phase, connected inconnect_phase, persistent — unlike sequences. Don't create it like an object. - Connecting the wrong port/export. The driver's
seq_item_portconnects to the sequencer'sseq_item_export; mismatched or missing connection breaks the pull channel. - Mismatched
REQ/RSPparameterization. The sequencer, driver, and items must agree on the item type; a mismatch fails the typed handling at elaboration. - Assuming arbitration is in your sequencer. Arbitration lives in
uvm_sequencer_base; your typed sequencer inherits it. Don't reimplement it.
Senior Design Review Notes
Interview Insights
A sequencer is a uvm_component that brokers between sequences and the driver, and its architecture has three parts. First, a TLM pull channel: the sequencer exposes a seq_item_export, which is a pull implementation, and the driver's seq_item_port connects to it; through this channel the driver pulls items by calling methods like get_next_item, try_next_item, item_done, get, peek, and put. Second, internal queues: an arbitration queue holding the sequences that are waiting for a grant, and a lock/grab list recording sequences that hold exclusivity, which overrides normal arbitration. At each grant — when the driver pulls — the sequencer selects from these queues, running the arbitration policy on the arbitration queue or honoring a lock, and hands the granted item out through the export. Third, a layered class hierarchy: uvm_sequencer parameterized by request and response types extends uvm_sequencer_param_base, which adds typed item and response handling, which extends uvm_sequencer_base, which holds the type-agnostic arbitration machinery — the arbitration queue, lock list, and policy — which extends uvm_component, making the sequencer permanent and part of the hierarchy with phases. So the architecture is a permanent component exposing a pull export, with internal arbitration and lock queues underneath, and a class hierarchy that separates generic arbitration from typed handling. Everything the sequences did from the outside — start_item registering in the queue, lock/grab populating the lock list, the driver's get_next_item pulling from the export — has this inside: the queues that hold and order sequences, and the channel that delivers items to the driver.
Both are pull methods the driver calls on its port to get the next item from the sequencer, but get_next_item is blocking and try_next_item is non-blocking. get_next_item blocks until an item is grantable — it waits for a sequence to have an item and win arbitration — so it always returns a valid item; the driver using it simply waits during idle periods when nothing is pending. try_next_item returns immediately: if an item is grantable right now, it returns it; if nothing is grantable — the arbitration queue is empty or all sequences are between items — it returns null. So the key behavioral difference is that get_next_item always gives you a valid item but may block, while try_next_item never blocks but may give you null. You use try_next_item when the driver has something else to do while waiting — drive idle cycles, check a side channel, handle another interface — so it doesn't want to block; and you use get_next_item when the driver has nothing else to do and is happy to wait. The critical consequence is that a driver using try_next_item must check the return for null before driving, because during idle periods it will get null, and driving a null item crashes. This is the most common sequencer-architecture bug: a driver that uses try_next_item but treats the result like get_next_item, dereferencing a null during idle and crashing intermittently. With get_next_item there's no null to mishandle because it blocks through the idle. So the rule is: get_next_item blocks and is always valid; try_next_item returns now and must be null-checked.
Exercises
- Name the layers. List the sequencer class hierarchy from
uvm_sequencertouvm_componentand state what concern each layer owns. - Connect the channel. Write the
connect_phaseline wiring the driver to the sequencer, and name the port and export types involved. - Fix the null crash. A driver uses
try_next_itemand crashes during idle. Rewrite the loop with the null check, and explain whyget_next_itemwouldn't crash. - Locate the behavior. For (a) arbitration, (b) typed response handling, (c) phasing — name the class in the hierarchy that owns each.
Summary
- A sequencer is a
uvm_component(permanent, in the hierarchy — unlike transient sequences) that brokers sequences (producers) and the driver (consumer) through a TLM pull channel with internal queues underneath. - The pull channel is the sequencer's
seq_item_export(a pull implementation) that the driver'sseq_item_portconnects to, exposingget_next_item(blocking, always valid),try_next_item(non-blocking, null when nothing grantable),item_done,get,peek,put. - The internal queues are the arbitration queue (sequences waiting for a grant) and the lock/grab list (exclusivity holders, overriding arbitration) — the pull methods are the door to these queues.
- The class hierarchy layers concerns:
uvm_sequencer(typed, your sequencer) →uvm_sequencer_param_base(typed item/response) →uvm_sequencer_base(arbitration queue, lock list, policy — type-agnostic) →uvm_component(permanent, phased). - The durable rule of thumb: understand the sequencer as a component exposing a pull export over internal arbitration/lock queues, connect the driver's
seq_item_portto itsseq_item_export, and remember the blocking/non-blocking split —get_next_itemwaits and is always valid, whiletry_next_itemreturns now and is null when nothing is grantable, so a driver usingtry_next_itemmust null-check it.
Next — Sequencer Arbitration: the architecture exposed the arbitration queue inside uvm_sequencer_base; the next chapter goes deeper into the arbitration engine itself — how the queue is ordered, how grants are issued from it, and the internal mechanics beneath the arbitration modes.