Contents

DPI-C tutorial · Part B · Step 08

Python as the golden model

The reference model you trust most is often three lines of Python: zlib.crc32, a numpy convolution, an unbounded int. This step wires such models to the two DUTs in common/rtl behind a DPI contract that a C model could implement just as well, and measures what the convenience costs.

Prereqs: steps 02 (svBitVecVal), 03 (chandle), 04 (open arrays), 06 (scoreboard), 07 (CPython C API)Measured on Verilator 5.052 + Python 3.14.7 + numpy 2.5.2, macOS x86_64Solutions are hidden: think first

§0 · TutorialThe step, explained

Step 06 built the golden-model pattern with a C reference; step 07 showed how to start an interpreter inside the simulator and call into it. This step puts the two together for real hardware: a CRC-32 stream unit whose reference is zlib.crc32, and an 8-tap Q1.15 FIR whose reference is a numpy convolution. The interesting questions are not "how do I call Python" (step 07 answered that) but: how do you shape the DPI boundary so the model is swappable, how do you move arrays and 200-bit integers across it without copies or truncation, where do errors go, how do you keep the model bit-exact, and when does the per-call cost start to dominate your regression time.

Everything below runs in lab/. Every number in this page comes from build/ logs and is asserted by lab/check.py (51 asserts), except the throughput figures in 0.11, which are machine dependent: check.py asserts their ratios, not their values.

0.1One contract, two backends

The single most useful design decision in this step was already made in step 06: the DPI imports are written as if the model were C, with plain C types, and kept in one package, 06_golden_model_c/lab/golden_pkg.sv. This step reuses that file unchanged (run.sh points at it; there is no copy). The testbench only knows the package. Two C files implement the same symbols: step 06's golden.c computes the answer itself, this step's golden_py.c forwards each call into Python (golden.py). Which one you get is decided by the link line, not by the SV source.

// 06_golden_model_c/lab/golden_pkg.sv (shared with step 06, unchanged)
package golden_pkg;
  import "DPI-C" function chandle      crc32_new();
  import "DPI-C" function void         crc32_free  (input chandle h);
  import "DPI-C" function void         crc32_clear (input chandle h);
  import "DPI-C" function int unsigned crc32_update(input chandle h, input byte unsigned b);
  import "DPI-C" pure function int unsigned crc32_msg(input byte unsigned msg[], input int len);
  import "DPI-C" function chandle  fir8_new  (input shortint coef[8]);
  import "DPI-C" function void     fir8_free (input chandle h);
  import "DPI-C" function shortint fir8_step (input chandle h, input shortint x);
  import "DPI-C" function void     fir8_block(input chandle h, input shortint x[],
                                              output shortint y[], input int n);
endpackage
# lab/run.sh: same testbench, two binaries
G6=../../06_golden_model_c/lab                       # the contract and the C backend live in step 06
PY="$G6/golden_pkg.sv py_extra_pkg.sv golden_py.c $G6/wall.c $(py_flags)"
C="$G6/golden_pkg.sv $G6/golden.c $G6/wall.c"
vlb crc_py  $PY $RTL/crc32_stream.sv tb_crc.sv
vlb crc_c   $C  $RTL/crc32_stream.sv tb_crc.sv

Things only Python can offer (a 200-bit multiply on unbounded ints, run-time bug selection, the text of a Python exception, a generic call-anything bridge) do not belong in a contract that C must also implement. They live in a second, Python-only package, lab/py_extra_pkg.sv (py_mul_wide, py_set_bug, py_last_error, py_call_i64), also implemented in golden_py.c. A testbench that must run on both backends imports only golden_pkg; tb_crc.sv, tb_fir.sv and tb_perf.sv do.

SV (unchanged) tb_crc / tb_fir DUT (common/rtl) golden_pkg(step 06)import "DPI-C"C types only link time: pick one golden_py.c 06 golden.c CPython 3.14 golden.py zlib · numpy · int plain C, same symbols, same results (asserted) The testbench cannot tell which backend it was linked with; check.py runs both and requires identical logs.
Fig. 1The model is chosen by the link line. Keeping the contract in C types (not "Python-shaped" arguments such as strings of code or pickled objects) is what makes the C backend possible, and the C backend is what makes the Python one trustworthy: two independent implementations that agree.

Design the imports for the model, not for the language behind it. A scoreboard that calls crc32_msg(msg, len) survives a rewrite of the model in C, C++, Python or a SystemC TLM model. A scoreboard that calls py_eval("zlib.crc32(...)") is married to Python forever.

Two small conventions from step 01 matter here. Both backends #include "Vtop__Dpi.h", the prototype header Verilator generates from the imports, so a C signature that drifts from golden_pkg.sv is a compile error rather than a crash. And both are compiled as C++ by Verilator (clang prints treating 'c' input as 'c++'), so the definitions sit inside #ifdef __cplusplus extern "C" {.

0.2CRC-32: the model is one line

crc32_stream.sv implements CRC-32/IEEE 802.3: reflected, polynomial 0xEDB88320, initial value and final XOR 0xFFFFFFFF. Its output crc is the CRC of every byte accepted since the last clear, i.e. the inverted register. That is exactly the value Python's zlib.crc32 returns, and zlib.crc32(data, value) continues a running CRC: the CRC of a + b equals zlib.crc32(b, zlib.crc32(a)). So the model is:

# lab/golden.py
def crc32_update(crc, data):
    """crc: CRC of the bytes so far (0 for an empty message); data: any bytes-like chunk."""
    return zlib.crc32(data, crc)

class Crc32:                                  # behind the chandle of crc32_new/clear/update
    def __init__(self): self.crc = 0
    def clear(self):    self.crc = 0
    def update(self, b):
        self.crc = zlib.crc32(bytes((b,)), self.crc)
        return self.crc                        # = the DUT's crc output after this byte

Notice what is not there: no table, no bit loop, no reflection, no XOR-out. That is the point of a Python golden model: the reference comes from a library used by every zip file on the planet, not from a second hand-written copy of the algorithm you are trying to verify. The lab's first check is the standard check value: the CRC of the ASCII string "123456789" is 0xCBF43926, and the DUT prints LOG crc check_value=cbf43926.

For crc32_msg the C side turns the open array into a memoryview that points at the simulator's own memory (no copy), calls the cached function, converts the result back, and releases the view:

/* lab/golden_py.c */
unsigned int crc32_msg(const svOpenArrayHandle msg, int len) {
  if (len < 0 || len > svSize(msg, 1)) { /* contract violation: print */ abort(); }   /* as golden.c */
  PyObject* f = callable("golden", "crc32_update");      /* imported once, then cached */
  if (!f) return 0;
  PyObject* mv = PyMemoryView_FromMemory((char*)svGetArrayPtr(msg), len, PyBUF_READ);
  PyObject* r  = PyObject_CallFunction(f, "IO", 0u, mv);    /* "I" = unsigned int */
  unsigned int v = r ? (unsigned int)PyLong_AsUnsignedLong(r) : 0;
  if (!r || PyErr_Occurred()) { fail("crc32_update"); v = 0; }   /* As*() returns -1 on error */
  Py_XDECREF(r);
  release(mv, "crc32_update");                           /* see 0.9 */
  return v;
}
simulator memory msg[0]… msg[255] byte unsigned msg[256] (fixed array) first len bytes svGetArrayPtr() memoryview (PyBUF_READ) zlib.crc32(mv, crc) reads in place, no copy one int back
Fig. 2Arrays cross by pointer, not by value. svGetArrayPtr is valid only for open arrays in "standard layout"; in Verilator that means a fixed-size unpacked array (step 04). The only conversion is the 32-bit result.

