Skip to content

Objects with both __slots__ and __dict__ have much larger size than needed (up to 4x) on Python 3.13 #135385

Open

Description

@ariebovenberg

Bug report

Bug description:

On Python 3.13, instances that have both __slots__ and __dict__ defined (e.g. through inheritance)
can have a size that is more than four times of that in previous Python versions (e.g. 3.12).

import tracemalloc
import gc


class _Point2D:
    __slots__ = ("x", "y")


class Point3D_OnlySlots(_Point2D):
    __slots__ = ("z",)

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


class Point3D_DictAndSlots(_Point2D):
    def __init__(self, x, y, z):
        self.x, self.y, self.z = x, y, z


class Point3D_OnlyDict:
    def __init__(self, x, y, z):
        self.x, self.y, self.z = x, y, z


gc.collect()  # clear freelists
tracemalloc.start()
_ = [Point3D_OnlySlots(1, 2, 3) for _ in range(1_000_000)]
print(
    f"1M Point3D (only __slots__) instances:         {tracemalloc.get_traced_memory()[0]:_} bytes"
)

gc.collect()  # clear freelists
tracemalloc.start()
_ = [Point3D_OnlyDict(1, 2, 3) for _ in range(1_000_000)]
print(
    f"1M Point3D (only __dict__) instances:          {tracemalloc.get_traced_memory()[0]:_} bytes"
)

gc.collect()  # clear freelists
tracemalloc.start()
_ = [Point3D_DictAndSlots(1, 2, 3) for _ in range(1_000_000)]
print(
    f"1M Point3D (__dict__ and __slots__) instances: {tracemalloc.get_traced_memory()[0]:_} bytes"
)

On python 3.13.3, this prints:

1M Point3D (only __slots__) instances:         64_448_792 bytes
1M Point3D (only __dict__) instances:          104_451_928 bytes
1M Point3D (__dict__ and __slots__) instances: 416_448_848 bytes

On python 3.12.11, this prints:

1M Point3D (only __slots__) instances:         64_448_792 bytes
1M Point3D (only __dict__) instances:          96_451_808 bytes
1M Point3D (__dict__ and __slots__) instances: 96_452_232 bytes

The apparent cause seems to be the object layout changes (see here)
which introduced inline values. It appears that these don't mesh well with __slots__.
Could this be because the inline values need to have a fixed offset?
What appears to cause the dramatic increase above is that having (nonempty) __slots__ will
trigger __dict__ materialization (size 30) as soon as a non-slot attribute is set. In contrast to the optimized "no slots" case, this dict doesn't shrink as more instances are created.

Of course, mixing __slots__ and __dict__ is not recommended, but it's a trap that can be easily fallen into.
For example, if forgetting to define __slots__ anywhere in a complex class hierarchy.

I'm unsure if I'm understanding exactly what's going on though. @markshannon perhaps you can shed some light on this?

Related: #115776, #115822

CPython versions tested on:

3.13

Operating systems tested on:

macOS

Linked PRs

Activity

  1. changed the title [-]Objects with `__slots__` and `__dict__` have much larger size than needed (up to 4x) on Python 3.13[/-] [+]Objects with both `__slots__` and `__dict__` have much larger size than needed (up to 4x) on Python 3.13[/+] on Jun 11, 2025
  2. added
    interpreter-core(Objects, Python, Grammar, and Parser dirs)
    3.13only security fixes
    3.14bugs and security fixes
    3.15bugs and security fixes
    on Jun 11, 2025
  3. Eclips4 commented on Jun 11, 2025

    @Eclips4
    Member

    Hello! Thanks for the report.
    Results on current main:

    1M Point3D (only __slots__) instances:         64_448_952 bytes
    1M Point3D (only __dict__) instances:          104_452_152 bytes
    1M Point3D (__dict__ and __slots__) instances: 120_452_152 bytes
  4. ariebovenberg commented on Jun 11, 2025

    @ariebovenberg
    ContributorAuthor

    Ah, silly of me not to check the current main as well. Is this something that will be backported into 3.13?

  5. shuimu5418 commented on Jun 11, 2025

    @shuimu5418

    File:Objects/typeobject.c
    Func:type_ready_managed_dict()
    Lines:9060-9062
    Cond:if (type->tp_itemsize == 0)


    EDIT: Apologies, I posted my analysis before seeing Eclips4's comment above. I have since confirmed locally by compiling and testing the main branch that this issue is indeed fixed there.

    It is too difficult for me. But I think i can link these issues:

  6. added 2 commits that reference this issue on Jun 11, 2025
  7. serhiy-storchaka commented on Aug 30, 2026

    @serhiy-storchaka
    Member

    The 3.13 regression is fixed, but such instances still reserve inline values for names which are stored in slots.

    __static_attributes__ contains every name assigned as self.X in the class body, and _PyDict_NewKeysForClass() inserts all of them into the shared keys, even when X is a slot. A member descriptor is a data descriptor, so such an attribute is never stored in the instance dict, and the reserved space is never used.

    Skipping them in _PyDict_NewKeysForClass() (a _PyType_Lookup() per name, at class creation only) gives for the example above:

    only __slots__              64_449_113 bytes
    only __dict__              104_452_257 bytes
    __dict__ and __slots__     104_452_697 bytes   (120_452_257 before)
    

    xml.dom.minidom, where all 11 names in Element.__static_attributes__ are slots, goes from 328 to 232 bytes per node. The whole test suite passes and I see no difference in benchmarks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    3.13only security fixes3.14bugs and security fixesinterpreter-core(Objects, Python, Grammar, and Parser dirs)performancePerformance or resource usagetype-bugAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions