UVM
Data Flow Through UVM
How transactions move via TLM ports, exports, and imps — the blocking sequencer-driver pull and the non-blocking analysis broadcast — as a graph separate from the hierarchy.
UVM Testbench Architecture · Module 3 · Page 3.4
The Engineering Problem
You built the static tree (Module 3.3) and you know what flows through it (transactions). Now the runtime question: how does a transaction actually get from one component to another? And here is the fact that trips up everyone: the data-flow graph is not the containment tree. The monitor lives inside the agent, but it sends transactions to the scoreboard, which lives in the environment — a path that runs sideways across the hierarchy, not up or down it. Components don't pass data by parent-child relationship; they pass it through explicit TLM connections wired separately.
So a UVM environment has two graphs laid over the same components: the containment hierarchy (who builds and owns whom — Module 3.3) and the data-flow graph (who sends transactions to whom — this chapter). Get the first wrong and components aren't phased; get the second wrong and they're phased but starved — a driver that blocks forever waiting for a transaction, or a scoreboard that silently receives nothing. The connections that carry data are their own structure, with their own rules, and you must build them deliberately.
How does a transaction move from component to component at run time — through which TLM connections — and why is that data-flow graph separate from the containment hierarchy?
Motivation — why data flow is its own structure
Treating the data-flow graph as a first-class thing, distinct from the hierarchy, is what makes a runnable, debuggable environment:
- Data flows sideways, not along ownership. The monitor (in the agent) feeds the scoreboard (in the env); the sequencer (in the agent) feeds the driver (also in the agent, but the connection is still explicit). None of these follow parent-child links. You must wire the data path, and knowing it is separate from containment is what lets you wire it correctly.
- The connection style encodes the semantics. A blocking pull (
get_next_item) means the driver waits for the sequencer — flow control built in. A non-blocking broadcast (write) means the monitor fires and forgets — decoupled, one-to-many. Choosing the right TLM pattern is choosing the right runtime behaviour. - Most runtime hangs and silent passes are connection bugs. A driver stuck forever is almost always an unconnected
seq_item_port; a scoreboard that checks nothing is almost always an unconnectedanalysis_port. These are data-flow-graph failures, invisible in the (correct) containment tree. - It is the basis of decoupling and reuse. Because producers connect to ports rather than calling specific consumers, you can re-wire the data flow — add a coverage subscriber, swap a scoreboard — without changing the components. The connection graph is where the environment's flexibility lives.
The motivation, in one line: the data-flow graph is a separate structure with its own connections and semantics, and almost every "it built fine but doesn't run right" bug is a wire missing from that graph.
Mental Model
Hold two overlays on the same components:
The hierarchy is the org chart; the data flow is the plumbing — and the plumbing doesn't follow the org chart. The org chart (containment) says who owns whom: the agent owns the monitor, the env owns the scoreboard. The plumbing (TLM connections) says where water flows: from the monitor, across to the scoreboard, regardless of who owns either. You lay the plumbing explicitly in
connect_phaseby joining a port (an outlet — the initiator that starts a transfer) to an export or imp (an inlet — the provider that receives or implements it). Two kinds of plumbing exist: a demand pipe where the consumer pulls and the producer must supply on demand (sequencer→driver, blocking), and a broadcast spray where one source sprays to any number of catchers (monitor→subscribers, non-blocking). A component built correctly (in the org chart) but with no plumbing connected is a room with power but no water — present, phased, and useless.
So for any transaction, ask two separate questions: who owns this component (hierarchy)? and where does its data go (which port connects to which export)? The answers are independent, and the second is the one that makes data actually move.
Visual Explanation — two graphs on the same components
The single most important picture is the overlay: the same components carry a containment tree and a data-flow graph, and they don't match.
The two edge sets are the whole lesson. The solid containment edges go straight down the tree (Module 3.3's structure). The dashed data-flow edges cut sideways: sequencer → driver and monitor → scoreboard cross between siblings and across hierarchy levels. The monitor sends to the scoreboard not because of any ownership relationship — they are in different subtrees — but because you explicitly connected its analysis port to the scoreboard's export. Data flow is wired, not inherited, and recognising it as a separate graph is what lets you reason about (and debug) where transactions actually go.
RTL / Simulation Perspective — wiring the data-flow graph
The data-flow graph is built in connect_phase with connect() calls that join ports to exports/imps. Below, the monitor declares a broadcast port, the driver pulls from the sequencer, and the env wires both connections.
class my_monitor extends uvm_monitor;
`uvm_component_utils(my_monitor)
uvm_analysis_port#(bus_txn) ap; // broadcast port: non-blocking, one-to-many
function void build_phase(uvm_phase phase); ap = new("ap", this); endfunction
task run_phase(uvm_phase phase);
bus_txn t;
forever begin
collect(t); // reconstruct a transaction from the pins
ap.write(t); // BROADCAST it — non-blocking, to any subscribers
end
endtask
endclass
class my_env extends uvm_env;
`uvm_component_utils(my_env)
my_agent agent; my_scoreboard sb; my_cov cov;
// ... build_phase creates agent, sb, cov ...
function void connect_phase(uvm_phase phase);
// BLOCKING PULL: driver gets items from the sequencer (port → export)
agent.drv.seq_item_port.connect(agent.sqr.seq_item_export);
// NON-BLOCKING BROADCAST: monitor feeds scoreboard AND coverage (one-to-many)
agent.mon.ap.connect(sb.analysis_export);
agent.mon.ap.connect(cov.analysis_export);
endfunction
endclassRead the two patterns. The blocking pull: the driver's seq_item_port.connect(sqr.seq_item_export) joins the driver (initiator) to the sequencer (provider); at run time the driver calls get_next_item, which blocks until the sequencer supplies a transaction, then item_done after driving — built-in flow control. The non-blocking broadcast: the monitor's ap is an analysis_port, connected to both the scoreboard's and coverage's analysis exports; ap.write(t) sends the transaction to every connected subscriber and returns immediately. Notice the direction convention — the port (initiator/producer side) calls connect on the export (provider/consumer side) — and that both connect calls live in connect_phase, after build_phase has created the components they join.
Verification Perspective — ports, exports, and imps
The TLM connection mechanism has three roles, and knowing which is which is what lets you wire connections in the right direction. A port initiates; an imp implements; an export forwards between them.
The three roles answer "which way does connect go?" The port is held by the initiator — the side that calls a method (the driver pulling, the monitor broadcasting). The imp is held by the implementer — the side that provides the method body (the write() in a uvm_subscriber, the item provision in a sequencer). The export is plumbing for when the implementer is nested inside a component and its capability must be exposed at the component's boundary. You always connect from the initiator's port toward the implementer's export/imp, and at run time the port's call (get_next_item, write) is routed to the imp's implementation. Getting the direction right is just asking who calls (port) and who implements (imp) — and connecting the former to the latter.
Runtime / Execution Flow — the full path of a transaction
Put the connections together and a transaction's complete journey is a chain of TLM transfers and one pin-level translation, end to end from the sequence that creates it to the scoreboard that checks it.
This chain is the runtime life of the data-flow graph. The first hop (sequence → sequencer → driver) is a blocking pull: the driver asks and waits, so stimulus is paced by the driver's readiness. The middle (driver → DUT → monitor) is the only signal-level part — the two translations you met in Module 3.2. The last hop (monitor → scoreboard/coverage) is a non-blocking broadcast: the monitor fires the transaction to all subscribers and moves on. Each hop is a TLM connection you wired in connect_phase, and the transaction is the payload moving along them. Trace any "where did this transaction go?" question along this chain, and trace any "why didn't it arrive?" question to the connection on the hop that's missing.
Waveform Perspective — blocking pull vs non-blocking broadcast
The two TLM patterns differ in time, and the waveform shows it: the sequencer→driver pull is a paced handshake (the driver waits for each item), while the monitor→subscriber broadcast is an instantaneous fire-and-forget after each observation.
The two TLM patterns in time — a paced pull and an instantaneous broadcast
10 cyclesThe seq_pull and ana_bcast pulses are the two halves of the data-flow graph at work. The pull is paced: the driver requests an item, the sequencer provides it, and only then does the driver drive — so the stimulus rate is governed by the driver/handshake, with flow control inherent in the blocking call. The broadcast is immediate: the moment the monitor finishes reconstructing a transaction, write() delivers it to every subscriber and returns, with no waiting and no knowledge of who listens. These two timing behaviours — demand-paced pull in, fire-and-forget broadcast out — are why the sequencer→driver and monitor→subscriber connections use different TLM mechanisms, and together they move every transaction through the environment.
DebugLab — the driver that blocked forever
The run hung with nothing driven — the sequencer-driver connection was never made
A testbench built correctly, the test started a sequence, and then the simulation hung — it ran but never progressed, no transactions reached the DUT, and eventually it timed out. The driver, the sequencer, and the sequence all existed in the tree at the right paths (topology was clean), yet not one transaction was ever driven. The driver appeared stuck.
A missing data-flow connection: the driver's seq_item_port was never connected to the sequencer's seq_item_export, so the driver's get_next_item() blocked forever waiting for a transaction that could never arrive:
build_phase: sequencer, driver created (both in the tree, correct paths) ✓
connect_phase: drv.seq_item_port.connect(sqr.seq_item_export); ← MISSING
driver run: seq_item_port.get_next_item(req); // blocks until an item arrives...
sequencer: has items, but no connected port to deliver them to → never reaches driver
result: get_next_item() blocks forever → driver hangs → no stimulus → timeout
fix: add the connect() in connect_phase (port → export, correct direction)The containment tree was perfect — every component built and phased — but the data-flow edge from sequencer to driver was never wired, so the blocking pull had nothing to pull from. A correct hierarchy with a missing connection.
The tell is a driver stuck in get_next_item with a clean topology — a data-flow gap, not a hierarchy gap. Diagnose connection problems separately from construction:
- A hung driver means an unconnected (or misconnected)
seq_item_port.get_next_itemblocks by design; if it never returns, the port isn't connected to a sequencer, or is connected in the wrong direction. Checkconnect_phasefor theconnectcall. - Verify the direction. The driver's port connects to the sequencer's export (
drv.seq_item_port.connect(sqr.seq_item_export)), not the reverse. Initiator's port → provider's export. - Separate the two graphs. A clean
print_topology()(containment correct) plus no data movement points squarely at the data-flow graph — inspect theconnect()calls, not the tree.
The data-flow graph must be wired deliberately and completely:
- Connect every port in
connect_phase. Eachseq_item_portto a sequencer's export, eachanalysis_portto its subscribers. A port with no connection is a hang (blocking pull) or a silent no-op (analysis broadcast). - Wire in the correct direction. Initiator's port → implementer's export/imp. Reversed connections fail to compile or don't carry data; get the port/imp roles straight (who calls vs who implements).
- Verify data actually moves. Don't trust a clean topology — confirm the driver drives and the scoreboard receives (count transactions). A built-but-unconnected environment looks healthy in the tree and does nothing at run time.
The one-sentence lesson: a clean hierarchy does not mean a connected data path — a driver that blocks forever in get_next_item has an unwired seq_item_port, so check the connect() calls in connect_phase, not the component tree.
Common Mistakes
- Confusing the data-flow graph with the containment tree. Data flows through explicit TLM connections, not parent-child links — the monitor sends to the scoreboard across the hierarchy. Wire data flow deliberately in
connect_phase; don't assume containment carries it. - Forgetting to connect a port. An unconnected
seq_item_portmakes the driver block forever (hang); an unconnectedanalysis_portmakeswrite()a silent no-op (scoreboard receives nothing, false pass). Connect every port. - Wrong connection direction. Connect the initiator's port to the implementer's export/imp (
port.connect(export)), not the reverse. Mixing up who-calls (port) and who-implements (imp) gives a connection that doesn't carry data. - Connecting outside
connect_phase. Connections join components thatbuild_phasecreated, so they belong inconnect_phase— after build, before run. Wiring earlier (components don't exist yet) or later (run already started) breaks the flow. - Assuming a clean topology means a working environment.
print_topology()shows containment, not connections. A perfectly-built tree with missing TLM wires builds fine and does nothing — verify data actually moves. - Using a blocking pattern where a broadcast is needed (or vice versa). Sequencer→driver is a blocking pull (flow-controlled); monitor→subscribers is a non-blocking broadcast (one-to-many, fire-and-forget). Using the wrong one breaks pacing or fan-out.
Senior Design Review Notes
Interview Insights
Through explicit TLM (transaction-level modelling) connections, which form a data-flow graph that is separate from the containment hierarchy. Components are wired in connect_phase by joining ports to exports or imps, and two patterns carry essentially all data flow. The first is the blocking pull from sequencer to driver: the driver's seq_item_port connects to the sequencer's seq_item_export, and at run time the driver calls get_next_item, which blocks until the sequencer provides a transaction, then item_done after driving — this gives built-in flow control. The second is the non-blocking analysis broadcast from monitor to subscribers: the monitor's analysis_port connects to one or more subscribers' analysis exports, and write() delivers the transaction to all of them and returns immediately — one-to-many and decoupled. The full path of a transaction is sequence → sequencer → driver → DUT → monitor → scoreboard/coverage, and crucially that path runs across the hierarchy (the monitor in the agent feeds the scoreboard in the env), not along parent-child links.
Exercises
- Two graphs. For a standard agent + env, draw the containment edges and the data-flow edges separately, and identify one data-flow edge that crosses the hierarchy (connects components in different subtrees).
- Pick the pattern. For each connection, say whether it's a blocking pull or a non-blocking broadcast and why: (a) sequencer to driver; (b) monitor to scoreboard; (c) monitor to coverage; (d) monitor to a second, later-added logger.
- Diagnose two failures. Distinguish the cause of (a) a driver that hangs in
get_next_itemand (b) a scoreboard that receives zero transactions while the sim runs to completion. Name the missing connection in each. - Direction. Write the
connect()call to wire a driver to a sequencer and a monitor to a scoreboard, and explain, using the port/imp roles, why each goes in the direction it does.
Summary
- Transactions move through explicit TLM connections — a data-flow graph that is separate from the containment hierarchy. The monitor (in the agent) feeds the scoreboard (in the env): data flow runs across the tree, not along ownership.
- The connection roles are port (initiator — who calls), imp (implementer — who provides the method), and export (forwarder across a boundary). You wire them in
connect_phase, connecting the initiator's port to the implementer's export/imp. - Two patterns carry everything: the blocking pull from sequencer to driver (
seq_item_port→seq_item_export,get_next_item/item_done) which paces stimulus, and the non-blocking broadcast from monitor to subscribers (analysis_port→ exports,write()) which fans observation out one-to-many. - The full path is sequence → sequencer → driver → DUT → monitor → scoreboard/coverage; every hop except the pins is a TLM transfer you must wire. Missing wires cause a hang (unconnected
seq_item_port) or a silent miss (unconnectedanalysis_port) — both invisible in a correct topology. - The durable rule of thumb: build two graphs deliberately — the hierarchy (who owns whom) and the data flow (who sends to whom) — and when an environment builds cleanly but doesn't run right, debug the
connect()calls, not the tree.
Next — Reusable Verification Architecture: you now know how components are structured (hierarchy) and how data moves between them (TLM). The next chapter steps back to the design discipline that ties it together — how to architect agents, environments, and connections so the whole verification environment is genuinely reusable across blocks, projects, and teams.