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.