Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Include/internal/pycore_tuple.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
34 changes: 34 additions & 0 deletions Lib/test/test_gc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Garbage collection in builds with the GIL now skips reachability traversal for exact tuple survivors that can be untracked immediately.
18 changes: 13 additions & 5 deletions Objects/tupleobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand All @@ -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
Expand Down
45 changes: 20 additions & 25 deletions Python/gc.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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);

@pablogsal pablogsal Oct 17, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repeats the flag clearing and heap_size update from _PyObject_GC_UNTRACK(). A similar tuple change I worked on in an earlier PR saved and restored the next node's gc_refs so it could use the existing helper.

Could we reuse that here after restoring this node's predecessor link? It adds header writes, so that cost needs checking before replacing the manual unlink.

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,
Expand All @@ -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,
Expand Down
Loading