A clocked checker and the UVM hand-off

Keep the clock and the sampling in SystemVerilog, let Python predict once per packet, and place the same pieces in a UVM scoreboard.

Lesson
D2.3
Time
30 min
Runs on
Verilator with C/C++
Check
5 questions

The checksum model said 3, the accumulator in the design showed 255, and the first guess was a wrong model. It was a sampling mistake: the testbench read the sum at the rising edge, in the same time step as the nonblocking update, and saw the previous value. One #1 later the design agreed with Python. This lesson keeps the clock and the sampling in SystemVerilog, lets Python predict once per packet, and shows where the same pieces go in a UVM scoreboard.

Four transfers into an 8-bit accumulator
012345validdata0250234sum02502522553255 + 4 wraps to 3

One column per rising edge. sum shows the value after that edge's nonblocking update; valid high at the edge is what makes a transfer count.

Predict once, drive, then observe

The packet {250, 2, 3, 4} has the 8-bit checksum 3: 250 + 2 + 3 + 4 is 259, and the low byte of 259 is 3. Python computes that once, before the first clock edge, and the testbench keeps it in expected. From then on Python is out of the loop: the clock, the stimulus and the comparison all stay in SystemVerilog.

The testbench drives valid and data on falling edges, away from the edge the design samples. The accumulator adds on each rising edge where valid is high, with a nonblocking assignment, so the new sum lands after every process that runs at that edge. The testbench waits #1 after the edge and then reads sum and counts the transfer. Four rising edges with valid high give 250, 252, 255 and 3.

A transfer is accepted when valid is high at a rising edge; that is the design's rule, and the testbench counts exactly those edges. A bus with a ready signal, a reset input or a transfer that can be cancelled needs its own rule. The count follows that rule, never the number of items the stimulus produced.

A literal beside the model

The py_open line asserts expected == 3 with a value worked out by hand. The Python answer and the hand-calculated one sit side by side, and the design is compared against both. Two copies of the same wrong algorithm would agree with each other every time; a literal breaks that circle.

Try the opposite experiment. Change the design to sum <= sum + data + 1 and run: four transfers add four extra counts, the design shows 7, and the assertion stops the run with got=7 expected=3. A checker that cannot fail on a broken design is not checking anything. Restore the line afterwards.

  1. Accumulate accepted data

    SystemVerilog controls the edges and samples after the nonblocking update.

    partial sums250, 252, 255, 3
    accepted4
  2. Check before shutdown

    The observed hardware value is compared with the literal and with the Python prediction, then the bridge is closed.

    design / Python3 / 3
    close status0

The same pieces in a UVM scoreboard

In a UVM bench the roles split across components, and each piece of this example has a place. The monitor watches the pins and publishes only transfers it saw accepted. The scoreboard holds the bridge, asks the model for a prediction per observed packet, and compares. A sequence describes what was requested, which is no proof of what the design took. So the scoreboard predicts from the monitor, never from the sequence. [U9.3](U9-3-uvm-c-model-dpi.html) builds exactly this with a C model.

Where the bridge lives in a UVM benchArrows show where transactions and calls go. The sequence and driver make requests; only the monitor says what was accepted; the scoreboard owns the bridge and calls the model.
Where the bridge lives in a UVM benchArrows show where transactions and calls go. The sequence and driver make requests; only the monitor says what was accepted; the scoreboard owns the bridge and calls the model. stimulus Sequence requests packets component Driver drives valid and data design Accumulator adds on rising edgeswith valid component Monitor publishes acceptedtransfers component Scoreboard holds the bridge;compares Python model.py checksum(packet) itemspinsobservedwrite(transfer)py_sum
  1. Open in the component

    The scoreboard calls py_open once, in its own build or start-of-run code, and py_close once, in final_phase. The handle lives in one component.

  2. Observe

    The monitor sees valid high at a rising edge and publishes the transfer through its analysis port. Requests that the design never took are not published.

    Follow the calls, values and waits
    1. data accepted transfer: Accumulator → Monitor
    2. data accepted transfer: Monitor → Scoreboard
  3. Predict

    With a whole packet observed, the scoreboard hands a copy to py_sum and gets the prediction back.

    Follow the calls, values and waits
    1. call py_sum(packet): Scoreboard → model.py
    2. return 3: model.py → Scoreboard
  4. Compare and drain

    The scoreboard compares the design's sum with the prediction. Before the test drops its objection, every expected comparison has happened or been cancelled on purpose.

Read the diagram as text
  • Sequence: requests packets
  • Driver: drives valid and data
  • Accumulator: adds on rising edges with valid
  • Monitor: publishes accepted transfers
  • Scoreboard: holds the bridge; compares
  • model.py: checksum(packet)
  • Sequence → Driver: items
  • Driver → Accumulator: pins
  • Accumulator → Monitor: observed
  • Monitor → Scoreboard: write(transfer)
  • Scoreboard → model.py: py_sum

The bridge belongs to one component, which opens it, uses it and closes it. Nothing else calls Python.

Three rules follow from the picture. Reset the model where the scoreboard handles the design's reset, so a model total never outlives the hardware it mirrors. Drain before the end: UVM phases and objections decide when a test may finish, and the final objection drops only after the work in flight is compared or cancelled. And keep slow Python work out of the analysis write call when many publishers would wait on it. A queue and one consuming process give a clearer flow, while Python still costs the same wall time.

What the example covers

This is a clocked accumulator with an embedded Python model, with no backpressure and no reset input. It shows the three decisions that carry over: who keeps the clock, when the model is asked, and who owns the bridge. The UVM track builds the agents, the transport and the completion accounting around them.

Try it

Four beat lines, one per accepted transfer, each read #1 after the rising edge. The last line puts the count, the design and the Python prediction side by side.

Change the design to sum <= sum + data + 1, run, and watch the assertion stop the run with got=7 expected=3. Then remove the #1 after the rising edge and look at which value each beat line shows.

RUNVerilator + C/C++ over DPI · simulator service
Edit the code, then run it
Expected: beat 1: data=250 sum=250 beat 2: data=2 sum=252 beat 3: data=3 sum=255 beat 4: data=4 sum=3 accepted=4 dut=3 python=3
Read included file: bridge.cpp
#include <Python.h>      // first: it sets the feature macros the C library headers read
#include "Vtb__Dpi.h"
#include "svdpi.h"
#include <cstdio>

// One interpreter, opened and closed from SystemVerilog, on the simulator's thread. Between calls
// the bridge holds exactly one Python object: the checksum function.
static PyObject* checksum = nullptr;

// Take the pending Python exception, print it as one line, and return the status code.
static int py_error(int code) {
  PyObject* exc = PyErr_GetRaisedException();   // clears the error indicator; the object is ours
  if (exc) {
    PyObject* text = PyObject_Str(exc);         // new reference
    std::printf("python %s: %s\n", Py_TYPE(exc)->tp_name, text ? PyUnicode_AsUTF8(text) : "?");
    Py_XDECREF(text);
    Py_DECREF(exc);
  }
  return code;
}

extern "C" int py_open() {
  if (checksum) return 0;              // already open: nothing to do
  if (Py_IsInitialized()) return 1;    // some other code runs this interpreter; leave it alone
  PyConfig config;
  PyConfig_InitIsolatedConfig(&config);   // ignore environment variables and the user site directory
  config.install_signal_handlers = 0;     // Ctrl-C stays with the simulator
  PyStatus status = Py_InitializeFromConfig(&config);
  PyConfig_Clear(&config);
  if (PyStatus_Exception(status)) return 2;
  PyObject* sys_path = PySys_GetObject("path");   // borrowed reference: never Py_DECREF
  PyObject* here = PyUnicode_FromString(".");     // new reference: ours to release
  if (!here || PyList_Insert(sys_path, 0, here) < 0) { Py_XDECREF(here); py_error(3); Py_FinalizeEx(); return 3; }
  Py_DECREF(here);
  PyObject* module = PyImport_ImportModule("model");   // new reference
  if (module) { checksum = PyObject_GetAttrString(module, "checksum"); Py_DECREF(module); }   // new reference, kept
  if (!checksum || !PyCallable_Check(checksum)) {
    Py_XDECREF(checksum); checksum = nullptr; py_error(4); Py_FinalizeEx(); return 4;
  }
  return 0;
}

extern "C" int py_sum(const svOpenArrayHandle data, int count, unsigned int* total) {
  *total = 0;                                   // the output is set on every path
  if (!checksum) return 1;                      // closed bridge
  if (svDimensions(data) != 1 || count < 0 || count > svSize(data, 1) || count > 1024) return 2;
  const int left = svLeft(data, 1), step = left <= svRight(data, 1) ? 1 : -1;
  PyObject* bytes = PyBytes_FromStringAndSize(nullptr, count);   // new reference
  if (!bytes) return py_error(3);
  char* target = PyBytes_AsString(bytes);
  for (int k = 0; k < count; ++k) {
    const unsigned char* p = static_cast<const unsigned char*>(svGetArrElemPtr1(data, left + k * step));
    if (!p) { Py_DECREF(bytes); return 2; }
    target[k] = static_cast<char>(*p);          // a copy: Python never keeps a pointer into the simulator
  }
  PyObject* result = PyObject_CallFunctionObjArgs(checksum, bytes, nullptr);   // new reference, or null
  Py_DECREF(bytes);
  if (!result) return py_error(4);              // the model raised
  const unsigned long value = PyLong_AsUnsignedLong(result);
  Py_DECREF(result);
  if (PyErr_Occurred()) return py_error(5);     // negative, or no integer at all
  if (value > 255) return 6;                    // a checksum is one byte
  *total = static_cast<unsigned int>(value);
  return 0;
}

extern "C" int py_close() {
  Py_XDECREF(checksum);
  checksum = nullptr;
  return Py_IsInitialized() ? Py_FinalizeEx() : 0;
}
Read included file: model.py
"""The reference calculation only. SystemVerilog keeps the clock, the stimulus and the check."""


def checksum(data):
    if not isinstance(data, bytes):
        raise TypeError("expected bytes")
    if len(data) > 64:
        raise ValueError("packet exceeds 64 bytes")
    return sum(data) & 0xff

Check yourself

Predict the outputThe accumulator receives 250 and then 10, each with valid high at one rising edge. What line does this program print?
`timescale 1ns/1ps
module accumulator(input logic clk, input logic valid, input logic [7:0] data, output logic [7:0] sum);
  initial sum = 0;
  always @(posedge clk) if (valid) sum <= sum + data;
endmodule

module tb;
  import "DPI-C" function int py_open();
  import "DPI-C" function int py_sum(input byte unsigned data[], input int count, output int unsigned total);
  import "DPI-C" function int py_close();
  byte unsigned packet[2] = '{250, 10};
  bit clk = 0, valid = 0;
  logic [7:0] data = 0, sum;
  accumulator dut(.clk, .valid, .data, .sum);
  always #5 clk = ~clk;
  initial begin
    foreach (packet[i]) begin
      @(negedge clk); valid = 1; data = packet[i];
      @(posedge clk); #1;
    end
    valid = 0;
    $display("%0d", sum);
    $finish;
  end
endmodule
Read included file: bridge.cpp
#include <Python.h>      // first: it sets the feature macros the C library headers read
#include "Vtb__Dpi.h"
#include "svdpi.h"
#include <cstdio>

// One interpreter, opened and closed from SystemVerilog, on the simulator's thread. Between calls
// the bridge holds exactly one Python object: the checksum function.
static PyObject* checksum = nullptr;

// Take the pending Python exception, print it as one line, and return the status code.
static int py_error(int code) {
  PyObject* exc = PyErr_GetRaisedException();   // clears the error indicator; the object is ours
  if (exc) {
    PyObject* text = PyObject_Str(exc);         // new reference
    std::printf("python %s: %s\n", Py_TYPE(exc)->tp_name, text ? PyUnicode_AsUTF8(text) : "?");
    Py_XDECREF(text);
    Py_DECREF(exc);
  }
  return code;
}

extern "C" int py_open() {
  if (checksum) return 0;              // already open: nothing to do
  if (Py_IsInitialized()) return 1;    // some other code runs this interpreter; leave it alone
  PyConfig config;
  PyConfig_InitIsolatedConfig(&config);   // ignore environment variables and the user site directory
  config.install_signal_handlers = 0;     // Ctrl-C stays with the simulator
  PyStatus status = Py_InitializeFromConfig(&config);
  PyConfig_Clear(&config);
  if (PyStatus_Exception(status)) return 2;
  PyObject* sys_path = PySys_GetObject("path");   // borrowed reference: never Py_DECREF
  PyObject* here = PyUnicode_FromString(".");     // new reference: ours to release
  if (!here || PyList_Insert(sys_path, 0, here) < 0) { Py_XDECREF(here); py_error(3); Py_FinalizeEx(); return 3; }
  Py_DECREF(here);
  PyObject* module = PyImport_ImportModule("model");   // new reference
  if (module) { checksum = PyObject_GetAttrString(module, "checksum"); Py_DECREF(module); }   // new reference, kept
  if (!checksum || !PyCallable_Check(checksum)) {
    Py_XDECREF(checksum); checksum = nullptr; py_error(4); Py_FinalizeEx(); return 4;
  }
  return 0;
}

extern "C" int py_sum(const svOpenArrayHandle data, int count, unsigned int* total) {
  *total = 0;                                   // the output is set on every path
  if (!checksum) return 1;                      // closed bridge
  if (svDimensions(data) != 1 || count < 0 || count > svSize(data, 1) || count > 1024) return 2;
  const int left = svLeft(data, 1), step = left <= svRight(data, 1) ? 1 : -1;
  PyObject* bytes = PyBytes_FromStringAndSize(nullptr, count);   // new reference
  if (!bytes) return py_error(3);
  char* target = PyBytes_AsString(bytes);
  for (int k = 0; k < count; ++k) {
    const unsigned char* p = static_cast<const unsigned char*>(svGetArrElemPtr1(data, left + k * step));
    if (!p) { Py_DECREF(bytes); return 2; }
    target[k] = static_cast<char>(*p);          // a copy: Python never keeps a pointer into the simulator
  }
  PyObject* result = PyObject_CallFunctionObjArgs(checksum, bytes, nullptr);   // new reference, or null
  Py_DECREF(bytes);
  if (!result) return py_error(4);              // the model raised
  const unsigned long value = PyLong_AsUnsignedLong(result);
  Py_DECREF(result);
  if (PyErr_Occurred()) return py_error(5);     // negative, or no integer at all
  if (value > 255) return 6;                    // a checksum is one byte
  *total = static_cast<unsigned int>(value);
  return 0;
}

extern "C" int py_close() {
  Py_XDECREF(checksum);
  checksum = nullptr;
  return Py_IsInitialized() ? Py_FinalizeEx() : 0;
}
Read included file: model.py
"""The reference calculation only. SystemVerilog keeps the clock, the stimulus and the check."""


def checksum(data):
    if not isinstance(data, bytes):
        raise TypeError("expected bytes")
    if len(data) > 64:
        raise ValueError("packet exceeds 64 bytes")
    return sum(data) & 0xff
Spot the bugAll four beats have been driven; the fourth one is on the pins and its rising edge has not come yet. Each option is what the testbench waits for before comparing sum with the prediction. Which one prints FAIL?
Read included file: bridge.cpp
#include <Python.h>      // first: it sets the feature macros the C library headers read
#include "Vtb__Dpi.h"
#include "svdpi.h"
#include <cstdio>

// One interpreter, opened and closed from SystemVerilog, on the simulator's thread. Between calls
// the bridge holds exactly one Python object: the checksum function.
static PyObject* checksum = nullptr;

// Take the pending Python exception, print it as one line, and return the status code.
static int py_error(int code) {
  PyObject* exc = PyErr_GetRaisedException();   // clears the error indicator; the object is ours
  if (exc) {
    PyObject* text = PyObject_Str(exc);         // new reference
    std::printf("python %s: %s\n", Py_TYPE(exc)->tp_name, text ? PyUnicode_AsUTF8(text) : "?");
    Py_XDECREF(text);
    Py_DECREF(exc);
  }
  return code;
}

extern "C" int py_open() {
  if (checksum) return 0;              // already open: nothing to do
  if (Py_IsInitialized()) return 1;    // some other code runs this interpreter; leave it alone
  PyConfig config;
  PyConfig_InitIsolatedConfig(&config);   // ignore environment variables and the user site directory
  config.install_signal_handlers = 0;     // Ctrl-C stays with the simulator
  PyStatus status = Py_InitializeFromConfig(&config);
  PyConfig_Clear(&config);
  if (PyStatus_Exception(status)) return 2;
  PyObject* sys_path = PySys_GetObject("path");   // borrowed reference: never Py_DECREF
  PyObject* here = PyUnicode_FromString(".");     // new reference: ours to release
  if (!here || PyList_Insert(sys_path, 0, here) < 0) { Py_XDECREF(here); py_error(3); Py_FinalizeEx(); return 3; }
  Py_DECREF(here);
  PyObject* module = PyImport_ImportModule("model");   // new reference
  if (module) { checksum = PyObject_GetAttrString(module, "checksum"); Py_DECREF(module); }   // new reference, kept
  if (!checksum || !PyCallable_Check(checksum)) {
    Py_XDECREF(checksum); checksum = nullptr; py_error(4); Py_FinalizeEx(); return 4;
  }
  return 0;
}

extern "C" int py_sum(const svOpenArrayHandle data, int count, unsigned int* total) {
  *total = 0;                                   // the output is set on every path
  if (!checksum) return 1;                      // closed bridge
  if (svDimensions(data) != 1 || count < 0 || count > svSize(data, 1) || count > 1024) return 2;
  const int left = svLeft(data, 1), step = left <= svRight(data, 1) ? 1 : -1;
  PyObject* bytes = PyBytes_FromStringAndSize(nullptr, count);   // new reference
  if (!bytes) return py_error(3);
  char* target = PyBytes_AsString(bytes);
  for (int k = 0; k < count; ++k) {
    const unsigned char* p = static_cast<const unsigned char*>(svGetArrElemPtr1(data, left + k * step));
    if (!p) { Py_DECREF(bytes); return 2; }
    target[k] = static_cast<char>(*p);          // a copy: Python never keeps a pointer into the simulator
  }
  PyObject* result = PyObject_CallFunctionObjArgs(checksum, bytes, nullptr);   // new reference, or null
  Py_DECREF(bytes);
  if (!result) return py_error(4);              // the model raised
  const unsigned long value = PyLong_AsUnsignedLong(result);
  Py_DECREF(result);
  if (PyErr_Occurred()) return py_error(5);     // negative, or no integer at all
  if (value > 255) return 6;                    // a checksum is one byte
  *total = static_cast<unsigned int>(value);
  return 0;
}

extern "C" int py_close() {
  Py_XDECREF(checksum);
  checksum = nullptr;
  return Py_IsInitialized() ? Py_FinalizeEx() : 0;
}
Read included file: model.py
"""The reference calculation only. SystemVerilog keeps the clock, the stimulus and the check."""


def checksum(data):
    if not isinstance(data, bytes):
        raise TypeError("expected bytes")
    if len(data) > 64:
        raise ValueError("packet exceeds 64 bytes")
    return sum(data) & 0xff
ConceptWhich event proves that the design accepted a transfer in this example?
ConceptWhy does the testbench wait #1 after the rising edge before reading sum?
ConceptIn a UVM scoreboard, which traffic should the Python model predict from?

Remember

  • Python predicts once per packet; the clock, the stimulus and the comparison stay in SystemVerilog.
  • Drive on the falling edge, let the design sample the rising edge, and read the result after its nonblocking update has landed.
  • A transfer counts when the design's own rule says so: here, valid high at a rising edge.
  • Keep a hand-calculated literal beside the model's answer, and show that a broken design makes the check fail.
  • In UVM, one component owns the bridge, predicts from observed accepted transfers, resets the model with the design, and drains before dropping its objection.