Bound work and prove completion

Put one absolute deadline around stimulus, checking and the quiet window, cancel its timer in the right process scope, and make a stuck test fail fast with a diagnosis.

Lesson
U10.3
Time
25 min
Runs on
Verilator with UVM
Check
6 questions

A nightly regression job is killed after six hours with no UVM message at all. The design had stopped raising ready after its second transfer; the driver waited for it, the sequence for the driver, and the test for the sequence. UVM's default timeout is 9,200 s of simulated time, nearly 10^12 cycles of a 10 ns clock, so the job's six-hour wall-clock limit killed the run long before UVM would have complained.

Simulated timeDesignDriverSequenceTest
15 nsTakes 16item_doneSends 32Waiting in seq.start
45 nsTakes 32, then ready stays 0item_doneSends 48Waiting
50 ns onNever ready againOffering 48, waiting for readyWaiting in finish_itemWaiting
9,200 s---UVM's default timeout, never reached in a six-hour job

Each layer waited correctly for the layer below. Nothing put a limit on the total.

Completion first, then a bound

When checking may end is settled in U7.3's section on closing the test: planned counts met, nothing waiting, then a 20 ns quiet window with checking still on. This lesson adds two things: one absolute deadline across stimulus, drain and that window, and the question of which processes the timer and its cancellation reach.

Put one deadline around the whole path

One deadline races the whole completion pathThe example's stuck run. The work branch covers stimulus, results checked, the quiet window and the final counts; the timer branch only counts to 500 ns. Arrows are calls, pin activity and branch completion.
One deadline races the whole completion pathThe example's stuck run. The work branch covers stimulus, results checked, the quiet window and the final counts; the timer branch only counts to 500 ns. Arrows are calls, pin activity and branch completion. branch Work branch Stimulus, resultschecked, quiet window,final counts stimulus seq.start(sqr) Sends 16, 32, 48 dut Stuckincrementer ready stays 0 afterits second accept checker Scoreboard Checks 17 at 35 ns,33 at 65 ns timer Timer branch #500ns, armed at 0ns wait join_any, thendisable bounded The first branch tofinish wins diagnosis DEADLINE error 2 of 3 inputs taken,driver offers x=48,ready=0 runsdriver offers xmonitor reportsresultsnever finishesfires at 500 nsreport what is stuck
  1. Arm both branches0 ns (illustrative)

    The work branch starts the sequence; the timer branch starts counting. One timer covers everything the work branch will do, not just seq.start.

    More context

    Both branches of fork : bounded start at 0 ns. The work branch's first call is seq.start; the timer branch is a single #500ns.

    Follow the calls, values and waits
    1. call seq.start: Work branch → seq.start(sqr)
    2. Waiting: join_any, then disable bounded — waiting for either branch
  2. First result checked35 ns (illustrative)

    The design accepted 16 at 15 ns; the scoreboard checks 17 at 35 ns.

    More context

    The two moves are 20 ns apart: the design takes 16 at 15 ns, and the log prints " 35 ns scoreboard y=17 checked".

    Follow the calls, values and waits
    1. data x=16: seq.start(sqr) → Stuck incrementer
    2. data y=17: Stuck incrementer → Scoreboard
  3. The second accept45 ns (illustrative)

    The design accepts 32 at 45 ns. It is its second accept, and after it the design keeps ready at 0.

    More context

    So far the run looks healthy: one result checked and one more accepted. The fault shows only in what does not happen next.

    Follow the calls, values and waits
    1. data x=32: seq.start(sqr) → Stuck incrementer
  4. The design stops accepting50 ns (illustrative)

    From 50 ns the driver offers 48 and waits; the sequence waits in finish_item.

    More context

    The design was built with STUCK_AFTER = 2. From here the pins do not change: valid stays high with x=48, and ready stays 0.

    Follow the calls, values and waits
    1. data x=48: seq.start(sqr) → Stuck incrementer
    2. Waiting: seq.start(sqr) — in finish_item(48)
    3. Waiting: Stuck incrementer — ready = 0
  5. Last available result65 ns (illustrative)

    The result of 32 is checked at 65 ns. Two inputs taken, two results checked; the work branch is still inside seq.start.

    More context

    " 65 ns scoreboard y=33 checked" is the last line before the deadline. Nothing tells the work branch that 48 will never be taken.

    Follow the calls, values and waits
    1. data y=33: Stuck incrementer → Scoreboard
    2. Waiting: Work branch — still in seq.start
  6. The deadline wins500 ns (illustrative)

    The timer branch finishes, so join_any returns. disable bounded kills the work branch, and the test reports what it found before dropping its objection.

    More context

    The design is still stuck and the driver, another component's process, still offers 48. With a healthy design the work branch finishes at 120 ns instead, and disable bounded cancels the timer.

    Follow the calls, values and waits
    1. return 500 ns: Timer branch → join_any, then disable bounded
    2. data DEADLINE: join_any, then disable bounded → DEADLINE error
    3. Released: join_any, then disable bounded
    4. Released: Work branch
    5. Released: seq.start(sqr)