The actual argument of an open-array formal must be a fixed-size unpacked array. A dynamic array is rejected at build time (Unsupported: DPI argument of type DYNARRAYDTYPE; with the import declared in the module, the message is the internal error Passing dynamic array or queue as actual argument to DPI open array is not yet supported). An assignment pattern literal is rejected too (Unsupported: Assignment pattern applies against non-struct/union data type: 'UNSIZEDARRAYDTYPE'). That is why the contract passes len next to a fixed msg[256], the C idiom of "buffer plus length". Commercial simulators accept dynamic arrays and queues here; the extra len costs nothing there.

0.3Where to compare: per byte or per message

A running CRC lets the scoreboard check the DUT at two granularities with the same model. Per byte: after every accepted byte, update the expected CRC with a one-byte chunk and compare against crc. Per message: collect the bytes, call the model once at the end, compare once. Both are in tb_crc.sv, selected by +per_byte:

if (per_byte) begin                                    // after each accepted byte
  exp_crc = crc32_update(h, msg[i]); n_calls++;          // h = crc32_new(), crc32_clear(h) per message
  check($sformatf("msg %0d byte %0d", m, i));
end
...
if (!per_byte) begin                                   // once per message
  exp_crc = crc32_msg(msg, len); n_calls++;
  check($sformatf("msg %0d", m));
end

The run sends 300 messages (one empty, one "123456789", 298 random of 1–256 bytes) with random idle cycles, 39 480 bytes in total, and passes in all four combinations:

runmodel callscheckserrorssim wall time
Python, per message300300025 ms
Python, per byte39 48039 480055 ms
C, per message300300023 ms
C, per byte39 48039 480042 ms

(Wall times from one run, measured from after crc32_new(), so the ~0.1 s of interpreter start-up and imports (0.11) is not in them. Most of the time is the DUT simulation itself, which is why the backends are close; the numbers move by tens of milliseconds from run to run on a loaded machine. Compare the call counts, which are exact, not these times; 0.11 measures the per-call cost properly.)

accepted bytes (in_valid) clear per byte 6 calls · 6 compares · per cycle per message 1 call · 1 compare · per message t →
Fig. 3The granularity of the comparison is a scoreboard decision, not a model decision: the same model serves both (crc32_update per byte, crc32_msg per message, one zlib.crc32 behind them). Fine-grained checks find the failing cycle; coarse checks cost fewer crossings.

Debug with fine-grained checks, regress with coarse ones. A per-transaction comparison is what you want while the DUT is broken (the first mismatching cycle is the bug report). Once the block is stable, a per-message or per-burst comparison does the same job with 100× fewer model calls. Make it a plusarg.

0.4A stateful model behind a chandle

A FIR has state: the last seven inputs. Step 03 kept per-instance C state behind a chandle; with Python the natural state container is an object, and the chandle simply is the PyObject*. The ownership rule is the whole design: the chandle owns exactly one strong reference. fir8_new returns the new reference that the constructor call produced (no extra Py_INCREF, no Py_DECREF), and fir8_free drops it.

void* fir8_new(const short* coef) {                 /* input shortint coef[8] -> const short* */
  PyObject* cls = callable("golden", "Fir8");
  if (!cls) return NULL;
  PyObject* c = Py_BuildValue("[hhhhhhhh]", coef[0], coef[1], coef[2], coef[3],
                                            coef[4], coef[5], coef[6], coef[7]);
  PyObject* obj = PyObject_CallOneArg(cls, c);           /* new reference ...           */
  Py_DECREF(c);
  if (!obj) fail("Fir8()");
  return obj;                                            /* ... handed to SV as the chandle */
}
void fir8_free(void* h) { Py_XDECREF((PyObject*)h); }    /* last ref: Fir8.__del__ runs here */

On the SV side nothing distinguishes this from step 03's C counter: h[u] = fir8_new(coef[u]) per DUT instance, fir8_step(h[u], x) per sample, fir8_free(h[u]) at the end. tb_fir.sv instantiates two fir8 with different coefficient sets (a sharp one that saturates often and a low-pass), so two independent Python objects carry two histories. The lab proves the reference count with a class counter Fir8.live, read through the generic bridge of 0.12: LOG live_before value=0, live_after_new value=1, live_after_free value=0.

SV g[0].dut g[1].dut h[0] h[1] chandle = PyObject* Python heap Fir8 #1 refcnt = 1c = sharp · h = [7 samples] Fir8 #2 refcnt = 1c = low-pass · h = [7 samples] Fir8.live2 → 0 fir8_free(h): DECREF → 0 → __del__ → live − 1
Fig. 4One Python object per DUT instance. The chandle owns one reference; SV must call fir8_free exactly once, before the interpreter is finalized at process exit.

Two classic reference bugs, both silent in short tests. Too few references: returning a borrowed pointer as the chandle (for example an object fetched with PyList_GetItem) means the object can die while SV still holds the handle; the next fir8_step reads freed memory. Too many: an extra Py_INCREF "to be safe" leaks every model instance; with one object per test nobody notices, with one per transaction the simulator grows without bound. A counter like Fir8.live turns both into a visible number.

The per-sample call uses PyObject_CallMethodOneArg with the method name interned once in a static, so no string object is built per sample:

short fir8_step(void* h, short x) {
  static PyObject* name;
  if (!h) return 0;
  if (!name) name = PyUnicode_InternFromString("step");
  PyObject* px = PyLong_FromLong(x);
  PyObject* r  = PyObject_CallMethodOneArg((PyObject*)h, name, px);
  Py_DECREF(px);
  short y = r ? (short)PyLong_AsLong(r) : 0;
  if (!r || PyErr_Occurred()) { fail("Fir8.step"); y = 0; }
  Py_XDECREF(r);
  return y;
}

0.5Bit-exact fixed point

A golden model is only useful if it is bit-exact; "close enough" turns every mismatch into a judgment call. fir8.sv defines its arithmetic in its header, and the model must reproduce each step:

$$\mathrm{acc}[n] = \sum_{k=0}^{7} c_k\,x[n-k], \qquad y[n] = \mathrm{sat}_{16}\!\left(\left\lfloor \frac{\mathrm{acc}[n] + 2^{14}}{2^{15}} \right\rfloor\right)$$

Reading in HW: eight 16×16 products into a 35-bit accumulator (no loss: $|\mathrm{acc}| \le 8 \cdot 2^{30} \lt 2^{34}$), add half an LSB of the output format, arithmetic shift right by 15 (floor), clamp to $[-2^{15}, 2^{15}-1]$.

Each piece has a Python twin that matches SV exactly, provided you stay in integers:

