Skip to content

gh-74690: typing._ProtocolMeta.__instancecheck__: Exit early for protocols that only have callable members - #103310

Closed
AlexWaygood wants to merge 1 commit into
python:mainfrom
AlexWaygood:callable-protocols-optimisation
Closed

gh-74690: typing._ProtocolMeta.__instancecheck__: Exit early for protocols that only have callable members#103310
AlexWaygood wants to merge 1 commit into
python:mainfrom
AlexWaygood:callable-protocols-optimisation

Conversation

@AlexWaygood

@AlexWaygoodAlexWaygood commented Apr 6, 2023

Copy link
Copy Markdown
Member

This speeds up this isinstance() call 12x, while maintaining all invariants:

fromtypingimportSupportsIntisinstance(object(), SupportsInt)

The performance of other kinds of isinstance() calls is not impacted.

Benchmark script
importtimefromtypingimportProtocol, runtime_checkable, SupportsInt@runtime_checkableclassHasX(Protocol):
x: 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)
Full benchmark results on `main`
Protocols with no callable members
Empty class with no attributes: 2.46
Subclass registered using ABCMeta.register: 0.45
Class with a property x: 1.40
Class with a ClassVar x: 1.44
Class with an instance var x: 6.41
Class that explicitly subclasses HasX: 0.63
Protocols with only callable members
Empty class with no attributes: 8.41
Subclass registered using ABCMeta.register: 3.32
Class with an __int__ method: 0.61
Class that explicitly subclasses SupportsInt: 0.61
Protocols with callable and non-callable members
Empty class with no attributes: 8.43
Subclass registered using ABCMeta.register: 1.61
Class with a property x and an __int__ method: 9.19
Class with a ClassVar x and an __int__ method: 9.22
Class with an instance var x and an __int__ method: 11.27
Class that explicitly subclasses NominalXWithInt: 0.62
Full benchmark results with this PR
Protocols with no callable members
Empty class with no attributes: 2.46
Subclass registered using ABCMeta.register: 0.45
Class with a property x: 1.41
Class with a ClassVar x: 1.41
Class with an instance var x: 6.40
Class that explicitly subclasses HasX: 0.61
Protocols with only callable members
Empty class with no attributes: 0.68
Subclass registered using ABCMeta.register: 3.36
Class with an __int__ method: 0.63
Class that explicitly subclasses SupportsInt: 0.61
Protocols with callable and non-callable members
Empty class with no attributes: 8.33
Subclass registered using ABCMeta.register: 1.60
Class with a property x and an __int__ method: 9.14
Class with a ClassVar x and an __int__ method: 9.17
Class with an instance var x and an __int__ method: 11.59
Class that explicitly subclasses NominalXWithInt: 0.62

@AlexWaygoodAlexWaygood added type-feature A feature request or enhancement performance Performance or resource usage stdlib Standard Library Python modules in the Lib/ directory topic-typing 3.12 only security fixes labels Apr 6, 2023
@AlexWaygood
AlexWaygood requested a review from carljmApril 6, 2023 10:38
@AlexWaygood

Copy link
Copy Markdown
MemberAuthor

while maintaining all invariants

Argh, I was wrong. While this PR has no impact on behaviour in 99% of common cases, there is a behaviour change in this edge case:

>>> from typing import*
>>> @runtime_checkable
... classFoo(Protocol):
... defmeth(self): ...
...
>>> classBar:
... def__init__(self):
... self.meth =lambda: None
...
>>> isinstance(Bar(), Foo) # True on `main`, False with this PR

@AlexWaygood

AlexWaygood commented Apr 6, 2023

Copy link
Copy Markdown
MemberAuthor

Strictly speaking, I suppose the optimisation would be safe for dunder methods, which are always looked up on the class instead of the instance. But not sure if this optimisation is worth complicating the logic such that we start differentiating between dunder and non-dunder methods in __instancecheck__.

@AlexWaygood

Copy link
Copy Markdown
MemberAuthor

Strictly speaking, I suppose the optimisation would be safe for dunder methods

I hate this idea, and can't come up with a better one that provides this optimisation. So, closing for now; will add more tests in a different PR.

@AlexWaygood
AlexWaygood deleted the callable-protocols-optimisation branch April 6, 2023 14:08
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3.12only security fixesawaiting core reviewDO-NOT-MERGEperformancePerformance or resource usageskip newsstdlibStandard Library Python modules in the Lib/ directorytopic-typingtype-featureA feature request or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AlexWaygood@bedevere-bot