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.191us...0.213us
2MiB...15MiB
0.185us...0.192us
3MiB...15MiB
_py_abc 0.334us...0.348us
10MiB...23MiB
0.335us...0.346us
10MiB...23MiB
issubclass(Child, Parent) _abc 0.175us...0.182us
0MiB
0.178us...0.189us
0MiB
_py_abc 0.328us...0.329us
6MiB...8MiB
0.330us...0.331us
5MiB...8MiB
isinstance(child, Grandparent) _abc 0.184us...0.188us
0MiB...2MiB
0.181us...0.227us
0MiB...2MiB
_py_abc 0.330us...0.333us
4MiB...6MiB
0.330us...0.374us
3MiB...6MiB
issubclass(Child, Grandparent) _abc 0.166us...0.169us
0MiB
0.166us...0.186us
0MiB
_py_abc 0.326us...0.329us
0MiB...2MiB
0.326us...0.329us
0MiB...1MiB
not isinstance(child, Sibling) _abc 0.184us...0.187us
2MiB...14MiB
0.184us...0.195us
1MiB...14MiB
_py_abc 0.564us...0.576us
14MiB...23MiB
0.564us...0.564us
13MiB...22MiB
not issubclass(Child, Sibling) _abc 0.169us...0.169us
0MiB
0.168us...0.179us
0MiB
_py_abc 0.531us...0.540us
10MiB...12MiB
0.526us...0.528us
7MiB...10MiB
not isinstance(child, Cousin) _abc 0.186us...0.186us
1MiB...2MiB
0.186us...0.201us
0MiB
_py_abc 0.572us...0.589us
8MiB...10MiB
0.565us...0.568us
6MiB...8MiB
not issubclass(Child, Cousin) _abc 0.170us...0.172us
0MiB
0.168us...0.169us
0MiB
_py_abc 0.534us...0.541us
5MiB...6MiB
0.521us...0.528us
2MiB...3MiB
not isinstance(child, Uncle) _abc 8.188us...8.728us
5705MiB...6332MiB
3.441us...3.518us
0MiB
_py_abc 13.098us...13.243us
3797MiB...4423MiB
10.383us...10.494us
5MiB
not issubclass(Child, Uncle) _abc 8.071us...8.358us
5704MiB
3.444us...3.464us
0MiB
_py_abc 13.292us...13.371us
3794MiB...3795MiB
10.336us...10.468us
3MiB

Memory increment is measured during isinstance() / issubclass() calls, not during preparation, like class creation or registration.

