A UVM scoreboard architecture that scales splits checking into two parts: a predictor that turns the DUT's input transactions into expected outputs, and a comparator that matches each expected transaction with the actual one seen at the DUT's output. UVM provides ready-made in-order comparators, and a keyed scoreboard handles DUTs that reorder their output.
The basic scoreboard in UVM Scoreboard keeps its reference model and its check in one write() function. That is fine for a register block, but it gets hard to maintain once the DUT has separate input and output interfaces, pipeline latency, or output that can arrive in a different order from the input.
9 min read | Intermediate
What You'll Learn
- Split a scoreboard into a predictor and a comparator
- Use
uvm_in_order_class_comparatorfor DUTs that keep transaction order - Write a keyed scoreboard for DUTs that reorder their output
- Report leftovers and totals correctly at the end of the test
The Predictor and Comparator Split
Two monitors observe the DUT: one on its input interface and one on its output interface. Each broadcasts what it sees through an analysis port.
The predictor subscribes to the input monitor. For every input transaction it computes what the DUT should produce, and writes that expected transaction to its own analysis port. It is the only place that knows how the DUT is supposed to behave, which is why it is often called the reference model.
The comparator receives expected transactions from the predictor and actual transactions from the output monitor. It pairs them up and compares them. It knows nothing about the DUT's function, only how to match one stream against the other.
This separation pays off quickly. When the specification changes, only the predictor changes. When another block has the same ordering behaviour, the comparator is reused as it is. And because both halves talk through analysis ports, either can be replaced through the factory.
Syntax: The Built-in In-Order Comparators
// For class-based transactions (uses the item's compare() and convert2string())
uvm_in_order_class_comparator #(T) cmp;
// For built-in types such as int or bit vectors
uvm_in_order_built_in_comparator #(T) cmp;
// Exports and port
cmp.before_export // analysis export for one stream (e.g. expected)
cmp.after_export // analysis export for the other stream (e.g. actual)
cmp.pair_ap // analysis port publishing every matched or mismatched pair
// Counters and reset
cmp.m_matches // number of matching pairs so far
cmp.m_mismatches // number of mismatching pairs so far
cmp.flush(); // resets both counters
The comparator buffers whichever stream arrives first and compares as soon as it has one transaction from each side, in arrival order. The two streams may be connected either way round, because the comparison does not care which side is "before". Timing between them is free: the output can lag the input by any number of cycles.
The class version calls the transaction's compare() to decide whether two items match, and its convert2string() to print them. So the transaction needs field macros or a do_compare(), plus a convert2string() that prints something useful.
Example: An In-Order Scoreboard
The DUT is a pipelined adder. Each input transaction add_in carries two operands, and each output transaction add_out carries the sum. Results come out in the same order the operands went in, a few cycles later.
The predictor
The predictor is a uvm_subscriber of the input type. Its write() builds the expected output and broadcasts it:
class add_predictor extends uvm_subscriber #(add_in);
`uvm_component_utils(add_predictor)
uvm_analysis_port #(add_out) exp_ap; // expected results go out here
function new(string name, uvm_component parent);
super.new(name, parent);
exp_ap = new("exp_ap", this);
endfunction
virtual function void write(add_in t);
add_out exp = add_out::type_id::create("exp");
exp.sum = t.a + t.b; // the reference model
exp_ap.write(exp);
endfunction
endclass
Note the type change. The predictor receives add_in but publishes add_out, so that expected and actual transactions are the same class and can be compared directly.
Wiring it up in the environment
class add_env extends uvm_env;
`uvm_component_utils(add_env)
in_agent m_in_agent;
out_agent m_out_agent;
add_predictor m_pred;
uvm_in_order_class_comparator #(add_out) m_cmp;
// Rest of the code (constructor)
virtual function void build_phase(uvm_phase phase);
super.build_phase(phase);
m_in_agent = in_agent::type_id::create("m_in_agent", this);
m_out_agent = out_agent::type_id::create("m_out_agent", this);
m_pred = add_predictor::type_id::create("m_pred", this);
m_cmp = new("m_cmp", this); // library class: plain new() is enough
endfunction
virtual function void connect_phase(uvm_phase phase);
super.connect_phase(phase);
m_in_agent.m_mon.ap.connect(m_pred.analysis_export); // inputs -> predictor
m_pred.exp_ap.connect(m_cmp.before_export); // expected -> comparator
m_out_agent.m_mon.ap.connect(m_cmp.after_export); // actual -> comparator
endfunction
virtual function void check_phase(uvm_phase phase);
super.check_phase(phase);
if (m_cmp.m_mismatches > 0)
`uvm_error("SCBD", $sformatf("%0d mismatches", m_cmp.m_mismatches))
endfunction
virtual function void report_phase(uvm_phase phase);
super.report_phase(phase);
`uvm_info("SCBD", $sformatf("Matches: %0d, mismatches: %0d", m_cmp.m_matches, m_cmp.m_mismatches), UVM_LOW)
endfunction
endclass
The check_phase turns any mismatch into a uvm_error, so the test fails clearly no matter how the comparator itself reports each pair. The report_phase prints one summary line, which is the first thing to look at after a regression.
Quick Check
Q: The DUT drops one result. Every later sum is still correct. What does the in-order comparator report?
Show Answer
A mismatch on every pair after the dropped one. The comparator pairs by position, so once one actual result is missing, each expected sum is compared against the result of the next input. A long run of mismatches that starts at one point usually means a lost or extra transaction, not a wrong calculation.
When Order Is Not Guaranteed
Many DUTs reorder transactions: a memory controller with several banks, an interconnect with several initiators, or anything that returns responses tagged with an ID. An in-order comparator would report false mismatches for all of them. The scoreboard must instead match each actual transaction with the expected one that has the same key.
The usual structure is an associative array of queues, indexed by the key. The scoreboard has two inputs, one for expected and one for actual transactions, which the `uvm_analysis_imp_decl macro makes possible, as described in Using _decl macro in TLM.
`uvm_analysis_imp_decl(_exp)
`uvm_analysis_imp_decl(_act)
class ooo_scoreboard extends uvm_scoreboard;
`uvm_component_utils(ooo_scoreboard)
uvm_analysis_imp_exp #(bus_rsp, ooo_scoreboard) exp_imp;
uvm_analysis_imp_act #(bus_rsp, ooo_scoreboard) act_imp;
bus_rsp pending[int][$]; // expected items, by transaction ID
int unsigned n_match, n_mismatch;
function new(string name, uvm_component parent);
super.new(name, parent);
exp_imp = new("exp_imp", this);
act_imp = new("act_imp", this);
endfunction
virtual function void write_exp(bus_rsp t);
pending[t.id].push_back(t); // remember what we expect
endfunction
virtual function void write_act(bus_rsp t);
bus_rsp exp;
if (!pending.exists(t.id) || pending[t.id].size() == 0) begin
`uvm_error("SCBD", $sformatf("Unexpected response: %s", t.convert2string()))
return;
end
exp = pending[t.id].pop_front(); // oldest expected item with this ID
if (pending[t.id].size() == 0) pending.delete(t.id);
if (exp.compare(t)) n_match++;
else begin
n_mismatch++;
`uvm_error("SCBD", $sformatf("Mismatch: exp %s act %s", exp.convert2string(), t.convert2string()))
end
endfunction
virtual function void check_phase(uvm_phase phase);
super.check_phase(phase);
foreach (pending[id])
`uvm_error("SCBD", $sformatf("ID %0d: %0d expected items never seen", id, pending[id].size()))
endfunction
endclass
Within one ID, items are still matched oldest first, which matches how most protocols behave: transactions with the same ID stay in order, while different IDs may overtake each other. If your DUT has no ID at all, the key can be any field that identifies a transaction uniquely, such as an address.
End-of-Test Checks
A scoreboard only knows the test is over when the run phase has finished, so the final checks belong in the phases that follow it:
check_phase: anything still waiting for a match is an error. Expected items never seen mean the DUT lost something. Actual items never expected mean it produced something extra.report_phase: print the totals, so a regression log shows at a glance how much was checked.
For these checks to mean anything, the last transactions must reach the scoreboard before the run phase ends. If the test drops its objection as soon as its sequence finishes, the final outputs are still in the DUT's pipeline and get reported as missing. A short drain time on the phase objection fixes that, as covered in UVM Objection.
Count what you checked. A scoreboard that reports zero errors because it received zero transactions is a broken testbench, not a passing test. Raising an error in check_phase when the match count is zero catches a disconnected monitor immediately.
Choosing a Structure
| DUT behaviour | Use |
|---|---|
| Output in the same order as input, same transaction type | uvm_in_order_class_comparator with a predictor |
| Output in order, but a different type from the input | A predictor that converts types, then an in-order comparator, or uvm_algorithmic_comparator with a transformer class |
| Output reordered, with an ID or other key | A keyed scoreboard like ooo_scoreboard |
| Simple storage, such as registers or memory | A single scoreboard holding a model of the storage, as in the basic UVM Scoreboard |
The general idea of reference models is not UVM-specific; Scoreboard Reference Models covers the different kinds of model and their trade-offs.
Common Mistakes
Mistake #1: Using an in-order comparator on a reordering DUT
If the DUT can legitimately return transactions in a different order, an in-order comparator floods the log with false mismatches. Match by key instead.
Mistake #2: Ending the run phase before the pipeline drains
The final transactions are still inside the DUT when the sequence ends. Without a drain time, they are reported as missing in check_phase, or never checked at all.
Mistake #3: Comparing objects the monitor keeps reusing
Comparators and keyed scoreboards store handles. If a monitor writes the same object every time and only changes its fields, the stored expected items change under you. Monitors should create a new object for every transaction, or the scoreboard should store a clone.
Mistake #4: Trusting a silent scoreboard
Zero mismatches with zero matches means nothing was checked. Check that the match count is greater than zero, and that nothing is left pending, before calling the test a pass.
Summary
In this article, you learned:
- A predictor turns input transactions into expected outputs; a comparator matches expected against actual
uvm_in_order_class_comparatormatches two streams in arrival order throughbefore_exportandafter_export- DUTs that reorder their output need a scoreboard that matches by ID or another key
check_phasereports leftovers andreport_phasereports totals, once the pipeline has drained- A scoreboard that counts zero matches should fail the test, not pass it