# lab/golden.py: Fir8.step (per sample) and Fir8.block (per array)
def step(self, x):
    c, h = self.cl, self.h                          # plain Python ints: see below
    acc = c[0]*x + c[1]*h[0] + c[2]*h[1] + c[3]*h[2] + c[4]*h[3] + c[5]*h[4] + c[6]*h[5] + c[7]*h[6]
    self.h = [x] + h[:6]
    return self._out(acc)                           # (acc + 2**14) >> 15, then saturate

def block(self, xin, yout):                       # xin/yout: buffers of int16, see 0.6
    x = np.frombuffer(xin, dtype=np.int16).astype(np.int64)
    full = np.concatenate([np.array(self.h[::-1], dtype=np.int64), x])   # oldest .. newest
    acc = np.convolve(full, self.c, mode="valid")   # len(x) outputs, exact in int64
    np.frombuffer(yout, dtype=np.int16)[:] = self._out(acc)
    self.h = full[::-1][:7].tolist()
    return len(x)

golden.py checks itself without any simulator (python3 golden.py): step, block in uneven chunks, a numpy-per-sample variant and a straight transcription of the formula above must agree on 3000 random samples. In simulation, both DUT instances match the model on all 2048 accepted samples; 631 of the 4096 outputs saturate (LOG fir samples=2048 calls=4096 errors=0 saturated=631), with either backend.

numpy is slow for eight numbers. The first version of step used np.dot and np.roll on the history. It is correct, and it runs at about 29 000 samples/s. The plain-int version above runs at about 1 160 000 samples/s, 40× faster (both measured without DPI by check.py). Every numpy call has a fixed overhead of a microsecond or more; it pays off only when one call processes hundreds of elements. Per-sample model: plain Python. Per-block model: numpy.

0.6Batching with zero-copy arrays

Every model call costs a DPI crossing, an interpreter entry, argument boxing and result unboxing, whatever the model does. Batching moves many samples per call. fir8_block takes an input open array and an output open array; the bridge exposes both to Python as memoryviews over the simulator's buffers, read-only for x and writable for y, so numpy reads the inputs in place and writes the outputs in place:

void fir8_block(void* h, const svOpenArrayHandle x, const svOpenArrayHandle y, int n) {
  if (n < 0 || n > svSize(x, 1) || n > svSize(y, 1)) { /* contract violation */ abort(); }
  if (!h) return;
  PyObject* mx = PyMemoryView_FromMemory((char*)svGetArrayPtr(x), 2 * n, PyBUF_READ);
  PyObject* my = PyMemoryView_FromMemory((char*)svGetArrayPtr(y), 2 * n, PyBUF_WRITE);
  PyObject* r  = PyObject_CallMethod((PyObject*)h, "block", "OO", mx, my);
  if (!r) fail("Fir8.block");
  Py_XDECREF(r);
  release(mx, "Fir8.block x");
  release(my, "Fir8.block y");
}

The scoreboard side collects the DUT's inputs and outputs in fixed arrays of 256, calls the model once per block and compares 256 pairs. Same stimulus, same result: calls=16 instead of calls=4096, zero errors, for both instances and both backends.

xs[nb] = in_x; ys[0][nb] = out_y[0]; ys[1][nb] = out_y[1];
if (++nb == BLK) begin                                // BLK = 256, N = 2048: no partial block
  for (int u = 0; u < 2; u++) begin
    fir8_block(h[u], xs, ym, nb); n_calls++;
    for (int i = 0; i < nb; i++) cmp(u, n - nb + 1 + i, ys[u][i], ym[i]);
  end
  nb = 0;
end
input shortint x[] SV xs[256] same address memoryview (READ) numpy reads output shortint y[] SV ym[256] temp on C stack numpy writes copied back new address on every call: never keep a pointer to it
Fig. 5Measured in Verilator 5.052 by printing svGetArrayPtr: an input fixed array is passed by address (the variable itself), an output open array is a stack temporary copied back after the call. The LRM allows either; you may only rely on the pointer during the call.

0.7Integers of any width

Wide arithmetic is where Python models shine and C models hurt. A bit [99:0] argument arrives in C as const svBitVecVal*: an array of 32-bit words, least significant word first (step 02). A Python int has no width. Since 3.13 the C API converts between the two in one call each: PyLong_FromUnsignedNativeBytes and PyLong_AsNativeBytes.

bit [99:0] a → const svBitVecVal* a (4 words) w[0]: 31..0 w[1]: 63..32 w[2]: 95..64 unused 2899..96 mask before converting PyLong_FromUnsignedNativeBytes(w, 16, LE) p: 7 words, 8 valid bits in w[6]
Fig. 6LSW-first 32-bit words of a little-endian host are one little-endian byte string, so a single call converts the whole vector (on a big-endian host you would convert word by word). The unused top bits of the last word are not guaranteed to be zero: mask them.
static PyObject* bits_to_py(const svBitVecVal* v, int nbits) {
  svBitVecVal w[16];
  int nw = (nbits + 31) / 32;
  memcpy(w, v, 4 * nw);
  if (nbits % 32) w[nw - 1] &= (1u << (nbits % 32)) - 1;  /* unused top bits */
  return PyLong_FromUnsignedNativeBytes(w, 4 * nw,
           Py_ASNATIVEBYTES_LITTLE_ENDIAN | Py_ASNATIVEBYTES_UNSIGNED_BUFFER);
}
static int py_to_bits(PyObject* o, svBitVecVal* v, int nbits) {
  int nw = (nbits + 31) / 32;
  Py_ssize_t need = PyLong_AsNativeBytes(o, v, 4 * nw, Py_ASNATIVEBYTES_LITTLE_ENDIAN |
                        Py_ASNATIVEBYTES_UNSIGNED_BUFFER | Py_ASNATIVEBYTES_REJECT_NEGATIVE);
  if (need < 0) return 0;                                /* not an int, or negative */
  svBitVecVal top = nbits % 32 ? ~((1u << (nbits % 32)) - 1) : 0;
  if (need > 4 * nw || (v[nw - 1] & top)) {             /* does not fit in nbits */
    PyErr_Format(PyExc_OverflowError, "result does not fit in %d bits", nbits);
    return 0;
  }
  return 1;
}

With those two helpers the 100×100→200-bit multiply model is return a * b, exposed as py_mul_wide in the Python-only py_extra_pkg.sv. A C model would need a 15-line schoolbook loop over 32-bit limbs with carries, which is exactly the kind of code you would otherwise have to verify too. The testbench checks the Python model against SV's own wide multiply, 200'(a) * 200'(b), on 1000 vectors including the corner cases (all ones, a carry into bit 100, zero): LOG wide n=1000 errors=0 and max_product=ffffffffffffffffffffffffe0000000000000000000000001, which is $(2^{100}-1)^2 = 2^{200} - 2^{101} + 1$.

Signed vectors need two extra steps: sign-extend bit $N-1$ into the unused bits before reading the buffer as signed, and decide what the model does when the mathematical result does not fit $N$ bits. SV wraps; Python never does. Exercise E10 does it for bit signed [99:0], where $-(-2^{99})$ is the trap.