Check Impl before after Impl before after
isinstance(child, Parent.register) _abc 0.388us...0.390us
0MiB
0.184us...0.191us
0MiB
_py_abc 0.688us...0.693us
0MiB
0.424us...0.427us
0MiB
issubclass(Child, Parent.register) _abc 0.240us...0.242us
0MiB
0.169us...0.178us
0MiB
_py_abc 0.681us...0.689us
0MiB
0.413us...0.420us
0MiB
isinstance(child, Grandparent.register) _abc 0.184us
0MiB
0.183us...0.191us
0MiB
_py_abc 0.393us...0.397us
0MiB
0.405us...0.409us
0MiB
issubclass(Child, Grandparent.register) _abc 0.170us...0.172us
0MiB
0.168us...0.169us
0MiB
_py_abc 0.384us...0.399us
0MiB
0.393us...0.397us
0MiB
not isinstance(child, Sibling.register) _abc 0.044us
0MiB
0.044us...0.045us
0MiB...2MiB
_py_abc 0.042us...0.043us
0MiB
0.043us
2MiB
not issubclass(Child, Sibling.register) _abc 0.028us
0MiB
0.028us
1MiB
_py_abc 0.028us
0MiB
0.028us
2MiB
not isinstance(child, Cousin.register) _abc 0.044us
0MiB
0.042us
2MiB
_py_abc 0.044us
0MiB
0.044us
3MiB
not issubclass(Child, Cousin.register) _abc 0.028us
0MiB
0.028us
2MiB
_py_abc 0.028us
0MiB
0.027us...0.028us
3MiB
not isinstance(child, Uncle.register) _abc 0.372us...0.382us
0MiB
0.386us...0.397us
2MiB...3MiB
_py_abc 1.352us...1.434us
0MiB
1.344us...1.365us
4MiB
not issubclass(Child, Uncle.register) _abc 0.356us...0.365us
0MiB
0.377us...0.378us
2MiB
_py_abc 1.306us...1.316us
0MiB
1.313us...1.342us
4MiB
Check Impl before after Impl before after
isinstance(child, Parent.__subclasses__) _abc 0.202us...0.209us
0MiB
0.241us...0.244us
0MiB
_py_abc 0.463us...0.468us
0MiB
0.590us...0.597us
0MiB
issubclass(Child, Parent.__subclasses__) _abc 0.189us...0.194us
0MiB
0.228us...0.234us
0MiB
_py_abc 0.457us...0.460us
0MiB
0.576us...0.583us
0MiB
isinstance(child, Grandparent.__subclasses__) _abc 0.199us...0.200us
0MiB
0.238us...0.246us
0MiB
_py_abc 0.463us...0.468us
0MiB
0.600us...0.603us
0MiB
issubclass(Child, Grandparent.__subclasses__) _abc 0.184us...0.188us
0MiB
0.221us...0.222us
0MiB
_py_abc 0.451us...0.455us
0MiB
0.586us...0.592us
0MiB
not isinstance(child, Sibling.__subclasses__) _abc 0.044us
0MiB
0.042us...0.043us
0MiB
_py_abc 0.043us
0MiB
0.044us...0.045us
1MiB
not issubclass(Child, Sibling.__subclasses__) _abc 0.028us
0MiB
0.028us
0MiB
_py_abc 0.027us...0.028us
0MiB
0.028us
1MiB
not isinstance(child, Cousin.__subclasses__) _abc 0.044us...0.046us
0MiB
0.042us
0MiB
_py_abc 0.044us
0MiB
0.044us
1MiB
not issubclass(Child, Cousin.__subclasses__) _abc 0.028us
0MiB
0.028us
0MiB
_py_abc 0.028us
0MiB
0.028us
1MiB
not isinstance(child, Uncle.__subclasses__) _abc 0.233us...0.238us
0MiB
0.384us...0.386us
0MiB
_py_abc 0.878us...0.884us
0MiB
1.199us...1.216us
2MiB
not issubclass(Child, Uncle.__subclasses__) _abc 0.216us...0.223us
0MiB
0.369us...0.373us
0MiB
_py_abc 0.835us...0.845us
0MiB
1.169us...1.185us
2MiB

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

@picnixz
Copy link
Member

picnixz commented Aug 4, 2025

Yes, please add both benchmarks.

@dolfinus dolfinus requested a review from AA-Turner as a code owner August 7, 2025 11:38
@dolfinus
Copy link
Author

dolfinus commented Aug 7, 2025

Added scripts and microbenchmark results to PR description

@picnixz
Copy link
Member

picnixz commented Sep 9, 2025

The numbers seem good but I'm a bit worried about the cached vs non-cached class. Or specifically, I'm worried about the regular isinstance(x, T) check where x is cached. It's unfortunately something that became slower. We however gained something when using register() on a true parent so for collections.abc types it's good.

The memory improvements are definitely important. But I really wonder whether the performance impact outweigh the memory improvements. I don't deny that they are good but making it slower is something that could be annoying as well.

As such, I'd like the opinion of other core devs such as @JelleZijlstra @sobolevn @serhiy-storchaka @encukou

@dolfinus
Copy link
Author

dolfinus commented Sep 9, 2025

I'm a bit worried about the cached vs non-cached class. It's unfortunately something that became slower.

Me too. I'm not sure why isinstance became a bit slower in C implementation when issubclass became faster, and isinstance implementation remains unchanged.

Maybe someone could run the same benchmark on other environment to get some numbers to compare. I haven't tested free-threading build, for example.

@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:
main.json
pr131914.json

Benchmarks with tag 'apps':

Benchmark main pr131914
chameleon 18.7 ms 18.6 ms: 1.01x faster
docutils 3.00 sec 3.03 sec: 1.01x slower
html5lib 71.8 ms 72.8 ms: 1.01x slower
tornado_http 134 ms 132 ms: 1.02x faster
Geometric mean (ref) 1.00x slower

Benchmark hidden because not significant (2): 2to3, sphinx

Benchmarks with tag 'asyncio':

