Dynamically Creating Usable Python Objects - The Overlay Class

overlay_banner

One of the features of Python that we exploit in PYNQ is the loose typing system that allows to generate and modify types at runtime. BY doing this we can offer more Pythonic APIs generated in large part from user-supplied data than would be possible in a more rigid type system. In the world of PYNQ we have overlays which contain one or more pieces of IP - distinct pieces of the programmable logic which are interacted with through drivers. One of the main ways PYNQ provides for interacting with overlays is the Overlay class which gives access to IP using dynamically generated attributes. For example.

ol = pynq.Overlay('my_biststream.bit')
ol.gpio_btns.channel1.read()

While on the surface this code appears to be simple, underneath there is a lot of functionality contained in those two lines. For these lines to be functional the PYNQ runtime needs to:

  1. Parse some metadata to figure out what’s in my_bitstream.bit
  2. Know that the gpio_btns IP should have a GPIO driver attached to it
  3. Add an attribute to ol called gpio_btns with the appropriate driver

Step one of this process has already been covered by my colleague in a previous blog. so here I’m going to see what goes into steps two and three. Through the course of the blog I’m going to build up a simplified version of the Overlay class to give some intuition as to what’s going on under the hood and to offer ideas for other uses of these techniques.

If you want to explore the code directly there is a Binder version of this post where you can run all of the example code.

Our starting dictionary

To learn more about how the dictionaries are parsed from the meta-data I would recommend going through Rock’s blog on the topic. For this example we’re going to use a very stripped down version of the IP dictionary that only contains the name of the IP and the type of the IP.

ip_dict = {
    'gpio_btns': {'type': 'xilinx.com:ip:axi_gpio:2.0'},
    'my_custom_ip': {'type': 'xilinx.com:hls:my_hls_ip:1.0'}
}

This example consists of an AXI GPIO controller which we will want to bind a dedicated driver to and some form of custom IP that we will bind a default driver to. Our overlay class is going to have two attributes - one for each IP - which are instances of the respective drivers.

Picking a driver

When implementing drivers there is some basic functionality that is going to be common to all memory-mapped devices - namely reading and writing to the registers in the device. It makes sense to encapsulate this inside of a base class for all drivers. This base class also makes a good candidate for what driver we should use when there isn’t some more specific available. In PYNQ this is the DefaultIP class and for consistency I’ll use the same name for this example. The implementation doesn’t impact how the binding process happens so I’m going to leave it blank to avoid complicating the code snippets.

class DefaultIP:
    pass

We can now implement a sub-class for our GPIO device - again the implementation doesn’t impact how we go about binding drivers to devices.

class AxiGPIO(DefaultIP):
    pass

Next we need some way to add driver bindings to the ip_dict. If we had a dictionary of type to driver class we could write a simple function that would add a new entry into each sub dictionary with the driver class to use:

_driver_bindings = {}

def _bind_drivers(ip_dict):
    for k, v in ip_dict.items():
        if v['type'] in _driver_bindings:
            v['driver'] = _driver_bindings[v['type']]
        else:
            v['driver'] = DefaultIP

The next question is how we go about populating the _driver_bindings dictionary. There are two ways we explored when implementing this in PYNQ:

  1. Having a registration function
  2. Automatically registering classes derived from DefaulIP

While we ultimately decided on the second approach it’s worth looking at both and their relative merits.

Adding a registration function

This is most straightforward approach and relies on the writer of the driver calling a function to tell the runtime what IP it supports

def register_driver(driver_type, cls):
    _driver_bindings[driver_type] = cls

For our AxiGPIO class we would add the following register call underneath our driver definition

register_driver('xilinx.com:ip:axi_gpio:2.0', AxiGPIO)

The main downside to this approach is that all the registration data is contained within the runtime dictionary. There is no way for external code to determine what IP a driver supports other than by querying an internal data structure. This limits how other libraries can make use of this information.

Automatically registering subclasses

This alternative is far more involved from an implementation point of view but has the advantage of giving a much more declarative style of programming which matches other driver binding systems. The core concept is to create a metaclass for the DefaultIP class which keeps track of all subclasses that are created and then registering them if they have a specific property - we use the name bindto.
Implementing automatic registration requires changing our DefaultIP class to use the registration metaclass:

class RegisterIP(type):
    def __init__(cls, name, bases, attrs):
        if 'bindto' in attrs:
            _driver_bindings[attrs['bindto']] = cls
        super().__init__(name, bases, attrs)

class DefaultIP(metaclass=RegisterIP):
    pass

The effect is that we now add a bindto attribute in our AxiGPIO class and the registration happens when the driver class is defined:

class AxiGPIO(DefaultIP):
    bindto = 'xilinx.com:ip:axi_gpio:2.0'

