The Python Golden Model
Every part of this demo is written twice: once in Python, once in SystemVerilog. That sounds like duplicated effort, and it is the single most important habit in this class.
The Python version is the golden model. It is not a sketch, or a prototype you throw away once the RTL works — it is the definition of the right answer, and it stays for the life of the design. When the hardware and the model disagree, the model is what says which one is wrong.
Why write it at all
The obvious objection is that you now have two things to keep correct instead of one. Three reasons it is worth it anyway.
You can be sure the hardware is right. Not “it looked right on the cases I
tried” — actually right. In int_prod the model and the RTL
are compared on all 65,536 possible inputs. There is no untested corner because
there is no corner left.
You can design before you build. Choosing between truncation and saturation is a real decision with real cost, and you want to make it by looking at the distortion each one causes — not after a day of writing RTL for whichever you guessed. A Python model answers that question in seconds. Unit 3 leans on this heavily, when the question becomes how many fractional bits a filter needs.
It survives the design. RTL gets rewritten — pipelined, retimed, split across clock domains, ported to a different device. Each rewrite has to produce the same numbers, and the golden model is what proves it did. A model written for a Unit 1 demo would still be checking the design three redesigns later.
What a golden model looks like
Here is the whole of the int_prod model:
def wrap(value, width):
"""Reduce value to width bits, two's complement — what truncation does."""
mask = (1 << width) - 1
out = np.asarray(value) & mask
return np.where(out >= 1 << (width - 1), out - (1 << width), out)
def saturate(value, width):
"""Clamp value into the signed width-bit range — the other response to overflow."""
lo, hi = -(1 << (width - 1)), (1 << (width - 1)) - 1
return np.clip(value, lo, hi)
def int_prod_model(a, b, width=8):
"""Every output of int_prod.sv, computed in Python at full precision first."""
prod = np.asarray(a, dtype=np.int64) * np.asarray(b, dtype=np.int64)
trunc = wrap(prod, width)
return {
"prod": prod,
"hi": (prod >> width) & ((1 << width) - 1),
"lo": prod & ((1 << width) - 1),
"trunc": trunc,
"sat": saturate(prod, width),
"overflow": (prod != trunc).astype(np.int64),
"hi_nonzero": (((prod >> width) & ((1 << width) - 1)) != 0).astype(np.int64),
}
Three things about how this is written are worth copying.
Compute at full precision first, then reduce. prod is a full-width product
with nothing thrown away, and trunc and sat are both derived from it. The
model never loses information by accident — every loss is an explicit step you
can point at. This is the opposite of what SystemVerilog does, and that is the
point: the model spells out what the hardware does silently.
The finite-precision behaviour is a named function. wrap and saturate are
the two policies, sitting side by side, each three lines long. Because they are
separate and named, you can compare them, test them, and reuse them — every later
unit in this class needs some version of both.
It is vectorised. a and b are whole arrays, so all 65,536 cases are
computed in one call with no loop. A model you can run over the entire input
space is a different kind of tool from one you run on six cases.
How the model reaches the hardware
The model does not talk to the simulator directly. It writes test vectors as
CSV, using a pandas DataFrame:
def gen_int_prod_vectors(vec_dir, width=8):
a, b = int_prod_inputs(width)
model = int_prod_model(a, b, width)
# every column the testbench will report, and what it should contain
golden = pd.DataFrame({c: np.asarray(model[c], dtype=np.int64) for c in INT_PROD_COLS})
golden.to_csv(vec_dir / "int_prod_golden.csv", index=False)
# the stimulus is just the input columns of the same table
golden[["a", "b"]].to_csv(vec_dir / "int_prod_in.csv", index=False)
Two files: the inputs, and the expected outputs — and notice the second is a slice of the first table rather than a separately-built thing, so they cannot disagree about what was asked.
The testbench reads the stimulus, drives the module, and writes what it got as a CSV of its own. A separate step reads both back and compares them:
flowchart LR
gen["int_prod_gen<br/>the Python model"]
stim[("int_prod_in.csv<br/>the questions")]
golden[("int_prod_golden.csv<br/>the answers")]
sim["int_prod_sim<br/>SystemVerilog"]
got[("int_prod_sv.csv<br/>what the RTL said")]
checkint_prod_check<br/>do they agree?
gen --> stim
gen --> golden
stim --> sim
sim --> got
got --> check
golden --> check
classDef data fill:#eef4fb,stroke:#5b8cbe,color:#123
classDef step fill:#f6f2e8,stroke:#b9975b,color:#123
classDef gate fill:#eaf5ec,stroke:#5a9367,color:#123
class stim,golden,got data
class gen,sim step
class check gate
diff = golden.compare(actual, result_names=("python", "systemverilog"))
DataFrame.compare returns only the cells that differ, which is more useful than
the first mismatch alone: if a width is wrong somewhere, you see an entire column
light up rather than one row.
CSV is worth a word. It is what the rest of the course uses, so the pattern you
learn here is the one the labs expect. pandas reads and writes it in a line at
each end. SystemVerilog reads it with $fscanf — the format string just needs
commas in it — and the header row means the columns are matched by name, so
the testbench and the model cannot quietly drift into different orders. And you
can open the file in a spreadsheet, or read it over someone’s shoulder at 2am,
which is not nothing.
The one wrinkle on the SystemVerilog side is that the header is not data:
// The first line of a CSV is the column names. Read it and throw it away.
code = $fgets(header, fin);
Two ways to check an answer
The two parts of this demo check themselves differently, and the difference is worth naming.
int_prod uses a golden value comparison: for these inputs, the answer must
be exactly this. It is the most common kind of check, and it is only as good as
the model that produced the values.
samp_pack adds a property: unpack(pack(x)) == x, for
every x. Nothing is stored; the claim is about the relationship between two
operations. Properties are often easier to trust than golden values, because you
do not have to first convince yourself the stored answers were right. They also
tend to survive redesign — that one stays true no matter how the packing is
implemented.
Good verification uses both. Reach for a property whenever you can state one.
Where Python will trip you
Python and SystemVerilog disagree by default, and the disagreement is systematic:
- Python keeps every bit unless you explicitly remove them. Integers are
unbounded, so
x << 13just gets bigger, and a negative number has infinitely many leading ones. - SystemVerilog removes every bit that does not fit, and does not mention it.
So a Python model of finite-precision hardware has to put the limits back in by
hand — that is exactly what wrap is for, and why pack_sample masks each field
with & 0xFFF before shifting it into place. Forget a mask and the Python side
quietly computes something the hardware never could.
The failure has a signature worth recognising: the model and the RTL agree on
small values and diverge on large or negative ones. That is almost always a
missing mask or a missing wrap in the model, not a bug in the RTL.
NumPy has the mirror-image trap. Its fixed-width integer types overflow silently
like hardware does, so np.int32 arithmetic can wrap where you meant it not to.
This model uses np.int64 throughout and reduces precision only in wrap and
saturate, so every loss is deliberate.
Go to packing a data structure.