UVM-14: Virtual Sequences and Sequencers — VLSI Trainers
VLSI Trainers UVM Series · UVM-14
UVM Series · UVM-14

Virtual Sequences & Sequencers

Coordinating stimulus across multiple protocol interfaces — what a virtual sequence is, the recommended sequencer-handle pattern, the legacy virtual sequencer pattern, base class and init_vseq methodology, sequential and parallel sub-sequence execution, and sequencer arbitration.

📋 The Multi-Interface Problem

Every sequence seen so far runs on a single sequencer targeting a single driver. This is fine for a block-level testbench with one protocol interface. But most real DUTs have multiple interfaces — a control bus for register writes, a data bus for payload traffic, an interrupt line, a DMA interface, a status bus.

A single sequence cannot target two different sequencers at once — start_item() and finish_item() communicate with whichever sequencer the sequence was started on. If you want to initialise a register over the APB bus and then start data traffic over the AHB bus in a coordinated way, you need a mechanism that can operate both sequencers simultaneously.

This is what the virtual sequence solves. It is a coordinating sequence that holds handles to multiple target sequencers and starts sub-sequences on each, orchestrating the complete test scenario across all interfaces.

Virtual Sequence — Coordinating Multiple Sequencers test run_phase virtual sequence (top_vseq) holds handles to all target sequencers APB Agent apb_seqr ← apb_drv AHB Agent ahb_seqr ← ahb_drv SPI Agent spi_seqr ← spi_drv apb_seq.start(apb_seqr) ahb_seq.start(ahb_seqr) spi_seq.start(spi_seqr) vlsitrainers.com
Figure 1 — Virtual sequence coordinating three agents. The test creates the virtual sequence, assigns the sequencer handles from the component hierarchy, and starts it. The virtual sequence body() then starts sub-sequences on each target sequencer, either in sequence or in parallel, coordinating the full test scenario.

📋 What a Virtual Sequence Is

A virtual sequence is a regular uvm_sequence parameterised on uvm_sequence_item (the base item class) rather than a specific protocol item. This is because a virtual sequence does not itself send sequence items — it only starts sub-sequences on other sequencers. The uvm_sequence_item parameter is a placeholder.

Because it does not send items directly, a virtual sequence is started with null as the sequencer argument. The actual sub-sequences it starts each get their own real sequencer handle.

// ── Recommended typedef ───────────────────────────────────
typedef uvm_sequence #(uvm_sequence_item) uvm_virtual_sequence;

// ── Virtual sequence structure ────────────────────────────
class top_vseq extends uvm_virtual_sequence;
  `uvm_object_utils(top_vseq)

  // Handles to the target sequencers — assigned before start()
  uvm_sequencer #(apb_seq_item) apb_seqr;
  uvm_sequencer #(ahb_seq_item) ahb_seqr;
  uvm_sequencer #(spi_seq_item) spi_seqr;

  function new(string name = "top_vseq");
    super.new(name);
  endfunction

  task body();
    apb_init_seq  apb_init;
    ahb_burst_seq ahb_burst;
    spi_config_seq spi_cfg;

    apb_init  = apb_init_seq::type_id::create("apb_init");
    ahb_burst = ahb_burst_seq::type_id::create("ahb_burst");
    spi_cfg   = spi_config_seq::type_id::create("spi_cfg");

    // ① Configure APB registers first (sequential)
    apb_init.start(apb_seqr, this);

    // ② Start AHB and SPI in parallel once APB init done
    fork
      ahb_burst.start(ahb_seqr, this);
      spi_cfg.start(spi_seqr,   this);
    join
  endtask
endclass

// ── Starting a virtual sequence: start(null) ──────────────
task run_phase(uvm_phase phase);
  top_vseq vseq;
  phase.raise_objection(this);
  vseq = top_vseq::type_id::create("vseq");

  // Assign sequencer handles from component hierarchy
  vseq.apb_seqr = m_env.m_apb_agent.m_seqr;
  vseq.ahb_seqr = m_env.m_ahb_agent.m_seqr;
  vseq.spi_seqr = m_env.m_spi_agent.m_seqr;

  vseq.start(null);    // null — vseq has no target sequencer of its own
  phase.drop_objection(this);
endtask
Virtual sequences start with null as the sequencer. Since a virtual sequence does not send items itself, there is no target sequencer. The sub-sequences it starts each receive their own sequencer. If you accidentally pass a real sequencer to a virtual sequence’s start(), the sequence runs but m_sequencer points to an incompatible sequencer — sub-sequence starts will fail at runtime.

📋 Sequencer Handle Pattern — Recommended

The recommended approach is to place sequencer handles directly inside the virtual sequence as member variables. The test assigns them before calling start(). This approach is simple, explicit, and does not require any changes to the component hierarchy.

✓ Recommended — handles in virtual sequence

Virtual sequence declares typed sequencer handles. Test assigns them from the component hierarchy. Handles are type-safe — wrong type causes compile error. Each virtual sequence class is self-documenting about which sequencers it needs.

~ Alternative — virtual sequencer component

A uvm_sequencer sub-class holds handles to all agent sequencers. Virtual sequence uses p_sequencer to access them. More infrastructure; useful when many virtual sequences share the same set of sequencers. Covered in the virtual sequencer section below.

// ── Base virtual sequence class — shared by all tests ─────
class top_vseq_base extends uvm_sequence #(uvm_sequence_item);
  `uvm_object_utils(top_vseq_base)

  // One handle per agent sequencer in the environment
  uvm_sequencer #(apb_seq_item) apb_seqr;
  uvm_sequencer #(ahb_seq_item) ahb_seqr;
  uvm_sequencer #(spi_seq_item) spi_seqr;

  function new(string name = "top_vseq_base");
    super.new(name);
  endfunction

  // body() to be overridden by derived virtual sequences
  virtual task body();
    `uvm_info("VSEQ", "Base virtual sequence — override body()", UVM_LOW)
  endtask
endclass

📋 Base Class and init_vseq Pattern

When a project has many tests all using virtual sequences, the sequencer handle assignment is repetitive and error-prone. The solution is an init_vseq() function on the base test class that encapsulates all the hierarchical path knowledge in one place. Derived tests call it before starting any virtual sequence.

init_vseq Pattern — Centralised Handle Assignment base_test (uvm_test) my_env m_env; function void init_vseq(top_vseq_base v); v.apb_seqr = m_env.m_apb_agent.m_seqr; v.ahb_seqr = m_env.m_ahb_agent.m_seqr; v.spi_seqr = m_env.m_spi_agent.m_seqr; endfunction extends derived_test (base_test) task run_phase(…); top_vseq_A vseq = top_vseq_A::type_id::create(…); init_vseq(vseq); // one call — all handles set vseq.start(null); uses top_vseq_A (top_vseq_base) apb_seqr ← assigned by init_vseq ahb_seqr ← assigned by init_vseq spi_seqr ← assigned by init_vseq body(): starts sub-seqs on each seqr vlsitrainers.com
Figure 2 — init_vseq pattern. The base test class contains an init_vseq() function that knows all the hierarchical paths to every sequencer. Derived tests call init_vseq() once with any virtual sequence that extends top_vseq_base — all handles are assigned in one call. If the hierarchy changes, only init_vseq() needs updating.
// ── Base test — owns the hierarchy knowledge ──────────────
class base_test extends uvm_test;
  `uvm_component_utils(base_test)

  my_env m_env;

  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction

  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    m_env = my_env::type_id::create("m_env", this);
  endfunction

  // ── Single function encapsulates all sequencer paths ───
  function void init_vseq(top_vseq_base vseq);
    vseq.apb_seqr = m_env.m_apb_agent.m_seqr;
    vseq.ahb_seqr = m_env.m_ahb_agent.m_seqr;
    vseq.spi_seqr = m_env.m_spi_agent.m_seqr;
  endfunction
endclass

// ── Derived test — clean: one init call, one start call ───
class gpio_test extends base_test;
  `uvm_component_utils(gpio_test)

  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction

  task run_phase(uvm_phase phase);
    gpio_vseq vseq;
    phase.raise_objection(this);

    vseq = gpio_vseq::type_id::create("vseq");
    init_vseq(vseq);      // one call — all seqr handles assigned
    vseq.start(null);    // virtual seq has no target sequencer

    phase.drop_objection(this);
  endtask
endclass

📋 Complete Working Example

A complete virtual sequence for a GPIO DUT: first configure all registers over APB (sequential), then verify configuration via a parallel read-back sequence, then run randomised data traffic:

class gpio_vseq extends top_vseq_base;
  `uvm_object_utils(gpio_vseq)

  function new(string name = "gpio_vseq");
    super.new(name);
  endfunction

  task body();
    apb_reg_init_seq   reg_init;
    apb_read_back_seq  readback;
    apb_rand_seq       apb_rand;
    spi_rand_seq       spi_rand;

    reg_init  = apb_reg_init_seq::type_id::create("reg_init");
    readback  = apb_read_back_seq::type_id::create("readback");
    apb_rand  = apb_rand_seq::type_id::create("apb_rand");
    spi_rand  = spi_rand_seq::type_id::create("spi_rand");

    // ① Configure DUT registers — sequential (must complete first)
    reg_init.start(apb_seqr, this);

    // ② Verify config — sequential after init
    readback.start(apb_seqr, this);

    // ③ Randomised traffic on APB and SPI in parallel
    fork
      apb_rand.start(apb_seqr, this);
      spi_rand.start(spi_seqr, this);
    join

    `uvm_info("VSEQ", "GPIO virtual sequence complete", UVM_LOW)
  endtask
endclass

📋 Legacy Virtual Sequencer Pattern

An older pattern uses a virtual sequencer — a uvm_sequencer component sub-class that contains handles to all agent sequencers. Virtual sequences that need access to multiple sequencers are started on this virtual sequencer and access the handles through p_sequencer.

// ── Virtual sequencer component ───────────────────────────
class top_virtual_seqr extends uvm_sequencer;
  `uvm_component_utils(top_virtual_seqr)

  // Handles to real agent sequencers — assigned in connect_phase
  uvm_sequencer #(apb_seq_item) apb_seqr;
  uvm_sequencer #(ahb_seq_item) ahb_seqr;
  uvm_sequencer #(spi_seq_item) spi_seqr;

  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction
endclass

// ── In env connect_phase: assign handles ──────────────────
function void connect_phase(uvm_phase phase);
  m_vseqr.apb_seqr = m_apb_agent.m_seqr;
  m_vseqr.ahb_seqr = m_ahb_agent.m_seqr;
  m_vseqr.spi_seqr = m_spi_agent.m_seqr;
endfunction

// ── Virtual sequence using p_sequencer ───────────────────
class my_vseq extends uvm_sequence #(uvm_sequence_item);
  `uvm_object_utils(my_vseq)
  `uvm_declare_p_sequencer(top_virtual_seqr)  // declares typed p_sequencer

  function new(string name = "my_vseq");
    super.new(name);
  endfunction

  task body();
    apb_init_seq apb_init = apb_init_seq::type_id::create("apb_init");
    // Access sequencers via p_sequencer (type-cast handle)
    apb_init.start(p_sequencer.apb_seqr, this);
  endtask
endclass

// ── Start on the virtual sequencer (not null) ─────────────
vseq.start(m_env.m_vseqr);
The sequencer handle pattern is preferred over the virtual sequencer. The virtual sequencer requires building an extra component in the hierarchy, wiring it in connect_phase, and using the `uvm_declare_p_sequencer macro. The handle pattern achieves the same result with less infrastructure. Use the virtual sequencer only if the project has a large number of virtual sequences that all share the same set of sequencers.

📋 Sequential and Parallel Execution

Virtual sequences can start sub-sequences in any combination of sequential and parallel execution. The key difference from single-agent sequences: sub-sequences on different sequencers can always run in parallel safely, because they each drive separate hardware interfaces. Sub-sequences on the same sequencer must be started sequentially (or through the sequencer’s arbitration mechanism).

task body();
  // ── Pattern 1: strictly sequential ───────────────────────
  seq_a.start(apb_seqr, this);   // A finishes
  seq_b.start(apb_seqr, this);   // then B starts on same seqr

  // ── Pattern 2: parallel on different sequencers ──────────
  fork
    seq_c.start(apb_seqr, this); // APB agent
    seq_d.start(ahb_seqr, this); // AHB agent — different HW, safe
    seq_e.start(spi_seqr, this); // SPI agent — different HW, safe
  join

  // ── Pattern 3: initialise all, then stream in parallel ────
  init_apb.start(apb_seqr, this);  // must finish before streaming
  fork
    stream_apb.start(apb_seqr, this);
    stream_spi.start(spi_seqr, this);
  join_any
  wait fork;  // safer than disable fork for sequences
endtask
Use wait fork instead of disable fork when possible. disable fork immediately terminates all forked threads, which can leave a sequence mid-handshake and lock the sequencer. wait fork blocks until all forked threads complete naturally — cleaner and safer. Use join_any + wait fork when you want to proceed after the first sub-sequence completes but still wait for the rest to finish cleanly.

📋 Sequencer Arbitration

When multiple sequences are started on the same sequencer concurrently, the sequencer decides which sequence item goes to the driver next. This is arbitration. By default, UVM uses round-robin (FIFO) arbitration — each sequence gets an item through in turn. The arbitration mode can be changed to support priority-based access.

Arbitration modeBehaviourUse case
SEQ_ARB_FIFORound-robin — each sequence gets one item per turn (default)General use — fair access to driver
SEQ_ARB_WEIGHTEDRandom weighted by priority valueProbabilistic traffic shaping
SEQ_ARB_RANDOMUniform random selection from all ready sequencesStress testing
SEQ_ARB_STRICT_FIFOHighest-priority sequence goes first; ties broken by FIFOInterrupt service routines
SEQ_ARB_STRICT_RANDHighest-priority sequence goes first; ties broken randomlyPriority with randomness
SEQ_ARB_USERUser-defined arbitration functionCustom scheduling algorithms
// ── Set arbitration mode in sequence or test ──────────────
task body();
  // Set STRICT_FIFO so highest-priority seq goes first
  m_sequencer.set_arbitration(SEQ_ARB_STRICT_FIFO);

  // Start high-priority interrupt handler in background
  fork
    isr_seq.start(m_sequencer, this, 500);  // priority=500 (high)
  join_none

  // Start normal traffic at lower priority
  normal_seq.start(m_sequencer, this, 100);  // priority=100 (low)
endtask

📋 grab() and ungrab()

A sequence can request exclusive access to a sequencer with grab(). While a sequence holds the grab, no other sequence can send items through that sequencer. This is used for interrupt service routines and other time-critical operations that must not be interleaved with other traffic.

task body();
  // grab() — exclusive access (blocks until granted)
  m_sequencer.grab(this);

  // All items sent here have exclusive access — no other
  // sequence can send items until ungrab() is called
  start_item(req);
  req.randomize();
  finish_item(req);

  // lock() is a variant — same as grab but waits in FIFO
  // grab() immediately pre-empts current item

  // ungrab() — release exclusive access
  m_sequencer.ungrab(this);
  // Other sequences can now access the sequencer
endtask
MethodBehaviourWhen to use
grab(this)Immediately gains exclusive access — pre-empts in-progress arbitrationHigh-priority interrupt handler that must respond immediately
lock(this)Gains exclusive access after the current item completes — waits in FIFOCritical section that should not be interrupted mid-burst
ungrab(this)Releases exclusive access. Other sequences resume.End of ISR or critical section. Must always be called after grab/lock.
Always call ungrab() after grab() — even if body() exits early. Forgetting ungrab() leaves the sequencer permanently locked. No other sequence can ever send another item through it. If your sequence may throw a fatal error or exit early, structure the body() to call ungrab() in all exit paths, or use a try/finally-like pattern with a disable.

📋 Quick Reference

ItemKey fact
Virtual sequence purposeCoordinates stimulus across multiple sequencers — the top of the sequence hierarchy
Base parameterisationuvm_sequence #(uvm_sequence_item) — does not send items directly
Start syntaxvseq.start(null) — null because vseq has no target sequencer
Sequencer handlesDeclared as member variables in virtual sequence; assigned by test before start()
init_vseq() purposeEncapsulates all hierarchical path assignments in one base test function
Sub-sequence startsub_seq.start(typed_seqr, this) — always pass this as parent
Parallel safe whenSub-sequences run on different sequencers (different HW interfaces)
Virtual sequencerLegacy: uvm_sequencer sub-class with agent sequencer handles. Less preferred than handle pattern.
`uvm_declare_p_sequencerMacro used with virtual sequencer pattern — declares typed p_sequencer handle
Default arbitrationSEQ_ARB_FIFO — round-robin, fair access
Priority arbitrationSEQ_ARB_STRICT_FIFO — highest priority wins; set via set_arbitration()
Start with priorityseq.start(seqr, parent, priority_value)
grab()Immediate exclusive sequencer access — pre-empts arbitration
lock()Exclusive access after current item — waits in FIFO
ungrab() ruleMust always be called after grab() — forgetting permanently locks sequencer
Scroll to Top