In the last article, I took an example of a simple Python class and using it to demo how dynamic the actual memory footprint of a Python object is and how deceptively large it can be.
The hidden cost of __dict__
Let me continue with our Person class to refresh our memories.
class Person:
def __init__(self, name, age, gender):
self.name = name
self.age = age
self.gender = gender
def __repr__(self):
return f"Person<{self.name}, {self.age}, {self.gender}>"
p1 = Person("Pranav", 20, "M")
Let us relook at the memory usage of this with pympler.
>>> from pympler.asizeof import asized
>>> print(asized(p1, detail=2).format())
Person<Pranav, 20, M> size=664 flat=56
__dict__ size=608 flat=296
[K] name size=56 flat=56
[V] name: 'Pranav' size=56 flat=56
[K] age size=56 flat=56
[K] gender size=56 flat=56
[V] gender: 'M' size=56 flat=56
[V] age: 20 size=32 flat=32
__class__ size=0 flat=0
The instance flat size is 56 bytes. The instance __dict__ adds a flat size of 296 bytes and a total size of 608 bytes.
So of the total size of 664, the dictionary contribution is,
>>> round(296*100.0/664.0, 2)
44.58
This flexibility given by a per instance dictionary is wonderful. One can dynamically attach attributes at any time but the memory cost is steep.
We have seen that key sharing dictionaries optimize per instance __dict__ memory costs, but even then the dictionaries can take up a lot of space of the memory footprint of Python instances. When you create a million instances, you also create a million dictionaries.
Is it possible to avoid this overhead ?
Enter __slots__
You can create a Python class with no instance __dict__ at all by pre-defining its attributes. This is done at the class definition in a __slots__ attribute.
class SlottedPerson:
__slots__ = ('name', 'age', 'gender')
def __init__(self, name, age, gender):
self.name = name
self.age = age
self.gender = gender
def __repr__(self):
return f"SlottedPerson<{self.name}, {self.age}, {self.gender}>"
p = SlottedPerson("Pranav", 20, "M")
It doesn't have a __dict__ .
>>> hasattr(p, '__dict__')
False
Boom - no per instance dictionary. Instead, CPython allocates a fixed size C array per instance and stores attributes by offset than by key lookup. One can think of this as replacing,
{'name': 'Pranav', 'age': 20, 'gender': 'M'}
with,
['Pranav', 20, 'M']
Let us check the memory usage of a slotted instance vs a normal instance.
>>> p1 = Person('Pranav', 20, 'M')
>>> print(asizeof.asized(p1, detail=2).format())
Person<Pranav, 20, M> size=664 flat=56
__dict__ size=608 flat=296
[K] name size=56 flat=56
[V] name: 'Pranav' size=56 flat=56
[K] age size=56 flat=56
[K] gender size=56 flat=56
[V] gender: 'M' size=56 flat=56
[V] age: 20 size=32 flat=32
__class__ size=0 flat=0
>>>
>>> p2 = SlottedPerson('Pranav', 20, 'M')
>>> print(asizeof.asized(p2, detail=2).format())
SlottedPerson<Pranav, 20, M> size=200 flat=56
name size=56 flat=56
gender size=56 flat=56
age size=32 flat=32
__class__ size=0 flat=0
The slotted instance is much more memory efficient as the overhead taken by the __dict__ no longer exists. It is about 70% lower on the memory footprint.
What about key sharing and its effect on the instance dictionaries ?
One can write a couple of functions to measure these effects.
Measuring the effect of key sharing optimizations
def dict_memory_share(obj):
""" Return % of memory occupied by an instance __dict__ """
dict_size = sys.getsizeof(obj.__dict__)
# Total size
net_size = asizeof.asizeof(obj)
return round(100.0*(dict_size/net_size), 2), net_size
def keysharing_limit(*args):
""" Find the limit of optimization where
an instance __dict__ memory stabilizes """
objs = []
size, prev_size = 0, 0
for i in range(1, 100):
p = Person(*args)
size = sys.getsizeof(p.__dict__)
dict_mem, net_size = dict_memory_share(p)
print(f"p{i} dict size: {size}, memory share: {dict_mem}, net_size: {net_size}")
if size == prev_size:
print(f'dict size stabilized in iteration #{i}')
break
prev_size = size
objs.append(p)
Running this (output lines in between are trimmed)
>>> keysharing_limit('Pranav', 20, 'M')
p1 dict size: 296, memory share: 44.58, net_size: 664
p2 dict size: 288, memory share: 43.9, net_size: 656
p3 dict size: 280, memory share: 43.21, net_size: 648
p4 dict size: 272, memory share: 42.5, net_size: 640
p5 dict size: 264, memory share: 41.77, net_size: 632
...
...
...
p26 dict size: 96, memory share: 20.69, net_size: 464
p27 dict size: 96, memory share: 20.69, net_size: 464
The key sharing (and other dictionary optimizations) kick in till about iteration #27 (this will vary across systems and Python versions). The instance after that takes an approx memory footprint of 464 bytes, with the dictionaries share going down from 45% to nearly 21%.
However the slotted instance with a fixed 200 bytes still is leaner!
So what is the catch ?
Trade-offs with __slots__
The main catch: A slotted class loses some of Python's dynamic magic.
No dynamic attributes
You can't add any attributes at run-time.
>>> p2.city = 'Bangalore'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'SlottedPerson' object has no attribute 'city'
Subclassing
One needs to re-declare slots with the additional attributes when sub-classing.
class SlottedEmployee(SlottedPerson):
__slots__ = ('job', 'company')
def __init__(self, name, age, gender, job, company):
self.name = name
self.age = age
self.gender = gender
self.job = job
self.company = company
def __repr__(self):
return f"SlottedEmployee<{self.name}, {self.age}, {self.gender}, {self.job}, {self.company}>"
>>> SlottedEmployee('Pranav', 20, 'M', 'Engineer', 'Firm')
SlottedEmployee<Pranav, 20, M, Engineer, Firm>
If you are sub-classing without adding any attributes (aliasing), you need to still declare __slots__ as an empty tuple. Otherwise the class reverts to a regular Python class using instance __dict__ .
class SlottedSame(SlottedPerson):
pass
>>> s1 = SlottedSame('Pranav', 20, 'M')
>>> hasattr(s1, '__dict__')
True
This makes slotted classes tricky when using multiple inheritance as the base classes need to agree on the attribute semantics.
Pickling
Using classes with __slots__ introduces some tricky corner cases with pickling. Modern Python versions handle most of these issues. I wont get into details here but this has mostly to do with how the dunder methods __getstate__ and __setstate__ are overriden in the slotted class.
Weak References - A different kind of Memory Saver
A weak reference is a Python object that points to another object without increasing its reference count.
>>> import weakref, gc
>>> p1 = Person('Pranav', 20, 'M')
>>> p1_ref = weakref.ref(p1)
>>> p1_ref() # Access the object
Person<Pranav, 20, M>
>>> del p1 # delete the object
>>> gc.collect() # force garbage collection
>>> p1_ref() # reference is gone!
>>>
Weak references are useful in scenarios where one deliberately don't want to hold a reference to an object - so that the referring objects can be allowed to be garbage collected upon release. Some examples are in implementing Caches, certain patterns like Publisher/Subscriber, Mediators etc.
A weak reference by itself doesn't reduce memory footprint of an object but it allows objects it refers to be garbage collected so it helps to manage the overall memory of the Python runtime if you have many hundreds of thousands (or millions) of Python objects. In other words, it reduces retention.
Weak refs and __slots__
Weak reference support need to be explicitly added to slotted classes by adding __weakref__ to the __slots__. Otherwise the instance is unable to be weakly referenced.
Lets check this,
>>> p2 = SlottedPerson('Pranav', 20, 'M')
>>> weakref.ref(p2)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: cannot create weak reference to 'SlottedPerson' object
Let us fix this.
class SlottedPerson:
__slots__ = ('name', 'age', 'gender', '__weakref__')
def __init__(self, name, age, gender):
self.name = name
self.age = age
self.gender = gender
def __repr__(self):
return f"SlottedPerson<{self.name}, {self.age}, {self.gender}>"
>>> p3 = SlottedPerson('Pranav', 20, 'M') # updated with __weakref__
>>> weakref.ref(p3)
<weakref at 0x7f244239d8a0; to 'SlottedPerson' at 0x7f2442220ac0>
>>> p3_ref = _ # use the ref
>>> p3_ref()
SlottedPerson<Pranav, 20, M>
A Cache using weakref and __slots__
A cache is a good example where a pattern using weakref and __slots__ come together since one wants the cache to be light on memory as well as to forget the objects when they are no longer referenced.
class Cache:
""" A cache that uses weakrefs """
cache = weakref.WeakValueDictionary()
@classmethod
def get_person(cls, user_id, age, gender):
""" A get_person function which uses a cache """
cache_key = '_'.join(map(str, (user_id, age, gender)))
if cache_key in cls.cache:
return cls.cache[cache_key]
user = SlottedPerson(user_id, age, gender)
cls.cache[cache_key] = user
return user
>>> Cache.get_person('Pranav', 20, 'M') # instance is created and cached
SlottedPerson<Pranav, 20, M>
>>> p_cache = Cache.get_person('Pranav', 20, 'M') # fetched from cache
>>> list(Cache.cache.keys()) # key exists in cache
['Pranav_20_M']
>>> del p_cache
>>> gc.collect() # force garbage collection
>>> list(Cache.cache.keys()) # user is auto-removed
[]
Where and when to use these patterns
The ideal place for code that uses __slots__ and combining with weakrefs would be when you are creating a number of short lived objects which need to avoid strong references and be light on memory. Some examples are,
- Caches or Caching proxies
- Event Managers/Monitors
- Object registries
- ORM row proxies
- Data Transfer Objects (DTOs)
A general rule of thumb for this can be,
- Generic object wrapper/observer/mediator classes which need to wrap up or refer to many objects while not actively managing their life cycle -
In Python standard library itself, the following modules use __slots__ .
- namedtuple in
collectionsmodule uses__slots__to optimize memory. - dataclasses module slots like
@dataclass(slots=True)
Outside the stdlib, the attrs package has supported slots for years, and the well known pydantic library supports __slots__ natively in its models .