Saturday, May 9, 2020

Python Verification Stimulus and Coverage: Constraints



Over the past few blog posts, we've looked at:
In this post, we will look at how to model constraints in Python using the PyVSC library. Several libraries that I'm familiar with (mostly C++) provide a way to embed constraints using an embedded domains-specific language. I've felt some discontent with the way that this is done because it's fairly different from what we're familiar with in languages that treat constraints as first-class language constructs.  Specifically, in C++, constraints are often modeled as lists of comma-separated expressions, and not as blocks of statements the way a first-class language would model them. This means that constraints that would normally be grouped in a block need to be split up into multiple blocks. And, when constraints are combined, they become difficult to read. One of my motivations in examining Python's support for embedded domain-specific languages was to see if Python offered a better approach. My feeling is that it most definitely does, and I hope you agree.

The examples in this post come from the PyVSC documentation. The latest version is always directly available on readthedocs.io.

Constraint Blocks

Constraint blocks in PyVSC are special class methods that are decorated with a constraint Python decorator. The presence of the constraint decorator causes PyVSC to see treat these methods, not as a regular Python method, but as an element to construct a constraint expression model. Here's a simple example:
@vsc.randobj
 class my_base_s(object):

     def __init__(self):
         self.a = vsc.rand_bit_t(8)
         self.b = vsc.rand_bit_t(8)

     @vsc.constraint
     def ab_c(self):
        self.a < self.b
PyVSC calls the methods marked as constraints once during the class-elaboration process and creates an internal model of the constraint statements captured in these constraint blocks. It also replaces the method with a class field that has special properties. More on that later.
By overloading operators (such as "<"), PyVSC is able to get an expression tree that can be given to the Boolector SMT solver that does all the heavy-lifting of constraint solving for PyVSC. 

As in SystemVerilog, constraint blocks are considered "virtual". In other words, having a constraint in a sub-class with the same name as a constraint in a base class causes the base-class constraint to be replaced with the sub-class constraint. 
@vsc.randobj
class my_base_s(object):

    def __init__(self):
        self.a = vsc.rand_bit_t(8)
        self.b = vsc.rand_bit_t(8)
        self.c = vsc.rand_bit_t(8)
        self.d = vsc.rand_bit_t(8)

    @vsc.constraint
    def ab_c(self):
       self.a < self.b

@vsc.randobj
class my_ext_s(my_base_s):

    def __init__(self):
        super().__init__()
        self.a = vsc.rand_bit_t(8)
        self.b = vsc.rand_bit_t(8)
        self.c = vsc.rand_bit_t(8)
        self.d = vsc.rand_bit_t(8)

    @vsc.constraint
    def ab_c(self):
       self.a > self.b
In the example above, the ab_c constraint in the sub-class my_ext_s overrides the ab_c constraint in the base class my_base_s. The effect is that instances of my_ext_s will enforce the relationship (a > b) instead of the base-class relationship (b < a).

Constraint Expressions

PyVSC supports specifying constraints using the familiar set of expression constructs we're all used to using: <, >, ==, !=, etc. The one set of operators that do not behave as you would expect in Python are the logical ones: and, or, etc. Python doesn't allow overriding these logical operators, unlike other operators. Consequently, we use bit-wise operators instead. 

with my_i.randomize_with() as it:
    it.a_small() | it.a_large()
In the example above, a_small and a_large are boolean constraints. Normally, in Python, we would combine these using or. In a PyVSC constraint, we use | instead.

There are two special expressions to be aware of that can be used in constraints: the 'in' expression and part select. Both of these are frequently used in Python, and both can be used in constraints with PyVSC.
@vsc.randobj
class my_s(object):

    def __init__(self):
        self.a = vsc.rand_bit_t(8)
        self.b = vsc.rand_bit_t(8)
        self.c = vsc.rand_bit_t(8)
        self.d = vsc.rand_bit_t(8)

    @vsc.constraint
    def ab_c(self):

       self.a in vsc.rangelist(1, 2, [4,8])
       self.c != 0
       self.d != 0

       self.c < self.d
       self.b in vsc.rangelist([self.c,self.d])
The 'in' expression operates on a PyVSC-type field on the left-hand side and a PyVSC rangelist on the right-hand side. A rangelist can contain individual values and ranges of values. A rangelist can contain both constant expressions, such as the literals shown in the first rangelist, and expressions involving other PyVSC variables.

