UVM
Transaction Lifecycle
The temporal journey of one transaction — created and randomized in zero time, sent through a blocking handshake while the driver consumes time on the pins, then completed and answered — and what blocks when.
Transaction Modeling · Module 8 · Page 8.3
The Engineering Problem
The previous chapter covered the interface by which an item is generated and consumed — the start_item/finish_item and get_next_item/item_done calls (Module 8.2). This chapter follows one transaction through its lifecycle in time: the ordered states it passes through from creation to completion, which of those happen in zero time and which consume simulation time, and — most importantly — what blocks when. Getting the lifecycle wrong is how you randomize too late and drive default values, or reuse one object and corrupt an item still in flight.
A transaction's life has a precise shape. It is created and randomized in zero time (object construction and constraint solving are function-land operations). It is then sent through a blocking handshake: start_item blocks until the sequencer grants this sequence arbitration, then finish_item blocks until the driver signals item_done. During that block, the driver drives the item on the pins — which consumes simulation time (clocks, handshakes). When the driver calls item_done, the item is complete, finish_item unblocks, and an optional response routes back. So a single transaction spans both worlds: a zero-time front (build, randomize) and a time-consuming middle (the handshake and driving), with the sequence suspended through the middle. That suspension is not incidental — it's what paces the sequence to the DUT, so stimulus can never outrun the design. This chapter is that timeline: the states, the zero-time/time-consuming split, the blocking points, and the lifecycle bugs that come from misordering them.
What is the lifecycle of one transaction in time — the ordered states from creation to completion, which happen in zero time versus consume simulation time, what blocks when, and how does the blocking pace the sequence to the DUT?
Motivation — why the lifecycle has this shape
The lifecycle's structure — zero-time front, blocking time-consuming middle — is not arbitrary; each part exists for a reason:
- Creation and randomization are zero-time because they're decisions, not actions. Building the object and solving its constraints decide what the transaction will be; nothing has happened on the wires yet. Decisions belong in function-land (zero time), so the sequence can prepare an item without advancing the clock.
- Driving consumes time because it's physical. Translating the item into pin activity — asserting signals, waiting for handshakes, clocking data beats — is the one part that interacts with the DUT, and that interaction unfolds over real clock cycles. The time cost lives entirely in the driver (Module 8.1's "how").
- The blocking is what synchronizes the sequence with the driver. If the sequence didn't block at
finish_item, it would race ahead generating items the driver hasn't consumed. Blocking untilitem_donemeans the sequence advances in lockstep with the driver — one item fully handled before the next begins (for a simple sequence). - That lockstep is back-pressure: the DUT paces the stimulus. Because the sequence is suspended until the driver finishes (and the driver finishes only as fast as the DUT accepts), the stimulus rate is governed by the design. A slow DUT naturally slows the sequence — no manual flow control needed.
- Each transaction needs its own lifetime because lifetimes overlap with references. An item being driven, recorded, or analyzed is still referenced by other components; if the sequence reuses that object for the next transaction, it mutates something still in use. Distinct objects keep each transaction's lifetime independent.
The motivation, in one line: the lifecycle separates deciding the transaction (zero-time create/randomize) from performing it (time-consuming driving), and blocks the sequence across the performance — which synchronizes sequence and driver, paces stimulus to the DUT, and demands that each transaction be its own object so overlapping references don't collide.
Mental Model
Hold the lifecycle as placing an order at a kitchen counter and waiting for it:
A transaction's life is an order at a counter: you decide and write it instantly, then you wait — blocked — while the kitchen cooks it, and the waiting is what keeps you in step with the kitchen. Writing the order — choosing the dish, filling in the details — is instant (create and randomize, zero time): you've decided what, but nothing is cooking yet. Then you hand it over and wait at the counter (
finish_itemblocks). First you wait for the counter to take your order (start_item— the grant, especially if others are queued). Then, once accepted, you wait while the kitchen actually cooks it — which takes real time (the driver driving the pins). You can't walk away and place ten more orders; you're held at the counter until this one is served (item_done). That waiting isn't a nuisance — it's exactly what stops you from flooding a kitchen that can only cook so fast: the kitchen's pace becomes your pace. When it's served, you might also get a receipt confirming what was made (the response). And crucially, you write each order on a fresh ticket — reusing one ticket and scribbling the next order over it would corrupt the one the kitchen is still cooking.
So a transaction's life is decide-instantly-then-wait-while-it-cooks: the zero-time front is your decision, the blocking middle is the kitchen's work, and the wait is the pacing. Write each on its own ticket, and randomize before you hand it over — scribbling the details after it's in the kitchen is too late.
Visual Explanation — the lifecycle as an ordered journey
The lifecycle is a fixed sequence of states. Seeing them in order — and noting which are zero-time and which consume time — is the whole picture.
The journey reads left to right as decide, then perform. Create (step 1) constructs the object — it exists, fields at defaults — a zero-time operation because nothing has touched the wires. Randomize (step 2) solves the constraints to decide the transaction's values — also zero-time, because choosing what an item is doesn't advance the clock. These two steps are the decision half: the sequence has fully determined the transaction without any simulation time passing. Then send (step 3) enters the blocking handshake: start_item suspends the sequence until the sequencer grants it arbitration, and finish_item hands the item to the driver and suspends until the driver is done. Drive (step 4) is where simulation time actually passes: the driver translates the item into pin activity over real clock cycles — the only time-consuming part of the lifecycle, and it happens while the sequence is blocked. Finally complete (step 5): item_done signals the driver is finished, which unblocks finish_item, and an optional response routes back for get_response. The shape to carry is the asymmetry: steps 1–2 are instantaneous decisions, steps 3–5 are a time-consuming performance during which the sequence waits. The boundary between "deciding" and "performing" — between randomize and finish_item — is exactly where the two lifecycle rules live.
RTL / Simulation Perspective — the lifecycle in code, annotated by time
The lifecycle maps directly onto the sequence and driver code, and annotating each line with zero-time versus consumes-time makes the structure explicit.
// ── SEQUENCE side: decide (zero time), then send (blocks) ──
task my_seq::body();
bus_item req = bus_item::type_id::create("req"); // CREATE — zero time
start_item(req); // SEND/grant — BLOCKS until granted
assert(req.randomize() with { addr inside {[0:'hF]}; }); // RANDOMIZE — zero time (between start & finish)
finish_item(req); // SEND/drive — BLOCKS until item_done
// ── (sequence is SUSPENDED here while the driver drives over clock cycles) ──
get_response(rsp); // optional — BLOCKS until response
endtask
// ── DRIVER side: the time-consuming middle ──
task my_driver::run_phase(uvm_phase phase);
forever begin
seq_item_port.get_next_item(req); // BLOCKS until an item is available
drive_on_pins(req); // CONSUMES TIME — clocks, handshakes, beats
seq_item_port.item_done(); // completes the item → unblocks finish_item
end
endtaskRead the lifecycle off the annotations. On the sequence side, create and randomize are zero-time — they decide the transaction without advancing the clock. Note the placement of randomize: it sits between start_item and finish_item, which is the correct lifecycle position (after the grant, before the send) — and the reason is mechanical: finish_item is what hands the item to the driver, so the item's values must be final before finish_item, not after. The two start_item/finish_item calls are the blocking points: start_item blocks until the sequencer grants, and finish_item blocks until the driver's item_done. The comment marks the crucial fact — while finish_item is blocked, the sequence is suspended, doing nothing, as the driver works. On the driver side, get_next_item blocks until an item arrives, drive_on_pins is the one time-consuming step (the pin-level "how" from Module 8.1, unfolding over clock cycles), and item_done completes the item — which is what unblocks the sequence's finish_item. So the code is the lifecycle: zero-time decisions on the sequence side, a time-consuming performance on the driver side, joined by the blocking handshake that suspends the sequence across the driver's work.
Verification Perspective — what blocks when
The defining feature of the lifecycle is its blocking structure: four calls block, each waiting on a specific event. Knowing exactly what each waits for is what lets you reason about hangs, pacing, and ordering.
The blocking structure is the lifecycle's logic. start_item blocks until the sequencer grants this sequence arbitration — so when many sequences share a sequencer, this is where a sequence waits its turn. finish_item blocks until the driver calls item_done — so the sequence is suspended for the entire duration the driver spends driving the item; this is the longest and most important block, because it's what synchronizes the sequence to the driver. On the driver side, get_next_item blocks until an item is available (i.e., until some sequence's finish_item has sent one) — so a driver with no stimulus simply waits. And get_response blocks until a matching response arrives (Module 8.2) — which is why a missing set_id_info hangs it forever. The pattern is an interlock: finish_item (sequence) and get_next_item/item_done (driver) wait on each other, so neither end can run ahead — the sequence can't send a second item until the driver finishes the first, and the driver can't get a second item until the sequence sends it. Reasoning about almost any sequence/driver timing question — Why is my sequence stuck? Why isn't the driver getting items? Why does get_response hang? — comes down to identifying which of these four blocks is waiting and what event it needs. The blocks are the lifecycle's joints.
Runtime / Execution Flow — zero-time decisions, timed performance, and pacing
At run time the lifecycle alternates between zero-time bursts (decide) and time-consuming spans (perform), and the blocking across the performance is what paces the sequence to the DUT.
The run-time loop reveals the pacing that the blocking creates. Decide (step 1) is a zero-time burst: the sequence creates and randomizes the next item instantly. Block (step 2) is the time-consuming span: finish_item suspends the sequence while the driver drives the item over real clock cycles — and the driver proceeds only as fast as the DUT accepts (waiting on ready signals, handshakes, backpressure from the design). Accept (step 3): when the DUT has taken the item and the driver completes, item_done fires and finish_item unblocks. Loop (step 4): the sequence decides the next item and the cycle repeats. The consequence is automatic flow control: the sequence advances exactly one item per DUT-paced driving cycle, so it cannot outrun the design — a slow DUT (lots of wait states) naturally slows the stimulus, and a fast DUT lets it run quickly, with no explicit rate management anywhere. This is why UVM stimulus is robust by default: the lifecycle's blocking makes the DUT the metronome. (Advanced patterns — pipelined drivers that accept multiple outstanding items — deliberately relax this lockstep for throughput, but they're an explicit departure from the default one-at-a-time pacing, and they're where the "give each transaction its own object" rule becomes critical, since multiple items are then in flight at once.)
Waveform Perspective — the lifecycle on a timeline
The lifecycle's zero-time/time-consuming split is clearest on an actual timeline: the decision happens at a single instant, then driving spans many cycles while the sequence waits.
One transaction's lifecycle: instant decision, then time-consuming driving while blocked
12 cyclesThe waveform shows the lifecycle's defining asymmetry: a point of decision and a span of performance. At t0 the decide pulse marks the sequence creating, randomizing, and calling finish_item — all collapsed into one instant, because deciding the transaction is zero-time. Immediately seq_blocked rises: the sequence is suspended, and stays suspended for the whole span that drv_active is high — the several clock cycles the driver spends realizing the item on the pins. When driving completes, item_done pulses, seq_blocked falls, and the sequence instantly decides the next item (the second decide pulse) before blocking again. So each transaction is a narrow decision followed by a wide blocked span, and the sequence's progress is the staircase of these spans — one item per driving cycle. The blocked spans are the pacing made visible: the sequence advances only as the driver (and the DUT behind it) allows. This is the timeline to carry: transactions are decided in instants and performed over spans, and the sequence lives mostly in the blocked spans, waiting — which is exactly why stimulus stays synchronized with the design.
DebugLab — the item randomized after it was already sent
A driver that always drove default values because randomize ran after finish_item
A sequence built items with random addresses and data, but the driver drove addr = 0, data = 0 every single time — as if randomization never happened. The constraints were correct, the randomize() call returned success (the assertion passed), and the item clearly had random values after the sequence finished with it. Yet on the pins, every transaction was the default. The randomization was happening; it just wasn't reaching the driver.
randomize() was called after finish_item() — so the item was already handed to the driver (with default values) before it was randomized:
sequence body:
req = bus_item::type_id::create("req"); // fields at DEFAULT (addr=0, data=0)
start_item(req);
finish_item(req); // ✗ SENT to driver NOW — with defaults
assert(req.randomize()); // randomized AFTER it was already driven (too late)
lifecycle: create → start_item → finish_item(driver drives addr=0) → item_done → randomize(too late)
result: driver drove the default item; the randomize mutated an object already driven
fix — randomize BETWEEN start_item and finish_item:
start_item(req);
assert(req.randomize()); // ✓ randomize while the item is still in the sequence
finish_item(req); // ✓ send the FINAL (randomized) item to the driverThis is a lifecycle-ordering error. In the transaction lifecycle, finish_item is the step that hands the item to the driver — and finish_item blocks until the driver has driven it. So by the time finish_item returns, the driver has already consumed the item's values. Calling randomize() after finish_item therefore mutates the object after it was already driven — far too late to affect the pins, which saw the default values the item had at finish_item. The randomize "worked" (the object does end up with random values), which is what makes it confusing: the values are right when you inspect the item afterward, but they were applied after the lifecycle moment that mattered. The fix is to place randomize() in its correct lifecycle position — between start_item and finish_item — so the item holds its final, randomized values before finish_item sends it. (This is also why the uvm_do macros randomize internally at exactly that point: they encode the correct lifecycle order so you can't get it wrong.) The general rule: the item must be fully decided before finish_item, because finish_item is where deciding ends and performing begins.
The tell is driven values that are always defaults despite a successful randomize. Diagnose lifecycle-ordering bugs:
- Check randomize placement relative to
finish_item. Confirmrandomize()is called beforefinish_item. A randomize afterfinish_itemis the signature — the item was sent before it was randomized. - Confirm the driven values match the item's values at
finish_item. Print the item just beforefinish_item; if it's at defaults there but random afterward, the randomization is mis-ordered. - Suspect manual handshakes over
uvm_do. Hand-writtenstart_item/finish_itemsequences are where ordering errors creep in; theuvm_domacros enforce the order. A bug here often means a hand-rolled handshake. - Distinguish from a missing randomize. A
randomize()that's absent (not just mis-ordered) also drives defaults — check whether it exists at all versus exists-but-too-late.
Decide the item fully before sending it:
- Randomize between
start_itemandfinish_item. Make the order create →start_item→ randomize →finish_itema fixed reflex, so the item is final before it's sent. - Prefer the
uvm_domacros for the common case. They encode the correct create/start/randomize/finish order, so you can't randomize too late; drop to manual handshakes only when you need control they don't give. - Treat
finish_itemas the point of no return. Afterfinish_item, the item is the driver's — don't mutate it expecting the change to reach the pins. Any value the driver needs must be set beforefinish_item. - Give each transaction its own object. Create a fresh item per iteration; don't reuse one handle, or an item still being driven (or recorded) can be mutated by the next iteration — the related lifetime bug.
The one-sentence lesson: finish_item is where deciding ends and performing begins — it hands the item to the driver and blocks until it's driven — so the item must be fully randomized before finish_item; randomizing after sends the driver the default item and mutates the values too late to reach the pins.
Common Mistakes
- Randomizing after
finish_item. The item was already sent with defaults; randomize betweenstart_itemandfinish_itemso the driver gets final values. - Reusing one transaction object across iterations. An item still being driven, recorded, or analyzed gets mutated by the next iteration; create a fresh object per transaction (or clone before handoff).
- Expecting
finish_itemnot to block. It blocks untilitem_done— the sequence is suspended for the driver's entire driving time. Treat it as a synchronization point, not a fire-and-forget send. - Mutating an item after
finish_item. Pastfinish_itemthe item belongs to the driver; later changes don't reach the pins. Set everything the driver needs beforefinish_item. - Assuming create/randomize consume time. They're zero-time decisions; only driving consumes simulation time. Mis-modeling this leads to wrong timing expectations.
- Calling
get_responsewith no responding driver. It blocks forever waiting for a response that never comes; only retrieve responses for items the driver answers.
Senior Design Review Notes
Interview Insights
A transaction's lifecycle is an ordered journey that spans zero-time decisions and time-consuming performance. First it's created — type_id::create constructs the object with its fields at defaults; this is zero-time, because nothing has touched the pins. Then it's randomized — randomize() solves the constraints to decide the values; also zero-time, since choosing what the item is doesn't advance the clock. Then it enters the blocking handshake: start_item blocks until the sequencer grants this sequence arbitration, and — importantly — randomization happens between start_item and finish_item, so the item is final before it's sent. finish_item then hands the item to the driver and blocks until the driver signals item_done. During that block, the driver drives the item on the pins, which consumes real simulation time over clock cycles — this is the one time-consuming part, and the sequence is suspended throughout it. When the driver finishes, item_done completes the item, finish_item unblocks, and optionally a response routes back for get_response to retrieve. So the shape is a zero-time front (create, randomize — deciding what the transaction is), a time-consuming middle (the handshake and driving — performing it), and completion. The boundary between deciding and performing is finish_item, and the two rules that follow are: randomize before finish_item (after is too late, the driver already got the defaults), and give each transaction its own object (so an in-flight item isn't corrupted by reuse).
Exercises
- Annotate the timeline. For create, randomize, start_item, finish_item, drive, item_done, label each as zero-time or time-consuming, and mark which two calls block and on what.
- Fix the ordering. A sequence calls
finish_item(req)thenreq.randomize(). State what the driver drives, why, and the corrected order. - Explain the pacing. A DUT inserts many wait states and the sequence slows down without any code change. Explain, using the blocking lifecycle, why the stimulus paces itself.
- Reason about a hang. A sequence is stuck at
finish_item. List the lifecycle reasons it might not be unblocking, and how you'd confirm which.
Summary
- A transaction's lifecycle is an ordered journey: create and randomize (both zero-time decisions), then
start_item(blocks for the grant) andfinish_item(blocks untilitem_done), during which the driver drives it on the pins (consumes simulation time), then complete and an optional response. - The lifecycle separates deciding the transaction (zero-time) from performing it (time-consuming), with
finish_itemas the boundary — and the sequence is suspended across the performance. - Four calls block:
start_item(until grant),finish_item(untilitem_done),get_next_item(until an item),get_response(until a matching response) — andfinish_item/get_next_iteminterlock the sequence and driver so they advance in lockstep. - That lockstep is back-pressure: the sequence advances one item per DUT-paced driving cycle, so stimulus can't outrun the design — automatic flow control with no manual pacing.
- The durable rule of thumb: randomize between
start_itemandfinish_item(after is too late — the driver already got the defaults), give each transaction its own object (so an in-flight item isn't corrupted by reuse), and treatfinish_itemas the point of no return — because the lifecycle's blocking is what keeps stimulus synchronized with, and paced by, the DUT.
Next — Field Macros: a transaction needs its data services — copy, compare, print, pack — to be checked and logged through its lifecycle; the next chapter covers the uvm_field_* macros that generate those automatically from a field declaration, and the trade-offs of the automation.