0.8Errors into the scoreboard

A Python exception inside a DPI call must never escape as a crash or, worse, as a plausible-looking return value. The C API reports failures in two ways and the bridge must check both: a NULL result from a call, and an error indicator with a valid-looking result from a conversion (PyLong_AsLong and friends return -1 and set the indicator on overflow). The lab's first version returned that -1 to SV: LOG overflow value=-1. Now every wrapper zeroes the result on error.

fail() turns the pending exception into a one-line message (PyErr_GetRaisedException, Python 3.12+). Step 06's contract has no error call, because a C model has nothing to report: it cannot raise. So the bridge does two things with the message. It prints it at once on stderr (golden_py: fails: KeyError: 0), and the wrapper returns 0, which the scoreboard then sees as a mismatch: loud, never silent, and no change to a testbench that also runs on C. It also keeps it for py_last_error() in py_extra_pkg.sv, which returns the message and clears it: a pull model for Python-only testbenches that want the text in their own log. Step 07 shows the push alternative, a context import that calls an exported SV function to report; pull keeps every import non-context, push reports even when the caller forgets to ask.

// tb_err.sv: imports golden_pkg (the contract) and py_extra_pkg (Python-only)
function automatic void show(string tag, longint v);
  $display("LOG %s value=%0d err=%s", tag, v, py_last_error());
endfunction
show("raises", py_call_i64("golden", "fails", none));    // value=0 err=fails: KeyError: 0

tb_err.sv provokes each failure class and prints what reaches SV:

causevaluepy_last_error()
module not on sys.path0no_such_module.f: ModuleNotFoundError: No module named 'no_such_module'
function name typo0golden.nope: AttributeError: module 'golden' has no attribute 'nope'
model raises0fails: KeyError: 0
result does not fit longint0big: OverflowError: int too big to convert
model kept the memoryview (0.9)0Fir8.block x: model kept a reference to a simulator buffer

$error stops the simulation: the log shows %Error: tb_err.sv:44: Assertion failed in top: scoreboard: 6 model errors, then Verilog $stop, and the process exits with status 1. Code after it does not run. With +verilator+error+limit+10 the same run prints Verilog $stop, ignored due to +verilator+error+limit and continues. Commercial simulators continue after $error by default. Portable scoreboards count errors, print a summary, and decide at the end.

0.9Buffer lifetime: the contract Python cannot see

A memoryview built with PyMemoryView_FromMemory does not own its memory. It is valid only while the simulator keeps the buffer alive, which the LRM guarantees only for the duration of the call. If the model keeps a reference and uses it later, it reads whatever the simulator has there by then: for an input fixed array, live SV storage; for an output array, a dead stack frame (Fig. 5).

The bridge does what it can. After the call, a reference count above 1 on the memoryview means the model stored it (self.kept = xin), and the bridge reports it. Then it calls release(), so any later access through that memoryview raises ValueError instead of reading garbage. What it cannot see is a numpy view: np.frombuffer(xin) holds its own internal buffer reference, not the memoryview object. The lab's keep_numpy bug shows the consequence. After the call returns, SV writes xs[0] = 1234, and Python reads it back through the kept view without any DPI call involved: LOG keep_numpy value=0 err= (undetected), then LOG kept0_after_sv_write value=1234.

Rule for model authors: buffers passed in are borrowed for the call. Compute, write outputs, return. If you need the data later, copy it (bytes(xin), np.frombuffer(xin, ...).copy()). The same rule holds for the const char* of an SV string argument and for every pointer DPI gives C.

0.10Proving the scoreboard can fail

A scoreboard that has never failed has not been tested. Bug injection is the backend's business, not the contract's, so the SV does not change: step 06's golden.c rebuilt with -DMUTANT=1, and golden.py started with GOLDEN_BUG=fir_trunc in the environment (same binary), both make the model truncate instead of rounding, a one-LSB difference that only shows on about half the samples. The per-sample scoreboard, the per-block scoreboard and the C backend all report the same first mismatch and the same count:

LOG first_mismatch unit=0 sample=0 dut=-12612 model=-12613
LOG fir samples=2048 calls=4096 errors=1729 saturated=631
FAIL

Three things are proven at once: the comparison is sensitive to one LSB; the per-block scoreboard reports the same absolute sample index as the per-sample one (the index arithmetic n - nb + 1 + i is right); and the Python and C models make the same mistake in the same place, so they share the contract and differ only in implementation.

The other half of "can it fail" is "did it test anything". Exercise E5 shows a scoreboard that prints PASS after checking zero bytes, because of an SV initializer rule. Always log what was covered (bytes=39480) and assert on it.

0.11What it costs

tb_perf.sv measures model throughput with no DUT: the same SV calls each backend 100 000 times (or in blocks of 1024), timing with step 06's wall_s() import (clock_gettime). The C backend is step 06's golden.c, whose CRC is table-driven. One run on this machine:

110²10⁴10⁶10⁸10¹⁰ items per second (log scale) C · FIR per sample203 M Py · FIR per sample1.2 M C · FIR block218 M Py · FIR block (numpy)30 M C · CRC per byte370 M Py · CRC per byte3.9 M C · CRC 1 KiB (table)571 MB/s Py · CRC 1 KiB (zlib)1.15 GB/s Py · empty round trip3.5 M $system(python3 …)25 /s
Fig. 7Model throughput, one run (Verilator 5.052, Python 3.14.7, macOS x86_64; check.py asserts the ratios, not the values). Orange: Python backend; teal: C backend; indigo: references.

What to read from it:

To put it in budget terms: if the DUT simulation itself runs at 10 k cycles/s (a mid-size block in Verilator), a per-transaction Python model at ~1 µs per call adds about 1 % to the wall time. Python per sample only hurts when the DUT is tiny and fast, as in these labs, or when the model call happens on every clock of a long regression. Exercise E8 does the arithmetic.

0.12A generic bridge

The typed wrappers above need C code per model function. For quick experiments a generic entry point helps: name the module and function as strings, pass integers, get an integer back. The callable is looked up once and cached in a dict keyed "module.func".

long long py_call_i64(const char* mod, const char* func, const svOpenArrayHandle args) {
  PyObject* f = callable(mod, func);                     /* cached after the first lookup */
  if (!f) return 0;
  int n = svSize(args, 1);
  const long long* a = (const long long*)svGetArrayPtr(args);
  PyObject* t = PyTuple_New(n);
  for (int i = 0; i < n; i++) PyTuple_SET_ITEM(t, i, PyLong_FromLongLong(a[i]));   /* steals */
  PyObject* r = PyObject_Call(f, t, NULL);
  Py_DECREF(t);
  long long v = r ? PyLong_AsLongLong(r) : 0;
  if (!r || PyErr_Occurred()) { fail(func); v = 0; }
  Py_XDECREF(r);
  return v;
}
import "DPI-C" function longint py_call_i64(input string mod, input string func, input longint args[]);
longint two[2] = '{40, 2};
$display("%0d", py_call_i64("operator", "add", two));   // 42: any importable function, no glue
$display("%0d", py_call_i64("math", "gcd", two));       // 2