Python provides array-slicing expressions, and PyVSC reuses these to implement bit-slicing of scalar fields.
@vsc.randobj
class my_s(object):

    def __init__(self):
        self.a = vsc.rand_bit_t(32)
        self.b = vsc.rand_bit_t(32)

    @vsc.constraint
    def ab_c(self):

        self.a[7:3] != 0
        self.a[4] != 0
In this example, the part-select operator is used to ensure that certain bits and bit-ranges within the 'a' field are non-zero.

Constraint Statements

Statements pose two additional challenges when developing an embedded domain-specific language: often keywords are already in use by the host language (and cannot be overloaded), and languages often don't provide easy way to overload statement blocks. Here, Python definitely has some benefits. Now, not surprisingly, Python doesn't allow overloading of built-in statements.But, it does provide an easy-to-use way of creating scopes that can be used to capture block statements. 
PyVSC provides three constraint statements. If/else and Implies are block statements, while the 'unique' constraint is not.

Python's mechanism for introducing a block of statements is the 'with' statement. When combined with a user-defined object, the 'with' statement allows a library writer to comprehend statement blocks. Here is an example of an if/else constraint:
@vsc.randobj
class my_s(object):

    def __init__(self):
        self.a = vsc.rand_bit_t(8)
        self.b = vsc.rand_bit_t(8)
        self.c = vsc.rand_bit_t(8)
        self.d = vsc.rand_bit_t(8)

    @vsc.constraint
    def ab_c(self):

        self.a == 5

        with vsc.if_then(self.a == 1):
            self.b == 1
        with vsc.else_if(self.a == 2):
            self.b == 2
The if_then class is used to form the 'if' branch of an if/else statement. Additional 'else-if' branches can be added on with the 'else_if' class. The final 'else' case is described with the 'else_then' class. 

The implies constraint is similar, since 'implies' applies to a constraint block:
class my_s(object):

    def __init__(self):
        super().__init__()
        self.a = vsc.rand_bit_t(8)
        self.b = vsc.rand_bit_t(8)
        self.c = vsc.rand_bit_t(8)

    @vsc.constraint
    def ab_c(self):

        with vsc.implies(self.a == 1):
            self.b == 1
Finally, the 'unique' constraint ensures that the specified terms have a unique value:
@vsc.rand_obj
class my_s(object):

    def __init__(self):
        self.a = vsc.rand_bit_t(32)
        self.b = vsc.rand_bit_t(32)
        self.c = vsc.rand_bit_t(32)
        self.d = vsc.rand_bit_t(32)

    @vsc.constraint
    def ab_c(self):
        vsc.unique(self.a, self.b, self.c, self.d)

Customizing Declared Constraints

In general, we want to specify most of our constraints as part of the class hierarchy. However, there are quite a few cases where we need to customize things a bit when we randomize the class. PyVSC provides two capabilities to enable this: in-line constraints, and constraint mode.

In-line constraints allow us to add extra constraints that are specific to a randomization. To use this capability, use the 'randomize_with' call instead the the 'randomize' call as shown below:
@vsc.randobj
class my_base_s(object):

    def __init__(self):
        self.a = vsc.rand_bit_t(8)
        self.b = vsc.rand_bit_t(8)

    @vsc.constraint
    def ab_c(self):
       self.a < self.b

item = my_base_s()
for i in range(10):
   with item.randomize_with() as it:
     it.a == i
The example above ensures that 'a' increments 0..9 across 10 randomization calls. Note that the fields of the class can either be referenced via the handle that is being randomized (item) or via a special variable created by the 'with' block.

Constraint-mode allows entire constraint blocks to be turned on and off. Error-injection is one use-case for this feature. 

 @vsc.randobj
 class my_item(object):

     def __init__(self):
         self.a = vsc.rand_bit_t(8)
         self.b = vsc.rand_bit_t(8)

     @vsc.constraint
     def valid_ab_c(self):
        self.a < self.b

item = my_item()
# Always generate valid values
for i in range(10):
   item.randomize()

item.valid_ab_c.constraint_mode(False)

# Allow invalid values
for i in range(10):
   item.randomize()
As shown in the example above, the first 10 randomizations have all constraints enabled. The next 10 randomizations have the 'valid_ab_c' constraint block disabled, allowing invalid values to be produced.

Next Steps

Over the past few posts, we've looked at the key features and capabilities of PyVSC, a Python library for modeling constraints and functional coverage for verification. I would encourage you to use PyVSC, report bugs, and suggest enhancements. PyVSC is, of course, an open-source project, so contributions in the form of pull requests for bug fixes, documentation updates, and enhancements are always welcome as well.

