14

PEP 412, implemented in Python 3.3, introduces improved handling of attribute dictionaries, effectively reducing the memory footprint of class instances. __slots__ was designed for the same purpose, so is there any point in using __slots__ any more?

In an attempt to find out the answer myself, I run the following test, but the results don't make much sense:

class Slots(object):
    __slots__ = ['a', 'b', 'c', 'd', 'e']
    def __init__(self):
        self.a = 1
        self.b = 1
        self.c = 1
        self.d = 1
        self.e = 1  

class NoSlots(object):
    def __init__(self):
        self.a = 1
        self.b = 1
        self.c = 1
        self.d = 1
        self.e = 1

Python 3.3 Results:

>>> sys.getsizeof([Slots() for i in range(1000)])
Out[1]: 9024
>>> sys.getsizeof([NoSlots() for i in range(1000)])
Out[1]: 9024

Python 2.7 Results:

>>> sys.getsizeof([Slots() for i in range(1000)])
Out[1]: 4516
>>> sys.getsizeof([NoSlots() for i in range(1000)])
Out[1]: 4516

I would have expected the size to differ at least for Python 2.7, so I assume there is something wrong with the test.

aquavitae
  • 17,414
  • 11
  • 63
  • 106
  • 1
    Have you measured the differences in real-world situations yet? :-) Also, `__slots__` can be (ab)used for it's side effects, such as the fact it prevents arbitrary attributes being added. – Martijn Pieters Dec 07 '12 at 10:42
  • Yes, I'm aware of the problem with __slots__, it was more of an academic question than relating to a specific use case. I tried running a few tests, but got found no difference between using slots and not, in python 3.3 or 2.7. But perhaps my test is faulty, so I'll post it too. – aquavitae Dec 07 '12 at 11:39

2 Answers2

5

No, PEP 412 does not make __slots__ redundant.


First, Armin Rigo is right that you're not measuring it properly. What you need to measure is the size of the object, plus the values, plus the __dict__ itself (for NoSlots only) and the keys (for NoSlots only).

Or you could do what he suggests:

cls = Slots if len(sys.argv) > 1 else NoSlots
def f():
    tracemalloc.start()
    objs = [cls() for _ in range(100000)]
    print(tracemalloc.get_traced_memory())
f()

When I run this on 64-bit CPython 3.4 on OS X, I get 8824968 for Slots and 25624872 for NoSlots. So, it looks like a NoSlots instance takes 88 bytes, while a Slots instance takes 256 bytes.


How is this possible?

Because there are still two differences between __slots__ and a key-split __dict__.

First, the hash tables used by dictionaries are kept below 2/3rds full, and they grow exponentially and have a minimum size, so you're going to have some extra space. And it's not hard to work out how much space by looking at the nicely-commented source: you're going to have 8 hash buckets instead of 5 slots pointers.

Second, the dictionary itself isn't free; it has a standard object header, a count, and two pointers. That might not sound like a lot, but when you're talking about an object that's only got a few attributes (note that most objects only have a few attributes…), the dict header can make as much difference as the hash table.

And of course in your example, the values, so the only cost involved here is the object itself, plus the the 5 slots or 8 hash buckets and dict header, so the difference is pretty dramatic. In real life, __slots__ will rarely be that much of a benefit.


Finally, notice that PEP 412 only claims:

Benchmarking shows that memory use is reduced by 10% to 20% for object-oriented programs

Think about where you use __slots__. Either the savings are so huge that not using __slots__ would be ridiculous, or you really need to squeeze out that last 15%. Or you're building an ABC or other class that you expect to be subclassed by who-knows-what and the subclasses might need the savings. At any rate, in those cases, the fact that you get half the benefit without __slots__, or even two thirds the benefit, is still rarely going to be enough; you'll still need to use __slots__.

The real win is in the cases where it isn't worth using __slots__; you'll get a small benefit for free.

(Also, there are definitely some programmers who overuse the hell out of __slots__, and maybe this change can convince some of them to put their energy into micro optimizing something else not quite as irrelevant, if you're lucky.)

Ethan Furman
  • 63,992
  • 20
  • 159
  • 237
abarnert
  • 354,177
  • 51
  • 601
  • 671
  • 3
    You gave the memory sizes for `NoSlots` and `Slots` instances, but are you sure of the order? Shouldn't `Slots` instances be lighter than `NoSlots` ones? This is what I obtain on Win 7 64 bits with Python 3.4. – ndou Jul 30 '15 at 14:43
  • Based on the comment to Armin Rigo, I think that he did flip `Slots` and `NoSlots`. Had I the requisite reputation, I would propose an edit. Can someone clean this up? – youngmit Sep 05 '19 at 18:28
4

The problem is sys.getsizeof(), which rarely returns what you expect. For example in this case it counts the "size" of an object without accounting for the size of its __dict__. I suggest you retry by measuring the real memory usage of creating 100'000 instances.

Note also that the Python 3.3 behavior was inspired by PyPy, in which __slots__ makes no difference, so I would expect it to make no difference in Python 3.3 too. As far as I can tell, __slots__ is almost never of any use now.

Armin Rigo
  • 12,048
  • 37
  • 48
  • I just ran the suggested test with 64-bit Python 3.4; according to `tracemalloc`, `[Slots() for _ in range(100000)]` allocates `8824968`, while with `NoSlots` it's `25624872`. See [code](http://pastebin.com/EF96k4na) to make sure I didn't do anything stupid. – abarnert Aug 03 '14 at 04:27
  • Also, I can't see how it could make _no_ difference. There's still the slack from keeping the hash table only two thirds loaded (that could be fixed by using an indirect indexed array for the values, or compacting the hash table when the first new copy is seen, or various other tricks, but none of them are being done). Also, the `dict` header itself isn't free—it may be only a small constant overhead, but by comparison with a minimum-sized table and 5 references to the small int `1`, it's the biggest part of the object. – abarnert Aug 03 '14 at 04:35
  • 1
    I didn't look in detail at the CPython 3.3 implementation, so I can't tell you why it still makes such a big difference to use `__slots__`. All I can tell for sure is that `__slots__` make strictly no difference in PyPy (both PyPy2 and PyPy3). – Armin Rigo Aug 03 '14 at 11:44
  • I haven't looked in detail at your PyPy implementation, but I'm guessing that you use indirect indexing instead of matched-slot indexing, so the values table is just an compact array rather than a 33%+ empty bucket array, and that a dict isn't 3x as big as a generic object? – abarnert Aug 03 '14 at 11:55
  • Thanks! I posted a comment on your blog instead of trying to reply here. But briefly: part of the CPython design (using a sparse array) was intentionally overly-conservative and might change later, the other part (using a full dict struct) might be harder to change. Unless/until both change, `__slots__` is not redundant in CPython. – abarnert Aug 03 '14 at 22:33