Many ways to build a Vec3

Classes in Python are easy

We all know that “Python is an interpreted, object-oriented, high-level programming language with dynamic semantics” and we can write Object oriented code using the class keyword. In the following examples I will demonstrate some of the different ways we can create classes python (currently using 3.9) and what advantages / disadvantages each method has.

In particular I will be focused on the speed of creation of objects by using the timeit module to repeatedly construct the class and see how long it takes.

You can download the source file I use here all timings presented are from a Macbook Pro M1 with 16Gb ram using Python 3.9.7 installed via pyenv

A simple Vec3 class

import timeit

class Vec3NormalNoFloat:
    def __init__(self, x: float = 0.0, y: float = 0.0, z: float = 0.0):
        self.x = x
        self.y = y
        self.z = z

number = 1000000
times = timeit.repeat(
    globals=globals(), stmt="v = Vec3NormalNoFloat()", repeat=3, number=number
)
print("Vec3NormalNoFloat {:.4f}s".format(min(times)))

This is a very common way of creating a simple class, it has three attributes x,y,z created in the __init__ method and the three named parameters in the initializer are assigned to zero allowing construction without params and I’ve also included type hints so 3rd party tools can check our code.

This runs very fast, with an overall time of 0.1758s for 1000000 repeated constructions.

Vec3NormalNoFloat 0.1758s

The main issue I have with this, especially coming from a C++ background, is the Duck Typing. At present this is a simple class but I’m going to add other methods such as dot and cross and these should only work on numeric types, and in particular for 3D graphics float types.

Ideally I would like to enforce this in construction (as this catches the error early), however python is not usually done like this, so what can we do to enforce some sort of float type on our class.

float()

The simplest way to enforce an attribute to be a float type is to use the float() constructor which will raise a ValueError if conversion is not possible.

>>> float('2')
2.0
>>> float('x')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: could not convert string to float: 'x'

The following class will now throw a ValueError if the values passed in the constructor are not convertable to float.

class Vec3Normal:
    def __init__(self, x: float = 0.0, y: float = 0.0, z: float = 0.0):
        self.x = float(x)
        self.y = float(y)
        self.z = float(z)

number = 1000000

times = timeit.repeat(
    globals=globals(),stmt="v = Vec3Normal()",
    repeat=3,number=number)
print("Vec3Normal {:.4f}s".format(min(times)))

Which results in a slower time of 0.2547s

Vec3Normal 0.2547s

isinstance()

Another way to enforce type is check to see if the value passed is an instance of the float object. We can do this using the isinstance built in function as follows

class Vec3NormalIsInstance:
    def __init__(self, x: float = 0.0, y: float = 0.0, z: float = 0.0):
        if isinstance(x, float) and isinstance(y, float) and isinstance(z, float):
            self.x = x
            self.y = y
            self.z = z
        else:
            raise AttributeError

times = timeit.repeat(
    globals=globals(),
    stmt="v = Vec3NormalIsInstance(0.1,0.1,0.1)",
    repeat=3,
    number=number
)
print("Vec3NormalIsInstance {:.4f}s".format(min(times)))

This version is slower that the previous two versions at 0.2600s I’m guessing the method overhead call is slower than the float conversions, this method will be discarded.

Vec3NormalIsInstance 0.2600s

__slots__

__slots__ tells python to not allocate a dictionary for our class, it also has the added bonus of faster attribute lookup and less memory footprint.

The following two example classes use the slots to set the attributes for the class, one explicitly tries to convert the floats, the other ignores this.

class Vec3Slot:
    __slots__ = ("x", "y", "z")

    def __init__(self, x: float = 0.0, y: float = 0.0, z: float = 0.0):
        self.x = float(x)
        self.y = float(y)
        self.z = float(z)


class Vec3SlotNoFloat:
    __slots__ = ("x", "y", "z")

    def __init__(self, x: float = 0.0, y: float = 0.0, z: float = 0.0):
        self.x = x
        self.y = y
        self.z = z

times = timeit.repeat(globals=globals(), stmt="v = Vec3Slot()", repeat=3, number=number)
print("Vec3Slot {:.4f}s".format(min(times)))

