Skip to content

gh-114733: Cache _PyObject_IS_GC() result in ob_gc_bits (free-threading) - #156994

Closed
NAVEENKUMARKR777 wants to merge 1 commit into
python:mainfrom
NAVEENKUMARKR777:gh-114733-cache-is-gc-bit
Closed

gh-114733: Cache _PyObject_IS_GC() result in ob_gc_bits (free-threading)#156994
NAVEENKUMARKR777 wants to merge 1 commit into
python:mainfrom
NAVEENKUMARKR777:gh-114733-cache-is-gc-bit

Conversation

@NAVEENKUMARKR777

Copy link
Copy Markdown

Summary

Implements gh-114733, suggested by @nascheme: _PyObject_IS_GC() is on the hot path for GC tracking and reference-counting decisions in the free-threaded build, but it always dereferences Py_TYPE(obj) and checks tp_flags (and possibly calls tp_is_gc). This caches the result in the previously-unused high bit of ob_gc_bits, set once at object creation time, so the common case becomes a single relaxed byte load on the object itself rather than a separate memory access into the type object.

This only affects Py_GIL_DISABLED builds (the new code is entirely behind #ifdef Py_GIL_DISABLED); the default build is byte-for-byte unchanged.

Handling the one dynamic case

Exactly one type in the codebase has a non-NULL tp_is_gc: _PyUOpExecutor_Type (the tier-2 optimizer's executor object), whose executor_is_gc() result can change after creation (e.g. once the executor is made immortal). Caching a stale bit for this type would be a real correctness hazard, so the bit is only cached when tp_is_gc == NULL — i.e. for every type except this one. Executors always fall through to the original, uncached check, unchanged from today.

Where the bit is set

Traced every object-construction path down to the single common choke point, new_reference() in Objects/object.c:

  • _PyObject_Init()/_PyObject_InitVar() (used by _PyObject_New(), _PyObject_GC_New(), _PyObject_GC_NewVar(), PyType_GenericAlloc()/_PyType_AllocNoTrack(), PyUnstable_Object_GC_NewWithExtraData()) all funnel through here, with Py_TYPE(op) already set.
  • The remaining direct callers (tuple/bytes/unicode in-place resize via realloc, the MemoryError freelist) reuse memory whose ob_type is already correct and cannot have changed, since a type's GC-ness never changes after creation.

Testing

  • Added _testcapi.pyobject_is_gc() (wrapping the public PyObject_IS_GC()) and a new IsGCTest in Lib/test/test_capi/test_object.py that checks both freshly allocated and freelist-recycled objects across GC and non-GC types.
  • Built both a regular and a --disable-gil (free-threaded) variant on Windows.
  • test_capi.test_object (including the new test) passes on both builds.
  • test_gc, test_free_threading, and a 6,884-test sweep across test_builtin, test_types, test_descr, test_dict, test_set, test_list, test_tuple, test_exceptions, test_copy, test_pickle, test_weakref, test_threading, test_asyncio, test_contextvars(test_context), test_generators, test_coroutines, test_class, test_super, test_json, test_re, test_functools etc. pass on the free-threaded build.
  • Wrote and ran an ad hoc multithreaded stress test (8 threads × 20k mixed GC/non-GC object allocations with concurrent gc.collect()) with no failures.

Test plan

  • New regression test (IsGCTest) passes on both GIL-enabled and free-threaded builds.
  • Broad free-threaded test sweep (thousands of tests across core data types, GC, threading, asyncio) shows no regressions.
  • Multithreaded stress test targeting concurrent allocation/collection shows no failures.

🤖 Generated with Claude Code

…hreading)

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 <noreply@anthropic.com>
@picnixz

picnixz commented Sep 5, 2026

Copy link
Copy Markdown
Member

What would be the performance effect of that change? I don't think new automated PRs are welcome for such delicate part of the interpreter.

@picnixz picnixz closed this Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants