Predictor, comparator, scoreboard
Split a scoreboard into a predictor that publishes expected transactions and a comparator that evaluates them, and know exactly what the stock in-order comparator reports.
- Lesson
- U8.1
- Time
- 30 min
- Runs on
- Verilator with UVM
- Check
- 6 questions
The U7.3 scoreboard has grown up. Its one-line model is now a 400-line pipeline model, the comparison has three special cases, and both live inside write_out. A colleague wants the same model to check the next chip, where results come back out of order. The only way to get it is to copy the file and delete half of it.
| Job | Where U7.3 does it | Who else needs it |
|---|---|---|
| Predict the result from the input | Inside write_in, one line | A second bench, a coverage model of expected values, a proxy for the design before RTL exists |
| Decide whether a result is correct | Inside write_out | Any design with the same output type; a bench that compares out of order |
| Keep the test honest about missing results | check_phase | Every bench |
| Keep the model and the comparison apart | Nowhere | Whoever has to change one without the other |
Three jobs in one class. Each has a different owner once the bench is shared.
Prediction and evaluation are different jobs
A scoreboard answers one question: did the design do what the specification says? The answer takes two steps. First something has to say what the design should have produced for the inputs it saw. Then something has to compare that with what the monitor saw coming out.
The Cookbook names the two parts. The predictor takes the same input stream as the design and produces expected output transactions. The evaluator, usually a comparator, takes the expected stream and the actual stream and decides, pair by pair. The scoreboard is the component that owns both and connects them.
Keeping them apart pays off quickly. The predictor can be a SystemVerilog class today and a C model over DPI next month, with the comparator untouched. The comparator can be replaced by an out-of-order one when the next design reorders, with the model untouched. And the predictor can stand in for the design itself while the RTL is still being written, so the rest of the bench is debugged early.
The whole lesson keeps the U7 incrementer bench: the design takes x, is busy for two edges, and shows y = x + 1 two edges later. The model stays one line. Everything around it changes shape.
A predictor is a subscriber with a port of its own
A predictor listens to one input stream, so the natural base is uvm_subscriber #(incr_item). That base gives it an analysis_export and demands a write function. What it adds is an analysis port of its own, uvm_analysis_port #(incr_result) ap, through which it broadcasts each prediction.
class incr_predictor extends uvm_subscriber #(incr_item);
uvm_analysis_port #(incr_result) ap;
int predicted;
function void write(incr_item t);
incr_result e = incr_result::type_id::create("e");
e.y = t.x + 1; // the model
predicted++;
ap.write(e); // whoever wants predictions, listens here
endfunction
endclassAny number of components can listen to that port: a comparator, a coverage collector that bins expected values, a logger. In the example two comparators listen at once, and the predictor does not know.
The predicted stream is a transaction stream like any other, so predictors chain. A model of an encoder can feed a model of a scrambler, each a subscriber to the previous one's port, and only the last port reaches the comparator. Each stage stays small enough to test on its own.
The stock in-order comparator
The library ships one evaluator for the simple case: uvm_in_order_class_comparator #(T). It has two analysis exports, before_export for the expected stream and after_export for the actual one, each feeding an internal analysis FIFO. Its run_phase takes one transaction from each FIFO, in arrival order, and calls before.compare(after).
For that to work, T must implement compare() and convert2string(), which incr_result does through do_compare and a one-line convert2string. The result type must also be a uvm_object, since compare lives there. A variant, uvm_in_order_built_in_comparator, uses == for integers and other built-in types.
| Event | What the stock comparator does | Severity |
|---|---|---|
| Pair compares equal | Prints [Comparator Match] y=17, m_matches++ | UVM_INFO |
| Pair differs | Prints [Comparator Mismatch] y=18 differs from y=17, m_mismatches++ | UVM_WARNING |
| Any pair | Publishes the pair on pair_ap for other listeners | none |
| End of test | Nothing: it has no check_phase | none |
What uvm_in_order_class_comparator reports. A mismatch is a warning, so the error count stays at zero.
That last column is the trap. In the quiz program the model is wrong by one, every pair mismatches, and the run still ends with UVM_ERROR : 0. A test that uses the stock comparator must read m_mismatches or count the Comparator Mismatch id in check_phase and raise its own error. The counters are public despite their m_ prefix.
Writing the comparator yourself
The stock comparator is a good first evaluator and a poor last one. What it compares is up to the item: it calls compare(), so a do_compare with masks or a mode (U8.3) works inside it unchanged. What it cannot change is the architecture around the pair: in-order pairing only (U8.2), a mismatch message without the input and its time, and no end-of-test check. Mismatches are warnings, though set_report_severity_id_override(UVM_WARNING, "Comparator Mismatch", UVM_ERROR) fixes that from outside. Write your own when the architecture is the problem. The shape to copy is the Cookbook's: two analysis FIFOs and a run_phase that synchronises them.
e while it waits.- 15 ns: predict
The monitor publishes x = 16. The predictor publishes e with y = 17 into before_fifo.
More context
Both writes run in the monitor's process and return in the same time step. The comparator's loop has been waiting in its first get since 0 ns.
Follow the calls, values and waits
- data x = 16: Monitor → Predictor
- data e: predicted y = 17: Predictor → before_fifo
- Waiting: compare(e, a) — blocked in before_fifo.get(e)
- 15 ns: hold
before_fifo.get(e)returns at once. The loop now blocks inafter_fifo.get(a), with the prediction ine, outside both FIFOs.More context
From here until 35 ns before_fifo is empty: the prediction exists only in the variable e.
Follow the calls, values and waits
- data e: predicted y = 17: before_fifo → Variable e
- Waiting: compare(e, a) — blocked in after_fifo.get(a)
- Released: compare(e, a)
- 35 ns: observe
The monitor publishes the result y = 17 into after_fifo, and
get(a)returns.More context
The result was observed two edges after its input. The write into after_fifo returns at once; the waiting get is what wakes.
Follow the calls, values and waits
- data a: observed y = 17: Monitor → after_fifo
- data a: observed y = 17: after_fifo → compare(e, a)
- Released: compare(e, a)
- 35 ns: compare
e.compare(a)matches, and the loop goes back tobefore_fifo.get(e)for the next pair.More context
The log line "35 ns compare y=17 matches prediction" comes from this call. Pairs two and three match at 65 and 95 ns.
Follow the calls, values and waits
- data e: predicted y = 17: Variable e → compare(e, a)
- Waiting: compare(e, a) — blocked in before_fifo.get(e) until 45 ns
- End of run: check_phase115 ns (illustrative)
A prediction still in
eor in before_fifo at the end, or a result left in after_fifo, is reported incheck_phase. Here after_fifo is empty.More context
The counts balanced at 95 ns; the test observed for 20 ns more and reports 0 results left over. Ending the run phase kills the loop, still waiting for a fourth prediction.
Follow the calls, values and waits
- data used() = 0: after_fifo → check_phase
- Released: compare(e, a)
Read the diagram as text
- Monitor: x = 16 taken at 15 ns; y = 17 seen at 35 ns
- Predictor: write(t): e.y = t.x + 1
- before_fifo: predicted y = 17
- after_fifo: observed y = 17
- Variable e: prediction held from 15 ns
- compare(e, a): match at 35 ns
- check_phase: predictions without results; results left in after_fifo
- Monitor → Predictor: in_ap: x = 16
- Predictor → before_fifo: ap: e
- before_fifo → Variable e: get(e) returns at 15 ns
- Monitor → after_fifo: out_ap: y = 17
- after_fifo → compare(e, a): get(a) returns at 35 ns
- Variable e → compare(e, a): e
- after_fifo → check_phase: used() at the end
Two independent paths meet only in the comparator. Neither write waits; only the comparator's task does.
The FIFOs matter. Each write runs in the monitor's process and must return at once, so it only drops its transaction into a FIFO; the waiting happens in the comparator's task. Which get comes first does not matter: the loop cannot compare until both have delivered.
All three pairs, edge by edge
A prediction arrives at each input edge and is held, in before_fifo or already in e, until its result arrives twenty nanoseconds later.
Stock comparator
stock = uvm_in_order_class_comparator #(incr_result)
::type_id::create("stock", this);
// mismatch: UVM_WARNING, counted in m_mismatches
// test must check m_mismatches itself
Three lines to wire up; a broken model gives warnings and a passing test.
Your comparator
before_fifo.get(e);
after_fifo.get(a);
if (e.compare(a)) matched++;
else `uvm_error("CMP", $sformatf("%s, predicted %s",
a.convert2string(), e.convert2string()))
Twenty lines; the severity, the message and the end-of-test check are yours.
Compare with e.compare(a), never with e == a. Two handles are equal only when they point at the same object, and a prediction and a monitored result never do. A comparison of handles reports every pair as a mismatch, which the spot question shows.
Where the scoreboard went
In the example the test builds the predictor and both comparators and connects them. In a real bench that wiring moves into a uvm_scoreboard subclass or the environment, which exposes the two analysis exports and hides the parts. The drain check from U7.3 moves too, and splits. The test's check_phase compares pred.predicted with cmp.matched + cmp.mismatched and reports any prediction that never met a result, whether it sits in before_fifo or was already taken out into e. The comparator's own check_phase reports the opposite leak: results left in after_fifo at the end, which the design produced and nobody predicted.
When checking may end
U7.3 S4 is the home of the end-of-test rule. Require the planned work, then keep the monitors sampling for a quiet window as long as the design's longest input-to-result latency, 20 ns here. Two things change when prediction and comparison are separate components.
First, the balance is a count across components: the test waits until the comparator has handled as many pairs as the predictor published. Second, an extra result has a place to wait. The comparator loop is blocked on before_fifo.get with no prediction to pair, so the result sits in after_fifo, and only the comparator's leftover check in check_phase reports it.
The example waits #(max_latency) after the balance, with max_latency a time field of the test, and prints the leftover count at 115 ns. Set the bench's fault switch and the design shows a fourth result at 105 ns: it lands in after_fifo and the comparator reports it. Dropping at the balance, 95 ns, ends the run before it arrives; the second spot question shows that.
Counts are predictions / pairs. A run that ends at the balance never sees the result at 105 ns.
A drain time set after the balance, set_drain_time(this, 20ns), is the same window written another way. A result the design invents long after it went idle falls outside any window. An assertion on the interface, no result without an outstanding input, catches that while the simulation runs.
Try it
The predictor publishes three predictions at the input edges (15, 45 and 75 ns). Both comparators receive them and the three results, and both report three matches. The stock comparator's matches appear as [Comparator Match] info lines; the hand-written one prints its own.
The counts balance at 95 ns and the test keeps observing for 20 ns. Set .EXTRA(1) on dut in tb and rerun: the quiet-window line reports 1 result left over and the comparator raises a UVM_ERROR for it. Change the model to t.x + 2 instead and the hand-written comparator reports three UVM_ERROR lines with both values; the stock one reports three warnings.
35 ns compare y=17 matches prediction 65 ns compare y=33 matches prediction 95 ns compare y=49 matches prediction 115 ns test quiet for 20 ns after the counts balanced, 0 result(s) left over predicted 3; hand-written: 3 matches, 0 mismatches; stock: 3 matches, 0 mismatches UVM_ERROR : 0Check yourself
Remember
- A scoreboard has two jobs: a predictor produces expected transactions from the input stream, and a comparator evaluates them against the actual stream.
- A predictor is a
uvm_subscriberwith an analysis port of its own, so any number of components can listen to its predictions, including another predictor. uvm_in_order_class_comparatorcompares in arrival order withcompare()and reports a mismatch as aUVM_WARNING; the test must checkm_mismatchesitself.- Your own comparator uses two analysis FIFOs and synchronises them in
run_phase; at the end it reports predictions without results and results without predictions. - Balanced counts are the earliest the test may stop: keep observing for a quiet window of the design's maximum latency, or a late extra result is never seen.