Skip to content

gh-92810: Reduce memory usage by ABCMeta.__subclasscheck__ #131914

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from

Conversation

dolfinus
Copy link

@dolfinus dolfinus commented Mar 30, 2025

test_performance_abc.py
from abc import ABCMeta
#from _py_abc import ABCMeta
import time
import psutil
import os

class Root(metaclass=ABCMeta):
    pass

class Class1(Root):
    pass

class Class2(Root):
    pass

class Class3(Root):
    pass

class Class4(Root):
    subclasses = []
    @classmethod
    def __subclasses__(cls):
        return cls.subclasses


def _create_subclass1():
    class NestedClass1(Class1):
        pass

    return NestedClass1


def _create_subclass2():
    class NestedClass2(Class2):
        pass

    return NestedClass2


def _create_subclass3():
    class NestedClass3:
        pass

    Class3.register(NestedClass3)

    return NestedClass3


def _create_subclass4():
    class NestedClass4:
        pass

    Class4.subclasses.append(NestedClass4)

    return NestedClass4


def test_performance():
    iters = 2000
    print("Creating new classes to check")
    objects_subclass1 = []
    for i in range(iters):
        subclass1 = _create_subclass1()
        objects_subclass1.append(subclass1())
    objects_subclass2 = []
    for i in range(iters):
        subclass2 = _create_subclass2()
        objects_subclass2.append(subclass2())

    objects_subclass3 = []
    for i in range(iters):
        subclass3 = _create_subclass3()
        objects_subclass3.append(subclass3())

    objects_subclass4 = []
    for i in range(iters):
        subclass4 = _create_subclass4()
        objects_subclass4.append(subclass4())

    # create one more subclass for sibling check
    another_subclass1 = _create_subclass1()
    another_subclass2 = _create_subclass2()
    another_subclass3 = _create_subclass3()
    another_subclass4 = _create_subclass4()

    print("Created subclasses =", len(objects_subclass1) + len(objects_subclass2) + len(objects_subclass3) + len(objects_subclass4))
    print("Consumed memory, Mb:", psutil.Process(os.getpid()).memory_info().rss / 1024 ** 2)
    print("Started compairing objects")

    # New subclass against parent, sibling, grantparent and cousin
    new_subclass1_vs_class1_ns = 0
    new_subclass1_vs_another_subclass1_ns = 0
    new_subclass1_vs_root_ns = 0
    new_subclass1_vs_class2_ns = 0
    for obj1 in objects_subclass1:
        start = time.perf_counter_ns()
        assert isinstance(obj1, Class1)
        new_subclass1_vs_class1_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert not isinstance(obj1, another_subclass1)
        new_subclass1_vs_another_subclass1_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert isinstance(obj1, Root)
        new_subclass1_vs_root_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert not isinstance(obj1, Class2)
        new_subclass1_vs_class2_ns += time.perf_counter_ns() - start

    # Same subclass against parent, sibling, grantparent and cousin
    cached_subclass1_vs_root_ns = 0
    cached_subclass1_vs_another_subclass1_ns = 0
    cached_subclass1_vs_class1_ns = 0
    cached_subclass1_vs_class2_ns = 0
    for _ in range(iters):
        start = time.perf_counter_ns()
        assert isinstance(obj1, Class1)
        cached_subclass1_vs_class1_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert not isinstance(obj1, another_subclass1)
        cached_subclass1_vs_another_subclass1_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert isinstance(obj1, Root)
        cached_subclass1_vs_root_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert not isinstance(obj1, Class2)
        cached_subclass1_vs_class2_ns += time.perf_counter_ns() - start

    # new class (via .register) against parent, sibling, grantparent and cousin
    new_subclass3_vs_class3_ns = 0
    new_subclass3_vs_another_subclass3_ns = 0
    new_subclass3_vs_root_ns = 0
    new_subclass3_vs_class1_ns = 0
    for obj3 in objects_subclass3:
        start = time.perf_counter_ns()
        assert isinstance(obj3, Class3)
        new_subclass3_vs_class3_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert not isinstance(obj3, another_subclass3)
        new_subclass3_vs_another_subclass3_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert isinstance(obj3, Root)
        new_subclass3_vs_root_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert not isinstance(obj3, Class1)
        new_subclass3_vs_class1_ns += time.perf_counter_ns() - start

    # same class (via .register) against parent, sibling, grantparent and cousin
    cached_subclass3_vs_class3_ns = 0
    cached_subclass3_vs_another_subclass3_ns = 0
    cached_subclass3_vs_root_ns = 0
    cached_subclass3_vs_class1_ns = 0
    for _ in range(iters):
        start = time.perf_counter_ns()
        assert isinstance(obj3, Class3)
        cached_subclass3_vs_class3_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert not isinstance(obj3, another_subclass3)
        cached_subclass3_vs_another_subclass3_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert isinstance(obj3, Root)
        cached_subclass3_vs_root_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert not isinstance(obj3, Class1)
        cached_subclass3_vs_class1_ns += time.perf_counter_ns() - start

    # new class (via __subclasses__) against parent, sibling, grantparent and cousin
    new_subclass4_vs_class4_ns = 0
    new_subclass4_vs_another_subclass4_ns = 0
    new_subclass4_vs_root_ns = 0
    new_subclass4_vs_class1_ns = 0
    for obj4 in objects_subclass4:
        start = time.perf_counter_ns()
        assert isinstance(obj4, Class4)
        new_subclass4_vs_class4_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert not isinstance(obj4, another_subclass4)
        new_subclass4_vs_another_subclass4_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert isinstance(obj4, Root)
        new_subclass4_vs_root_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert not isinstance(obj4, Class1)
        new_subclass4_vs_class1_ns += time.perf_counter_ns() - start

    # same class (via __subclasses__) against parent, sibling, grantparent and cousin
    cached_subclass4_vs_class4_ns = 0
    cached_subclass4_vs_another_subclass4_ns = 0
    cached_subclass4_vs_root_ns = 0
    cached_subclass4_vs_class1_ns = 0
    for _ in range(iters):
        start = time.perf_counter_ns()
        assert isinstance(obj4, Class4)
        cached_subclass4_vs_class4_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert not isinstance(obj4, another_subclass4)
        cached_subclass4_vs_another_subclass4_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert isinstance(obj4, Root)
        cached_subclass4_vs_root_ns += time.perf_counter_ns() - start

        start = time.perf_counter_ns()
        assert not isinstance(obj4, Class1)
        cached_subclass4_vs_class1_ns += time.perf_counter_ns() - start

    print("Completed compairing classes.")
    print("Consumed memory, Mb:", psutil.Process(os.getpid()).memory_info().rss / 1024 ** 2)

    total_ns = sum([
        cached_subclass1_vs_root_ns,
        cached_subclass1_vs_another_subclass1_ns,
        cached_subclass1_vs_class1_ns,
        cached_subclass1_vs_class2_ns,
        cached_subclass3_vs_class3_ns,
        cached_subclass3_vs_another_subclass3_ns,
        cached_subclass3_vs_root_ns,
        cached_subclass3_vs_class1_ns,
        cached_subclass4_vs_class4_ns,
        cached_subclass4_vs_another_subclass4_ns,
        cached_subclass4_vs_root_ns,
        cached_subclass4_vs_class1_ns,
        new_subclass1_vs_class1_ns,
        new_subclass1_vs_another_subclass1_ns,
        new_subclass1_vs_root_ns,
        new_subclass1_vs_class2_ns,
        new_subclass3_vs_class3_ns,
        new_subclass3_vs_another_subclass3_ns,
        new_subclass3_vs_root_ns,
        new_subclass3_vs_class1_ns,
        new_subclass4_vs_class4_ns,
        new_subclass4_vs_another_subclass4_ns,
        new_subclass4_vs_root_ns,
        new_subclass4_vs_class1_ns,
    ])
    print("Total, sec:", total_ns / 10**9)

    print("isinstance(cached class, parent), us:", cached_subclass1_vs_root_ns / iters / 1000)
    print("isinstance(cached class, sibling), us:", cached_subclass1_vs_another_subclass1_ns / iters / 1000)
    print("isinstance(cached class, grandparent), us:", cached_subclass1_vs_class1_ns / iters / 1000)
    print("isinstance(cached class, cousin), us:", cached_subclass1_vs_class2_ns / iters / 1000)

    print("isinstance(cached class, parent via .register()), us:", cached_subclass3_vs_class3_ns / iters / 1000)
    print("isinstance(cached class, sibling via .register()), us:", cached_subclass3_vs_another_subclass3_ns / iters / 1000)
    print("isinstance(cached class, grandparent via .register()), us:", cached_subclass3_vs_root_ns / iters / 1000)
    print("isinstance(cached class, cousin via .register()), us:", cached_subclass3_vs_class1_ns / iters / 1000)

    print("isinstance(cached class, parent via __subclasses__), us:", cached_subclass4_vs_class4_ns / iters / 1000)
    print("isinstance(cached class, sibling via __subclasses__), us:", cached_subclass4_vs_another_subclass4_ns / iters / 1000)
    print("isinstance(cached class, grandparent via __subclasses__), us:", cached_subclass4_vs_root_ns / iters / 1000)
    print("isinstance(cached class, cousin via __subclasses__), us:", cached_subclass4_vs_class1_ns / iters / 1000)

    print("isinstance(new class, parent), us:", new_subclass1_vs_class1_ns / len(objects_subclass1) / 1000)
    print("isinstance(new class, sibling), us:", new_subclass1_vs_another_subclass1_ns / len(objects_subclass1) / 1000)
    print("isinstance(new class, grandparent), us:", new_subclass1_vs_root_ns / len(objects_subclass1) / 1000)
    print("isinstance(new class, cousin), us:", new_subclass1_vs_class2_ns / len(objects_subclass1) / 1000)

    print("isinstance(new class, parent via .register()), us:", new_subclass3_vs_class3_ns / len(objects_subclass3) / 1000)
    print("isinstance(new class, sibling via .register()), us:", new_subclass3_vs_another_subclass3_ns / len(objects_subclass3) / 1000)
    print("isinstance(new class, grandparent via .register()), us:", new_subclass3_vs_root_ns / len(objects_subclass3) / 1000)
    print("isinstance(new class, cousin via .register()), us:", new_subclass3_vs_class1_ns / len(objects_subclass3) / 1000)

    print("isinstance(new class, parent via __subclasses__), us:", new_subclass4_vs_class4_ns / len(objects_subclass4) / 1000)
    print("isinstance(new class, sibling via __subclasses__), us:", new_subclass4_vs_another_subclass4_ns / len(objects_subclass4) / 1000)
    print("isinstance(new class, grandparent via __subclasses__), us:", new_subclass4_vs_root_ns / len(objects_subclass4) / 1000)
    print("isinstance(new class, cousin via __subclasses__), us:", new_subclass4_vs_class1_ns / len(objects_subclass4) / 1000)