Two Verilator constraints shape it: an open-array actual must be a fixed array variable (no '{...} literal, no dynamic array), and SV has no zero-length fixed array. So a call with no arguments passes a one-element dummy (longint none[1]), and functions meant for the generic bridge accept *_. Forgetting that is a TypeError that the bridge reports, and if the caller does not read py_last_error(), a 0 that only the stderr line explains: it happened twice while writing the exercises.

typed wrapper (fir8_step)generic (py_call_i64)
C per model functionyes, ~15 linesnone
typesexact (shortint, bit [199:0], arrays)int64 in, int64 out
prototype checkcompiler, via Vtop__Dpi.hat run time, by Python (TypeError)
swappable for a C modelyes (0.1)no: the call names Python
use forthe scoreboard's modelexploration, small utilities, one-off checks

0.13Beyond hand-written glue

Everything in this step is ~250 lines of C you now understand line by line. That understanding is what lets you judge the libraries that generate such glue for you. In M. Ballance's taxonomy (Properly Introducing Python to your UVM Testbench, DVCon US 2026), there are two ways to put Python in a verification environment:

approachexampleease of integrationreuse of SV/UVM
signal level: Python drives and samples DUT signals through VPI/VHPIcocotb, pyuvmhighlow
function wrapper: generated Python/SV API over DPIPyHDL-IF, pysv, pybind, this tutoriallowhigh

This tutorial is the function-wrapper approach done by hand. The options, as of October 2026:

toolhowfits whenwatch out
hand-written bridge (this step)CPython C API behind a C-typed DPI contracta few model entry points, wide/array types you control, C and Python backends side by sideyou own refcounts, errors and buffer lifetime
pysv 0.4.0 (May 2026)@sv() on Python functions/classes; generates the SV package (classes wrap a chandle) and a pybind11 shared librarymany small functions, Python classes as SV classes, little C knowledge on the teamre-parses the function source on every call (per its docs), so per-sample calls are slow; build-time absolute sys.path
PyHDL-IF 0.2.1 (Aug 2026)@hif.api/@hif.imp; SV structs from ctypes.Structure; async methods become time-consuming tasks; UVM proxies; pytest launched from SVUVM environments where Python writes sequences/scoreboards and SV keeps the agents; Questa, Xcelium, VCS, Verilatorbinary wheels only for Linux x86_64 (build from source elsewhere); young API
cocotb 2.1 / pyuvm 5.0the whole testbench is Python (asyncio-style coroutines) over VPI; the golden model is just a Python call, no DPInew block-level testbenches, especially on Verilator (5.036+ with --timing) and Icaruscannot reuse existing SV/UVM agents; VPI signal access is slower than compiled SV

The choice follows the testbench, not the model. If the testbench is SV/UVM and stays SV/UVM, use a function wrapper (hand-written for a handful of models, PyHDL-IF when Python should drive UVM). If you are writing a new block testbench from scratch and the team prefers Python, write the testbench in cocotb and the "golden model" stops being a DPI problem at all.

0.14Recipe: a Python golden model over DPI

  1. Write the imports in a package, with C types, as if the model were C. Implement a C backend if at all possible; keep both linkable.
  2. Include V<top>__Dpi.h in every bridge file. Cache callables; intern method names used per sample.
  3. Arrays: fixed SV arrays plus a length; svGetArrayPtr into a memoryview; numpy frombuffer on it. Outputs written in place through a writable memoryview.
  4. Wide vectors: mask the unused top bits, PyLong_FromUnsignedNativeBytes in, PyLong_AsNativeBytes out with a fit check. Signed: sign-extend first, and make the model wrap like SV.
  5. Stateful models: one Python object per DUT instance behind a chandle that owns one reference; free before Py_FinalizeEx; count live objects.
  6. Errors: check NULL and PyErr_Occurred(); return a neutral value; surface the message (pull or push) and never compare a failed model result.
  7. Bit-exact arithmetic in integers only; document the rounding and saturation in the DUT header and transcribe it.
  8. Per-sample models in plain Python, block models in numpy; compare per transaction while debugging, per block in regression.
  9. Inject a model bug and see the scoreboard fail; log what was covered and assert it.

§1 · ObjectivesWhat you can do after this step

Prerequisites: open arrays and svGetArrayPtr (step 04), chandle lifetimes (step 03), the scoreboard pattern (step 06), CPython init/refcounts/errors (step 07).

§2 · Exercises11 graded exercises, with solutions

★ basic · ★★ intermediate · ★★★ real-world. Code solutions live in lab/ex/ (or the lab root) and are built, run and asserted by check.py.

E1 ★Predict. golden.crc32_update(crc, chunk), the function behind crc32_msg and Crc32.update, is chained as crc = crc32_update(crc, chunk) across three chunks b"12", b"345", b"6789", starting from crc = 0. What is crc at the end, and what would it be if the chain started from 0xFFFFFFFF "because that is the CRC initial value"?

Show solution

0xCBF43926, the CRC of "123456789": zlib.crc32(b, zlib.crc32(a)) == zlib.crc32(a + b) (check.py asserts the two-chunk version). The running value is the final CRC so far (register inverted), and the empty message's CRC is 0, so 0 is the correct start. 0xFFFFFFFF is the initial value of the register; passing it as the running CRC means "continue a message whose CRC is 0xFFFFFFFF", which zlib turns back into register value 0: a different, wrong answer. A C implementation keeps the other convention: step 06's golden.c stores the register (starting at 0xFFFFFFFF) and inverts only on output. Mixing the two conventions is the trap.

E2 ★Ex. 12.1 (book), Python edition. Make shift(i, n) a Python golden model: shift a 32-bit unsigned i left by n if n > 0, right by -n if negative. Call it through py_call_i64 and compare with SV's own shift on 200 random cases, including |n| ≥ 32.

Show solution
# lab/ex/ex_models.py
M32 = (1 << 32) - 1
def shift(i, n):
    return (i << n) & M32 if n >= 0 else (i & M32) >> -n
// lab/ex/ex_shift.sv (loop body)
i = $urandom; n = $urandom_range(0, 70) - 35;
a = '{longint'(i), longint'(n)};                         // a: longint a[2] (a variable, not a literal actual)
got = 32'(py_call_i64("ex_models", "shift", a));
exp_v = (n >= 0) ? i << n : i >> -n;                      // SV: shift by >= width gives 0

Result: LOG ex_shift n=200 errors=0. Compare with the C version in step 01: Python needs the & M32 because its ints never overflow, while C needs a guard for n ≥ 32 because that shift is undefined behavior. Each language has its own way of not being SV.

E3 ★Find the bugs. A colleague's bridge function passes all tests:

int py_square(int x) {
  return PyLong_AsLong(PyObject_CallFunction(f_square, "i", x));
}
Show solution

Three bugs. (1) The result object is never released: one leaked int per call. Tests with small values do not show it, because ints in −5…256 are cached (and immortal since Python 3.12), so the "leak" is a no-op for them; with large values the process grows forever. (2) If the call raises, PyObject_CallFunction returns NULL and PyLong_AsLong(NULL) sets a SystemError and returns −1: the scoreboard receives −1 as if it were the model's answer. (3) A result outside int range returns −1 with OverflowError set, also silently. The fix is the pattern of 0.2: keep r, check NULL and PyErr_Occurred(), report, return a neutral value, Py_XDECREF(r).

E4 ★★Predict, for Verilator 5.052. The import is crc32_msg(input byte unsigned msg[], input int len). Which actuals for msg build? (a) byte unsigned f[5]; (b) byte unsigned d[] (dynamic); (c) byte unsigned q[$]; (d) the literal '{8'h31, 8'h32}.

Show solution

Only (a). (b) and (c) fail at build time: Unsupported: DPI argument of type DYNARRAYDTYPE (tb_dyn_fail.sv, asserted), or, with the import inside the module, Passing dynamic array or queue as actual argument to DPI open array is not yet supported. (d) fails with Unsupported: Assignment pattern applies against non-struct/union data type: 'UNSIZEDARRAYDTYPE' (seen in ex_shiftreg.sv before the fix). The LRM allows all four; commercial simulators accept them. Portable rule: fixed array variable + explicit length.

E5 ★★Find the bug. This scoreboard prints PASS with 0 errors, and the CRC check value line prints check_value=00000000. What is wrong?

initial begin
  ...
  for (int m = 0; m < 300; m++) begin
    int len = (m == 0) ? 0 : (m == 1) ? 9 : $urandom_range(1, 256);
    clear = 1; @(negedge clk); clear = 0;
    exp_crc = 0;
    for (int i = 0; i < len; i++) begin ... end
    exp_crc = crc32_msg(msg, len); check(...);
  end
Show solution

len is declared inside a block of an initial process, so it is a static variable, and a static variable's initializer runs once, at time zero, not on every loop iteration. At time zero m is not even set yet; len is 0 forever. Every message is empty, the DUT's CRC after clear is 0, the model's CRC of nothing is 0: 300 perfect matches on zero bytes. The lab reproduces it (+define+STATIC_BUG): LOG crc msgs=300 bytes=0 calls=300 checks=300 errors=0, PASS. Verilator gives no warning. Fix: automatic int len = ... (or declare, then assign). The meta-lesson: log what was covered (bytes=) and assert on it, not only on errors=0.

E6 ★★Find the bug. The coefficients live in logic signed [15:0] c[8], because that is the DUT port type. h = fir8_new(c); does not build. Why, and what are the two fixes? Then: can the TB instead declare shortint coef[8] and connect it to the DUT's coef port?

Show solution

An unpacked array passed to an unpacked array formal must have equivalent element types. logic signed [15:0] is 4-state, shortint is 2-state: Verilator says Shape of the argument does not match the shape of the parameter ('logic[15:0]' v.s. 'shortint') (tb_shape_fail.sv). Fixes: copy into a shortint array before the call (tb_fir.sv declares shortint coef[2][8] and connects that to the DUT); or change the import to input logic signed [15:0] coef[8] and handle svLogicVecVal in C (step 02). The reverse direction fails too: a shortint array on a logic signed [15:0] array port is Array element types are not equivalent (IEEE 1800-2023 6.22.2) (tb_port_fail.sv).

A Verilator inconsistency to know about: tb_fir.sv connects a shortint sub-array coef[u] to fir8.coef and builds, while tb_port_fail.sv, connecting a whole shortint coef[8] through .*, does not. That inconsistency is Verilator's; by the LRM both are illegal (6.22.2 requires equivalent element types for unpacked array port connections). Do not rely on it: keep TB arrays in the DUT's type and convert at the DPI call.

E7 ★★Write. Ethernet appends the CRC (the FCS) to the frame, least significant byte first. A receiver runs the same CRC over payload + FCS. Show, with the DUT and the model, that the result is the same constant for every frame, and find that constant.

Show solution
// lab/ex/ex_crc_frames.sv (loop body)
automatic int len = $urandom_range(46, 1500);
foreach (frame[i]) frame[i] = 8'($urandom);
fcs = crc32_msg(frame, len);
for (int k = 0; k < 4; k++) frame[len + k] = fcs[8*k +: 8];   // LSB first
// clear, stream len+4 bytes into the DUT ...
model = crc32_msg(frame, len + 4);
if (crc !== 32'h2144DF1C || model !== 32'h2144DF1C) n_err++;

20 frames of 46–1500 bytes: LOG ex_crc_frames frames=20 residue=2144df1c errors=0. The residue 0x2144DF1C is the CRC-32 "magic check" value; receivers compare against it instead of recomputing and comparing the FCS. The exercise also shows a model doing double duty: generator (FCS) and checker.

E8 ★★Budget. A regression streams 10 M accepted samples through each of the two FIR instances, and the Verilator model of the DUT plus TB runs at ~2 M cycles/s with in_valid high 80 % of the time. Using the throughputs in Fig. 7, estimate the wall time added by (a) the per-sample Python model, (b) the 256-sample block Python model, (c) the per-sample C model.

Show solution

DUT simulation: 10 M samples / 0.8 = 12.5 M cycles at 2 M cycles/s ≈ 6.3 s. Model calls: 2 × 10 M = 20 M samples. (a) 20 M / 1.2 M per s ≈ 17 s: the model costs almost three times the simulation. (b) 20 M / 30 M per s ≈ 0.7 s, about +11 %. (c) 20 M / 203 M per s ≈ 0.1 s, +1.6 %. Per-sample Python is fine for debug runs and short tests; for the regression, batch (or keep the C model and run the Python one nightly as a cross-check of the C one).

E9 ★★★Ex. 12.2–12.6 (book), Python edition. A 32-bit shift register with a load flag (Ex. 12.2), several independent instances (12.3), a call counter shared by all instances (12.4), an initial value at construction (12.5), encapsulated in a class (12.6). Implement it as a Python class, and extend the bridge with three generic functions so that any Python class can live behind a chandle: py_new(mod, cls, args[]), py_call_method_i64(obj, method, args[]), py_free(obj).

Show solution
# lab/ex/ex_models.py
class Shift:
    calls = 0                                  # Ex. 12.4: class attribute, shared
    def __init__(self, init=0):
        self.reg = init & M32                  # Ex. 12.5
    def shift(self, i, n, ld):
        Shift.calls += 1
        self.reg = shift(i if ld else self.reg, n)
        return self.reg
    def total_calls(self, *_):                 # *_: the bridge always passes >= 1 arg
        return Shift.calls
/* lab/ex/golden_ex.c (includes ../golden_py.c for callable() and fail()) */
void* py_new(const char* mod, const char* cls, const svOpenArrayHandle args) {
  PyObject* c = callable(mod, cls);
  if (!c) return NULL;
  PyObject* t = args_tuple(args);
  PyObject* obj = PyObject_Call(c, t, NULL);           /* the chandle's one reference */
  Py_DECREF(t);
  if (!obj) fail(cls);
  return obj;
}
long long py_call_method_i64(void* obj, const char* method, const svOpenArrayHandle args);
void py_free(void* obj) { Py_XDECREF((PyObject*)obj); }

Output: s1_load=00000010 (load 1, shift 4), s1_shift=00000100, s2_shift=40000000 (init 0x80000001 >> 1), s2_shift2=80000000, total_calls=4. The first run printed total_calls=0: total_calls(self) raised TypeError on the dummy argument, the bridge returned 0, and the TB did not read py_last_error(). It does now.

E10 ★★★Write. A model takes and returns bit signed [99:0] and computes the negation. Write the C conversion (both directions) and the Python model so that the result equals SV's -a for every input, including $-2^{99}$.

Show solution

In: mask, then sign-extend bit 99 into the 28 unused bits of word 3, and read the 16 bytes as a signed buffer. Out: write signed into 16 bytes and check that bits 127…99 are all equal (the value fits in 100 signed bits); then clean the unused bits. Python: $-(-2^{99}) = 2^{99}$ does not fit 100 signed bits; SV wraps it to $-2^{99}$, so the model must wrap too.

w[3] = (w[3] & 0x8) ? (w[3] | 0xFFFFFFF0u) : (w[3] & 0xF);       /* sign-extend bit 99 */
PyObject* f  = callable("ex_models", "neg100");       /* FIRST: it initializes Python */
PyObject* pa = f ? PyLong_FromNativeBytes(w, 16, Py_ASNATIVEBYTES_LITTLE_ENDIAN) : NULL;
...
Py_ssize_t need = PyLong_AsNativeBytes(r, y, 16, Py_ASNATIVEBYTES_LITTLE_ENDIAN);
if (need < 0) fail("neg100");
else if (need > 16 || ((y[3] >> 3) != 0 && (y[3] >> 3) != 0x1FFFFFFFu)) /* report overflow */;
y[3] &= 0xF;
def wrap(v, bits):                             # two's-complement wrap, like SV
    return ((v + (1 << (bits - 1))) % (1 << bits)) - (1 << (bits - 1))
def neg100(a):
    return wrap(-a, 100)

LOG signed min_neg=8000000000000000000000000 and errors=0 on 500 vectors. The first version called PyLong_FromNativeBytes before callable(): on the first call the interpreter was not initialized yet and the simulator died with a segmentation fault. Any C API call before Py_Initialize is undefined behavior; route every entry point through the function that initializes.

E11 ★★★Write. The DUT gets an asynchronous reset in the middle of the stream (sample 300 of 600). Make the model follow, without touching golden.py or golden_py.c, and show what the scoreboard sees if the model is not reset.

Show solution

Subclass the model with a reset() and create it through the generic py_new() of E9; the contract's fir8_step() works on it unchanged, because the chandle is just a PyObject* with a step method.

class Fir8R(golden.Fir8):
    def __init__(self, *coef):                  # 8 scalar args: what py_new can pass
        super().__init__(coef)
    def reset(self, *_):
        self.h = [0] * 7
        return 0
if (n == 300) begin
  rst_n = 0; @(negedge clk); rst_n = 1;
  void'(py_call_method_i64(h, "reset", none));
  if (py_last_error() != "") n_err++;               // never ignore it
end

With the reset: errors=0. Without it (+no_model_reset): errors=7, exactly the 7 samples during which the model's history still holds pre-reset data. A short burst of mismatches right after a reset is the signature of a model that does not see reset.

§3 · Quiz14 questions, self-grading

Pick an answer and press Check. The explanation covers the right answer and the tempting wrong one.

1. Why does the shared golden_pkg.sv declare crc32_msg(input byte unsigned msg[], input int len) instead of py_call(input string expr)?

(b) A C-typed contract is language-neutral: step 06's golden.c implements the same symbols and the prototype header catches drift. (a) and (d) are false: input string maps to const char* and the generic bridge uses it. (c) is false too (PyRun_String exists), it is just a bad contract.

2. The running CRC after the bytes a is c = zlib.crc32(a). Which call gives the CRC of a + b?

(c) zlib's second argument is the previous final CRC; it undoes the final XOR internally. (a) undoes the final XOR by hand, which zlib already does internally, so it double-inverts. (b) is not how CRCs combine (it ignores the shift of a's contribution by len(b) bytes). (d) counts a twice.

