Performance and scale
Measure what a bench spends per item, remove the usual costs (objections per item, config lookups in loops, formatted messages, recording, object churn), and stop a pending table from growing without bound.
- Lesson
- U15.3
- Time
- 30 min
- Runs on
- Verilator with UVM
- Check
- 5 questions
Two small changes made a bench about six times slower per item and made its memory climb on long tests. One change raised an objection for every item in the driver; the other was a scoreboard table that never forgot a request the design had dropped. Nothing failed while the traffic ran, so nothing pointed at either change.
| Per item, measured on this machine | Host time |
|---|---|
| Driver loop, no objection | about 16 us |
| Same loop, objection per item | about 100 us |
uvm_config_db::get with 41 entries of that name | 268 to 310 us (five runs) |
Reading cfg.gap through a held handle | under 0.1 us |
Host times on this machine (Verilator 5.052, -O0 build, a shared host): one run of the example, plus a five-run range for the lookup. Numbers and ratios belong to this build, workload and host; the operation counts are what carry over.
Measure counts before time
Host time per operation changes with the machine, the build and the load. Counts do not. So measure in this order. First make sure two runs are comparable: the same test, seed, simulated time, simulator build and host conditions. Then compare their total host time and simulated time. Then count the expensive events per item, and only then time the few operations the counts point to.
| Count per item | How to get it | Target in this bench |
|---|---|---|
| Objection calls reaching the test | Override raised() and dropped() in the test | 0 during traffic |
| Config lookups | +UVM_CONFIG_DB_TRACE: count GET lines after a marker message at the start of run_phase | 0 after build |
| Messages formatted | Messages printed per item; calls to $sformatf outside the macros | Few, and only at the verbosity in use |
| Objects allocated | Count create, new and clone calls, and compare with items | One per stored or published transaction |
| Table and FIFO sizes | Sample size() at fixed times | Flat after warm-up |
The first numbers to collect for a slow or growing run, once two runs are known to be comparable.
The example's stopwatch reads the host clock through a three-line C function, wall_us() in clock.c, built with the program. Its numbers below are one run on this machine; rerun it and they will move.
An objection per item
uvm_test_top.e.agt.drv. Arrows are calls made by the objection as it propagates: one raised() per level for each raise, one dropped() per level for each drop.- Raise
The worker raises for one item. The count is added at each level above it, and each level's
raised()runs: the test sees one call.More context
Every level runs its
raised()on the way up. With no item objection the test was notified 0 times for 2000 items.Follow the calls, values and waits
- call raised(): e.agt.drv → e.agt
- call raised(): e.agt → e
- call raised(): e → uvm_test_top
- call raised(): uvm_test_top → uvm_top
- Drop to zero
After 10 ns the worker drops. Its own count is now 0, so the library schedules a drain in a background process instead of propagating at once.
More context
A raise in the same time step, before the drain runs, cancels it: 2000 zero-time pairs notify the test once right after the loop (the first raise) and twice 1 ns later.
Follow the calls, values and waits
- call drop_objection: e.agt.drv → Drain process
- Waiting: Drain process — drain scheduled, not yet run
- The drain propagates
The background process runs the drain and passes the drop up, level by level; the test sees a second call. Measured: 4000 calls at the test for 2000 items.
More context
The example prints "2000 items, objection per item: the test was notified 4000 times": one raise and one drop per item. A
#0after each drop lets every drain run, giving 4000 at once.Follow the calls, values and waits
- call dropped(): Drain process → e.agt
- call dropped(): e.agt → e
- call dropped(): e → uvm_test_top
- call dropped(): uvm_test_top → uvm_top
- Released: Drain process
Read the diagram as text
- e.agt.drv: raise, 10 ns of work, drop
- e.agt: raised(), dropped()
- e: raised(), dropped()
- uvm_test_top: Counts the calls it receives
- uvm_top: Total decides when the phase may end
- Drain process: Forked at each level whose count reaches 0
- e.agt.drv → e.agt: propagate
- e.agt → e: propagate
- e → uvm_test_top: propagate
- uvm_test_top → uvm_top: propagate
- e.agt.drv → Drain process: count 0: schedule
- Drain process → e.agt: drop passes up
One test-level raise and drop around the whole traffic replaces these per-item pairs; it starts at the test, so it never climbs through drv, agt and e.
An objection exists to keep a phase from ending while work is pending. Raising and dropping one per item in a component three levels down repeats the whole propagation for every item. The test was notified 4000 times for 2000 items, and each item cost about six times as much host time as without the objection.
Hold one objection in the test, or in the root sequence's starting phase, for the whole traffic. If per-item objections come from code you cannot change, phase.get_objection().set_propagate_mode(0) sends each raise and drop straight to the top. That skips the calls at the levels between, but it does not remove the per-item drain. For work still in flight when the traffic ends, keep the completion accounting of U10.3. A drain time only adds a grace period, sized from a known latency bound; the final checks still prove that the work completed.
Why a raise right after a drop can look free
Suppose a component drops to zero and raises again in the same time step, before the background process has run. The library finds the pending drain and cancels it, so that pair adds nothing above the component. The example measures it: 2000 zero-time pairs notify the test once at the end of the loop (the first raise) and twice 1 ns later, when the last drop has propagated. Read such a count only after the scheduler has had a chance to run.
A #0 after each drop yields to the background process, which in this bench lets each drain run before the next raise: the example counts 4000 notifications for 2000 pairs. That holds here because no drain time is set and no all_dropped callback waits; with either, the next raise can still cancel a drain in progress. This is a scheduling detail of uvm-core 2020.3.1 (m_raise searches the scheduled, forked and running drains first), not a reason to raise per item.
Configuration lookups belong in build
A get is a search. The library collects every entry with the requested name and matches each entry's scope against the caller's path. The example sets 41 entries named gap at made-up paths, as a large bench with one per agent might. One lookup took 268 to 310 us here over five runs; with one entry, 18 to 22 us. Reading cfg.gap through a held handle took under 0.1 us. A narrower scope does not help, because every entry with that name is still checked. These entries use exact paths; wildcard and regex scopes add pattern matching for each entry, so they are likely to cost more (not measured here).
Lookup per item
task run_phase(uvm_phase phase);
forever begin
seq_item_port.get_next_item(req);
void'(uvm_config_db #(int)::get(this, "", "gap", gap));
repeat (gap) @(posedge vif.clk);
...
One search per item, and the cost grows with every bench that adds an entry with the same name.
Lookup once, keep the handle
function void build_phase(uvm_phase phase);
if (!uvm_config_db #(agent_cfg)::get(this, "", "cfg", cfg))
`uvm_fatal("CFG", "no agent_cfg")
endfunction
...
repeat (cfg.gap) @(posedge vif.clk); // a field read
One search at build. A test that changes cfg.gap mid-run is seen at the next item, because the handle is shared. Setting a new object in the database would not reach this held handle.
If a value really must change through the database at run time, have one thread call uvm_config_db #(int)::wait_modified(this, "", "gap"). It blocks until a set to that name reaches this component; the thread then calls get once and updates a field, instead of polling.
Messages and recording
| Operation, at UVM_HIGH, filtered by the current verbosity | Host time each |
|---|---|
The uvm_info macro with a $sformatf message | about 0.3 us |
uvm_report_info(id, $sformatf(...), UVM_HIGH) | about 2 us |
begin_tr + end_tr, recording off | about 8 us |
begin_tr + end_tr, recording on (text database) | about 60 us |
Measured on this machine, one run. Neither message call printed anything, because the default verbosity is UVM_MEDIUM.
The macro checks the verbosity before it evaluates its message, so a filtered $sformatf is never formatted. Calling uvm_report_info directly formats the string first and filters it afterwards. Use the macros in hot paths, and keep UVM_HIGH detail there for the run that needs it.
Writing records is opt-in, but the bracketing is not. Every item that passes through a sequencer gets begin_tr and end_tr automatically from start_item and finish_item, and those calls cost time even with recording off (the third row). Enable recording only on the components you need, for the failing seed. Where per-item cost matters, call disable_auto_item_recording() on the sequencer or through the driver's seq_item_port, or define UVM_DISABLE_AUTO_ITEM_RECORDING. A driver that brackets items itself should call it too, so items are not stamped twice (U2.4).
Objects in hot loops
Creating a transaction through the factory cost about 7 us here, new about 5 us (one run on this machine), and rewriting one object's fields almost nothing. The factory's extra cost buys overrides; keep it for objects a test may replace. Field macros do not slow construction: their cost appears in copy, compare, print and record, which U2.4 covers.
Reuse is safe once every holder has released the handle or taken its own copy. The pending table keeps each expectation's handle until the response arrives. If the generator rewrites one object instead of creating one per request, every waiting entry points at the same object and shows the last request's fields. U7.2 states the rule for analysis traffic. A publisher that sends a fresh object and never changes it after write lets listeners keep handles; one that reuses its object forces each keeper to copy. Here, one fresh object per stored expectation is the simple choice.
Memory that grows: an unbounded pending table
An out-of-order checker keeps each expectation until its response arrives (U8.2). The example switches from timing loops to a second experiment: traffic() is a synthetic response schedule, not a design, and it calls both tables directly. Request ids are unique, 0 to 1999, and every eighth response is dropped. Without an age limit, those expectations wait forever, and the table grows with the length of the test. When several requests can share a tag, use U8.2's per-tag queues instead of one entry per id.
| Traffic time | Unbounded table | Bounded table (100 ns) |
|---|---|---|
| 5 us | 65 | 4 |
| 10 us | 127 | 4 |
| 15 us | 190 | 4 |
| 20 us | 252 | 4 |
| After the end scan | 250 never answered | 0 waiting, 250 missing |
Measured entries waiting in each table. The unbounded table grows by one entry per eight requests; the bounded one never held more than 5.
Memory is entries times their size, and each entry here holds a transaction handle. The portable measurement is the entry count at fixed times. A count that rises with test length while traffic is steady is a symptom: either a backlog, where arrivals outpace a working consumer, or retained state that will never retire. Here the dropped responses prove the second. The same check applies to a table, a queue or an unread analysis FIFO.
An age limit detects each lost response close to where it happened and frees its entry. The first was detected and timestamped 180 ns into the traffic; the end scan then reports the total as an error. The limit must come from the specified legal latency, and the sweep must run throughout the test. Set it too small and the checker reports good responses as missing, then as unexpected when they arrive. Without a known latency bound, use a capacity limit, backpressure or a completion rule instead of silently removing outstanding work. Both tables keep the end scan.
Try it
The host lines are this run's stopwatch readings and change on every run; the other lines do not. The two errors are the end scans: 250 expectations never answered in the unbounded table, and 250 missing responses in the bounded one, the first detected 180 ns into the traffic and reported at the end.
Move phase.raise_objection(this) and drop_objection out of items into the caller and the per-item cost falls back to the first line's. In traffic, change bounded.max_age = 100 to 10 and watch good responses turn into missing and unexpected ones.
// clock.c: host wall-clock time for the measurements, built together with main.sv.
#include <time.h>
#include "svdpi.h"
#include "Vtb__Dpi.h" // the prototype generated from the import in main.sv
double wall_us(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return t.tv_sec * 1e6 + t.tv_nsec / 1e3;
}
2000 items, no item objection: the test was notified 0 times 2000 items, objection per item: the test was notified 4000 times 2000 zero-time pairs: notified 1 times at once, 2 after 1 ns 2000 pairs with #0 after each drop: notified 4000 times at once, 4000 after 1 ns unbounded table: entries at 5, 10, 15, 20 us: 65 127 190 252 bounded table: entries at 5, 10, 15, 20 us: 4 4 4 4 unbounded table: 1750 matched, largest 253, 250 still waiting at the end bounded table: 1750 matched, largest 5, 250 missing, first detected at 180 ns UVM_ERROR : 2Read included file: clock.c
// clock.c: host wall-clock time for the measurements, built together with main.sv.
#include <time.h>
#include "svdpi.h"
#include "Vtb__Dpi.h" // the prototype generated from the import in main.sv
double wall_us(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return t.tv_sec * 1e6 + t.tv_nsec / 1e3;
}
Check yourself
Remember
- Measure counts per item before host time: they are stable and point at the mechanism that multiplied.
- Hold one objection for the whole traffic; an objection per item propagates up every level and cost about six times the host time per item here.
- Look up configuration in build and keep a handle; a
getsearches every entry with that name. - Use the message macros in hot paths, keep recording off in regressions, and reuse an object only after every holder has released or copied it.
- Sample table sizes over time; bound a pending table by its specified legal latency (or capacity), and keep the end scan.