Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Backport performance improvements to runtime-checkable protocols #137

Merged
merged 4 commits into from
Apr 12, 2023

Conversation

AlexWaygood
Copy link
Member

@AlexWaygood AlexWaygood commented Apr 12, 2023

This PR backports the following CPython PRs (all by me), which, taken together, substantially improve the performance of isinstance() checks against runtime-checkable protocols on Python 3.12 compared to Python 3.11:

Here is a benchmark script:
import time
from typing_extensions import Protocol, runtime_checkable

@runtime_checkable
class HasX(Protocol):
    x: int

@runtime_checkable
class SupportsInt(Protocol):
    def __int__(self) -> int: ...

@runtime_checkable
class SupportsIntAndX(Protocol):
    x: int
    def __int__(self) -> int: ...

class Empty:
    description = "Empty class with no attributes"

class Registered:
    description = "Subclass registered using ABCMeta.register"

HasX.register(Registered)
SupportsInt.register(Registered)
SupportsIntAndX.register(Registered)

class PropertyX:
    description = "Class with a property x"
    @property
    def x(self) -> int:
        return 42

class HasIntMethod:
    description = "Class with an __int__ method"
    def __int__(self):
        return 42

class PropertyXWithInt:
    description = "Class with a property x and an __int__ method"
    @property
    def x(self) -> int:
        return 42
    def __int__(self):
        return 42

class ClassVarX:
    description = "Class with a ClassVar x"
    x = 42

class ClassVarXWithInt:
    description = "Class with a ClassVar x and an __int__ method"
    x = 42
    def __int__(self):
        return 42

class InstanceVarX:
    description = "Class with an instance var x"
    def __init__(self):
        self.x = 42

class InstanceVarXWithInt:
    description = "Class with an instance var x and an __int__ method"
    def __init__(self):
        self.x = 42
    def __int__(self):
        return 42
    
class NominalX(HasX):
    description = "Class that explicitly subclasses HasX"
    def __init__(self):
        self.x = 42

class NominalSupportsInt(SupportsInt):
    description = "Class that explicitly subclasses SupportsInt"
    def __int__(self):
        return 42

class NominalXWithInt(SupportsIntAndX):
    description = "Class that explicitly subclasses NominalXWithInt"
    def __init__(self):
        self.x = 42


num_instances = 500_000

classes = {}
for cls in (
    Empty, Registered, PropertyX, PropertyXWithInt, ClassVarX, ClassVarXWithInt,
    InstanceVarX, InstanceVarXWithInt, NominalX, NominalXWithInt, HasIntMethod,
    NominalSupportsInt
):
    classes[cls] = [cls() for _ in range(num_instances)]


def bench(objs, title, protocol):
    start_time = time.perf_counter()
    for obj in objs:
        isinstance(obj, protocol)
    elapsed = time.perf_counter() - start_time
    print(f"{title}: {elapsed:.2f}")


print("Protocols with no callable members\n")
for cls in Empty, Registered, PropertyX, ClassVarX, InstanceVarX, NominalX:
    bench(classes[cls], cls.description, HasX)

print("\nProtocols with only callable members\n")
for cls in Empty, Registered, HasIntMethod, NominalSupportsInt:
    bench(classes[cls], cls.description, SupportsInt)

print("\nProtocols with callable and non-callable members\n")
for cls in (
    Empty, Registered, PropertyXWithInt, ClassVarXWithInt, InstanceVarXWithInt,
    NominalXWithInt
):
    bench(classes[cls], cls.description, SupportsIntAndX)
Benchmark results on `main`, using Python 3.11
Protocols with no callable members

Empty class with no attributes: 4.29
Subclass registered using ABCMeta.register: 20.07
Class with a property x: 14.12
Class with a ClassVar x: 14.08
Class with an instance var x: 14.02
Class that explicitly subclasses HasX: 14.04

Protocols with only callable members

Empty class with no attributes: 14.14
Subclass registered using ABCMeta.register: 21.59
Class with an __int__ method: 7.06
Class that explicitly subclasses SupportsInt: 7.04

