Skip to content

Conversation

@dolfinus
Copy link

@dolfinus dolfinus commented Mar 30, 2025

For python build using --enable-optimizations:

benchmark.py
import argparse
import gc
import os
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Callable

import psutil

WIDTH = 40


def current_memory():
    gc.collect()
    return psutil.Process(os.getpid()).memory_info().rss


def format_time(time_ns: float):
    return f"{time_ns / 10**3: >7.3f} us"

def format_memory(memory_bytes: float):
    return f"{memory_bytes / 2**20: >7.3f} MiB"


@dataclass
class Benchmark:
    testcase: str
    iterations: int
    time_ns: float = 0
    memory_diff_bytes: float = 0
    skipped: bool = False

    def skip(self):
        self.skipped = True
        return self

    @contextmanager
    def run(self):
        start_time_ns = time.time_ns()
        yield self
        self.time_ns = time.time_ns() - start_time_ns

    @contextmanager
    def memory(self):
        start_memory = current_memory()
        yield self
        self.memory_diff_bytes = current_memory() - start_memory


@dataclass
class BenchmarkCollection:
    testcase: str
    benchmarks: list[Benchmark] = field(default_factory=list)

    def add(self, benchmark: Benchmark):
        if benchmark.skipped:
            return
        self.benchmarks.append(benchmark)

    def avg_time(self):
        if not self.benchmarks:
            return 0
        return sum(benchmark.time_ns / benchmark.iterations for benchmark in self.benchmarks) / len(self.benchmarks)

    def std_time(self):
        if not self.benchmarks:
            return 0
        return sum((benchmark.time_ns / benchmark.iterations - self.avg_time()) ** 2 for benchmark in self.benchmarks) ** 0.5

    def min_time(self):
        return min((benchmark.time_ns / benchmark.iterations for benchmark in self.benchmarks), default=0)

    def max_time(self):
        return max((benchmark.time_ns / benchmark.iterations for benchmark in self.benchmarks), default=0)

    def min_memory(self):
        return min((benchmark.memory_diff_bytes for benchmark in self.benchmarks), default=0)

    def max_memory(self):
        return max((benchmark.memory_diff_bytes for benchmark in self.benchmarks), default=0)

    def report(self):
        print(f"Testcase: {self.testcase}")
        if not self.benchmarks:
            print(f"... skipped")
            print("-" * WIDTH)
            return

        print("Time:")
        print(f" avg:     {format_time(self.avg_time())} +/- {format_time(self.std_time())}")
        print(f" min/max: {format_time(self.min_time())} ... {format_time(self.max_time())}")
        print("Memory:")
        print(f" min/max: {format_memory(self.min_memory())} ... {format_memory(self.max_memory())}")
        print("-" * WIDTH)




