From 9a79b53efefd1e8d1807cda1e39a78ddd9644e4b Mon Sep 17 00:00:00 2001 From: Mark Falco Date: Wed, 14 Oct 2026 18:00:15 -0400 Subject: [PATCH] gh-159437: Skip traversal for untrackable tuple survivors --- Include/internal/pycore_tuple.h | 5 +++ Lib/test/test_gc.py | 34 ++++++++++++++ ...-10-14-00-00-00.gh-issue-159437.gcTupS.rst | 1 + Objects/tupleobject.c | 18 +++++--- Python/gc.c | 45 +++++++++---------- 5 files changed, 73 insertions(+), 30 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-14-00-00-00.gh-issue-159437.gcTupS.rst diff --git a/Include/internal/pycore_tuple.h b/Include/internal/pycore_tuple.h index e294b16b3df60f6..7cdc68c87384d51 100644 --- a/Include/internal/pycore_tuple.h +++ b/Include/internal/pycore_tuple.h @@ -11,6 +11,11 @@ extern "C" { #include "pycore_object.h" // _PyObject_GC_IS_TRACKED #include "pycore_structs.h" // _PyStackRef +/* Check whether an object is eligible for tuple untracking. + * `op`: The live object to inspect. + * Returns: True for an exact tracked tuple with no NULL or potentially tracked items. + */ +extern bool _PyTuple_CanUntrack(PyObject *op); extern void _PyTuple_MaybeUntrack(PyObject *); extern void _PyTuple_DebugMallocStats(FILE *out); diff --git a/Lib/test/test_gc.py b/Lib/test/test_gc.py index 919100f2d5ce041..bfd50a0a4be0770 100644 --- a/Lib/test/test_gc.py +++ b/Lib/test/test_gc.py @@ -107,6 +107,25 @@ def test_tuple(self): del l self.assertEqual(gc.collect(), 2) + @requires_gil_enabled("tests the generation-list collector") + def test_unreachable_tuple_leaf_is_collected(self): + """Keep unreachable tuple leaves in the collected-object count.""" + was_enabled = gc.isenabled() + gc.disable() + self.addCleanup(gc.enable if was_enabled else gc.disable) + gc.collect() + + cycle = [] + # Iterator construction keeps this atomic-only tuple tracked until GC. + leaf = tuple(iter((object(),))) + cycle.extend((cycle, leaf)) + self.assertTrue(gc.is_tracked(leaf)) + + # The tuple must stay tracked until classification so both objects + # contribute to the collected count. + del cycle, leaf + self.assertEqual(gc.collect(), 2) + def test_class(self): class A: pass @@ -1289,6 +1308,21 @@ def test_heap_size(self): del l self.assertEqual(count, _testinternalcapi.get_tracked_heap_size()) + @requires_gil_enabled('need generational GC') + @unittest.skipIf(_testinternalcapi is None, "requires _testinternalcapi") + def test_heap_size_after_tuple_untracking(self): + """GC heap accounting reflects tuples untracked during collection.""" + gc.collect() + count = _testinternalcapi.get_tracked_heap_size() + # Iterator-based construction initially tracks even atomic-only tuples. + value = tuple(iter((object(),))) + self.assertTrue(gc.is_tracked(value)) + self.assertEqual(count + 1, _testinternalcapi.get_tracked_heap_size()) + + gc.collect() + self.assertFalse(gc.is_tracked(value)) + self.assertEqual(count, _testinternalcapi.get_tracked_heap_size()) + class GCCallbackTests(unittest.TestCase): def setUp(self): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-14-00-00-00.gh-issue-159437.gcTupS.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-14-00-00-00.gh-issue-159437.gcTupS.rst new file mode 100644 index 000000000000000..61c70022e8b6296 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-14-00-00-00.gh-issue-159437.gcTupS.rst @@ -0,0 +1 @@ +Garbage collection in builds with the GIL now skips reachability traversal for exact tuple survivors that can be untracked immediately. diff --git a/Objects/tupleobject.c b/Objects/tupleobject.c index 599ffad4f7b1027..34389c4a69e23e0 100644 --- a/Objects/tupleobject.c +++ b/Objects/tupleobject.c @@ -134,14 +134,14 @@ PyTuple_SetItem(PyObject *op, Py_ssize_t i, PyObject *newitem) return 0; } -void -_PyTuple_MaybeUntrack(PyObject *op) +bool +_PyTuple_CanUntrack(PyObject *op) { PyTupleObject *t; Py_ssize_t i, n; if (!PyTuple_CheckExact(op) || !_PyObject_GC_IS_TRACKED(op)) - return; + return false; t = (PyTupleObject *) op; n = Py_SIZE(t); for (i = 0; i < n; i++) { @@ -151,9 +151,17 @@ _PyTuple_MaybeUntrack(PyObject *op) them yet. */ if (!elt || _PyObject_GC_MAY_BE_TRACKED(elt)) - return; + return false; + } + return true; +} + +void +_PyTuple_MaybeUntrack(PyObject *op) +{ + if (_PyTuple_CanUntrack(op)) { + _PyObject_GC_UNTRACK(op); } - _PyObject_GC_UNTRACK(op); } /* Fast, but conservative check if an object maybe tracked diff --git a/Python/gc.c b/Python/gc.c index 5dc79e20191ebc7..7733e8d0e283998 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -14,7 +14,7 @@ #include "pycore_object_alloc.h" // _PyObject_MallocWithType() #include "pycore_pyerrors.h" #include "pycore_pystate.h" // _PyThreadState_GET() -#include "pycore_tuple.h" // _PyTuple_MaybeUntrack() +#include "pycore_tuple.h" // _PyTuple_CanUntrack() #include "pycore_weakref.h" // _PyWeakref_ClearRef() #include "pydtrace.h" @@ -595,18 +595,26 @@ move_unreachable(PyGC_Head *young, PyGC_Head *unreachable) while (gc != young) { if (gc_get_refs(gc)) { - /* gc is definitely reachable from outside the - * original 'young'. Mark it as such, and traverse - * its pointers to find any other objects that may - * be directly reachable from it. Note that the - * call to tp_traverse may append objects to young, - * so we have to wait until it returns to determine - * the next object to visit. - */ + // gc is definitely reachable from outside the original 'young'. + // Unless it can be untracked, traverse its pointers to find other + // reachable objects. tp_traverse may append objects to young, + // so wait until it returns to determine the next object to visit. PyObject *op = FROM_GC(gc); - traverseproc traverse = Py_TYPE(op)->tp_traverse; _PyObject_ASSERT_WITH_MSG(op, gc_get_refs(gc) > 0, "refcount is too small"); + // An untrackable tuple cannot lead to another GC candidate. + if (PyTuple_CheckExact(op) && _PyTuple_CanUntrack(op)) { + // Ordinary untracking would overwrite the next node's gc_refs. + PyGC_Head *next = GC_NEXT(gc); + _PyGCHead_SET_NEXT(prev, next); + gc->_gc_prev &= _PyGC_PREV_MASK_FINALIZED; + gc->_gc_next = 0; + _PyInterpreterState_GET()->gc.heap_size--; + gc = next; + continue; + } + + traverseproc traverse = Py_TYPE(op)->tp_traverse; // NOTE: visit_reachable may change gc->_gc_next when // young->_gc_prev == gc. Don't do gc = GC_NEXT(gc) before! (void) traverse(op, @@ -616,21 +624,8 @@ move_unreachable(PyGC_Head *young, PyGC_Head *unreachable) _PyGCHead_SET_PREV(gc, prev); // gc is not COLLECTING state after here. gc_clear_collecting(gc); - if (PyTuple_CheckExact(op)) { - // Untracking gc overwrites the gc_refs of the next object. - PyGC_Head *next = GC_NEXT(gc); - uintptr_t next_refs = next->_gc_prev; - _PyTuple_MaybeUntrack(op); - next->_gc_prev = next_refs; - if (gc->_gc_next != 0) { - prev = gc; - reachable++; - } - } - else { - prev = gc; - reachable++; - } + prev = gc; + reachable++; } else { /* This *may* be unreachable. To make progress,