if __name__ == "__main__":
    test_performance()

For 8k nested subclasses:

Impl Memory before, MB Memory after, MB
_abc 5304.320 47.453
_py_abc 3469.648 53.980
Impl Total time before, seconds Total time after, seconds
_abc 512.120 66.352
_py_abc 323.217 260.137
Check Impl us per check, before us per check, after Impl us per check, before us per check, after
isinstance(cached class, parent) _abc 2.061 1.515 _py_abc 2.411 2.185
isinstance(cached class, sibling) _abc 2.027 1.470 _py_abc 3.900 3.614
isinstance(cached class, grandparent) _abc 2.073 1.514 _py_abc 2.395 2.173
isinstance(cached class, cousin) _abc 2.058 1.482 _py_abc 3.903 3.608
isinstance(cached class, parent via .register()) _abc 1.901 2.071 _py_abc 2.555 2.243
isinstance(cached class, sibling via .register()) _abc 0.417 0.467 _py_abc 0.522 0.455
isinstance(cached class, grandparent via .register()) _abc 1.133 1.213 _py_abc 2.500 2.233
isinstance(cached class, cousin via .register()) _abc 1.152 1.251 _py_abc 4.087 3.805
isinstance(cached class, parent via __subclasses__) _abc 1.619 1.317 _py_abc 2.032 2.349
isinstance(cached class, sibling via __subclasses__) _abc 0.607 0.430 _py_abc 0.422 0.484
isinstance(cached class, grandparent via __subclasses__) _abc 1.642 1.188 _py_abc 2.039 2.313
isinstance(cached class, cousin via __subclasses__) _abc 1.678 1.217 _py_abc 3.351 3.956
isinstance(new class, parent) _abc 7.125 7.546 _py_abc 12.892 14.153
isinstance(new class, sibling) _abc 6.337 6.069 _py_abc 13.839 15.672
isinstance(new class, grandparent) _abc 3.606 4.028 _py_abc 9.841 10.264
isinstance(new class, cousin) _abc 15_164.291 4_489.091 _py_abc 20_130.58 17_344.474
isinstance(new class, parent via .register()) _abc 4.005 7.557 _py_abc 707.748 739.004
isinstance(new class, sibling via .register()) _abc 0.967 1.144 _py_abc 1.475 1.624
isinstance(new class, grandparent via .register()) _abc 80_945.567 9_185.747 _py_abc 59_578.90 37_271.663
isinstance(new class, cousin via .register()) _abc 4.026 4_630.594 _py_abc 7.661 18_586.478
isinstance(new class, parent via __subclasses__) _abc 83.738 350.901 _py_abc 223.813 496.861
isinstance(new class, sibling via __subclasses__) _abc 1.323 1.464 _py_abc 1.136 1.348
isinstance(new class, grandparent via __subclasses__) _abc 159_816.392 9_749.391 _py_abc 80_883.07 37_469.294
isinstance(new class, cousin via __subclasses__) _abc 4.323 4_727.533 _py_abc 7.663 18_088.251

@bedevere-app
Copy link

bedevere-app bot commented Mar 30, 2025

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

Modules/_abc.c Outdated
if (scls == NULL) {
goto end;
}
int r = PyObject_IsSubclass(subclass, scls);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have a UAF here. PyObject_IsSubclass can call __subclasscheck__ which can itseslf call arbitrary code so you might mutate subclasses. The issue already exists with the existing code but can you confirm that we can indeed produce a UAF? (if you don't know how to do it, I'll try to investigate this separately tomorrow)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you confirm that we can indeed produce a UAF?

Sorry, my C knowledge is very minimal, I don't know anything about this yet

@bedevere-app
Copy link

bedevere-app bot commented Mar 31, 2025

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

3 similar comments
@bedevere-app
Copy link

bedevere-app bot commented Mar 31, 2025

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

@bedevere-app
Copy link

bedevere-app bot commented Mar 31, 2025

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

@bedevere-app
Copy link

bedevere-app bot commented Mar 31, 2025

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

@python-cla-bot
Copy link

python-cla-bot bot commented Apr 6, 2025

All commit authors signed the Contributor License Agreement.

CLA signed

@bedevere-app
Copy link

bedevere-app bot commented Apr 21, 2025

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

@dolfinus dolfinus force-pushed the improvement/ABCMeta_subclasscheck branch from abf4bfe to b7603e0 Compare April 21, 2025 11:03
@bedevere-app
Copy link

bedevere-app bot commented Apr 21, 2025

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

@bedevere-app
Copy link

bedevere-app bot commented Apr 21, 2025

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

@bedevere-app
Copy link

bedevere-app bot commented Apr 21, 2025

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

@dolfinus dolfinus changed the title gh-92810: Avoid O(n^2) complexity in ABCMeta.__subclasscheck__ gh-92810: Reduce memory usage by ABCMeta.__subclasscheck__ Apr 23, 2025
@bedevere-app
Copy link

bedevere-app bot commented Jun 13, 2025

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

@bedevere-app
Copy link

bedevere-app bot commented Jun 13, 2025

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

@dolfinus dolfinus marked this pull request as ready for review June 13, 2025 14:15
@dolfinus dolfinus requested a review from picnixz June 13, 2025 14:16
@dolfinus
Copy link
Author

dolfinus commented Jun 13, 2025

I've added a simple recursion check to cls.__subclasses__() clause, both to _py_abc and _abc. This avoids adding the same class to all ABC children class caches, reducing both memory usage and time for very large class trees. Microbenchmark & results are in the description.

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

Successfully merging this pull request may close these issues.

2 participants