def isinstance_parent(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("isinstance(child, Parent)", iterations=classes * comparisons)
    with benchmark.memory():
        class Parent(metaclass=metaclass):
            pass

        def generate_object():
            class Child(Parent):
                pass
            return Child()

        generated = [generate_object() for _ in range(classes)]
        with benchmark.run():
            for child in generated:
                for _ in range(comparisons):
                    assert isinstance(child, Parent)

    return benchmark


def issubclass_parent(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("issubclass(Child, Parent)", iterations=classes * comparisons)
    with benchmark.memory():
        class Parent(metaclass=metaclass):
            pass

        def generate_class():
            class Child(Parent):
                pass
            return Child

        generated = [generate_class() for _ in range(classes)]
        with benchmark.run():
            for child in generated:
                for _ in range(comparisons):
                    assert issubclass(child, Parent)

    return benchmark


def isinstance_grandparent(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("isinstance(child, Grandparent)", iterations=classes * comparisons)
    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            pass

        def generate_object():
            class Child(Parent):
                pass
            return Child()

        generated = [generate_object() for _ in range(classes)]
        with benchmark.run():
            for child in generated:
                for _ in range(comparisons):
                    assert isinstance(child, Grandparent)

    return benchmark

def issubclass_grandparent(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("issubclass(Child, Grandparent)", iterations=classes * comparisons)
    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            pass

        def generate_class():
            class Child(Parent):
                pass
            return Child

        generated = [generate_class() for _ in range(classes)]
        with benchmark.run():
            for child in generated:
                for _ in range(comparisons):
                    assert issubclass(child, Grandparent)

    return benchmark


def isinstance_sibling(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not isinstance(child, Sibling)", iterations=classes * comparisons)
    with benchmark.memory():
        class Parent(metaclass=metaclass):
            pass

        def generate_objects():
            class Child(Parent):
                pass

            class Sibling(Parent):
                pass

            return Child(), Sibling

        generated = [generate_objects() for _ in range(classes)]
        with benchmark.run():
            for child, sibling_class in generated:
                for _ in range(comparisons):
                    assert not isinstance(child, sibling_class)

    return benchmark


def issubclass_sibling(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not issubclass(Child, Sibling)", iterations=classes * comparisons)
    with benchmark.memory():
        class Parent(metaclass=metaclass):
            pass

        def generate_classes():
            class Child(Parent):
                pass

            class Sibling(Parent):
                pass

            return Child, Sibling

        generated = [generate_classes() for _ in range(classes)]
        with benchmark.run():
            for child_class, sibling_class in generated:
                for _ in range(comparisons):
                    assert not issubclass(child_class, sibling_class)

    return benchmark


def isinstance_cousin(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not isinstance(child, Cousin)", iterations=classes * comparisons)
    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            pass

        class Uncle(Grandparent):
            pass

        def generate_objects():
            class Child(Parent):
                pass

            class Cousin(Uncle):
                pass

            return Child(), Cousin

        generated = [generate_objects() for _ in range(classes)]
        with benchmark.run():
            for child, cousin_class in generated:
                for _ in range(comparisons):
                    assert not isinstance(child, cousin_class)

    return benchmark


def issubclass_cousin(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not issubclass(Child, Cousin)", iterations=classes * comparisons)
    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            pass

        class Uncle(Grandparent):
            pass

        def generate_classes():
            class Child(Parent):
                pass

            class Cousin(Uncle):
                pass

            return Child, Cousin

        generated = [generate_classes() for _ in range(classes)]
        with benchmark.run():
            for child_class, cousin_class in generated:
                for _ in range(comparisons):
                    assert not issubclass(child_class, cousin_class)

    return benchmark


def isinstance_uncle(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not isinstance(child, Uncle)", iterations=classes * comparisons)
    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            pass

        class Uncle(Grandparent):
            pass

        def generate_objects():
            class Child(Parent):
                pass

            class Cousin(Uncle):
                pass

            return Child(), Cousin

        generated = [generate_objects() for _ in range(classes)]
        with benchmark.run():
            for child, _ in generated:
                for _ in range(comparisons):
                    assert not isinstance(child, Uncle)

    return benchmark


def issubclass_uncle(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not issubclass(Child, Uncle)", iterations=classes * comparisons)
    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            pass

        class Uncle(Grandparent):
            pass

        def generate_classes():
            class Child(Parent):
                pass

            class Cousin(Uncle):
                pass

            return Child, Cousin

        generated = [generate_classes() for _ in range(classes)]
        with benchmark.run():
            for child_class, _ in generated:
                for _ in range(comparisons):
                    assert not issubclass(child_class, Uncle)

    return benchmark





def isinstance_parent_via_register(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("isinstance(child, Parent.register)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Parent(metaclass=metaclass):
            pass

        def generate_object():
            class Child:
                pass

            Parent.register(Child)
            return Child()

        generated = [generate_object() for _ in range(classes)]
        with benchmark.run():
            for child in generated:
                for _ in range(comparisons):
                    assert isinstance(child, Parent)

    return benchmark


def issubclass_parent_via_register(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("issubclass(Child, Parent.register)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Parent(metaclass=metaclass):
            pass

        def generate_class():
            class Child:
                pass

            Parent.register(Child)
            return Child

        generated = [generate_class() for _ in range(classes)]
        with benchmark.run():
            for child_class in generated:
                for _ in range(comparisons):
                    assert issubclass(child_class, Parent)

    return benchmark


def isinstance_grandparent_via_register(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("isinstance(child, Grandparent.register)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent:
            pass

        Grandparent.register(Parent)

        def generate_object():
            class Child(Parent):
                pass

            return Child()

        generated = [generate_object() for _ in range(classes)]
        with benchmark.run():
            for child in generated:
                for _ in range(comparisons):
                    assert isinstance(child, Grandparent)

    return benchmark


def issubclass_grandparent_via_register(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("issubclass(child, Grandparent.register)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent:
            pass

        Grandparent.register(Parent)

        def generate_class():
            class Child(Parent):
                pass

            return Child

        generated = [generate_class() for _ in range(classes)]
        with benchmark.run():
            for child in generated:
                for _ in range(comparisons):
                    assert issubclass(child, Grandparent)

    return benchmark


def isinstance_sibling_via_register(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not isinstance(child, Sibling.register)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Parent(metaclass=metaclass):
            pass

        def generate_object():
            class Child:
                pass

            class Sibling:
                pass

            Parent.register(Child)
            Parent.register(Sibling)
            return Child(), Sibling

        generated = [generate_object() for _ in range(classes)]
        with benchmark.run():
            for child, sibling_class in generated:
                for _ in range(comparisons):
                    assert not isinstance(child, sibling_class)

    return benchmark


def issubclass_sibling_via_register(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not issubclass(Child, Sibling.register)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Parent(metaclass=metaclass):
            pass

        def generate_class():
            class Child:
                pass

            class Sibling:
                pass

            Parent.register(Child)
            Parent.register(Sibling)
            return Child, Sibling

        generated = [generate_class() for _ in range(classes)]
        with benchmark.run():
            for child_class, sibling_class in generated:
                for _ in range(comparisons):
                    assert not issubclass(child_class, sibling_class)

    return benchmark


def isinstance_cousin_via_register(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not isinstance(child, Cousin.register)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            pass

        class Uncle(Grandparent):
            pass

        def generate_object():
            class Child:
                pass

            class Cousin:
                pass

            Parent.register(Child)
            Uncle.register(Cousin)
            return Child(), Cousin

        generated = [generate_object() for _ in range(classes)]
        with benchmark.run():
            for child, cousin_class in generated:
                for _ in range(comparisons):
                    assert not isinstance(child, cousin_class)

    return benchmark


def issubclass_cousin_via_register(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not issubclass(child, Cousin.register)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            pass

        class Uncle(Grandparent):
            pass

        def generate_class():
            class Child:
                pass

            class Cousin:
                pass

            Parent.register(Child)
            Uncle.register(Cousin)
            return Child, Cousin

        generated = [generate_class() for _ in range(classes)]
        with benchmark.run():
            for child, cousin_class in generated:
                for _ in range(comparisons):
                    assert not issubclass(child, cousin_class)

    return benchmark


def isinstance_uncle_via_register(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not isinstance(child, Uncle.register)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            pass

        class Uncle(Grandparent):
            pass

        def generate_object():
            class Child:
                pass

            class Cousin:
                pass

            Parent.register(Child)
            Uncle.register(Cousin)
            return Child(), Cousin

        generated = [generate_object() for _ in range(classes)]
        with benchmark.run():
            for child, _ in generated:
                for _ in range(comparisons):
                    assert not isinstance(child, Uncle)

    return benchmark


def issubclass_uncle_via_register(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not issubclass(child, Uncle.register)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            pass

        class Uncle(Grandparent):
            pass

        def generate_class():
            class Child:
                pass

            class Cousin:
                pass

            Parent.register(Child)
            Uncle.register(Cousin)
            return Child, Cousin

        generated = [generate_class() for _ in range(classes)]
        with benchmark.run():
            for child, _ in generated:
                for _ in range(comparisons):
                    assert not issubclass(child, Uncle)

    return benchmark









def isinstance_parent_via_subclasses(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("isinstance(child, Parent.__subclasses__)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Parent(metaclass=metaclass):
            subclasses: list[type] = []

            @classmethod
            def __subclasses__(cls):
                return cls.subclasses

        def generate_object():
            class Child:
                pass

            Parent.subclasses.append(Child)
            return Child()

        generated = [generate_object() for _ in range(classes)]
        with benchmark.run():
            for child in generated:
                for _ in range(comparisons):
                    assert isinstance(child, Parent)

    return benchmark


def issubclass_parent_via_subclasses(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("issubclass(Child, Parent.__subclasses__)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Parent(metaclass=metaclass):
            subclasses: list[type] = []

            @classmethod
            def __subclasses__(cls):
                return cls.subclasses

        def generate_class():
            class Child:
                pass

            Parent.subclasses.append(Child)
            return Child

        generated = [generate_class() for _ in range(classes)]
        with benchmark.run():
            for child_class in generated:
                for _ in range(comparisons):
                    assert issubclass(child_class, Parent)

    return benchmark


def isinstance_grandparent_via_subclasses(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("isinstance(child, Grandparent.__subclasses__)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            subclasses: list[type] = []

            @classmethod
            def __subclasses__(cls):
                return cls.subclasses

        def generate_object():
            class Child:
                pass

            Parent.subclasses.append(Child)
            return Child()

        generated = [generate_object() for _ in range(classes)]
        with benchmark.run():
            for child in generated:
                for _ in range(comparisons):
                    assert isinstance(child, Grandparent)

    return benchmark


def issubclass_grandparent_via_subclasses(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("issubclass(child, Grandparent.__subclasses__)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            subclasses: list[type] = []

            @classmethod
            def __subclasses__(cls):
                return cls.subclasses

        def generate_class():
            class Child:
                pass

            Parent.subclasses.append(Child)
            return Child

        generated = [generate_class() for _ in range(classes)]
        with benchmark.run():
            for child in generated:
                for _ in range(comparisons):
                    assert issubclass(child, Grandparent)

    return benchmark


def isinstance_sibling_via_subclasses(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not isinstance(child, Sibling.__subclasses__)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Parent(metaclass=metaclass):
            subclasses: list[type] = []

            @classmethod
            def __subclasses__(cls):
                return cls.subclasses

        def generate_object():
            class Child:
                pass

            class Sibling:
                pass

            Parent.subclasses.append(Child)
            Parent.subclasses.append(Sibling)
            return Child(), Sibling

        generated = [generate_object() for _ in range(classes)]
        with benchmark.run():
            for child, sibling_class in generated:
                for _ in range(comparisons):
                    assert not isinstance(child, sibling_class)

    return benchmark


def issubclass_sibling_via_subclasses(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not issubclass(Child, Sigling.__subclasses__)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Parent(metaclass=metaclass):
            subclasses: list[type] = []

            @classmethod
            def __subclasses__(cls):
                return cls.subclasses

        def generate_class():
            class Child:
                pass

            class Sibling:
                pass

            Parent.subclasses.append(Child)
            Parent.subclasses.append(Sibling)
            return Child, Sibling

        generated = [generate_class() for _ in range(classes)]
        with benchmark.run():
            for child_class, sibling_class in generated:
                for _ in range(comparisons):
                    assert not issubclass(child_class, sibling_class)

    return benchmark


def isinstance_cousin_via_subclasses(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not isinstance(child, Cousin.__subclasses__)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            subclasses: list[type] = []

            @classmethod
            def __subclasses__(cls):
                return cls.subclasses

        class Uncle(Grandparent):
            subclasses: list[type] = []

            @classmethod
            def __subclasses__(cls):
                return cls.subclasses

        def generate_object():
            class Child:
                pass

            class Cousin:
                pass

            Parent.subclasses.append(Child)
            Uncle.subclasses.append(Cousin)
            return Child(), Cousin

        generated = [generate_object() for _ in range(classes)]
        with benchmark.run():
            for child, cousin_class in generated:
                for _ in range(comparisons):
                    assert not isinstance(child, cousin_class)

    return benchmark


def issubclass_cousin_via_subclasses(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not issubclass(child, Cousin.__subclasses__)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            subclasses: list[type] = []

            @classmethod
            def __subclasses__(cls):
                return cls.subclasses

        class Uncle(Grandparent):
            subclasses: list[type] = []

            @classmethod
            def __subclasses__(cls):
                return cls.subclasses

        def generate_class():
            class Child:
                pass

            class Cousin:
                pass

            Parent.subclasses.append(Child)
            Uncle.subclasses.append(Cousin)
            return Child, Cousin

        generated = [generate_class() for _ in range(classes)]
        with benchmark.run():
            for child, cousin_class in generated:
                for _ in range(comparisons):
                    assert not issubclass(child, cousin_class)

    return benchmark


def isinstance_uncle_via_subclasses(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not isinstance(child, Uncle.__subclasses__)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            subclasses: list[type] = []

            @classmethod
            def __subclasses__(cls):
                return cls.subclasses

        class Uncle(Grandparent):
            subclasses: list[type] = []

            @classmethod
            def __subclasses__(cls):
                return cls.subclasses

        def generate_object():
            class Child:
                pass

            class Cousin:
                pass

            Parent.subclasses.append(Child)
            Uncle.subclasses.append(Cousin)
            return Child(), Cousin

        generated = [generate_object() for _ in range(classes)]
        with benchmark.run():
            for child, _ in generated:
                for _ in range(comparisons):
                    assert not isinstance(child, Uncle)

    return benchmark


def issubclass_uncle_via_subclasses(metaclass: type, classes: int, comparisons: int) -> Benchmark:
    benchmark = Benchmark("not issubclass(child, Uncle.__subclasses__)", iterations=classes * comparisons)

    if metaclass is type:
        return benchmark.skip()

    with benchmark.memory():
        class Grandparent(metaclass=metaclass):
            pass

        class Parent(Grandparent):
            subclasses: list[type] = []

            @classmethod
            def __subclasses__(cls):
                return cls.subclasses

        class Uncle(Grandparent):
            subclasses: list[type] = []

            @classmethod
            def __subclasses__(cls):
                return cls.subclasses

        def generate_class():
            class Child:
                pass

            class Cousin:
                pass

            Parent.subclasses.append(Child)
            Uncle.subclasses.append(Cousin)
            return Child, Cousin

        generated = [generate_class() for _ in range(classes)]
        with benchmark.run():
            for child, _ in generated:
                for _ in range(comparisons):
                    assert not issubclass(child, Uncle)

    return benchmark





test_cases = [
    isinstance_parent,
    issubclass_parent,
    isinstance_grandparent,
    issubclass_grandparent,
    isinstance_sibling,
    issubclass_sibling,
    isinstance_cousin,
    issubclass_cousin,
    isinstance_uncle,
    issubclass_uncle,
    #
    isinstance_parent_via_register,
    issubclass_parent_via_register,
    isinstance_grandparent_via_register,
    issubclass_grandparent_via_register,
    isinstance_sibling_via_register,
    issubclass_sibling_via_register,
    isinstance_cousin_via_register,
    issubclass_cousin_via_register,
    isinstance_uncle_via_register,
    issubclass_uncle_via_register,
    #
    isinstance_parent_via_subclasses,
    issubclass_parent_via_subclasses,
    isinstance_grandparent_via_subclasses,
    issubclass_grandparent_via_subclasses,
    isinstance_sibling_via_subclasses,
    issubclass_sibling_via_subclasses,
    isinstance_cousin_via_subclasses,
    issubclass_cousin_via_subclasses,
    isinstance_uncle_via_subclasses,
    issubclass_uncle_via_subclasses
]

def get_parser():
    parser = argparse.ArgumentParser()
    parser.add_argument("--metaclass", type=str, default="abc.ABCMeta", choices=["abc.ABCMeta", "_py_abc.ABCMeta", "builtins.type"], help="ABCMeta implementation")
    parser.add_argument("--case", nargs="*", type=str, default="all", help="Test case name, or 'isinstance' or 'issubclass', or 'all'")
    parser.add_argument("--rounds", type=int, default=3, help="Number of times to run each test case")
    parser.add_argument("--classes", type=int, default=3_000, help="Number of classes to generate within each test case")
    parser.add_argument("--comparisons", type=int, default=1000, help="Number of per-class comparisons within each test case")
    return parser


def parse_args(argv=None):
    cases: dict[str, list[Callable]] = {case.__name__: [case] for case in test_cases}
    cases["isinstance"] = [case for case in test_cases if "isinstance" in case.__name__]
    cases["issubclass"] = [case for case in test_cases if "issubclass" in case.__name__]
    cases["all"] = test_cases

    parser = get_parser()
    args = parser.parse_args(argv)
    selected = []
    for name, functions in cases.items():
        for func in functions:
            if name in args.case:
                selected.append(func)

    module, klass = args.metaclass.rsplit(".", 1)
    metaclass = getattr(__import__(module), klass)

    return metaclass, selected, args.rounds, args.classes, args.comparisons


if __name__ == "__main__":
    metaclass, selected, rounds, classes, comparisons = parse_args()
    print(f"Implementation: {metaclass}")
    print(f"Rounds: {rounds}")
    print(f"Classes: {classes}")
    print(f"Comparisons: {comparisons}")

    print("=" * WIDTH)
    print(f"Memory before tests: {format_memory(current_memory())}")
    results: dict[str, BenchmarkCollection] = {}
    for testcase in selected:
        for _ in range(rounds):
            benchmark = testcase(metaclass, classes, comparisons)
            if testcase.__name__ not in results:
                results[testcase.__name__] = BenchmarkCollection(benchmark.testcase)
            results[testcase.__name__].add(benchmark)
    print(f"Memory after tests: {format_memory(current_memory())}")
    print("=" * WIDTH)

    for item in results.values():
        item.report()
sudo ./python -m pyperf system tune
taskset -c 0 ./python benchmark.py --metaclass abc.ABCMeta --rounds 3 --classes 5000
taskset -c 0 ./python benchmark.py --metaclass _py_abc.ABCMeta --rounds 3 --classes 5000
Impl Max memory before, MB Max memory after, MB
_abc 6332 48
_py_abc 4423 59
Impl Total time before Total time after
_abc 19m 57s 3m 31s
_py_abc 17m 14s 9m 43s
Check Impl before after Impl before after
isinstance(child, Parent) _abc 0.194us
2MiB...15MiB
0.185us
3MiB...15MiB
_py_abc 0.337us
10MiB...24MiB
0.335us
10MiB...24MiB
issubclass(Child, Parent) _abc 0.179us
0MiB...1MiB
0.177us
0MiB...1MiB
_py_abc 0.326us
6MiB...8MiB
0.326us
5MiB...8MiB
isinstance(child, Grandparent) _abc 0.188us
0MiB...2MiB
0.182us
0MiB...1MiB
_py_abc 0.331us
4MiB...7MiB
0.334us
4MiB...7MiB
issubclass(Child, Grandparent) _abc 0.173us
0MiB
0.167us
0MiB
_py_abc 0.324us
1MiB...2MiB
0.324us
0MiB...1MiB
not isinstance(child, Sibling) _abc 0.189us
1MiB...14MiB
0.183us
1MiB...14MiB
_py_abc 0.567us
14MiB...24MiB
0.559us
13MiB...22MiB
not issubclass(Child, Sibling) _abc 0.172us
0MiB
0.167us
0MiB...1MiB
_py_abc 0.526us
9MiB...11MiB
0.520us
8MiB...11MiB
not isinstance(child, Cousin) _abc 0.189us
0MiB
0.184us
1MiB...2MiB
_py_abc 0.558us
8MiB...10MiB
0.555us
7MiB...9MiB
not issubclass(Child, Cousin) _abc 0.172us
0MiB
0.167us
0MiB...1MiB
_py_abc 0.522us
5MiB...6MiB
0.521us
3MiB...4MiB
not isinstance(child, Uncle) _abc 8.126us
5706MiB...6331MiB
3.219us
0MiB...1MiB
_py_abc 13.092us
3796MiB...4423MiB
10.085us
6MiB
not issubclass(Child, Uncle) _abc 8.029us
5704MiB
3.216us
0MiB
_py_abc 13.174us
3794MiB...3795MiB
10.085us
4MiB

Memory increment is measured during isinstance() / issubclass() calls, not during preparation, like class creation or registration where actual registry allocation is performed. So memory usage in tables below is almost always 0.

Timing drop for 2 first rows is mostly due to cls._abc_cache.add(scls) call within def register(slc, scls) which wasn't implemented before.

Check Impl before after Impl before after
isinstance(child, Parent.register) _abc 0.413us
0MiB
0.182us
0MiB
_py_abc 0.689us
0MiB
0.422us
0MiB
issubclass(Child, Parent.register) _abc 0.249us
0MiB
0.172us
0MiB
_py_abc 0.671us
0MiB
0.405us
0MiB
isinstance(child, Grandparent.register) _abc 0.188us
0MiB
0.184us
0MiB
_py_abc 0.399us
0MiB
0.401us
0MiB
issubclass(Child, Grandparent.register) _abc 0.174us
0MiB
0.169us
0MiB
_py_abc 0.386us
0MiB
0.384us
0MiB
not isinstance(child, Sibling.register) _abc 0.042us
0MiB
0.043us
1MiB
_py_abc 0.042us
0MiB
0.041us
2MiB
not issubclass(Child, Sibling.register) _abc 0.028us
0MiB
0.027us
1MiB
_py_abc 0.028us
0MiB
0.027us
2MiB
not isinstance(child, Cousin.register) _abc 0.042us
0MiB
0.043us
2MiB
_py_abc 0.042us
0MiB
0.041us
3MiB
not issubclass(Child, Cousin.register) _abc 0.028us
0MiB
0.032us
2MiB
_py_abc 0.028us
0MiB
0.028us
3MiB
not isinstance(child, Uncle.register) _abc 0.372us
0MiB
0.375us
2MiB...3MiB
_py_abc 1.350us
0MiB
1.368us
4MiB
not issubclass(Child, Uncle.register) _abc 0.359us
0MiB
0.363us
2MiB
_py_abc 1.321us
0MiB
1.315us
4MiB

This became a bit slower due to new checks, but this is rare case.

Check Impl before after Impl before after
isinstance(child, Parent.__subclasses__) _abc 0.211us
0MiB
0.238us
0MiB
_py_abc 0.472us
0MiB
0.575us
0MiB
issubclass(Child, Parent.__subclasses__) _abc 0.197us
0MiB
0.228us
0MiB
_py_abc 0.458us
0MiB
0.563us
0MiB
isinstance(child, Grandparent.__subclasses__) _abc 0.205us
0MiB
0.235us
0MiB
_py_abc 0.470us
0MiB
0.584us
0MiB
issubclass(Child, Grandparent.__subclasses__) _abc 0.190us
0MiB
0.224us
0MiB
_py_abc 0.458us
0MiB
0.568us
0MiB
not isinstance(child, Sibling.__subclasses__) _abc 0.042us
0MiB
0.043us
0MiB
_py_abc 0.042us
0MiB
0.042us
1MiB
not issubclass(Child, Sibling.__subclasses__) _abc 0.028us
0MiB
0.027us
0MiB
_py_abc 0.028us
0MiB
0.027us
1MiB
not isinstance(child, Cousin.__subclasses__) _abc 0.042us
0MiB
0.043us
0MiB
_py_abc 0.042us
0MiB
0.042us
1MiB
not issubclass(Child, Cousin.__subclasses__) _abc 0.028us
0MiB
0.028us
0MiB
_py_abc 0.028us
0MiB
0.027us
1MiB
not isinstance(child, Uncle.__subclasses__) _abc 0.237us
0MiB
0.381us
1MiB
_py_abc 0.877us
0MiB
1.191us
2MiB
not issubclass(Child, Uncle.__subclasses__) _abc 0.223us
0MiB
0.371us
0MiB
_py_abc 0.839us
0MiB
1.152us
2MiB

Flamegraphs for _py_abc impl and test issubclass_uncle (the most time and memory consuming case on main):
main_vs_pr131914.tar.gz

@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.

@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.

Signed-off-by: Martynov Maxim <martinov_m_s_@mail.ru>
Signed-off-by: Martynov Maxim <martinov_m_s_@mail.ru>
Signed-off-by: Martynov Maxim <martinov_m_s_@mail.ru>
Signed-off-by: Martynov Maxim <martinov_m_s_@mail.ru>
Signed-off-by: Martynov Maxim <martinov_m_s_@mail.ru>
@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

This comment was marked as outdated.

@dolfinus dolfinus marked this pull request as draft November 4, 2025 19:28
@dolfinus
Copy link
Author

dolfinus commented Nov 17, 2025

pyperformance results doesn't show a much difference:

sudo ./venv/bin/python -m pyperf system tune
./venv/bin/python -m pyperformance run --rigorous --affinity 0,1,2,3,4,5

main_cd4d0ae75c_rigorous_affinity.json
pr131914_rigorous_affinity.json

Details

Benchmarks with tag 'apps':

Benchmark main_cd4d0ae75c_rigorous_affinity pr131914_rebased_cd4d0ae75c_rigorous_affinity
2to3 302 ms 299 ms: 1.01x faster
chameleon 18.4 ms 18.7 ms: 1.01x slower
docutils 2.97 sec 2.92 sec: 1.02x faster
sphinx 1.13 sec 1.12 sec: 1.01x faster
tornado_http 114 ms 111 ms: 1.02x faster
Geometric mean (ref) 1.01x faster

Benchmark hidden because not significant (1): html5lib

Benchmarks with tag 'asyncio':

Benchmark main_cd4d0ae75c_rigorous_affinity pr131914_rebased_cd4d0ae75c_rigorous_affinity
async_tree_eager_memoization 225 ms 217 ms: 1.03x faster
async_tree_eager_cpu_io_mixed 536 ms 537 ms: 1.00x slower
async_generators 501 ms 507 ms: 1.01x slower
async_tree_cpu_io_mixed_tg 653 ms 662 ms: 1.01x slower
async_tree_eager_cpu_io_mixed_tg 615 ms 625 ms: 1.02x slower
async_tree_io_tg 665 ms 678 ms: 1.02x slower
async_tree_memoization_tg 350 ms 357 ms: 1.02x slower
async_tree_none_tg 280 ms 286 ms: 1.02x slower
Geometric mean (ref) 1.01x slower

Benchmark hidden because not significant (13): async_tree_eager_tg, asyncio_tcp, async_tree_cpu_io_mixed, async_tree_none, async_tree_eager_io_tg, async_tree_memoization, async_tree_eager, asyncio_websockets, asyncio_tcp_ssl, async_tree_eager_io, coroutines, async_tree_io, async_tree_eager_memoization_tg

Benchmarks with tag 'math':

Benchmark main_cd4d0ae75c_rigorous_affinity pr131914_rebased_cd4d0ae75c_rigorous_affinity
nbody 109 ms 104 ms: 1.04x faster
pidigits 287 ms 288 ms: 1.00x slower
Geometric mean (ref) 1.01x faster

Benchmark hidden because not significant (1): float

Benchmarks with tag 'regex':

Benchmark main_cd4d0ae75c_rigorous_affinity pr131914_rebased_cd4d0ae75c_rigorous_affinity
regex_v8 23.9 ms 23.1 ms: 1.04x faster
regex_effbot 2.88 ms 2.83 ms: 1.02x faster
regex_dna 199 ms 198 ms: 1.00x faster
regex_compile 151 ms 152 ms: 1.01x slower
Geometric mean (ref) 1.01x faster

Benchmarks with tag 'serialize':

Benchmark main_cd4d0ae75c_rigorous_affinity pr131914_rebased_cd4d0ae75c_rigorous_affinity
pickle_dict 31.7 us 30.3 us: 1.05x faster
xml_etree_process 77.6 ms 75.0 ms: 1.03x faster
xml_etree_generate 111 ms 108 ms: 1.03x faster
pickle_list 5.04 us 4.93 us: 1.02x faster
xml_etree_parse 166 ms 163 ms: 1.02x faster
pickle_pure_python 378 us 373 us: 1.01x faster
json_dumps 10.9 ms 10.8 ms: 1.01x faster
json_loads 27.4 us 27.6 us: 1.01x slower
unpickle 15.0 us 15.1 us: 1.01x slower
tomli_loads 2.25 sec 2.34 sec: 1.04x slower
Geometric mean (ref) 1.01x faster

Benchmark hidden because not significant (4): xml_etree_iterparse, unpickle_list, unpickle_pure_python, pickle

Benchmarks with tag 'startup':

Benchmark main_cd4d0ae75c_rigorous_affinity pr131914_rebased_cd4d0ae75c_rigorous_affinity
python_startup_no_site 7.99 ms 8.00 ms: 1.00x slower
python_startup 13.8 ms 13.9 ms: 1.00x slower
Geometric mean (ref) 1.00x slower

Benchmarks with tag 'template':

Benchmark main_cd4d0ae75c_rigorous_affinity pr131914_rebased_cd4d0ae75c_rigorous_affinity
genshi_xml 59.0 ms 58.4 ms: 1.01x faster
genshi_text 25.6 ms 26.9 ms: 1.05x slower
Geometric mean (ref) 1.02x slower

Benchmark hidden because not significant (1): mako

All benchmarks:

Benchmark main_cd4d0ae75c_rigorous_affinity pr131914_rebased_cd4d0ae75c_rigorous_affinity
pickle_dict 31.7 us 30.3 us: 1.05x faster
nbody 109 ms 104 ms: 1.04x faster
go 129 ms 125 ms: 1.04x faster
regex_v8 23.9 ms 23.1 ms: 1.04x faster
xml_etree_process 77.6 ms 75.0 ms: 1.03x faster
async_tree_eager_memoization 225 ms 217 ms: 1.03x faster
fannkuch 460 ms 446 ms: 1.03x faster
unpack_sequence 47.9 ns 46.5 ns: 1.03x faster
hexiom 6.63 ms 6.45 ms: 1.03x faster
xml_etree_generate 111 ms 108 ms: 1.03x faster
pathlib 15.2 ms 14.9 ms: 1.02x faster
nqueens 103 ms 100 ms: 1.02x faster
typing_runtime_protocols 198 us 194 us: 1.02x faster
pickle_list 5.04 us 4.93 us: 1.02x faster
tornado_http 114 ms 111 ms: 1.02x faster
docutils 2.97 sec 2.92 sec: 1.02x faster
xml_etree_parse 166 ms 163 ms: 1.02x faster
regex_effbot 2.88 ms 2.83 ms: 1.02x faster
scimark_monte_carlo 73.9 ms 72.6 ms: 1.02x faster
sqlglot_v2_normalize 130 ms 128 ms: 1.02x faster
dulwich_log 72.3 ms 71.2 ms: 1.02x faster
raytrace 325 ms 321 ms: 1.01x faster
sqlglot_v2_optimize 64.9 ms 64.0 ms: 1.01x faster
chaos 66.1 ms 65.2 ms: 1.01x faster
scimark_lu 129 ms 127 ms: 1.01x faster
pickle_pure_python 378 us 373 us: 1.01x faster
xdsl_constant_fold 52.4 ms 51.8 ms: 1.01x faster
sphinx 1.13 sec 1.12 sec: 1.01x faster
pyflate 459 ms 455 ms: 1.01x faster
mdp 1.48 sec 1.46 sec: 1.01x faster
genshi_xml 59.0 ms 58.4 ms: 1.01x faster
scimark_fft 309 ms 306 ms: 1.01x faster
2to3 302 ms 299 ms: 1.01x faster
dask 1.38 sec 1.37 sec: 1.01x faster
bpe_tokeniser 5.31 sec 5.28 sec: 1.01x faster
json_dumps 10.9 ms 10.8 ms: 1.01x faster
deepcopy_memo 29.3 us 29.1 us: 1.01x faster
regex_dna 199 ms 198 ms: 1.00x faster
python_startup_no_site 7.99 ms 8.00 ms: 1.00x slower
python_startup 13.8 ms 13.9 ms: 1.00x slower
pidigits 287 ms 288 ms: 1.00x slower
async_tree_eager_cpu_io_mixed 536 ms 537 ms: 1.00x slower
crypto_pyaes 80.9 ms 81.2 ms: 1.00x slower
pprint_pformat 1.85 sec 1.86 sec: 1.00x slower
create_gc_cycles 2.40 ms 2.41 ms: 1.00x slower
logging_format 7.41 us 7.45 us: 1.01x slower
regex_compile 151 ms 152 ms: 1.01x slower
json_loads 27.4 us 27.6 us: 1.01x slower
richards_super 57.5 ms 58.0 ms: 1.01x slower
unpickle 15.0 us 15.1 us: 1.01x slower
logging_simple 6.51 us 6.58 us: 1.01x slower
async_generators 501 ms 507 ms: 1.01x slower
async_tree_cpu_io_mixed_tg 653 ms 662 ms: 1.01x slower
chameleon 18.4 ms 18.7 ms: 1.01x slower
pprint_safe_repr 906 ms 919 ms: 1.01x slower
comprehensions 18.6 us 18.9 us: 1.02x slower
async_tree_eager_cpu_io_mixed_tg 615 ms 625 ms: 1.02x slower
scimark_sparse_mat_mult 4.70 ms 4.79 ms: 1.02x slower
async_tree_io_tg 665 ms 678 ms: 1.02x slower
spectral_norm 106 ms 108 ms: 1.02x slower
async_tree_memoization_tg 350 ms 357 ms: 1.02x slower
async_tree_none_tg 280 ms 286 ms: 1.02x slower
richards 49.4 ms 50.5 ms: 1.02x slower
tomli_loads 2.25 sec 2.34 sec: 1.04x slower
genshi_text 25.6 ms 26.9 ms: 1.05x slower
coverage 125 ms 135 ms: 1.08x slower
Geometric mean (ref) 1.00x faster

Benchmark hidden because not significant (38): xml_etree_iterparse, unpickle_list, bench_mp_pool, subparsers, sqlglot_v2_transpile, sqlalchemy_declarative, async_tree_eager_tg, many_optionals, unpickle_pure_python, deltablue, meteor_contest, sqlalchemy_imperative, html5lib, asyncio_tcp, bench_thread_pool, async_tree_cpu_io_mixed, async_tree_none, async_tree_eager_io_tg, telco, generators, sqlglot_v2_parse, async_tree_memoization, scimark_sor, pickle, gc_traversal, async_tree_eager, asyncio_websockets, sqlite_synth, asyncio_tcp_ssl, float, deepcopy, deepcopy_reduce, mako, logging_silent, async_tree_eager_io, coroutines, async_tree_io, async_tree_eager_memoization_tg

@dolfinus dolfinus marked this pull request as ready for review November 17, 2025 19:18
@dolfinus dolfinus marked this pull request as draft November 17, 2025 20:18
@dolfinus dolfinus marked this pull request as ready for review November 17, 2025 23:50
@dolfinus
Copy link
Author

dolfinus commented Nov 18, 2025

I've updated microbenchmark script and results in the PR description: #131914 (comment).

Previous microbenchmark implementation created global large class tree, and tested different isinstance/issubclass cases on it. New version creates a dedicated class tree for each check, and destroys unused classes after each check. This makes results more reliable.

According to these results, and to pyperformance run on my machine, timing is not that different comparing to main. Microbenchmark results can drift in 2-5% range between runs.

@dolfinus dolfinus requested a review from picnixz November 24, 2025 22:05
@dolfinus
Copy link
Author

dolfinus commented Dec 4, 2025

Alternative implementation which eliminates for scls in cls.__subclasses__() in by bubbling up cls.register(subclass) up to the root ABC class: #141171. IMHO that's a more elegant solution.

@picnixz
Copy link
Member

picnixz commented Dec 14, 2025

I've seen your work but I really don't have enough time to estimate the impact for all cases. It's not that it's a bad change but ABC is extremely used and even a tiny change should be carefully considered. Now that I also have a job, I've got less time for reviews so I'd like to ask @JelleZijlstra to possibly have a look at either this PR or the alternative if he's available.

  • If we consider this a bugfix, we can take as much time as we want (but I think it's better to consider this a new feature even if it's fixing performance issues)
  • If we consider this a new feature for 3.15, we have until May 2026.

@dolfinus
Copy link
Author

dolfinus commented Dec 14, 2025

Thanks @picnixz, I understand your point. There is no rush here, the change can be landed any time.
Regarding potential ABCMeta behavior change, I can split this PR into 2 smaller ones - one including only new tests, and another one with recursion guard, to show that behavior remains the same.

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.

5 participants