In short: A UVM virtual sequence is a top-level sequence that owns no driver of its own; it runs on a virtual sequencer that holds handles to every real sequencer in your agents, so one piece of code can start and synchronize stimulus across the whole testbench.
Once a verification environment grows past a single interface, the hard part is no longer generating transactions on one bus — it is orchestrating several buses at once. A PCIe DUT needs a configuration write on an APB agent, then a DMA descriptor pushed on an AXI agent, then an interrupt expected back on a third interface. None of those agents knows about the others. The mechanism UVM gives you to coordinate them is the virtual sequence.
Why a plain sequence is not enough
A normal uvm_sequence runs on exactly one uvm_sequencer, which is bound to one driver, which drives one interface. That one-to-one chain is deliberate and clean. But a realistic scenario — "boot the chip, program four registers, then stream traffic while toggling reset" — spans interfaces that each have their own sequencer. You cannot express that from inside a single agent's sequence, because that sequence has no legal way to reach another agent's driver.
The virtual sequence breaks the deadlock by sitting one level above the agents. It does not produce transactions directly. Instead it calls `uvm_do_on (or raw start()) on child sequences, targeting whichever real sequencer each child belongs to. The "virtual" in the name simply means it has no transaction type and no driver of its own.
p_sequencer.The virtual sequencer
A virtual sequencer is just a uvm_sequencer that you subclass and populate with handles to the real sequencers:
class top_vsqr extends uvm_sequencer;
`uvm_component_utils(top_vsqr)
apb_sequencer apb_sqr; // handles, not instances
axi_sequencer axi_sqr;
irq_sequencer irq_sqr;
function new(string name, uvm_component parent);
super.new(name, parent); endfunction
endclass
Crucially the virtual sequencer does not instantiate those agents. The agents are built normally inside the environment, and in the env's connect_phase you wire the handles:
vsqr.apb_sqr = apb_agent.sequencer;
vsqr.axi_sqr = axi_agent.sequencer;
vsqr.irq_sqr = irq_agent.sequencer;
⭐ A virtual sequencer owns no driver and parameterizes to no transaction type — extends uvm_sequencer with no type argument. If you accidentally give it a transaction type, tools like Synopsys VCS and Cadence Xcelium will still elaborate it, but you have quietly created a real sequencer that nothing drives.
p_sequencer: the typed handle
Inside a sequence, m_sequencer is the generic uvm_sequencer_base you are running on. You cannot reach apb_sqr through it because the base class knows nothing about your fields. The `uvm_declare_p_sequencer macro solves this by creating a typed, down-cast copy called p_sequencer:
class cfg_then_dma_vseq extends uvm_sequence;
`uvm_object_utils(cfg_then_dma_vseq)
`uvm_declare_p_sequencer(top_vsqr) // creates p_sequencer of type top_vsqr
function new(string name="cfg_then_dma_vseq"); super.new(name); endfunction
task body();
apb_write_seq cfg;
axi_burst_seq dma;
irq_wait_seq irq;
`uvm_do_on(cfg, p_sequencer.apb_sqr) // program the DUT
`uvm_do_on(dma, p_sequencer.axi_sqr) // kick a 256-beat burst
`uvm_do_on(irq, p_sequencer.irq_sqr) // block until completion IRQ
endtask
endclass
The macro expands, roughly, to $cast(p_sequencer, m_sequencer) in the sequence's m_set_p_sequencer hook. That one cast is why a virtual sequence must be started on a virtual sequencer of the declared type — start it on the wrong sequencer and the cast fails at run time with a fatal, not at compile time.
Running sequences: parallel vs. serial
Because body() is just SystemVerilog, you choose the concurrency. The three `uvm_do_on calls above are serial. To stress the DUT with simultaneous traffic, wrap them in fork...join:
fork
`uvm_do_on(dma_a, p_sequencer.axi_sqr)
`uvm_do_on(dma_b, p_sequencer.axi2_sqr)
join
This is where virtual sequences earn their keep. Coordinated reset injection, back-pressure during a burst, or a register poll that races against DMA completion are all just fork/join patterns over handles — and because every child sequence still arbitrates for its own driver through its own sequencer, UVM's arbitration and grab()/lock() semantics keep working per interface.
| Concept | Owns a driver? | Typed handle | Typical use |
|---|---|---|---|
| Real sequence | Yes (via its sequencer) | n/a | Drive one interface |
| Virtual sequence | No | p_sequencer | Coordinate many interfaces |
| Real sequencer | Connected to driver | — | Arbitrate items for one driver |
| Virtual sequencer | No | holds sub-sequencer handles | Routing hub only |
A common alternative: skip the virtual sequencer
You do not strictly need a virtual sequencer. Many modern environments give the virtual sequence direct handles to the sequencers (set via the config DB or by the test) and start it on null. This drops the p_sequencer cast entirely and is simpler to maintain, which is the style the Siemens UVM Cookbook now recommends for new code. The classic p_sequencer approach remains the one interviewers ask about and the one you will meet in legacy IP, so you must know both.
⭐ Interview trap: m_sequencer always points at the sequencer you were started on; p_sequencer is the same object cast to the declared type. If p_sequencer is null in your body(), you forgot `uvm_declare_p_sequencer or started on the wrong sequencer.
For the mechanics of the underlying one-interface building block, review our companion guide on the UVM sequence and sequencer, and for timing-sensitive coordination see UVM phasing and objections. Teams that want hands-on, PDK-backed lab practice often pair this with structured training from ChipXpert's VLSI programs.
Practical gotchas
- Objections: raise/drop objections in the virtual sequence (or the test), not in every child — otherwise the run ends when the first child finishes.
- Reset handling: if a child sequence can be mid-flight during reset, guard it so the virtual sequence can
disable forkcleanly; dangling items leave the sequencer locked. - Randomization: randomize the virtual sequence's knobs once at the top, then pass values down, so the whole scenario stays reproducible from one seed.
Ready to build multi-agent testbenches that actually scale? Our CourseTron UVM Verification track walks you from a single sequencer to full chip-level virtual sequences with graded labs on VCS and Xcelium.
FAQ
Is a virtual sequencer mandatory for a virtual sequence?
No. You can give the virtual sequence direct sequencer handles via the config DB and start it on null. The virtual sequencer plus p_sequencer is the classic pattern and is cleaner when many sequences share the same set of targets.
What is the difference between m_sequencer and p_sequencer?
m_sequencer is the base-class handle to whatever sequencer you were started on. p_sequencer is a typed down-cast of that same object, created by `uvm_declare_p_sequencer, so you can reach your custom fields like the sub-sequencer handles.
Can child sequences run in parallel?
Yes. Since body() is ordinary SystemVerilog, wrap `uvm_do_on calls in fork...join. Each child still arbitrates independently on its own real sequencer, so per-interface locking and grabbing behave normally.
Why does my p_sequencer come back null?
Almost always because the virtual sequence was started on a sequencer whose type does not match the one in `uvm_declare_p_sequencer, so the internal $cast silently fails. Start it on the correct virtual sequencer type.