3. fir8_new returns the result of PyObject_CallOneArg(cls, c) as the chandle. What must fir8_free do?

(a) The call returned a new (owned) reference; storing it in the chandle transfers that ownership to SV, and freeing drops it. (b) Python cannot see SV's chandle variables; nothing ever drops the reference: a leak. (c) is a double free: there was only one reference. (d) frees memory Python owns with the wrong allocator.

4. The FIR model must reproduce (acc + 2**14) >>> 15 for a signed 35-bit acc. Which Python expression is bit-exact for all values?

(d) Integer >> floors, like SV's arithmetic shift of a signed value. (a) round is round-half-to-even: different on exact halves. (b) int() truncates toward zero: wrong for negative values. (c) works here only because a 35-bit value fits a double exactly; it is the habit that breaks at 54 bits.

5. Per sample, why is the plain-int step() ~40× faster than the np.dot/np.roll version?

(b) numpy amortizes its per-call cost over many elements; with 8 elements there is nothing to amortize. (a) is false for int64 arrays. (c) is irrelevant here (one thread). (d) cannot be: both versions are measured by check.py without DPI at all (1.16 M/s vs 29 k/s).

6. In Verilator 5.052, svGetArrayPtr(y) for an output open array y returns…

(c) Measured: the address differs on every call and is on the C stack; the value reaches SV after the call returns. (a) is what Verilator does for an input fixed array. Either way, the LRM only promises validity during the call, which is why the model must not keep it.