As we’ve now tied the IP blocks that the driver supports to the driver class other systems can use this information without knowing about the internal registration dictionary. Any code can query the bindto property of a driver class independently of PYNQ.

Regardless of which method was used we can now bind drivers into our starting dictionary

_bind_drivers(ip_dict)

Creating the attributes

Now we know what driver each attribute should use we need to go about constructing them and adding them to the overlay class. For this we have three main options:

  1. Dynamically create a new subtype with the specified attributes
  2. Use setattr to add the attributes to an Overlay instance
  3. Implement __getattr__ to create the attributes as needed

We’ll continue with our previous example and use the same dictionary as a starting point augmented with the driver classes for each IP:

ip_dict = {
    'gpio_btns': {'type': 'xilinx.com:ip:axi_gpio:2.0', 'driver': AxiGPIO},
    'my_custom_ip': {'type': 'xilinx.com:hls:my_hls_ip:1.0', 'driver': DefaultIP}
}

While comparing approaches we need to consider both the complexity of the code required as well as how well it integrates with Python and IPython productivity tools - specifically the help system and tab-completion.

Creating a new subclass

In this instance we place all of our functionality in a new OverlayBase class and Overlay becomes a construction function that creates and instantiates a new class

class OverlayBase:
    pass

def Overlay():
    attrs = {k: v['driver']() for k, v in ip_dict.items()}
    NewClass = type('Overlay', (OverlayBase,), attrs)
    return NewClass()

ol = Overlay()

The main benefit of this approach is that our dynamically create type works with all of the existing Python and IPython features with no additional effort. For example help(ol) will identify the attributes and list their types with no additional work on our behalf:

help(ol)
Help on Overlay in module __main__ object:

class Overlay(OverlayBase)
 |  Method resolution order:
 |      Overlay
 |      OverlayBase
 |      builtins.object
 |
 |  Data and other attributes defined here:
 |
 |  gpio_btns = <GPIODriver object>
 |
 |  my_hls_ip = <DefaultIP object>
 |

Likewise tab-completion in IPython works exactly as expected.

The main downside is that it’s not obvious how a custom subclass could be built on top of this infrastructure. Our base overlay, for example, comes with a BaseOverlay class which sub-classes Overlay and renames and sets up some of the IP to make it simpler to interact with.

For circumstances where subclassing is not required however this approach provides the most natural API. As an example we use this approach in PYNQ for generating our register map objects.

Using setattr

The next option is to have an Overlay class which adds each attribute in the dictionary to the current instance as part of constructing the object.

class Overlay:
    def __init__(self):
        for k, v in ip_dict.items():
            setattr(self, k, v['driver']())

ol = Overlay()

Now it’s easy to sub-class the array and we still get autocompletion as the objects have been explicitly added to the object. The downside is that help(ol) now has no idea of these new attributes that we’ve added.

Help on Overlay in module __main__ object:

class Overlay(builtins.object)
 |  Methods defined here:
 |  
 |  __init__(self)
 |      Initialize self.  See help(type(self)) for accurate signature.
 |

In fact it’s event worse that this as help only considers the documentation of the class not of the instance so we can’t even update the __doc__ attribute of the instance to add more details. One thing that we can do is use the IPython help system (the ? operator) which will display the instance docstring:

ol.__doc__ = "An Instance docstring"
ol?

This is how we get some documentation about the overlay instances in PYNQ and why we recommend using ? instead of help where possible.

Overriding __getattr__

Both of the previous two methods have created the driver objects at object creation which is not always desirable in PYNQ - e.g. the bitstream might not have been downloaded and we don’t want drivers to start access IP that doesn’t exist. Override __getattr__ provides with a convenient mechanism to delay creating the driver instances until they’re accessed at which point we can check if the bitstream exists before creating the object.

class Overlay:
    def __getattr__(self, key):
        if key in ip_dict:
            # Bitstream download check can be added here
            return ip_dict[key]['driver']()
        return super().__getattr__(key)
    
    def __dir__(self):
        return list(ip_dict.keys()) + list(super().__dir__())

ol = Overlay()

The downside is that now we lose any help from the Python environment when it comes to docstrings or tab-completion. Unlike with docstring where we are in the same bind as option two we can get tab-completion to work through overloading the __dir__ special method but this require more work than the previous options.

For PYNQ though, being able to delay creating the objects is paramount for our Overlay class so this is the option we take even though it is more work to integrate back into the Python usability features.

Closing thoughts

I’ve tried in this blog to give some insight into what goes into to creating an API that on the surface seems to be simple. Hopefully this has given you a better understanding of how the Overlay class works if you are already using PYNQ or provide some ideas in how to implement your own data-driver classes. In the interests of simplicity I’ve not touched on how hierarchies and other aspects of complex designs are handled but those features all use the same basic concepts presented here.

3 Likes