For the last few months (pretty close to a year, actually) I've been really focused on hardware-centric functional verification infrastructure, with a focus on Python. I'm thinking it's time to get back to focusing a bit more on firmware development and embedded-software verification. There have been some interesting developments in this space, recently, which have the potential to enable significant changes in the way we develop and verify firmware and other low-level software. Look for more on that soon. Until then, stay safe and keep learning!
 
Disclaimer
The views and opinions expressed above are solely those of the author and do not represent those of my employer or any other party.

Saturday, April 25, 2020

Python Verification: Working with Coverage Data



Before jumping into this week's post, I wanted to offer a bit of an apology to my readers. I recently realized that, despite being a Google property, Blogger only notifies authors of comments for moderation if the author has specifically registered a 'moderator' email with the site. So, apologies to those of you that have commented on posts directly on the Blogger site and watched those comments hang out in limbo indefinitely. I should now receive notifications of new comments.

In my last post, we looked at modeling and sampling functional coverage in Python using the Python Verification Stimulus and Coverage (PyVSC). In that post, I showed how a textual coverage report could be generated to the console by calling an API. But, there is much more that we want to do with functional coverage data. The key question is: how do we store and manipulate it?

Storing Coverage Data

There are two big motivations for storing coverage data. The first is that we often wish to aggregate coverage across a large number of tool runs. In order to do that, we need a way to persist the coverage data collected by each individual tool run. The second is that we want to run analysis on the collected and aggregated coverage data. We want a way to browse through the data interactively, and create nice-looking reports and charts.

Standard Coverage Models

Storing coverage data isn't much different than storing any other data. The first big question to answer is whether there is a standard way of of representing the data, or whether we need to invent one. While I've certainly had fun in the past inventing new formats for representing and storing data, considering all the requirements and designing in appropriate features to represent all the key features of a given type of data is a time consuming problem. Certainly something that should be undertaken as a last resort.

The good news is that there are several existing formats for representing coverage. The bad news is that the vast majority are focused on representing code coverage data (eg Coburtura), not functional coverage data. That said, there is one industry standard for representing functional coverage and code coverage: Accellera Unified Coverage Interoperability Standard

While the  UCIS defines several things, it doesn't define a standard database format. That said, what it does define is very useful. Specifically it defines:
  • A data model for representing functional coverage, code coverage, and assertion coverage
  • A C-style API for accessing and modifying this data model
  • An XML interchange format to assist in moving data from one database implementation to another. In a pinch, the XML interchange format can even be used as a very simplistic database.
Design is tough, so it's almost always most efficient to make use of the work of a committee of smart and capable people instead of starting over. UCIS is certainly not perfect. There are some "bugs" in the spec, and some internal inconsistencies. That said, it's far better than starting with a blank sheet of paper. The next challenge was adapting UCIS to Python.

PyUCIS Library

Much of my work recently has been in Python, so I wanted a way to work with the UCIS data model in Python. The PyUCIS library is a pure-Python library for working with the UCIS data model. A block diagram of the architecture is shown below. 


Front-End API

The core of the PyUCIS library is an implementation of the UCIS API. Remember that the API defined by the UCIS is a C-style API, while Python is much more object-oriented. I initially decided to implement just an object-oriented version of the UCIS API, but then realized that reusing existing code snippets written in C would be much harder without an implementation of the C-style API. Fortunately, building a C-style compatibility API on top of the object-oriented one was fairly straightforward.

Backend

The PyUCIS library uses a back-end to store the data being accessed via the front-end API. The PyUCIS library currently implements two back ends: an in-memory back-end, and an interface to existing C-API implementations of the UCIS API.

The in-memory back-end stores coverage data in Python data structures. While it's not possible to persist the data model directly, the contents can be saved to and restored from the XML interchange format specified by the UCIS.

The C-library back-end uses the Python ctypes library to call the UCIS C API as implemented by a tool-specific shared library. This allows PyUCIS to access data in databases implemented by tools that support UCIS.

While PyUCIS doesn't currently implement its own native database for storing coverage data, it's likely that it will in the future. Fortunately, Python provides an SQLite database as part of the core interpreter installation. Stay tuned here.

Built-in Apps

