From d52873760b3475545536fe0e9aed9c1d24a2754e Mon Sep 17 00:00:00 2001 From: NAVEENKUMARKR777 Date: Sat, 5 Sep 2026 21:37:39 +0530 Subject: [PATCH] gh-114733: Cache _PyObject_IS_GC() result in ob_gc_bits (free-threading) In the free-threaded build, _PyObject_IS_GC() is on the hot path for GC tracking and reference-counting decisions, but it always dereferences Py_TYPE(obj) and checks tp_flags (and possibly calls tp_is_gc). Cache the result in the previously-unused high bit of ob_gc_bits at object creation time, so the common case becomes a single relaxed byte load on the object itself. The one type with a non-NULL tp_is_gc (_PyUOpExecutor_Type) can change its "is GC" result after creation (e.g. once made immortal), so objects of that type are deliberately left uncached and always take the original, uncached path. The bit is set once in new_reference(), the common choke point reached by _PyObject_Init() (and therefore _PyObject_New(), _PyObject_GC_New(), PyType_GenericAlloc(), etc.) as well as every freelist-reuse and realloc-based resize path, where Py_TYPE(op) is already valid. This only affects Py_GIL_DISABLED builds; the default build is unchanged. Co-Authored-By: Claude Sonnet 5 --- Include/internal/pycore_gc.h | 2 + Include/internal/pycore_object.h | 12 ++++++ Lib/test/test_capi/test_object.py | 37 +++++++++++++++++++ ...-09-05-21-45-00.gh-issue-114733.h7Nq2m.rst | 3 ++ Modules/_testcapi/object.c | 7 ++++ Objects/object.c | 16 +++++++- 6 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-09-05-21-45-00.gh-issue-114733.h7Nq2m.rst 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