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.

JobWhere U7.3 does itWho else needs it
Predict the result from the inputInside write_in, one lineA second bench, a coverage model of expected values, a proxy for the design before RTL exists
Decide whether a result is correctInside write_outAny design with the same output type; a bench that compares out of order
Keep the test honest about missing resultscheck_phaseEvery bench
Keep the model and the comparison apartNowhereWhoever 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.

The predictor knows the model and nothing about comparing.
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
endclass

Any 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.

EventWhat the stock comparator doesSeverity
Pair compares equalPrints [Comparator Match] y=17, m_matches++UVM_INFO
Pair differsPrints [Comparator Mismatch] y=18 differs from y=17, m_mismatches++UVM_WARNING
Any pairPublishes the pair on pair_ap for other listenersnone
End of testNothing: it has no check_phasenone

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.

x = 16 becomes y = 17 twice: predicted and observedArrows show transactions moving. The comparator takes the prediction out first and holds it in e while it waits.
x = 16 becomes y = 17 twice: predicted and observedArrows show transactions moving. The comparator takes the prediction out first and holds it in `e` while it waits. publisher Monitor x = 16 taken at 15ns; y = 17 seen at 35ns subscriber Predictor write(t): e.y = t.x + 1 queue before_fifo predicted y = 17 queue after_fifo observed y = 17 variable Variable e prediction held from15 ns consumer compare(e, a) match at 35 ns check check_phase predictions withoutresults; results leftin after_fifo in_ap: x = 16ap: eget(e) returns at 15nsout_ap: y = 17get(a) returns at 35nseused() at the end
  1. 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
    1. data x = 16: Monitor → Predictor
    2. data e: predicted y = 17: Predictor → before_fifo
    3. Waiting: compare(e, a) — blocked in before_fifo.get(e)
  2. 15 ns: hold

    before_fifo.get(e) returns at once. The loop now blocks in after_fifo.get(a), with the prediction in e, 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
    1. data e: predicted y = 17: before_fifo → Variable e
    2. Waiting: compare(e, a) — blocked in after_fifo.get(a)
    3. Released: compare(e, a)
  3. 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
    1. data a: observed y = 17: Monitor → after_fifo
    2. data a: observed y = 17: after_fifo → compare(e, a)
    3. Released: compare(e, a)
  4. 35 ns: compare

    e.compare(a) matches, and the loop goes back to before_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
    1. data e: predicted y = 17: Variable e → compare(e, a)
    2. Waiting: compare(e, a) — blocked in before_fifo.get(e) until 45 ns
  5. End of run: check_phase115 ns (illustrative)

    A prediction still in e or in before_fifo at the end, or a result left in after_fifo, is reported in check_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
    1. data used() = 0: after_fifo → check_phase
    2. 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
Two streams meet one pair at a time
012345edge (ns)153545657595input x16-32-48-prediction e.y17-33-49-result y-17-33-49run_phase waits onafterbeforeafterbeforeafterbeforefirst comparethird compare

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.

The quiet window catches a late extra result
01234edge (ns)758595105115input x48-result seen-4949 (extra)-counts3 / 23 / 3phaserunbalancedquiet windowendsdropping here misses itextra lands in after_fifo

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.

RUNVerilator + UVM 2020.3.1 · simulator service
Edit the code, then run it
Expected among the UVM messages: 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 : 0

Check yourself

Predict the outputThis bench keeps only the stock comparator and uses a predictor whose model is t.x + 2. The sequence sends 16, 32 and 48. What line does the test's report_phase print?
`timescale 1ns/1ns
`include "uvm_macros.svh"
import uvm_pkg::*;

// The incrementer from U7: takes x at a rising edge where valid and ready are 1, is busy for two
// edges, and shows y = x + 1 with out_valid for one cycle so that a monitor sees it two edges later.
interface incr_if (input logic clk);
  logic       valid = 0;
  logic [7:0] x = 0;
  logic       ready;
  logic       out_valid = 0;
  logic [7:0] y = 0;
endinterface

// EXTRA is a fault switch for the bench: 1 makes the design show one result too many, one edge
// after its third result.
module incr_pipe #(parameter int BUSY = 2, parameter bit EXTRA = 0) (incr_if bus);
  logic [1:0] busy = 0;
  logic       staged = 0;
  logic [7:0] stage = 0;
  int         shown = 0;
  bit         extra_sent = 0;
  assign bus.ready = (busy == 0);
  always @(posedge bus.clk) begin
    bus.out_valid <= staged;
    if (staged) shown <= shown + 1;
    if (EXTRA && shown == 3 && !extra_sent) begin
      bus.out_valid <= 1;                  // the fault: a fourth result nobody asked for
      extra_sent    <= 1;
    end
    bus.y         <= stage;
    staged        <= bus.valid && bus.ready;
    stage         <= bus.x + 1;
    if (bus.valid && bus.ready) busy <= BUSY;
    else if (busy != 0)         busy <= busy - 1;
  end
endmodule

class incr_item extends uvm_sequence_item;
  `uvm_object_utils(incr_item)
  bit [7:0] x;
  function new(string name = "incr_item"); super.new(name); endfunction
endclass

// The result. compare() and convert2string() are what a stock comparator calls.
class incr_result extends uvm_sequence_item;
  `uvm_object_utils(incr_result)
  bit [7:0] y;
  function new(string name = "incr_result"); super.new(name); endfunction
  function bit do_compare(uvm_object rhs, uvm_comparer comparer);
    incr_result o;
    if (!$cast(o, rhs)) return 0;
    return comparer.compare_field_int("y", y, o.y, 8);
  endfunction
  function string convert2string(); return $sformatf("y=%0d", y); endfunction
endclass