times = timeit.repeat(
    globals=globals(), stmt="v = Vec3SlotNoFloat()", repeat=3, number=number
)
print("Vec3SlotNoFloat {:.4f}s".format(min(times)))

This results in the following :-

Vec3Slot 0.2143s
Vec3SlotNoFloat 0.1308s

As you can see the slots approach is faster than a normal class, and in particular the one without float conversion.

an aside on setattr

An added bonus on using the __slots__ approach is the removal of the class __dict__. Typically we can dynamically add an attribute to a class by using code such as :-

a=Vec3Normal()
setattr(a,'r',0.4) 

Trying this on a class with __slots__ will result in an AttributeError making the class secure against extension

namedtuple

Python 3.3 introduced the collections module which adds the namedtuple class. This allows us to assign a name to elements of a tuple.

class Vec3NamedTuple(namedtuple("Vec3NamedTuple", "x y z")):
    __slots__ = ()

    def __new__(cls, x=0.0, y=0.0, z=0.0):
        return super().__new__(cls, float(x), float(y), float(z))

times = timeit.repeat(
    globals=globals(), stmt="v = Vec3NamedTuple()", repeat=3, number=number
)
print("Vec3NamedTuple {:.4f}s".format(min(times)))

This gives us a time of 0.3578 seconds which is slower, removing the float conversion is faster but still slower than other methods. The main reason for this is the construction of the named tuple class.

Vec3NamedTuple 0.3578s
Vec3NamedTupleNoFloat 0.2721s

It is possible to pre-generate the tuple and use this as follows

xyz=namedtuple("xyz",'x y z')
class Vec3PreGenTuple :
    def __init__(self, x: float = 0.0, y: float = 0.0, z: float = 0.0):
        self.p=xyz(x,y,z)

This does give a speedup compared to the previous version of 0.2964s however it is still slower than other versions.

@dataclass

Data classes were introduced in python 3.7 with pep 557 the idea is that these classes are used to mainly contain data however they can also have other methods like a normal class. To generate these classes we use the decorator @dataclass as follows

@dataclass
class Vec3DataClass:
    x: float = 0.0
    y: float = 0.0
    z: float = 0.0

    def __init__(self, x=0.0, y=0.0, z=0.0):
        self.x = float(x)
        self.y = float(y)
        self.z = float(z)

Using this method gives a time of 0.2528, removing the float conversion is again faster and give a fairly good result, however the __slots__ version is still faster.

Vec3DataClass 0.2528s
Vec3DataClassNoFloat 0.1770s

Conclusions

So from my experiments I get the following results (sorted from fastest to lowest),

Vec3SlotNoFloat 0.1315s
Vec3DataClassNoFloat 0.1767s
Vec3NormalNoFloat 0.1788s
Vec3Slot 0.2163s
Vec3Normal 0.2532s
Vec3DataClass 0.2534s
Vec3NormalIsInstance 0.2612s
Vec3PreGenTuple 0.3001s
Vec3NamedTuple 0.3557s
pynglVec3 3.0633s

I have decided to use the __slot__ versions for all the Math classes within the nccapy package I use for teaching. I will not be doing the explicit float conversion mentioned in the initial design, and allow the classes to duck type as normal as this is considered to be pythonic, our client code can catch any errors, and with the addition of type hints used in the code tools will also spot user errors before they occur.

To quote the Zen of python

import this
Errors should never pass silently.
Unless explicitly silenced.

Future Work

One thing you may notice in the results above is a test using my PyNGL library. This is the python wrapper (using pybind11) to the C++ NGL library and used as follows.

from pyngl import Vec3 as pynglVec3
times = timeit.repeat(
        globals=globals(), stmt="v = pynglVec3()", repeat=3, number=number
    )
print("pynglVec3 {:.4f}s".format(min(times)))

This takes a massive 3.066 seconds which was unexpected, I need to investigate why this is so slow, and I think I also need to compare it against other libraries such as PyGLM I also need to concider using the buffer protocol to allow easier access for libraries such as OpenGL and Vulkan.

Next
Previous

Related