Skip to content

Conversation

@AlexWaygood
Copy link
Member

@AlexWaygoodAlexWaygood 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:
importtimefromtyping_extensionsimportProtocol, runtime_checkable@runtime_checkableclassHasX(Protocol): x: int@runtime_checkableclassSupportsInt(Protocol): def__int__(self) ->int: ... @runtime_checkableclassSupportsIntAndX(Protocol): x: intdef__int__(self) ->int: ... classEmpty: description="Empty class with no attributes"classRegistered: description="Subclass registered using ABCMeta.register"HasX.register(Registered) SupportsInt.register(Registered) SupportsIntAndX.register(Registered) classPropertyX: description="Class with a property x"@propertydefx(self) ->int: return42classHasIntMethod: description="Class with an __int__ method"def__int__(self): return42classPropertyXWithInt: description="Class with a property x and an __int__ method"@propertydefx(self) ->int: return42def__int__(self): return42classClassVarX: description="Class with a ClassVar x"x=42classClassVarXWithInt: description="Class with a ClassVar x and an __int__ method"x=42def__int__(self): return42classInstanceVarX: description="Class with an instance var x"def__init__(self): self.x=42classInstanceVarXWithInt: description="Class with an instance var x and an __int__ method"def__init__(self): self.x=42def__int__(self): return42classNominalX(HasX): description="Class that explicitly subclasses HasX"def__init__(self): self.x=42classNominalSupportsInt(SupportsInt): description="Class that explicitly subclasses SupportsInt"def__int__(self): return42classNominalXWithInt(SupportsIntAndX): description="Class that explicitly subclasses NominalXWithInt"def__init__(self): self.x=42num_instances=500_000classes={} forclsin ( Empty, Registered, PropertyX, PropertyXWithInt, ClassVarX, ClassVarXWithInt, InstanceVarX, InstanceVarXWithInt, NominalX, NominalXWithInt, HasIntMethod, NominalSupportsInt ): classes[cls] = [cls() for_inrange(num_instances)] defbench(objs, title, protocol): start_time=time.perf_counter() forobjinobjs: isinstance(obj, protocol) elapsed=time.perf_counter() -start_timeprint(f"{title}: {elapsed:.2f}") print("Protocols with no callable members\n") forclsinEmpty, Registered, PropertyX, ClassVarX, InstanceVarX, NominalX: bench(classes[cls], cls.description, HasX) print("\nProtocols with only callable members\n") forclsinEmpty, Registered, HasIntMethod, NominalSupportsInt: bench(classes[cls], cls.description, SupportsInt) print("\nProtocols with callable and non-callable members\n") forclsin ( 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.

@AlexWaygoodAlexWaygood 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
MemberAuthor

@AlexWaygoodAlexWaygoodApr 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):
returnget_origin(obj) isUnpack
Copy link
MemberAuthor

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
MemberAuthor

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.

@JelleZijlstraJelleZijlstra merged commit 6c93956 into python:mainApr 12, 2023
@AlexWaygoodAlexWaygood deleted the improve-protocol-perf branch April 12, 2023 17:07
@Tenzer
Copy link

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
MemberAuthor

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

Cheers!

Sign up for freeto 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

@AlexWaygood@Tenzer@JelleZijlstra