class incr_seq extends uvm_sequence #(incr_item);
  `uvm_object_utils(incr_seq)
  bit [7:0] values[$];
  function new(string name = "incr_seq"); super.new(name); values = '{16, 32, 48}; endfunction
  task body();
    foreach (values[i]) begin
      incr_item it = incr_item::type_id::create("it");
      start_item(it);
      it.x = values[i];
      finish_item(it);
    end
  endtask
endclass

class incr_driver extends uvm_driver #(incr_item);
  `uvm_component_utils(incr_driver)
  virtual incr_if vif;
  function new(string name, uvm_component parent); super.new(name, parent); endfunction
  task run_phase(uvm_phase phase);
    forever begin
      @(negedge vif.clk);
      seq_item_port.try_next_item(req);
      if (req == null) begin
        vif.valid <= 0;
        continue;
      end
      vif.valid <= 1;
      vif.x     <= req.x;
      do @(posedge vif.clk); while (!vif.ready);
      seq_item_port.item_done();
    end
  endtask
endclass

class incr_monitor extends uvm_monitor;
  `uvm_component_utils(incr_monitor)
  virtual incr_if vif;
  uvm_analysis_port #(incr_item)   in_ap;
  uvm_analysis_port #(incr_result) out_ap;
  function new(string name, uvm_component parent);
    super.new(name, parent);
    in_ap  = new("in_ap", this);
    out_ap = new("out_ap", this);
  endfunction
  task run_phase(uvm_phase phase);
    forever begin
      @(posedge vif.clk);
      if (vif.valid && vif.ready) begin
        incr_item t = incr_item::type_id::create("t");
        t.x = vif.x;
        in_ap.write(t);
      end
      if (vif.out_valid) begin
        incr_result r = incr_result::type_id::create("r");
        r.y = vif.y;
        out_ap.write(r);
      end
    end
  endtask
endclass

// Prediction only. It knows the model and nothing about comparing.
class incr_predictor extends uvm_subscriber #(incr_item);
  `uvm_component_utils(incr_predictor)
  uvm_analysis_port #(incr_result) ap;
  int predicted;
  function new(string name, uvm_component parent); super.new(name, parent); ap = new("ap", this); endfunction
  function void write(incr_item t);
    incr_result e = incr_result::type_id::create("e");
    e.y = t.x + 2;
    predicted++;
    ap.write(e);
  endfunction
endclass

class predict_test extends uvm_test;
  `uvm_component_utils(predict_test)
  uvm_sequencer #(incr_item) sqr;
  incr_driver    drv;
  incr_monitor   mon;
  incr_predictor pred;  uvm_in_order_class_comparator #(incr_result) stock;
  function new(string name, uvm_component parent); super.new(name, parent); endfunction
  function void build_phase(uvm_phase phase);
    virtual incr_if vif;
    sqr   = uvm_sequencer #(incr_item)::type_id::create("sqr", this);
    drv   = incr_driver::type_id::create("drv", this);
    mon   = incr_monitor::type_id::create("mon", this);
    pred  = incr_predictor::type_id::create("pred", this);
    stock = uvm_in_order_class_comparator #(incr_result)::type_id::create("stock", this);
    if (!uvm_config_db #(virtual incr_if)::get(this, "", "vif", vif))
      `uvm_fatal("NOVIF", "no virtual interface")
    drv.vif = vif;
    mon.vif = vif;
  endfunction
  function void connect_phase(uvm_phase phase);
    drv.seq_item_port.connect(sqr.seq_item_export);
    mon.in_ap.connect(pred.analysis_export);
    pred.ap.connect(stock.before_export);
    mon.out_ap.connect(stock.after_export);
  endfunction
  task run_phase(uvm_phase phase);
    incr_seq seq = incr_seq::type_id::create("seq");
    phase.raise_objection(this);
    seq.start(sqr);
    wait (stock.m_matches + stock.m_mismatches == 3);
    phase.drop_objection(this);
  endtask
  function void report_phase(uvm_phase phase);
    uvm_report_server rs = uvm_report_server::get_server();
    $display("stock comparator: %0d matches, %0d mismatches; errors %0d, warnings %0d",
             stock.m_matches, stock.m_mismatches, rs.get_severity_count(UVM_ERROR), rs.get_severity_count(UVM_WARNING));
  endfunction
endclass

module tb;
  bit clk;
  always #5 clk = ~clk;
  incr_if bus (clk);
  incr_pipe dut (bus);
  initial begin
    uvm_root::get().set_timeout(2000ns, 1);
    uvm_config_db #(virtual incr_if)::set(null, "uvm_test_top", "vif", bus);
    run_test("predict_test");
  end
endmodule
Spot the bugEach option is the body of the hand-written comparator's run_phase loop. The bench sends 16, 32 and 48, and the test passes when three pairs match with no error. Which body fails?
Spot the bugThe design in this bench is faulty: it shows a fourth result at 105 ns, one edge after its third. Each option is what the test does after waiting for the counts to balance at 95 ns, before it drops its objection. The longest legal latency is 20 ns. Which ending lets the run pass without the extra result ever reaching the comparator?
ConceptWhy keep the predictor and the comparator in separate components rather than in one write function?
ConceptA test uses only uvm_in_order_class_comparator and ends with UVM_ERROR : 0. What does that prove about the compared pairs?
ConceptThe hand-written comparator calls before_fifo.get(e) and then after_fifo.get(a). What happens if a result reaches after_fifo before its prediction reaches before_fifo?

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_subscriber with an analysis port of its own, so any number of components can listen to its predictions, including another predictor.
  • uvm_in_order_class_comparator compares in arrival order with compare() and reports a mismatch as a UVM_WARNING; the test must check m_mismatches itself.
  • 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.