Read the diagram as text
  • Work branch: Stimulus, results checked, quiet window, final counts
  • seq.start(sqr): Sends 16, 32, 48
  • Stuck incrementer: ready stays 0 after its second accept
  • Scoreboard: Checks 17 at 35 ns, 33 at 65 ns
  • Timer branch: #500ns, armed at 0 ns
  • join_any, then disable bounded: The first branch to finish wins
  • DEADLINE error: 2 of 3 inputs taken, driver offers x=48, ready=0
  • Work branch → seq.start(sqr): runs
  • seq.start(sqr) → Stuck incrementer: driver offers x
  • Stuck incrementer → Scoreboard: monitor reports results
  • Work branch → join_any, then disable bounded: never finishes
  • Timer branch → join_any, then disable bounded: fires at 500 ns
  • join_any, then disable bounded → DEADLINE error: report what is stuck

The run's output: y=17 checked at 35 ns, y=33 checked at 65 ns, then the 500 ns deadline diagnosis and one UVM_ERROR.

Race the complete operation against a timer, and keep the timer alive through stimulus, results checked, the quiet window and the final counts. Bounding only seq.start leaves a second hang: all requests accepted, one result that never arrives.

Simplified from the example: one absolute 500 ns deadline stays armed until every completion condition has been checked.
fork : bounded
  begin
    seq.start(sqr);
    stimulus_finished = 1;
    wait (sb.inputs == 3 && sb.checked == 3 && sb.expected.size() == 0);
    #(quiet_window);           // U7.3's quiet window, checking still on
    @(negedge vif.clk);
    // Recheck the counts: a late extra result is an error.
    finished = 1;
  end
  begin #500ns; end            // the deadline
join_any
disable bounded;
if (!finished) `uvm_error("DEADLINE", "completion did not finish")

The example's design stops accepting after two inputs. At 500 ns the diagnostic reads "2 of 3 inputs taken, driver offers x=48, ready=0". With a healthy design the work branch finishes at 120 ns. A missing final response instead reports that stimulus finished, with the checked and waiting counts.

Size the deadline from bounds, not from one run: the healthy path takes 120 ns here, and 500 ns leaves room for this design's fixed pipeline. A design that may stall, queue more work or run a slower clock needs the limit recalculated from those bounds.

Keep the absolute limit even when the quiet window restarts on activity. A faulty design that keeps producing output can then postpone completion forever.

Cancel the timer without killing unrelated work

Which processes a cancellation reachesProcess arrows read "spawns". The named block bounded is a lexical scope, drawn separately with "names" arrows; it is not a process. The background child is a variant (the quiz's scenario): the example has none.
Which processes a cancellation reachesProcess arrows read "spawns". The named block `bounded` is a lexical scope, drawn separately with "names" arrows; it is not a process. The background child is a variant (the quiz's scenario): the example has none. process Test run_phase The calling process child Work branch Stimulus andcompletion checks child Timer branch #500ns scope Scope: bounded Names the twobranches of fork :bounded variant Backgroundtraffic Variant: forkedearlier with join_none child Helper process The isolated pattern:its own work and timerchildren other process Driver andmonitor Other components'run_phase processes spawnsspawnsspawns (variant)spawnsnamesnames
  1. disable bounded

    A named disable ends the active branches of the block it names: the work branch and the timer. Both are direct children of run_phase; the name adds a scope, not a process. A background child is outside that name.

    More context

    The name is how the statement finds the branches. In the stuck example it ends the work branch at 500 ns; in a healthy run it ends the timer.

    Follow the calls, values and waits
    1. call disable bounded: Scope: bounded → Work branch
    2. call disable bounded: Scope: bounded → Timer branch
  2. disable fork

    disable fork ends every descendant of the calling process: both branches and, in the variant, the background traffic started earlier.

    More context

    The reach follows process ancestry, not the text of the fork: the background child came from an earlier fork ... join_none but is still a child of run_phase.

    Follow the calls, values and waits
    1. call disable fork: Test run_phase → Work branch
    2. call disable fork: Test run_phase → Timer branch
    3. call disable fork: Test run_phase → Background traffic
  3. Inside a helper process

    Called inside a helper process, disable fork reaches only the helper's own descendants. This is the isolated pattern in the details panel, for timed code that other processes may share.

    More context

    The helper's own work and timer children are not drawn. Its disable fork stops there, so the background child and every other child of run_phase keep running.

    Follow the calls, values and waits
    1. call fork the helper: Test run_phase → Helper process
  4. Outside every form

    The driver and monitor are not descendants of this process, so no form stops them. A driver still offering 48 keeps valid high.

    Follow the calls, values and waits
    1. Waiting: Driver and monitor — driver still offering 48
  5. wait fork instead

    wait fork waits for every child. Waiting for the timer, a successful run sits until 500 ns, and a timer that reports in its own branch (the quiz variant) fires after a successful run. In the variant it also waits for a forever-running background child and may never return.

    More context

    A separate, successful run. The timer's return reaches run_phase only at 500 ns, and in the variant the forever-running background child never returns, so wait fork stays blocked.

    Follow the calls, values and waits
    1. return timer ends: Timer branch → Test run_phase
    2. Waiting: Test run_phase — in wait fork: still waiting for the background child
    3. Released: Driver and monitor
Read the diagram as text
  • Test run_phase: The calling process
  • Work branch: Stimulus and completion checks
  • Timer branch: #500ns
  • Scope: bounded: Names the two branches of fork : bounded
  • Background traffic: Variant: forked earlier with join_none
  • Helper process: The isolated pattern: its own work and timer children
  • Driver and monitor: Other components' run_phase processes
  • Test run_phase → Work branch: spawns
  • Test run_phase → Timer branch: spawns
  • Test run_phase → Background traffic: spawns (variant)
  • Test run_phase → Helper process: spawns
  • Scope: bounded → Work branch: names
  • Scope: bounded → Timer branch: names

Cancel the timer with a named disable or inside a helper process of its own; never with a wait that includes the timer.

join_any returns when either branch finishes. Record that the work branch finished, then cancel the other branch. wait fork would wait for the timer too, so a timer that reports unconditionally fires after a successful run.

The named disable bounded reaches only that block. disable fork reaches every descendant of the calling process, including background traffic it started earlier. Neither reaches a driver or monitor, whose processes belong to other components.

Isolate a timeout inside a reusable task

A statically named block can have several active invocations. For reusable concurrent code, give the timed operation its own helper process before using disable fork.

The caller's earlier background children are outside this helper. Initialize status per invocation and report a timeout after it returns.
fork
  begin                         // the isolated helper process
    fork
      do_work();
      begin #limit; timed_out = 1; end
    join_any
    disable fork;               // descendants of this helper only
  end
join

Diagnose before deciding on recovery

The example reports an error and ends. Disabling the work branch stops the wait inside seq.start; it does not lower the driver's valid, flush the request the driver holds, or reset the design. Only stop_sequences() flushes that request; a sequence's own kill() removes it from the arbitration queues but leaves the driver's request in place.

Continuing after a timeout needs the U10.2 procedure. stop_sequences() flushes the request, a reset-aware driver abandons it without item_done, and the design's own cancellation rule updates the scoreboard and models. Never label missing work canceled just to balance the counts.

Simulated-time and wall-clock limits guard different hangs. A simulated-time deadline, local or UVM's global set_timeout, fires only while time advances. A zero-delay loop stops time, so the job runner's wall-clock limit is the only thing that ends it. When the global timeout does expire, it reports fatal PH_TIMEOUT and says only that time ran out.

Global timeout and useful local diagnostics

uvm_root::get().set_timeout(2000ns, 1) sets this module's run-phase backstop; expiry reports fatal PH_TIMEOUT and says only that time ran out. The library default is 9,200 s of simulated time.

A local report should name the stopped stage, the accepted and checked counts, the pending work and any active driver request. Check a request handle for null before reading it: the driver may be idle while the scoreboard waits for a missing output.

Transfer: a design that may stall

Change the design: under backpressure it may legally hold ready low for up to 300 ns at a time, and a test now sends 20 items through a 4-deep queue. Recompute the deadline, and explain why doubling one observed run time is not a bound.

Reason through the deadline

Build the limit from worst cases: every item may meet the full 300 ns stall, plus the pipeline latency, the drain of the queue and the quiet window. A run that happened to see two short stalls says nothing about the next seed.

Report which stage the deadline caught: stimulus still blocked, results still waiting, or extra output during the window. Then inject one permanent stall and one missing result in separate runs; each must end at the deadline with its own diagnosis, and a legal 300 ns stall must not.

Try it

The design was built with STUCK_AFTER = 2, so it never raises ready after its second accept. The sequence waits for 48 forever, and at 500 ns the deadline wins and the test reports what it found.

Two edits turn the run into its other outcomes. Measured on this runner:

VariantEditWhat the run prints
HealthyRemove #(.STUCK_AFTER(2)) from the design instance95 ns scoreboard y=49 checked, then 120 ns test completion checks finished; quiet window over; 0 errors
Last result lostAlso change the monitor's if (vif.out_valid) begin to if (vif.out_valid && vif.y != 49) begin500 ns test deadline: stimulus_done=1, inputs=3, checked=2, waiting=1, the DEADLINE error and [SB] 1 expected results never arrived; 2 errors

Healthy: stimulus ends at 75 ns, the last result is checked at 95 ns, the quiet window runs to 115 ns, and the counts are checked at the 120 ns falling edge. Lost result: all three inputs are taken, so the deadline names the missing result instead of a blocked driver.

RUNVerilator + UVM 2020.3.1 · simulator service
Edit the code, then run it
Expected among the UVM messages: 35 ns scoreboard y=17 checked 65 ns scoreboard y=33 checked 500 ns test deadline: 2 of 3 inputs taken, driver offers x=48, ready=0 UVM_ERROR : 1

Check yourself

Predict the outputThe driver waits 30 ns before its first get_next_item, then takes 100 ns per item. The sequence sends 5 items. What line does the $display print?
`timescale 1ns/1ns
`include "uvm_macros.svh"
import uvm_pkg::*;

class item_c extends uvm_sequence_item;
  `uvm_object_utils(item_c)
  function new(string name = "item_c"); super.new(name); endfunction
endclass

class drv_c extends uvm_driver #(item_c);
  `uvm_component_utils(drv_c)
  int idle = 0, per_item = 10, count;
  function new(string name, uvm_component parent); super.new(name, parent); endfunction
  task run_phase(uvm_phase phase);
    #(idle);
    forever begin
      seq_item_port.get_next_item(req);
      #(per_item);
      count++;
      seq_item_port.item_done();
    end
  endtask
endclass

class leaf_seq extends uvm_sequence #(item_c);
  `uvm_object_utils(leaf_seq)
  int n = 1, done;
  function new(string name = "leaf_seq"); super.new(name); endfunction
  task body();
    repeat (n) begin
      item_c it = item_c::type_id::create("it");
      start_item(it);
      finish_item(it);
      done++;
    end
  endtask
endclass

class my_test extends uvm_test;
  `uvm_component_utils(my_test)
  uvm_sequencer #(item_c) sqr;
  drv_c drv;
  function new(string name, uvm_component parent); super.new(name, parent); endfunction
  function void build_phase(uvm_phase phase);
    sqr = uvm_sequencer #(item_c)::type_id::create("sqr", this);
    drv = drv_c::type_id::create("drv", this);
    drv.idle = 30;
    drv.per_item = 100;
  endfunction
  function void connect_phase(uvm_phase phase);
    drv.seq_item_port.connect(sqr.seq_item_export);
  endfunction
  task run_phase(uvm_phase phase);
    leaf_seq seq = leaf_seq::type_id::create("seq");
    seq.n = 5;
    phase.raise_objection(this);
    fork
      seq.start(sqr);
      #250;
    join_any
    disable fork;
    $display("at %0t: %0d of 5 items done", $time, seq.done);
    phase.drop_objection(this);
  endtask
endclass

module tb;
  initial run_test("my_test");
endmodule
Spot the bugThe driver takes 10 ns per item, so the 3-item sequence finishes at 30 ns. Each option bounds the sequence with a 1,000 ns timer. Which one reports a timeout that never happened?
Predict the outputThe expected work is checked at 95 ns. The test then waits for a 20 ns quiet window that restarts whenever a result is seen, inside a 500 ns absolute deadline. A faulty design produces an extra result every 15 ns from 105 ns. What line does the $display print?
`timescale 1ns/1ns
`include "uvm_macros.svh"
import uvm_pkg::*;

class quiet_test extends uvm_test;
  `uvm_component_utils(quiet_test)
  event seen;
  int   extras;
  function new(string name, uvm_component parent); super.new(name, parent); endfunction
  task run_phase(uvm_phase phase);
    bit quiet = 0;
    phase.raise_objection(this);
    fork                                    // a faulty design: one extra result every 15 ns
      begin #105; forever begin extras++; ->seen; #15; end end
    join_none
    fork : bounded
      begin
        #95;                                // the expected work is checked at 95 ns
        forever begin                       // a 20 ns quiet window that restarts on activity
          fork : window
            begin #20; quiet = 1; end
            @(seen);
          join_any
          disable window;
          if (quiet) break;
        end
      end
      #500;                                 // the absolute deadline
    join_any
    disable bounded;
    $display("at %0t: quiet=%0b, %0d extra results", $time, quiet, extras);
    phase.drop_objection(this);
  endtask
endclass

module tb;
  initial run_test("quiet_test");
endmodule
ConceptA test forks a background traffic process at the start of run_phase, then later uses fork ... join_any with disable fork to bound a sequence. What else does disable fork kill?
ConceptAfter the deadline kills a stuck sequence, the test wants to reset the design and continue. Which step does it need that the example did not?
ConceptWhy add a local deadline when the global timeout already ends a hung test?

Remember

  • When checking may end is U7.3's rule; this lesson bounds the time it may take.
  • Keep one absolute deadline armed through stimulus, results checked, the quiet window and the final counts; report the stalled stage and pending work.
  • Size the deadline from worst-case bounds, and keep it even when the quiet window restarts on activity.
  • Cancel the timer with a named block or inside its own helper process; wait fork waits for the timer, and disable fork also reaches earlier background children.
  • Simulated-time limits fire only while time advances; a wall-clock limit in the job runner covers a stopped scheduler.