7. A model does self.kept = np.frombuffer(xin, np.int16) inside block(). What does the bridge's refcount check report?

(b) Measured: keep_numpy value=0 err= (empty), and the kept view later reads SV's xs[0] = 1234. The check only catches storing the memoryview itself (a). release() succeeds (c). (d) may happen for an output buffer kept and read later, but not deterministically: that is what makes it dangerous.

8. Why must bits_to_py mask the top word of a bit [99:0] argument?

(a) H.7.7 leaves the unused bits of the canonical representation unspecified; an unmasked read can turn a 100-bit value into a 128-bit one. (b) Python ints are unbounded. (c) Masking does not reorder bytes. (d) Verilator is 2-state and bit has no bval anyway.

9. PyLong_AsLongLong(r) is called on r = 2**70. What does it return, and how do you know it failed?

(c) The lab's first version forwarded the −1 to SV (LOG overflow value=-1). Because −1 is also a valid result, only the error indicator distinguishes them. (a) is what PyLong_AsUnsignedLongLongMask does on purpose. (b): conversion functions return C values, not objects.

10. A scoreboard reports a model failure with $error and wants to keep running to count all failures. On Verilator 5.052 it…

(d) Measured in tb_err: the code after $error does not run without the plusarg, and runs with it. (a) is the LRM default and commercial behavior, which is why the trap exists. The lab builds with --assert and still stops (b).

11. With the fir_trunc bug (GOLDEN_BUG=fir_trunc), the per-sample and per-block scoreboards both report first_mismatch sample=0 and 1729 errors. What does the equality of the two reports prove?

