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.
§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.
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;
}
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:
| run | model calls | checks | errors | sim wall time |
|---|---|---|---|---|
| Python, per message | 300 | 300 | 0 | 25 ms |
| Python, per byte | 39 480 | 39 480 | 0 | 55 ms |
| C, per message | 300 | 300 | 0 | 23 ms |
| C, per byte | 39 480 | 39 480 | 0 | 42 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.)
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.
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:
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:
- Products and sums: Python
intis unbounded; numpyint64is wide enough (34 bits needed). Never usefloat:np.convolveon float64 is exact only up to 53 bits and you would be relying on luck. - Floor shift:
>>on a negative Pythonintor numpyint64rounds toward −∞, like SV>>>on a signed value. C's>>on a negative signed value is implementation-defined in C (defined as arithmetic since C++20, and arithmetic on every real compiler); the C backend comments it. - Saturation:
min(32767, max(-32768, r))ornp.clip. - History: the DUT's delay line only shifts when
in_validis high; the model only advances when it is called. Calling the model on every clock instead of every accepted sample is the most common stateful-model bug (see exercise E11 for its cousin, reset).
# 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
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.
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:
| cause | value | py_last_error() |
|---|---|---|
module not on sys.path | 0 | no_such_module.f: ModuleNotFoundError: No module named 'no_such_module' |
| function name typo | 0 | golden.nope: AttributeError: module 'golden' has no attribute 'nope' |
| model raises | 0 | fails: KeyError: 0 |
result does not fit longint | 0 | big: OverflowError: int too big to convert |
| model kept the memoryview (0.9) | 0 | Fir8.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:
What to read from it:
- The crossing is cheap, the interpreter is not free. An empty Python call through the bridge costs about 0.3 µs (3.5 M calls/s). A per-sample Python FIR call costs about 0.85 µs. The same call into C costs about 5 ns. Per-sample Python models are ~170× slower than C ones.
- Batching recovers most of it. The numpy block model runs at 30 M samples/s, 25× the per-sample Python model and within 7× of C.
- Python can beat your C.
zlib.crc32over 1 KiB blocks runs at 1.15 GB/s, 2× faster than step 06's table-driven C loop (571 MB/s), because zlib is optimized C with wider tables and SIMD. The cost of Python is per call, not per byte. - The first call pays the start-up. Interpreter initialization plus importing numpy took 102 ms (
first_call_us=102210). Pay it once, at time 0 (step 07 does it in an explicitpy_init). - Spawning a process per call (Spear §12.9,
$system) runs at 25 calls/s: ~40 ms each, 140 000× slower than the embedded call.
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 function | yes, ~15 lines | none |
| types | exact (shortint, bit [199:0], arrays) | int64 in, int64 out |
| prototype check | compiler, via Vtop__Dpi.h | at run time, by Python (TypeError) |
| swappable for a C model | yes (0.1) | no: the call names Python |
| use for | the scoreboard's model | exploration, 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:
| approach | example | ease of integration | reuse of SV/UVM |
|---|---|---|---|
| signal level: Python drives and samples DUT signals through VPI/VHPI | cocotb, pyuvm | high | low |
| function wrapper: generated Python/SV API over DPI | PyHDL-IF, pysv, pybind, this tutorial | low | high |
This tutorial is the function-wrapper approach done by hand. The options, as of October 2026:
| tool | how | fits when | watch out |
|---|---|---|---|
| hand-written bridge (this step) | CPython C API behind a C-typed DPI contract | a few model entry points, wide/array types you control, C and Python backends side by side | you 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 library | many small functions, Python classes as SV classes, little C knowledge on the team | re-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 SV | UVM environments where Python writes sequences/scoreboards and SV keeps the agents; Questa, Xcelium, VCS, Verilator | binary wheels only for Linux x86_64 (build from source elsewhere); young API |
| cocotb 2.1 / pyuvm 5.0 | the whole testbench is Python (asyncio-style coroutines) over VPI; the golden model is just a Python call, no DPI | new block-level testbenches, especially on Verilator (5.036+ with --timing) and Icarus | cannot 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
- 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.
- Include
V<top>__Dpi.hin every bridge file. Cache callables; intern method names used per sample. - Arrays: fixed SV arrays plus a length;
svGetArrayPtrinto a memoryview; numpyfrombufferon it. Outputs written in place through a writable memoryview. - Wide vectors: mask the unused top bits,
PyLong_FromUnsignedNativeBytesin,PyLong_AsNativeBytesout with a fit check. Signed: sign-extend first, and make the model wrap like SV. - Stateful models: one Python object per DUT instance behind a chandle that owns one reference; free before
Py_FinalizeEx; count live objects. - Errors: check
NULLandPyErr_Occurred(); return a neutral value; surface the message (pull or push) and never compare a failed model result. - Bit-exact arithmetic in integers only; document the rounding and saturation in the DUT header and transcribe it.
- Per-sample models in plain Python, block models in numpy; compare per transaction while debugging, per block in regression.
- Inject a model bug and see the scoreboard fail; log what was covered and assert it.
§1 · ObjectivesWhat you can do after this step
- Design a DPI model contract in C types that a C and a Python backend both implement, and select the backend at link time.
- Use a Python library function (
zlib.crc32) as a bit-exact reference, including its chaining semantics. - Hold one Python object per DUT instance behind a
chandlewith correct reference ownership, and prove it. - Transcribe fixed-point rounding and saturation into integer Python/numpy that matches SV bit for bit.
- Pass arrays into and out of Python without copies, and state exactly how long those buffers are valid.
- Convert
svBitVecValvectors of any width to and from Pythonint, unsigned and signed, with overflow detection. - Route every Python failure into the scoreboard, and know what
$errordoes in Verilator. - Estimate the per-call cost and choose between per-sample, per-block, C, or a framework (pysv, PyHDL-IF, cocotb).
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)?
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?
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?
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?
>> 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?
6. In Verilator 5.052, svGetArrayPtr(y) for an output open array y returns…
7. A model does self.kept = np.frombuffer(xin, np.int16) inside block(). What does the bridge's refcount check report?
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?
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?
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…
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?
12. Which observation from Fig. 7 is most important when deciding between a per-sample Python model and a C model?
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?
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?
§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.
| file | what it shows |
|---|---|
| ../../06_golden_model_c/lab/golden_pkg.sv | the contract, shared with step 06 (0.1) |
| ../../06_golden_model_c/lab/golden.c, wall.c | the C backend and the wall clock, from step 06 |
| py_extra_pkg.sv | Python-only extras: py_mul_wide, py_call_i64, py_set_bug, py_last_error |
| golden.py | the Python models + self-check |
| golden_py.c | Python bridge: step 06's symbols + the extras |
| tb_crc.sv | CRC scoreboard, +per_byte; +define+STATIC_BUG for E5 |
| tb_fir.sv | two FIR instances, +block; bug via GOLDEN_BUG=fir_trunc / -DMUTANT=1 |
| tb_wide.sv | 100×100→200-bit multiply (Python) vs SV |
| tb_err.sv | generic bridge, chandle refcount, every failure class, $error |
| tb_perf.sv | throughput (Fig. 7) |
| tb_*_fail.sv, pragma_fail.sv | expected build failures (E4, E6, a Verilator comment trap) |
| ex/ | exercise solutions E2, E7, E9, E10, E11 |
Results (from build/, asserted):
| experiment | Python backend | C backend |
|---|---|---|
| CRC, 300 messages, 39 480 bytes, per message | PASS, 300 calls | PASS, 300 calls |
| CRC, per byte | PASS, 39 480 calls | PASS, 39 480 calls |
| CRC check value | cbf43926 | |
| FIR ×2, 2048 samples, per sample / per block | PASS, 4096 / 16 calls | PASS, 4096 / 16 calls |
FIR with fir_trunc / MUTANT=1 | first mismatch sample 0 (dut −12612, model −12613), 1729 errors, both backends, both modes | |
| wide multiply, 1000 vectors | PASS | — (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
| need | SV side | C bridge | Python side / pitfall |
|---|---|---|---|
| bytes in | input byte unsigned d[], input int len | PyMemoryView_FromMemory(svGetArrayPtr(d), len, PyBUF_READ) | fixed array actual only (Verilator); valid during the call |
| int16 block in/out | input shortint x[], output shortint y[] | memoryview READ / WRITE over svGetArrayPtr | np.frombuffer(y, np.int16)[:] = …; output is a temp in Verilator |
| model object | chandle h = fir8_new(...) | return PyObject_CallOneArg(cls, c); | chandle owns 1 ref; Py_XDECREF in free, before finalize |
| per-sample method | fir8_step(h, x) | PyObject_CallMethodOneArg(h, interned, px) | plain Python ints, not numpy |
| wide unsigned | bit [N-1:0] | PyLong_FromUnsignedNativeBytes / PyLong_AsNativeBytes | mask unused bits; check fit; LE host |
| wide signed | bit signed [N-1:0] | sign-extend, PyLong_FromNativeBytes | model must wrap like SV |
| int result | longint f(...) | PyLong_AsLongLong + PyErr_Occurred() | −1 is also a valid value |
| errors | string py_last_error() (extras) | PyErr_GetRaisedException → stderr + message | contract call returns 0 → mismatch; $error stops Verilator |
| any function | py_call_i64(mod, fn, args) | cached callable, tuple of PyLong | Python takes *_; errors only at run time |
Rules of thumb
- Write the imports for the model, in C types; link Python or C behind them. Python-only conveniences go in a separate package.
- Prefer a library reference (
zlib, numpy,int) over a second hand-written algorithm. - Bit-exact means integers: floor shifts, explicit rounding, explicit saturation, explicit wrap.
- Buffers and strings from DPI are borrowed for the call. Copy what you keep.
- Every conversion can fail with a valid-looking value: check
PyErr_Occurred(). - A chandle owns one reference. Count live objects.
- The cost is per call: batch, compare per block in regression, per transaction while debugging.
- Inject a model bug and log coverage counts; a PASS that tested nothing is still a PASS.
- Commercial simulators accept dynamic arrays, literals, and keep going after
$error; Verilator does not. Write to the intersection. - 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.