Benchmark main pr131914
async_tree_eager_tg 247 ms 242 ms: 1.02x faster
async_generators 506 ms 496 ms: 1.02x faster
async_tree_cpu_io_mixed 676 ms 669 ms: 1.01x faster
async_tree_eager 125 ms 126 ms: 1.01x slower
asyncio_tcp_ssl 1.29 sec 1.31 sec: 1.01x slower
async_tree_cpu_io_mixed_tg 663 ms 672 ms: 1.01x slower
async_tree_eager_cpu_io_mixed_tg 619 ms 633 ms: 1.02x slower
async_tree_none_tg 285 ms 294 ms: 1.03x slower
async_tree_memoization_tg 349 ms 362 ms: 1.04x slower
async_tree_eager_memoization_tg 314 ms 327 ms: 1.04x slower
Geometric mean (ref) 1.01x slower

Benchmark hidden because not significant (11): async_tree_io, async_tree_none, async_tree_eager_memoization, async_tree_eager_io_tg, asyncio_websockets, async_tree_eager_io, async_tree_eager_cpu_io_mixed, coroutines, asyncio_tcp, async_tree_io_tg, async_tree_memoization

Benchmarks with tag 'math':

Benchmark main pr131914
nbody 105 ms 103 ms: 1.02x faster
pidigits 288 ms 290 ms: 1.01x slower
float 73.6 ms 75.1 ms: 1.02x slower
Geometric mean (ref) 1.00x slower

Benchmarks with tag 'regex':

Benchmark main pr131914
regex_compile 153 ms 151 ms: 1.01x faster
regex_dna 191 ms 197 ms: 1.03x slower
regex_v8 22.4 ms 23.3 ms: 1.04x slower
regex_effbot 2.71 ms 2.86 ms: 1.06x slower
Geometric mean (ref) 1.03x slower

Benchmarks with tag 'serialize':

Benchmark main pr131914
unpickle_list 5.02 us 4.95 us: 1.01x faster
tomli_loads 2.26 sec 2.25 sec: 1.00x faster
xml_etree_parse 166 ms 167 ms: 1.01x slower
unpickle_pure_python 252 us 254 us: 1.01x slower
unpickle 15.0 us 15.1 us: 1.01x slower
json_dumps 11.1 ms 11.2 ms: 1.01x slower
xml_etree_generate 110 ms 112 ms: 1.02x slower
xml_etree_process 75.2 ms 76.9 ms: 1.02x slower
pickle_dict 31.2 us 32.0 us: 1.03x slower
Geometric mean (ref) 1.00x slower

Benchmark hidden because not significant (5): pickle_pure_python, pickle_list, xml_etree_iterparse, pickle, json_loads

Benchmarks with tag 'startup':

Benchmark main pr131914
python_startup_no_site 8.15 ms 8.18 ms: 1.00x slower
python_startup 14.0 ms 14.1 ms: 1.00x slower
Geometric mean (ref) 1.00x slower

Benchmarks with tag 'template':

Benchmark main pr131914
mako 12.6 ms 12.5 ms: 1.00x faster
Geometric mean (ref) 1.00x faster

Benchmark hidden because not significant (2): genshi_text, genshi_xml

All benchmarks:

