diff --git a/Include/internal/pycore_gc.h b/Include/internal/pycore_gc.h index 84cbb56a9192156..e23cd073c024e1c 100644 --- a/Include/internal/pycore_gc.h +++ b/Include/internal/pycore_gc.h @@ -43,6 +43,8 @@ static inline PyObject* _Py_FROM_GC(PyGC_Head *gc) { # define _PyGC_BITS_SHARED (1<<4) # define _PyGC_BITS_ALIVE (1<<5) // Reachable from a known root. # define _PyGC_BITS_DEFERRED (1<<6) // Use deferred reference counting +# define _PyGC_BITS_IS_GC (1<<7) // Cached result of _PyType_IS_GC() + // for types with a NULL tp_is_gc #endif #ifdef Py_GIL_DISABLED diff --git a/Include/internal/pycore_object.h b/Include/internal/pycore_object.h index 41786cb267c2e96..fe0cba52828b0b9 100644 --- a/Include/internal/pycore_object.h +++ b/Include/internal/pycore_object.h @@ -823,6 +823,18 @@ _PyObject_GET_WEAKREFS_LISTPTR_FROM_OFFSET(PyObject *op) static inline int _PyObject_IS_GC(PyObject *obj) { +#ifdef Py_GIL_DISABLED + // _PyGC_BITS_IS_GC is cached at object creation time (see new_reference() + // in Objects/object.c) for the common case of a type with a NULL + // tp_is_gc: this avoids dereferencing the type object on every call. + // Only _PyUOpExecutor_Type currently has a non-NULL tp_is_gc, whose + // result can change after creation (e.g. once made immortal), so such + // objects never get the bit set and always fall through to the general + // check below. + if (_PyObject_HAS_GC_BITS(obj, _PyGC_BITS_IS_GC)) { + return 1; + } +#endif PyTypeObject *type = Py_TYPE(obj); return (_PyType_IS_GC(type) && (type->tp_is_gc == NULL || type->tp_is_gc(obj))); diff --git a/Lib/test/test_capi/test_object.py b/Lib/test/test_capi/test_object.py index b4585adb07ece4b..25c788a25a139f1 100644 --- a/Lib/test/test_capi/test_object.py +++ b/Lib/test/test_capi/test_object.py @@ -224,6 +224,43 @@ def test_is_uniquely_referenced(self): self.assertFalse(_testcapi.is_uniquely_referenced(42)) # CRASHES is_uniquely_referenced(NULL) +class IsGCTest(unittest.TestCase): + """Test PyObject_IS_GC()""" + + class Slotted: + __slots__ = () + + def check_is_gc(self, expected, factory): + # Check both freshly allocated objects and ones obtained after + # churning through a freelist (gh-114733: the free-threaded build + # caches the result of PyObject_IS_GC() at allocation/reinitialization + # time, so both paths must agree with the general, uncached result). + for _ in range(1050): + obj = factory() + self.assertEqual(_testcapi.pyobject_is_gc(obj), expected) + del obj + + def test_is_gc_true(self): + self.check_is_gc(True, list) + self.check_is_gc(True, dict) + self.check_is_gc(True, set) + self.check_is_gc(True, tuple) + self.check_is_gc(True, lambda: (1, 2, 3)) + self.check_is_gc(True, self.Slotted) + + def test_is_gc_false(self): + # object() itself has no GC support: instances hold no references + # and so cannot participate in reference cycles. + self.check_is_gc(False, object) + self.check_is_gc(False, int) + self.check_is_gc(False, float) + self.check_is_gc(False, complex) + self.check_is_gc(False, str) + self.check_is_gc(False, bytes) + self.check_is_gc(False, lambda: 42) + self.check_is_gc(False, lambda: 4.2) + + class CAPITest(unittest.TestCase): def check_negative_refcount(self, code): # bpo-35059: Check that Py_DECREF() reports the correct filename diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-05-21-45-00.gh-issue-114733.h7Nq2m.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-05-21-45-00.gh-issue-114733.h7Nq2m.rst new file mode 100644 index 000000000000000..59d634ea9d6ed32 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-05-21-45-00.gh-issue-114733.h7Nq2m.rst @@ -0,0 +1,3 @@ +In the free-threaded build, cache whether an object's type supports cyclic +garbage collection in the object's header at creation time, avoiding a type +dereference on every :c:func:`PyObject_IS_GC` check. diff --git a/Modules/_testcapi/object.c b/Modules/_testcapi/object.c index 425ec540b62dcc0..306cfa96fe8425e 100644 --- a/Modules/_testcapi/object.c +++ b/Modules/_testcapi/object.c @@ -561,6 +561,12 @@ pyobject_dump(PyObject *self, PyObject *args) Py_RETURN_NONE; } +static PyObject * +pyobject_is_gc(PyObject *self, PyObject *obj) +{ + return PyBool_FromLong(PyObject_IS_GC(obj)); +} + static PyObject * pysentinel_new(PyObject *self, PyObject *args) { @@ -597,6 +603,7 @@ static PyMethodDef test_methods[] = { {"pyobject_is_unique_temporary_new_object", pyobject_is_unique_temporary_new_object, METH_NOARGS}, {"test_py_try_inc_ref", test_py_try_inc_ref, METH_NOARGS}, {"test_py_set_immortal", test_py_set_immortal, METH_NOARGS}, + {"pyobject_is_gc", pyobject_is_gc, METH_O}, {"test_xincref_doesnt_leak",test_xincref_doesnt_leak, METH_NOARGS}, {"test_incref_doesnt_leak", test_incref_doesnt_leak, METH_NOARGS}, {"test_xdecref_doesnt_leak",test_xdecref_doesnt_leak, METH_NOARGS}, diff --git a/Objects/object.c b/Objects/object.c index 856d9fc41a41546..59280ca3d045dc9 100644 --- a/Objects/object.c +++ b/Objects/object.c @@ -2741,14 +2741,26 @@ new_reference(PyObject *op) #else op->ob_flags = 0; op->ob_mutex = (PyMutex){ 0 }; + // Cache whether the type is a GC type with a static (NULL) tp_is_gc, + // so _PyObject_IS_GC() can skip dereferencing the type object on + // future calls (gh-114733). Py_TYPE(op) is already valid here: callers + // either just set it (_PyObject_Init()) or are reinitializing a + // reused block (realloc, freelist) whose type cannot have changed, + // since types never change their GC-ness after creation. Types with + // a non-NULL tp_is_gc (only _PyUOpExecutor_Type at present) are left + // uncached, since their "is GC" result can change at runtime; they + // always take the slow path in _PyObject_IS_GC(). + PyTypeObject *tp = Py_TYPE(op); + uint8_t gc_bits = (_PyType_IS_GC(tp) && tp->tp_is_gc == NULL) + ? _PyGC_BITS_IS_GC : 0; #ifdef _Py_THREAD_SANITIZER _Py_atomic_store_uintptr_relaxed(&op->ob_tid, _Py_ThreadId()); - _Py_atomic_store_uint8_relaxed(&op->ob_gc_bits, 0); + _Py_atomic_store_uint8_relaxed(&op->ob_gc_bits, gc_bits); _Py_atomic_store_uint32_relaxed(&op->ob_ref_local, 1); _Py_atomic_store_ssize_relaxed(&op->ob_ref_shared, 0); #else op->ob_tid = _Py_ThreadId(); - op->ob_gc_bits = 0; + op->ob_gc_bits = gc_bits; op->ob_ref_local = 1; op->ob_ref_shared = 0; #endif