Protocols with callable and non-callable members

Empty class with no attributes: 15.43
Subclass registered using ABCMeta.register: 24.64
Class with a property x and an __int__ method: 15.52
Class with a ClassVar x and an __int__ method: 15.28
Class with an instance var x and an __int__ method: 15.27
Class that explicitly subclasses NominalXWithInt: 15.28
Benchmark results with this PR branch, using Python 3.11
Protocols with no callable members

Empty class with no attributes: 0.58
Subclass registered using ABCMeta.register: 0.56
Class with a property x: 0.27
Class with a ClassVar x: 0.26
Class with an instance var x: 0.26
Class that explicitly subclasses HasX: 0.21

Protocols with only callable members

Empty class with no attributes: 0.59
Subclass registered using ABCMeta.register: 1.27
Class with an __int__ method: 0.19
Class that explicitly subclasses SupportsInt: 0.19

Protocols with callable and non-callable members

Empty class with no attributes: 0.60
Subclass registered using ABCMeta.register: 1.57
Class with a property x and an __int__ method: 1.03
Class with a ClassVar x and an __int__ method: 0.97
Class with an instance var x and an __int__ method: 0.97
Class that explicitly subclasses NominalXWithInt: 0.67

Note that if we choose to backport python/cpython#103034 (using inspect.getattr_static instead of getattr in _ProtocolMeta.__instancecheck__), it will undo a lot of the performance improvements that this PR brings. I leave the decision of whether or not to backport that PR to another PR/issue.

@AlexWaygood AlexWaygood marked this pull request as ready for review April 12, 2023 11:34
Comment on lines -445 to +442
tvars = typing._collect_type_vars(cls.__orig_bases__)
tvars = _collect_type_vars(cls.__orig_bases__)
Copy link
Member Author

@AlexWaygood AlexWaygood Apr 12, 2023

Choose a reason for hiding this comment

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

This change was required because typing._collect_type_vars doesn't exist on Python 3.11 (but typing_extensions._collect_type_vars does), and with this PR, we now re-implement Protocol on <=3.11, whereas we previously only re-implemented it on <=3.9

Comment on lines +1834 to +1835
def _is_unpack(obj):
return get_origin(obj) is Unpack
Copy link
Member Author

Choose a reason for hiding this comment

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

This change was required in order for tests to pass on Python 3.11. Without this change, a function somewhere was failing with NameError when the tests were run on Python 3.11, because the function was calling _is_unpack(), and we previously only defined _is_unpack on Python <=3.10.

@AlexWaygood
Copy link
Member Author

AlexWaygood commented Apr 12, 2023

We could possibly also backport "fast versions" of the runtime-checkable protocols that the typing module provides: https://docs.python.org/3/library/typing.html#protocols

As a result of the performance improvements I'm backporting here, isinstance() checks against the typing versions of these protocols should be much faster on 3.12 than it is on 3.11.

@JelleZijlstra JelleZijlstra merged commit 6c93956 into python:main Apr 12, 2023
@AlexWaygood AlexWaygood deleted the improve-protocol-perf branch April 12, 2023 17:07
@Tenzer
Copy link

Tenzer commented May 23, 2023

This change seems to have broken the latest version of pydash when used with typing-extensions 4.6.0. There's a bug report about this here: dgilland/pydash#197.

I did a bisect of every change between typing-extensions 4.5.0 and 4.6.0, and this was pointed out as the culprit. I've had a look at the code changes here but don't have a good enough grasp of what's going wrong that is causing this to fail.

This seems to be the relevant Pydash code that relates to the error:
https://github.com/dgilland/pydash/blob/051fe69c3e523f903a0a0ea6ca6a5c2d4b83a3e7/src/pydash/utilities.py#L577-L638

@AlexWaygood
Copy link
Member Author

Thanks @Tenzer, looks to me like it's probably a bug in typing_extensions. I've opened #181 so we can track this properly.

@Tenzer
Copy link

Tenzer commented May 23, 2023

Cheers!

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

Successfully merging this pull request may close these issues.

3 participants