Benchmark main pr131914
bench_mp_pool 72.1 ms 63.4 ms: 1.14x faster
scimark_sparse_mat_mult 5.05 ms 4.76 ms: 1.06x faster
logging_simple 6.64 us 6.51 us: 1.02x faster
async_tree_eager_tg 247 ms 242 ms: 1.02x faster
async_generators 506 ms 496 ms: 1.02x faster
nbody 105 ms 103 ms: 1.02x faster
nqueens 102 ms 100 ms: 1.02x faster
tornado_http 134 ms 132 ms: 1.02x faster
unpickle_list 5.02 us 4.95 us: 1.01x faster
regex_compile 153 ms 151 ms: 1.01x faster
scimark_monte_carlo 73.3 ms 72.4 ms: 1.01x faster
pyflate 462 ms 457 ms: 1.01x faster
async_tree_cpu_io_mixed 676 ms 669 ms: 1.01x faster
dulwich_log 72.4 ms 71.8 ms: 1.01x faster
spectral_norm 107 ms 106 ms: 1.01x faster
pprint_safe_repr 908 ms 901 ms: 1.01x faster
scimark_fft 317 ms 315 ms: 1.01x faster
chameleon 18.7 ms 18.6 ms: 1.01x faster
sqlglot_v2_normalize 130 ms 129 ms: 1.01x faster
fannkuch 442 ms 440 ms: 1.00x faster
pprint_pformat 1.85 sec 1.84 sec: 1.00x faster
mako 12.6 ms 12.5 ms: 1.00x faster
tomli_loads 2.26 sec 2.25 sec: 1.00x faster
sqlalchemy_declarative 144 ms 144 ms: 1.00x faster
bpe_tokeniser 5.33 sec 5.31 sec: 1.00x faster
python_startup_no_site 8.15 ms 8.18 ms: 1.00x slower
python_startup 14.0 ms 14.1 ms: 1.00x slower
xml_etree_parse 166 ms 167 ms: 1.01x slower
bench_thread_pool 1.29 ms 1.30 ms: 1.01x slower
raytrace 320 ms 322 ms: 1.01x slower
mdp 1.47 sec 1.48 sec: 1.01x slower
unpickle_pure_python 252 us 254 us: 1.01x slower
unpack_sequence 48.9 ns 49.2 ns: 1.01x slower
meteor_contest 120 ms 120 ms: 1.01x slower
deltablue 3.63 ms 3.65 ms: 1.01x slower
pidigits 288 ms 290 ms: 1.01x slower
gc_traversal 5.06 ms 5.09 ms: 1.01x slower
unpickle 15.0 us 15.1 us: 1.01x slower
json_dumps 11.1 ms 11.2 ms: 1.01x slower
logging_silent 116 ns 117 ns: 1.01x slower
async_tree_eager 125 ms 126 ms: 1.01x slower
docutils 3.00 sec 3.03 sec: 1.01x slower
generators 34.6 ms 35.1 ms: 1.01x slower
asyncio_tcp_ssl 1.29 sec 1.31 sec: 1.01x slower
async_tree_cpu_io_mixed_tg 663 ms 672 ms: 1.01x slower
html5lib 71.8 ms 72.8 ms: 1.01x slower
richards 50.2 ms 50.9 ms: 1.01x slower
typing_runtime_protocols 194 us 197 us: 1.01x slower
xml_etree_generate 110 ms 112 ms: 1.02x slower
subparsers 46.9 ms 47.8 ms: 1.02x slower
comprehensions 18.4 us 18.8 us: 1.02x slower
pathlib 15.0 ms 15.3 ms: 1.02x slower
chaos 65.1 ms 66.4 ms: 1.02x slower
richards_super 57.5 ms 58.6 ms: 1.02x slower
float 73.6 ms 75.1 ms: 1.02x slower
async_tree_eager_cpu_io_mixed_tg 619 ms 633 ms: 1.02x slower
xml_etree_process 75.2 ms 76.9 ms: 1.02x slower
pickle_dict 31.2 us 32.0 us: 1.03x slower
shortest_path 477 ms 490 ms: 1.03x slower
regex_dna 191 ms 197 ms: 1.03x slower
async_tree_none_tg 285 ms 294 ms: 1.03x slower
regex_v8 22.4 ms 23.3 ms: 1.04x slower
async_tree_memoization_tg 349 ms 362 ms: 1.04x slower
async_tree_eager_memoization_tg 314 ms 327 ms: 1.04x slower
regex_effbot 2.71 ms 2.86 ms: 1.06x slower
go 124 ms 131 ms: 1.06x slower
Geometric mean (ref) 1.00x slower

Benchmark hidden because not significant (41): async_tree_io, async_tree_none, deepcopy_reduce, scimark_lu, coverage, pickle_pure_python, 2to3, async_tree_eager_memoization, deepcopy, genshi_text, pickle_list, sqlite_synth, async_tree_eager_io_tg, k_core, many_optionals, xml_etree_iterparse, pickle, asyncio_websockets, sqlalchemy_imperative, logging_format, deepcopy_memo, scimark_sor, xdsl_constant_fold, sqlglot_v2_optimize, create_gc_cycles, async_tree_eager_io, json_loads, async_tree_eager_cpu_io_mixed, sqlglot_v2_transpile, sqlglot_v2_parse, coroutines, crypto_pyaes, sphinx, asyncio_tcp, hexiom, dask, async_tree_io_tg, genshi_xml, connected_components, telco, async_tree_memoization

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

4 participants