The final part of the PyUCIS library are a set of built-in apps. These are used to perform simple manipulations on the coverage data and create outputs. Currently, PyUCIS only contains one built-in app: reading and writing the UCIS XML interchange format. That said, there are a couple planned on the roadmap:
  • A merge app to combine data from multiple UCIS data models
  • A report app to produce a textual or HTML coverage report

PyUCIS Apps

The top layer of the PyUCIS architecture diagram are external applications that use the PyUCIS API. At the moment, there is only one and it's a proof of concept. PyUCIS Viewer is a Python Qt5-based GUI for viewing coverage data.


While the viewer is certainly primitive (and incomplete) at the moment, hopefully this provides some ideas for what can be done with the data accessed via the PyUCIS API.

Next Steps

PyUCIS is a pretty early-stage tool. I'm using it to save coverage data from the PyVSC library, and to produce some simple text coverage reports, but there's still quite a bit to do. As always, if you'd like to contribute to this or other projects, I'd welcome the help. 
In the next post, I'll return the Python Verification Stimulus and Coverage (PyVSC) library to look at modeling constrained-random stimulus. Until then, stay safe!

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

Saturday, April 11, 2020

Python Verification Stimulus and Coverage: Functional Coverage



In my last two posts (here and here), I've been talking about modeling random stimulus, constraints, and functional coverage in Python. After looking at the fundamentals of capturing the specifics of data types such that they can be used for hardware verification last week, lets look at using those data types for modeling functional coverage.

As I mentioned last week, I'm using this series of blog posts as a guide and as motivation to document the PyVSC package that implements the random stimulus and coverage that I'm describing. You can find documentation on the functional-coverage features in the Coverage chapter of the documentation on readthedocs.org.

Covergroups

SystemVerilog, as well as several other verification languages, groups functional coverage elements in a construct called a covergroup. A covergroup is a type (like a class) that can be instanced multiple times, and whose instances maintain a relationship back to the type. 

A PyVSC covergroup is a Python class with a special covergroup decorator. 

 @vsc.covergroup
 class my_covergroup(object):

     def __init__(self):
         self.with_sample(
             a=bit_t(4)
             )
         self.cp1 = vsc.coverpoint(self.a, bins={
             "a" : vsc.bin(1, 2, 4),
             "b" : vsc.bin(8, [12,15])
             })

my_cg_1 = my_covergroup()
my_cg_2 = my_covergroup()

The covergroup decorator ensures that core methods and attributes are present in the covergroup class without requiring the class to derive from a specific base class. The decorator also ensures that certain introspection and class-construction steps are performed after the user's constructor code runs and before returning to the caller. Fortunately, all these details are hidden so a PyVSC covergroup behaves like any other Python class. Creating an instance of a covergroup is as simple as creating an instance of the class, as shown above.

Coverpoints, Bins, and Crosses

Once we have a covergroup type defined, we need to define the values, value ranges, and combinations of values we wish to observe during verification. These features are captured by coverpoints, bins, and coverpoint crosses.

All of these features are captured by calling PyVSC methods.
@vsc.covergroup
 class my_covergroup(object):

     def __init__(self, a : callable):

         self.cp1 = vsc.coverpoint(a, bins={
             "a" : vsc.bin(1, 2, 4),
             "b" : vsc.bin(8, [12,15])
             })
For example, above we create a single coverpoint with two bins. The first bin ("a") will be considered covered if the coverpoint sees a value of 1, 2, or 4. The second will be considered covered if the coverpoint sees a value of 8, or 12..15.
@vsc.covergroup
 class my_covergroup(object):

     def __init__(self, a : callable):

         self.cp1 = vsc.coverpoint(a, bins={
             "a" : vsc.bin_array([], 1, 2, 4),
             "b" : vsc.bin_array([4], [8,16])
             })
Creating arrays of bins is also important, of course. In the example above, we create two arrays of bins. The bin array labeled "a" will consist of three individual bins that, respectively, monitor the values 1, 2, and 4. The second bin array ("b") will consist of four individual bins that monitor the values 8..16. In this case, each bin will monitor two coverpoint values (8..9, 9..10, etc). If you're familiar with SystemVerilog, these features -- and even the syntax -- should look very familiar.

One question, of course, is how we associate a name with our coverpoints. If you've used verification frameworks that are embedded in C++ (eg SystemC, or SystemC SCV), you're probably used to specifying strings to name each element. One nice aspect of Python is that we're able to introspect the names of the variables to which features like coverpoints are assigned. So, part of the "magic" that our coverpoint decorator applies is to determine the names of coverpoints based on the class attributes to which coverpoints are assigned.

