PyVSC Meets Dataclasses

18 min read

PyVSC meets dataclasses: structure declared once per class, constraints parsed, not executed. Two code cards side by side. Classic @vsc.randobj: fields built in __init__ with self.size = vsc.rand_bit_t(4), and a constraint written with vsc.if_then. Dataclass, import vsc.dc as vdc: size: vdc.u4 = vdc.rand() declared on the class, and a constraint written with a plain Python if not self.w. A dark card shows the payoff: 15 times faster creating objects with the same solver and a new front-end, and errors reported with file and line when the class is defined, not silently wrong answers.

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

Where the structure work happens. Classic @vsc.randobj, for every new object: build fields in __init__, scan the object with dir() twice, run the constraint methods to record expressions, build a solve model for that object, then solve with dv-solve. Basic workload: 140 microseconds per new object. Dataclass @vdc.dataclass, once per class: read fields from the annotations, parse the constraints with ast at import, build the solve model and cache it on the class. Then, for every new object: bind values into the cached model and solve with dv-solve. Basic workload: 11.9 microseconds per new 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

Parse, don't execute: the constraint method's source is read once, when the class is defined, and never runs. Three boxes left to right. Source, txn.py, lines 9 to 13: @vdc.constraint, def c(self), 0x1000 <= self.addr < 0x2000, if not self.write, self.size <= 4. Python ast: Compare with two operators over addr, 0x1000 and 0x2000; If on UnaryOp Not of write, containing Compare size <= 4. Constraint model, cached on the class: addr >= 0x1000, addr < 0x2000, not write implies size <= 4. Below, in red: or, at import, if it can't be translated: constraint 'c' (txn.py:11): integer constraints have no true division; use '//'.

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

New objects are where it pays. CPU microseconds per randomize() call, both front-ends on dv-solve, one scale for both panels. Cold, a new object each call, 15 times faster: basic 140 classic vs 11.9 dataclass; simple 129 vs 11.2; alu 232 vs 13.7; wide64 161 vs 11.4; dist_weighted 163 vs 19.9; nested2 215 vs 14.4; arr8 289 vs 9.5. Warm, the same object re-randomized, 1.17 times faster: basic 8.5 vs 7.6; simple 8.2 vs 7.0; alu 9.8 vs 8.9; wide64 8.3 vs 7.1; dist_weighted 15.5 vs 14.8; nested2 11.6 vs 9.2; arr8 6.4 vs 5.2.

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 / max over arrays, comprehensions (other than a generator inside all() / 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.u8 and 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_randomize when 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.

The views and opinions expressed above are solely those of the author and do not represent those of my employer or any other party.