(b) The models differ in failure only by design (truncation); what could differ is the scoreboard bookkeeping, and identical first index and count show it is right. (a) The DUT is the reference here: the model has the injected bug. (c) Both modes truncate under the bug; the point is the bookkeeping. (d) Same model, different comparison code: that is exactly what is being tested.

12. Which observation from Fig. 7 is most important when deciding between a per-sample Python model and a C model?

(a) Per-call overhead dominates: per block, numpy is within 7× of C and zlib beats the C table loop by 2×. (b) holds only per call with a tiny model. (c) over-generalizes one library. (d) is a one-time cost, paid at time 0.

13. A model function written for the generic bridge is declared def total_calls(self): and called with longint none[1]. What does SV get?

(c) The bridge passes every element of the array; Python raises TypeError (one argument too many), the bridge returns 0 and records the message. That is why generic-bridge functions take *_. (a) would need an explicit count argument. (b): Python signatures are invisible to the SV compiler; that is the price of the generic bridge.

14. Your team has a UVM environment with reusable SV agents and wants Python to write sequences and scoreboards. Which option fits best?

(b) Function wrappers keep the SV agents and call Python for behavior; PyHDL-IF adds UVM proxies for exactly this. (a) and (d) are signal-level: the SV agents cannot be reused. (c) costs ~48 ms per call.

§4 · LabRun it, swap the backend, break it

Predict first. Before running: how many model calls does the per-byte CRC run make? Will the per-block FIR run see the same first mismatch as the per-sample run under fir_trunc? Which is faster for 1 KiB CRC blocks, the Python or the C backend?

Run.

cd tutorial/08_python_golden_model/lab
python3 golden.py           # the models alone, no simulator
python3 check.py            # 18 builds (3 must fail) + 1 lint, 23 runs, 51 asserts (~20 s)
python3 check.py --no-run   # re-check existing logs
cat build/results.txt       # every LOG/PASS/FAIL line

run.sh exports PYTHONPATH=$PWD:$PWD/ex so the embedded interpreter finds golden.py; run a binary by hand the same way: PYTHONPATH=$PWD ./build/vl_fir_py/Vtop +block, or GOLDEN_BUG=fir_trunc PYTHONPATH=$PWD ./build/vl_fir_py/Vtop to see it fail.

filewhat it shows
../../06_golden_model_c/lab/golden_pkg.svthe contract, shared with step 06 (0.1)
../../06_golden_model_c/lab/golden.c, wall.cthe C backend and the wall clock, from step 06
py_extra_pkg.svPython-only extras: py_mul_wide, py_call_i64, py_set_bug, py_last_error
golden.pythe Python models + self-check
golden_py.cPython bridge: step 06's symbols + the extras
tb_crc.svCRC scoreboard, +per_byte; +define+STATIC_BUG for E5
tb_fir.svtwo FIR instances, +block; bug via GOLDEN_BUG=fir_trunc / -DMUTANT=1
tb_wide.sv100×100→200-bit multiply (Python) vs SV
tb_err.svgeneric bridge, chandle refcount, every failure class, $error
tb_perf.svthroughput (Fig. 7)
tb_*_fail.sv, pragma_fail.svexpected build failures (E4, E6, a Verilator comment trap)
ex/exercise solutions E2, E7, E9, E10, E11

Results (from build/, asserted):

experimentPython backendC backend
CRC, 300 messages, 39 480 bytes, per messagePASS, 300 callsPASS, 300 calls
CRC, per bytePASS, 39 480 callsPASS, 39 480 calls
CRC check valuecbf43926
FIR ×2, 2048 samples, per sample / per blockPASS, 4096 / 16 callsPASS, 4096 / 16 calls
FIR with fir_trunc / MUTANT=1first mismatch sample 0 (dut −12612, model −12613), 1729 errors, both backends, both modes
wide multiply, 1000 vectorsPASS— (Python-only extra)
static-initializer TB (E5)PASS with bytes=0: the trap
Ethernet FCS residue (E7)2144df1c for 20 frames
model reset (E11)0 errors with reset, 7 without

Break it yourself. Comment out the mask in bits_to_py and run tb_wide (the result depends on what Verilator leaves in the top bits: it may well still pass, which is the problem with relying on it). Delete v = 0; in py_call_i64 and look at LOG overflow. Add Py_INCREF(obj) in fir8_new and watch live_after_free.

§5 · Cheat sheetStep 08 on one screen

needSV sideC bridgePython side / pitfall
bytes ininput byte unsigned d[], input int lenPyMemoryView_FromMemory(svGetArrayPtr(d), len, PyBUF_READ)fixed array actual only (Verilator); valid during the call
int16 block in/outinput shortint x[], output shortint y[]memoryview READ / WRITE over svGetArrayPtrnp.frombuffer(y, np.int16)[:] = …; output is a temp in Verilator
model objectchandle h = fir8_new(...)return PyObject_CallOneArg(cls, c);chandle owns 1 ref; Py_XDECREF in free, before finalize
per-sample methodfir8_step(h, x)PyObject_CallMethodOneArg(h, interned, px)plain Python ints, not numpy
wide unsignedbit [N-1:0]PyLong_FromUnsignedNativeBytes / PyLong_AsNativeBytesmask unused bits; check fit; LE host
wide signedbit signed [N-1:0]sign-extend, PyLong_FromNativeBytesmodel must wrap like SV
int resultlongint f(...)PyLong_AsLongLong + PyErr_Occurred()−1 is also a valid value
errorsstring py_last_error() (extras)PyErr_GetRaisedException → stderr + messagecontract call returns 0 → mismatch; $error stops Verilator
any functionpy_call_i64(mod, fn, args)cached callable, tuple of PyLongPython takes *_; errors only at run time

Rules of thumb

  1. Write the imports for the model, in C types; link Python or C behind them. Python-only conveniences go in a separate package.
  2. Prefer a library reference (zlib, numpy, int) over a second hand-written algorithm.
  3. Bit-exact means integers: floor shifts, explicit rounding, explicit saturation, explicit wrap.
  4. Buffers and strings from DPI are borrowed for the call. Copy what you keep.
  5. Every conversion can fail with a valid-looking value: check PyErr_Occurred().
  6. A chandle owns one reference. Count live objects.
  7. The cost is per call: batch, compare per block in regression, per transaction while debugging.
  8. Inject a model bug and log coverage counts; a PASS that tested nothing is still a PASS.
  9. Commercial simulators accept dynamic arrays, literals, and keep going after $error; Verilator does not. Write to the intersection.
  10. When Python should drive the testbench, not just model it, look at PyHDL-IF (UVM) or cocotb (new TBs).

Sources: Spear & Tumbush, SystemVerilog for Verification, 3rd ed., ch. 12 (Ex. 12.1–12.6, §12.9); IEEE 1800-2023 clause 35, H.7.7, H.8.4, H.8.10; Python 3.14 C API (PyLong_AsNativeBytes, PyErr_GetRaisedException); zlib documentation (crc32); M. Ballance, Properly Introducing Python to your UVM Testbench, DVCon US 2026; pysv and PyHDL-IF documentation. Measurements: lab/check.py.