PyVSC Meets Dataclasses
The last post ended with a number that a solver can’t explain. Switching PyVSC to dv-solve took riscv-dv’s Python generator from 128 to 474 instructions a second, without changing pygen. Porting pygen’s instruction classes to a new front-end took it to 6,553, another 13.8x. That step had nothing to do with the solver. It came from changing how a PyVSC model is written down.
This post is about that front-end: PyVSC’s dataclass modeling style, which
shipped in PyVSC 1.0 as vsc.dc. It’s opt-in, it runs next to classic
@vsc.randobj classes in the same program, and it addresses three problems
that years of PyVSC use have made clear:
- Cost. Classic PyVSC rediscovers the structure of every object it creates, even though every object of a class has the same structure.
- Modeling constructs. Too much boilerplate, plus special helpers
(
vsc.if_then,vsc.rangelist) for things Python already has syntax for. - Ambiguity. Places where Python and SystemVerilog disagree about what an expression means, and places where operator overloading quietly turns what you wrote into something else.
The fix for all three is the same idea, applied twice: declare structure with types, and read constraints instead of running them.
Dataclasses: structure that belongs to the class
Here’s a small memory transaction written both ways. It’s the same transaction with the same constraints, and both versions run on the same solver.
import vsc
@vsc.randobj
class ClassicMemTxn(object):
def __init__(self):
self.write = vsc.rand_bit_t(1)
self.addr = vsc.rand_bit_t(32)
self.size = vsc.rand_bit_t(4)
self.data = vsc.rand_list_t(vsc.bit_t(8), 8)
@vsc.constraint
def c(self):
self.size.inside(vsc.rangelist(1, 2, 4, 8))
self.addr.inside(vsc.rangelist((0x1000, 0x1fff)))
(self.addr & (self.size - 1)) == 0
with vsc.if_then(self.write == 0):
self.size <= 4
with vsc.foreach(self.data) as x:
x != 0
import vsc
import vsc.dc as vdc
vsc.set_solver_backend("dv-solve")
@vdc.dataclass
class MemTxn(vdc.RandClass):
write: vdc.u1 = vdc.rand()
addr: vdc.u32 = vdc.rand()
size: vdc.u4 = vdc.rand()
data: list[vdc.u8] = vdc.rand(size=8)
@vdc.constraint
def c(self):
self.size in {1, 2, 4, 8}
0x1000 <= self.addr < 0x2000
self.addr & (self.size - 1) == 0
if not self.write:
self.size <= 4
for x in self.data:
x != 0
Two things change. The fields move out of __init__ and onto the class, as
typed declarations. And the constraint body becomes ordinary Python: a set
literal instead of rangelist, a chained comparison, if instead of
vsc.if_then, for instead of vsc.foreach. The constraints themselves
are identical.
Declaring data
The data vocabulary is small:
| You want | Write |
|---|---|
| An unsigned or signed field of N bits (1–64) | a: vdc.u8, b: vdc.s16 |
| A wider field, or a width with no alias | w: vdc.bitv = vdc.rand(width=128) |
| A random field | = vdc.rand() |
| A non-random field (an input to the constraints) | = vdc.field() |
| A fixed-size or variable-size array | list[vdc.u8] = vdc.rand(size=8) or vdc.rand(max_size=16) |
| An enumerated field | kind: Kind = vdc.rand(), where Kind is a Python Enum or IntEnum |
| A nested random object | hdr: Header = vdc.rand(), where Header is another RandClass |
| A random-cyclic field | = vdc.randc() |
Whether a field is random is stated in the declaration, not inferred from
which vsc type object happened to be assigned in __init__. And after a
solve, field values are plain Python values: txn.addr is an int, so
there’s no .get_val().
Why it’s faster: once per class, not once per object
A classic @vsc.randobj object learns what it is when it’s built. PyVSC
scans the instance with dir() (twice) to find its fields, then runs
every constraint method so operator overloading can record the expressions
they build. Only then can it build a model to hand the solver. The common
verification loop (create an item, randomize it, send it, drop it, repeat)
pays that whole cost on every iteration, for structure that never changes.
The dataclass front-end does that work once per class:
- the field layout comes from the class’s annotations
- each constraint body is parsed when the class is defined, into a form that doesn’t depend on any particular object
- the first
randomize()builds the solve model and caches it on the class. Every later object, new or not, binds its values into that model and goes straight to the solver.
Classic PyVSC could only reuse its model when you re-randomized the same object. The dataclass front-end reuses it for every object of the class, and that’s where the speed comes from.
None of this means classic PyVSC was badly designed. It’s Python’s default behavior. Any Python object can gain attributes at any time, so classic PyVSC couldn’t assume two objects of one class had the same random fields, and it checked each one. That flexibility costs time on every object, and verification models almost never use it.
The dataclass front-end gives that flexibility up. A class’s random model is
fixed by its declaration. You can still add attributes to an object, but
they won’t be randomized or constrained. If a model really does depend on
per-object structure, keep it on @vsc.randobj. The two styles can be used
in the same program.
Constraints that mean what they say
What operator overloading does to Python syntax
Classic PyVSC builds constraints by overloading Python’s operators. That
works for <, &, + and the rest, but Python doesn’t let a library
overload and, or, not, if or chained comparisons. Each of those asks
the expression whether it’s “true”, and a classic PyVSC expression always
answers yes, so the parts Python short-circuits or branches around get
handled differently from what you meant.
The table below shows what that does in practice. Each construct was randomized 200 times in classic PyVSC (both 0.9.5 and today’s 1.1.0 behave the same) and in the dataclass front-end:
| You write | Classic (run, with operator overloading) | Dataclass (parsed) |
|---|---|---|
0x1000 <= a < 0x2000 as its own line |
Works. Both halves end up as separate constraints. | Works |
(10 <= a < 20) | (a == 5) |
10 <= a becomes a separate, always-on constraint, so a == 5 is never drawn |
Works; a == 5 is drawn |
if w == 0: s < 4 |
w == 0 becomes a constraint, so w is always 0 |
An implication; w takes both values |
not (a == 0) |
a is always 0 |
a is never 0 |
a > 3 & a < 6 |
Nearly every draw is outside 4–5 | Error when the class is defined (see below) |
sum(l) == 100 |
Silently ignored; 200 of 200 draws violate it | Works |
The first row is the trap. Chained comparisons work when they stand alone, so
people learn that they work. The same expression inside an |, or a Python
if in place of vsc.if_then, adds a constraint you didn’t write and gives
no warning. You only notice if you go looking at the distribution.
Parse, don’t execute
The dataclass front-end doesn’t run a constraint method at all. When the
class is defined, it reads the method’s source, parses it with Python’s own
ast module, and translates the syntax tree into constraints. Since the
code is never run, there’s nothing for Python to short-circuit. A chained
comparison is a single Compare node with two operators. An if is an
If node with a condition and a body. not is a UnaryOp. Each one
translates to what it means.
Python users already trust a library that reads their code instead of just
running it. pytest has rewritten assert statements for years so that a
failing assert a == b can show you both values. Zuspec’s
modeling layer is built on the same principle, and its design was the
reference for this front-end.
What you can write
Here’s a single class that uses much of the constraint grammar:
from enum import IntEnum
import vsc
import vsc.dc as vdc
vsc.set_solver_backend("dv-solve")
MAX_BEATS = 16
class Kind(IntEnum):
READ = 0
WRITE = 1
ATOMIC = 2
@vdc.dataclass
class Burst(vdc.RandClass):
kind: Kind = vdc.rand()
addr: vdc.u32 = vdc.rand()
beats: list[vdc.u8] = vdc.rand(max_size=MAX_BEATS)
last: vdc.u8 = vdc.rand()
prio: vdc.u3 = vdc.rand()
@vdc.constraint
def shape(self):
1 <= len(self.beats) <= MAX_BEATS
match self.kind:
case Kind.ATOMIC:
len(self.beats) == 1
case Kind.READ:
len(self.beats) in {1, 4, 8, 16}
0x1000 <= self.addr < 0x2000
self.addr[1:0] == 0
@vdc.constraint
def payload(self):
sum(self.beats) <= 1000
all(b != 0xFF for b in self.beats)
self.last == (self.addr[7:0] if self.kind == Kind.WRITE else 0)
@vdc.constraint
def weights(self):
vdc.dist(self.kind, [vdc.weight(Kind.READ, 60),
vdc.weight(Kind.WRITE, 30),
vdc.weight(Kind.ATOMIC, 10)])
vdc.soft(self.prio < 4)
Over 2,000 draws on dv-solve, kind comes out 61% / 29% / 9% against
weights of 60 / 30 / 10, and every draw satisfies every constraint. The
grammar, in summary:
| Construct | Example |
|---|---|
| Comparisons, including chains | 0 < a < 50, lo <= a < hi |
| Boolean logic | and, or, not |
| Conditionals | if / elif / else (an if with no else is an implication), x if c else y, match on enum and constant values |
| Set membership | x in {1, 2, 4}, x in [1, 2, (10, 20)], x in range(lo, hi), x in self.arr |
| Arrays | for x in self.arr, len(self.arr), sum(self.arr), all(...) / any(...), self.arr[self.idx] |
| Bits and arithmetic | a[7:4], & | ^ ~ << >>, //, %, ** with a constant exponent, abs, scalar min / max |
| Python values | module constants, class constants, closure variables and enum members, read when the class is defined |
| SystemVerilog features | vdc.soft, vdc.dist / vdc.weight, vdc.unique, vdc.solve_order |
sum() works now. In classic PyVSC it was silently ignored, and you had to
know to write self.l.sum instead.
Ambiguities: pick one meaning and say which
Python and SystemVerilog sometimes give the same text different meanings. A front-end that runs Python code inherits whichever meaning Python’s operators happen to produce. A front-end that parses the code has to choose, so this one chooses on purpose and documents the choice:
| Construct | Python says | SystemVerilog says | The dataclass front-end |
|---|---|---|---|
x in range(lo, hi) |
hi excluded |
(inside {[lo:hi]} includes hi) |
Python’s meaning. vdc.r[lo:hi] and (lo, hi) pairs give SV’s inclusive range. |
a / b |
true division, a float | integer division | Rejected. Write //. |
~a on an 8-bit field |
-a - 1, an unbounded integer |
invert the bits at the expression’s width | SV’s meaning: invert the bits at the expression’s width. Write not for logical negation. |
a[7:4] |
a slice; empty, since 7 > 4 | bits 7 down to 4 | SV’s meaning: a part-select. Slicing an array is rejected. |
a > 3 & a < 6 |
a > (3 & a) < 6, a chain |
(a > 3) & (a < 6) |
Rejected, with a message explaining the precedence. |
| A module constant | read every time the code runs | — | Read once, when the class is defined. |
The last row is the one most likely to catch someone porting from classic
PyVSC. A classic constraint method runs on every randomization, so it can
read a global that changes between calls. A dataclass constraint is read
once. A value that changes per call belongs in a non-random field
(vdc.field()) or in a randomize_with block.
Errors when the class is defined, with a file and line
When the parser meets something it can’t translate, it doesn’t guess. It raises an error at import, when the class is defined, naming the constraint, the file and the line (shortened to the file name here):
constraint 'c' (txn.py:11): integer constraints have no true division; use '//'
constraint 'c' (txn.py:10): '&' binds tighter than '>', so this is a chained
comparison; write (x > 3) and (x < 6), or parenthesize each comparison: (x > 3) & (x < 6)
constraint 'c' (txn.py:11): local variables are not supported in a constraint;
write the expression where it is used
The same applies when there’s no source to read. A class defined in a REPL,
with exec, or with python -c can’t be parsed, and you get an error
saying so instead of a class with no constraints.
Today the parser rejects local variables, min / max over an array,
array slices, while, list, set and dict comprehensions (a generator inside
all() / any() is the one exception), lambdas, is, and calls to your
own Python functions. Each one is a clear error on the line where it appears.
That’s the main improvement over the classic table above: when something
isn’t supported, you find out right away, before anything is randomized.
Inline constraints are parsed too
randomize_with blocks use the same grammar. A sub-expression that doesn’t
mention the object being randomized is ordinary Python, evaluated in the
caller’s scope on every call. That’s how you pass in per-call values:
def next_access(self, acc, i, read_only):
with acc.randomize_with() as it:
self.base + 4 * i <= it.addr < self.base + 4 * i + 0x100
it.size in self.allowed
if read_only:
not it.write
return acc
read_only is a Python bool that’s only known at call time, and the
if read_only: is decided in Python. Inside a class constraint, if on a
random field becomes an implication instead.
Reusable constraints: generic constraints
Procedural code reuses logic through functions. Constraints have had only a
partial equivalent: dynamic constraints (in PSS, and vsc.dynamic_constraint
in classic PyVSC) give a block a name you can turn on, but they take no
parameters, so the same windows, alignments and mode rules end up written
again in every class that needs them. PSS 3.1 addressed this with generic
constraints (§13.1.1): named constraint blocks that apply only where
another constraint refers to them, and that can take parameters. The
dataclass front-end follows the PSS semantics:
@vdc.dataclass
class Packet(vdc.RandClass):
length: vdc.u16 = vdc.rand()
addr: vdc.u32 = vdc.rand()
@vdc.constraint.generic
def small(self):
self.length <= 64
@vdc.constraint.generic
def jumbo(self):
self.length > 1500
@vdc.constraint
def in_window(self, lo, hi): # has parameters, so it's generic
lo <= self.addr < hi
@vdc.constraint
def aligned(self, n): # a single return: a value generic
return (self.addr // n) * n
@vdc.constraint
def legal(self):
0 < self.length <= 9000
self.small() or self.jumbo()
self.in_window(0x1000, 0x2000) or self.in_window(0x8000, 0x9000)
self.addr == self.aligned(64)
small and jumbo hold only where legal refers to them. Here that’s an
or, so every packet is either small or jumbo and the solver decides which.
in_window is a reusable relation with parameters. The arguments can be
constants or other random fields. aligned is a value generic. It acts
like a function call that stays relational inside the solver, and after a
solve, pkt.aligned(64) also works as an ordinary method that computes the
value from the fields.
A generic can also be applied to a single solve:
with pkt.randomize_with() as it:
it.small()
This only works because constraints are parsed. Once the library reads the method body instead of running it, a call to another constraint is just one more node in the tree to translate. References are checked when the class is defined too. Referring to an unknown generic, creating a reference cycle, or referring to a fixed constraint (which already always holds) is an error, raised when the class is defined.
One caveat: a() or b() is a plain disjunction, not a coin flip. In the
example above, the jumbo range has about 7,500 legal lengths and the small
range has 64, and in our runs 296 to 299 of every 300 packets were jumbo. If
you want an even split, add a vdc.dist over a selector field.
Performance
These numbers come from PyVSC’s committed benchmark suite: seven workloads,
CPU time per randomize() call, with each answer checked against an
independent oracle before and after timing. Both front-ends run on dv-solve,
so the only difference is the front-end. The suite’s score is a geometric
mean weighted by workload family (scalar, distribution, composite, arrays),
so the four scalar workloads count together as one family. There are two
patterns. Cold
builds a new object and randomizes it once, which is what sequence items and
generators do. Warm re-randomizes the same object.
| Speed-up (family-weighted geometric mean) | Cold | Warm |
|---|---|---|
| Front-end alone: classic → dataclass, both on dv-solve | 15.0x | 1.17x |
| Whole stack: classic + Boolector → dataclass + dv-solve | 41.0x | 54.1x |
| Dataclass + dv-solve vs. stock PyVSC 0.9.5 | 98.9x | 124x |
The front-end result is what “once per class” predicts. When objects are new, classic PyVSC rebuilds everything for each one and the dataclass front-end rebuilds nothing, so it’s 15x faster. When the same object is re-randomized, classic PyVSC was already reusing its model, so there’s little left to gain, and the result is 1.17x. If your code randomizes one long-lived object in a loop, the solver change from the last post is the one that helps you. If it creates objects, this one does.
This suite also runs constrainedrandom where it can express the workload, and it isn’t a clean sweep. constrainedrandom is faster per call than the dataclass front-end on every warm workload it can express, and on four of the six cold ones. On constrainedrandom’s own benchmark suite (the first post), the new stack comes out ahead. Which one wins depends on the shape of the problem, so more real workloads would help here.
riscv-dv
The first post showed riscv-dv’s Python generator going from 128 to 13,569
instructions a second. The largest single step, 474 to 6,553 (13.8x), was
porting pygen’s instruction classes to the dataclass front-end. That run
had zero constraint violations under the benchmark’s checker, and every
generated program assembled. It’s one test (riscv_arithmetic_basic_test,
rv32imc), and the port isn’t upstream, so I’ll leave the details for a
future post.
Porting a class
Most of a port is mechanical:
| Classic | Dataclass |
|---|---|
@vsc.randobj |
@vdc.dataclass on a vdc.RandClass subclass |
self.a = vsc.rand_bit_t(8) in __init__ |
a: vdc.u8 = vdc.rand() |
self.a = vsc.bit_t(8) |
a: vdc.u8 = vdc.field() |
vsc.rand_list_t(vsc.uint8_t(), 4) |
list[vdc.u8] = vdc.rand(size=4) |
with vsc.if_then(c): / vsc.else_then |
if c: / else: |
with vsc.foreach(self.l) as x: |
for x in self.l: |
a.inside(vsc.rangelist(1, (4, 8))) |
a in [1, (4, 8)] |
vsc.dynamic_constraint |
vdc.constraint.generic |
with obj.randomize_with() as it: |
unchanged |
What isn’t mechanical is anything that relied on a constraint method being
run: reading a global that changes between calls, calling a Python helper
inside a constraint, or using ~ to mean “not”. The parser flags helper
calls when the class is defined. For globals and ~, check the code
yourself.
Start with the classes your testbench creates most often, usually sequence items. That’s where the cold-path speed-up applies. A dataclass object and a classic object can be used side by side, so you can port one class at a time.
Where it falls short
- It’s a defined subset of Python, not all of Python. Local variables,
min/maxover arrays, comprehensions (other than a generator insideall()/any()) and calls to your own functions are rejected today. You get a clear error, but you still have to rewrite the constraint. - Static type checking isn’t there yet. The width aliases (
vdc.u8and friends) are generated at import, so mypy can’t see them. - The per-class model is shared. It isn’t safe to randomize objects of
the same class from multiple threads at once, or to randomize one object
from inside another’s
pre_randomize/post_randomizewhen both are of the same class. - It doesn’t make every program faster. Re-randomizing one object gets about 1.2x from the front-end. The big gain is in creating new objects.
Next: coverage
Constraints are half of PyVSC. The other half is functional coverage, and it’s the half many users chose PyVSC for. The next post applies the same approach to coverage: covergroups as typed classes, coverpoints whose bins come from the field types, and crosses, with the same IEEE 1800 semantics and the same UCIS output underneath.
Closing
The approach in this post wasn’t invented for PyVSC. Dataclasses are a standard part of Python. Reading code instead of running it is what pytest has done for years. Generic constraints come from the PSS standard, and the overall design comes from Zuspec. Putting them together in an open library means any Python DV project can use the same approach. Any Python DSL that describes intent, whether stimulus, coverage, configuration or hardware, can declare structure with types and parse behavior instead of running it.
Some specific requests:
- Port one sequence item and tell us which constructs the parser rejected. That list decides which parts of the grammar we extend next.
- Try generic constraints on a constraint pattern you’ve copied across classes, and tell us whether the PSS form covers it.
- Tell us about any constraint whose parsed meaning surprised you. The ambiguity table above is our current answer, and we’d rather fix the rule than have people work around it.
Issues and examples go to fvutils/pyvsc. Every construct someone reports becomes a test, and every rule we settle becomes one less thing the next Python DV library has to work out on its own.