We don't just care about individual variable values, of course. We often need to ensure that combinations of variable values are exercised as part of verification. That's where coverpoint crosses come into play.
@vsc.covergroup
class my_covergroup(object):

    def __init__(self):
        self.with_sample(
            a=bit_t(4),
            b=bit_t(4)
        )
        self.cp1 = vsc.coverpoint(self.a, bins={
            "a" : vsc.bin_array([], [1,15])
            })
        self.cp2 = vsc.coverpoint(self.b, bins={
            "a" : vsc.bin_array([], [1,15])
            })

        self.cp1X2 = vsc.cross([self.cp1, self.cp2])
Using the cross method, we can create a cross between two existing coverpoints. In the example above, we have created a cross that has 225 cross bins.

Sampling Coverage Data

One topic that we haven't yet looked at is how we will get the data to be sampled from the testbench to the covergroup for the PyVSC library to sample. PyVSC, like SystemVerilog, provides a sample method on the covergroup that is called by the testbench to cause the coverpoints and crosses to sample data. PyVSC provides two ways to channel data from the testbench to the covergroup to be sampled when the sample method is called.
@vsc.covergroup
class my_covergroup(object):

    def __init__(self):
        self.with_sample(
            a=bit_t(4)
            )
        self.cp1 = vsc.coverpoint(self.a, bins={
            "a" : vsc.bin(1, 2, 4),
            "b" : vsc.bin(8, [12,15])
            })

cg = my_covergroup()
cg.sample(1)
cg.sample(12)
The first method for passing data to sample is to pass that data via the sample-method call. To use this method, your covergroup constructor must call the with_sample() method to specify the parameter names and types that will be accepted by the sample method. Note that the parameters need to be one of the PyVSC  types that we discussed last week. 

In the example above, the sample method will accept one parameter that is a 4-bit unsigned integer. Note that calling the with_sample() method will create attributes in the covergroup class that can be passed to coverpoint(s) as the variable to sample.

The second method for passing data for sampling to the covergroup is to specify the mapping when an instance of the covergroup class is created. 
@covergroup
 class my_covergroup(object):

     def __init__(self, a, b): # Need to use lambda for non-reference values
         super().__init__()

         self.cp1 = coverpoint(a,
             bins=dict(
                 a = bin_array([], [1,15])
             ))

         self.cp2 = coverpoint(b, bins=dict(
             b = bin_array([], [1,15])
             ))


 a = 0;
 b = 0;

 cg = my_covergroup(lambda:a, lambda:b)

 a=1
 b=1
 cg.sample() # Hit the first bin of cp1 and cp2

This approach uses Python lambda expressions to allow the covergroup to query the current value of variables when the sample method is called.

Reporting

Once we start collecting functional coverage data, we'll want to know which combinations have been covered and which have not been covered. PyVSC provides a couple of options for seeing what combinations have been covered and which have not. I'll cover a couple of the simple ones in this post, and we'll come back for the more-ambitious one in a future post.

The simplest approaches to querying coverage are via APIs and as text reports. Each instance of a covergroup class provides a get_coverage() method that returns the percentage of coverage goals that have been achieved. In other words, when no combinations have been covered, this method will return 0, and will return 100 when all have been achieved. 

PyVSC also provides several methods for creating a coverage report as a Python object, a string, and displaying the coverage report in the transcript. 

A textual coverage report looks something like this:

TYPE my_cg : 75.000000%
    CVP a_cp : 75.000000%
    Bins:
        a : 3
        a : 3
        a : 17
        a : 0
    INST my_cg : 75.000000%
        CVP a_cp : 75.000000%
        Bins:
            a : 3
            a : 3
            a : 1
            a : 0
    INST my_cg_1 : 100.000000%
        CVP a_cp : 100.000000%
        Bins:
            a : 3
            a : 3
            a : 3
            a : 6

The textual coverage report reports on each type of covergroup that is in use, and on every instance of that covergroup.

Coming up Next

If you're interested in investigating  PyVSC a bit more, please see the documentation on readthedocs.org. If you'd like to try out the library yourself, you can install it (Linux-only) for Python3 using pip:
% pip install pyvsc

The textual coverage report I showed above is interesting, but is a pretty simplistic way to look at coverage data. In my next post, I'll talk about how we can store our coverage data in a form that can be merged, reported, and displayed. 



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