Repository navigation
Improve performance of isinstance() for misses and improve documentation #159580
Description
Feature or enhancement
When isinstance(obj, cls) fails the PyObject_TypeCheck() test, object_isinstance() looks up obj.__class__ through the full attribute machinery to handle proxies that override __class__. For ordinary objects that lookup resolves to object's own __class__ getset descriptor, so it calls the getset, type-checks, increfs the type, compares it to Py_TYPE(obj) and decrefs it again, all to confirm what was already known.
This PR checks whether __class__ resolves to object's getset descriptor for a type that uses generic attribute lookup. If so the answer is False without any call. Every isinstance() call that returns False with an exact type as the second argument benefits, including each non-matching entry of a tuple of types, which is the common isinstance(o, (str, int, float, ...)) dispatch pattern.
The isinstance() documentation now states that a __class__ attribute differing from type(object) is also consulted, which was undocumented.
Benchmarks
Running isinstance(point, Other) with point a dataclass
| case | main | PR | speedup | p10..p90 |
|---|---|---|---|---|
ctl: a + b (ints) |
49.7 ns | 49.2 ns | 1.00x | 0.94..1.08 |
ctl: s1 + s2 (str) |
67.6 ns | 67.7 ns | 1.00x | 0.96..1.08 |
isinstance(1, int) (exact hit) |
55.5 ns | 55.2 ns | 1.00x | 0.95..1.10 |
isinstance(1, str) (False) |
74.1 ns | 64.0 ns | 1.15x | 1.09..1.26 |
isinstance(obj, Other) (False) |
69.7 ns | 59.6 ns | 1.16x | 1.09..1.26 |
isinstance(1, (str, bytes, float)) (3 misses) |
164.8 ns | 135.3 ns | 1.22x | 1.13..1.32 |
isinstance(1, (str, int)) (miss, hit) |
109.5 ns | 100.1 ns | 1.11x | 1.05..1.17 |
On the free-threaded build the gain is larger under contention. On main the __class__ lookup returns a new reference to the instance's type, so when the instance is of a heap type (any Python class) every thread does an atomic incref/decref on the shared type object. The fast path takes no reference.
| threads | main | PR | main / PR |
|---|---|---|---|
| 1 | 7.1 ms | 6.3 ms | 1.12x |
| 2 | 8.6 ms | 6.9 ms | 1.25x |
| 4 | 22.3 ms | 7.4 ms | 3.01x |
| 8 | 56.3 ms | 12.3 ms | 4.57x |
dataclasses.is_dataclass(point), whose first step is a failing isinstance(obj, type):
| threads | main | PR | main / PR |
|---|---|---|---|
| 1 | 19.6 ms | 16.7 ms | 1.18x |
| 2 | 24.0 ms | 21.4 ms | 1.12x |
| 4 | 40.7 ms | 37.5 ms | 1.08x |
| 8 | 83.0 ms | 75.2 ms | 1.10x |
Benchmark scripts
Contended runs (free-threaded build):
import time, threading, dataclasses
@dataclasses.dataclass
class Point:
x: int
y: int
class Other:
pass
is_dataclass = dataclasses.is_dataclass
WORK = 100_000
def case_micro():
pt = Point(1, 2) # per-thread instance; the shared object is the type
for _ in range(WORK):
isinstance(pt, Other)
def case_is_dataclass():
pt = Point(1, 2)
for _ in range(WORK):
is_dataclass(pt)
def run(fn, nthreads):
ts = [threading.Thread(target=fn) for _ in range(nthreads)]
t0 = time.perf_counter()
for t in ts: t.start()
for t in ts: t.join()
return time.perf_counter() - t0
for name, fn in (("isinstance(dc, Other)", case_micro), ("is_dataclass(dc)", case_is_dataclass)):
for n in (1, 2, 4, 8):
print(name, n, f"{min(run(fn, n) for _ in range(3)) * 1e3:.1f} ms")Single-threaded table:
import pyperf
runner = pyperf.Runner()
runner.timeit("isinstance(1, int) (exact hit, control)", "isinstance(x, int)", setup="x = 1")
runner.timeit("isinstance(1, str) (False)", "isinstance(x, str)", setup="x = 1")
runner.timeit("isinstance(obj, Other) (False)", "isinstance(c, D)",
setup="C = type('C', (), {}); D = type('D', (), {}); c = C()")
runner.timeit("isinstance(1, (str, bytes, float)) (3 misses)", "isinstance(x, (str, bytes, float))", setup="x = 1")
runner.timeit("isinstance(1, (str, int)) (miss, hit)", "isinstance(x, (str, int))", setup="x = 1")Has this already been discussed elsewhere?
No discussion on the optimization, but the behavior of ininstance has been discussed a lot:
- isinstance is calling ob.__getattribute__ as a fallback instead of object.__class__.__get__ #76864: proposal to bypass
__getattribute__for the__class__lookup; rejected, overridden__getattribute__must stay honoured (this PR keeps it). - Fix documentation for __instancecheck__ #79264, Overzealous Optimization For
isinstance#144873: the exact-type shortcut skips__instancecheck__by design; doc PR gh-144873: Document isinstance Edge Case #144997. isinstancecheck againstcollection.abcclasses fails if value has no__class__#153772 / gh-153772: Make abc isinstance() tolerate instances without __class__ #154149: abcisinstancefalls back totype(obj)when__class__is unavailable, matching the built-in.- Poor thread scaling when constructing instances or accessing attributes #139103, Free-threading scalability issue in ABC isinstance checking #157526: free-threading scaling reports with the same refcount-contention cause.
- Documenting isinstance behavior with faked
__class__(Discourse, 2026): the doc change in this PR covers that request.
Activity