From 37c3d44f5d7ab7ee42cccb980c5ff9b6c9ad2e08 Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Wed, 18 Mar 2026 15:19:56 +0500 Subject: [PATCH 1/8] Extend statics in the GC --- Include/internal/pycore_interp_structs.h | 29 +++++----- Modules/gcmodule.c | 4 +- Python/gc.c | 69 +++++++++++++++--------- 3 files changed, 65 insertions(+), 37 deletions(-) diff --git a/Include/internal/pycore_interp_structs.h b/Include/internal/pycore_interp_structs.h index 4822360a8f08d0..031f79c4e72266 100644 --- a/Include/internal/pycore_interp_structs.h +++ b/Include/internal/pycore_interp_structs.h @@ -177,21 +177,12 @@ struct gc_generation { generations */ }; -struct gc_collection_stats { - /* number of collected objects */ - Py_ssize_t collected; - /* total number of uncollectable objects (put into gc.garbage) */ - Py_ssize_t uncollectable; - // Total number of objects considered for collection and traversed: - Py_ssize_t candidates; - // Duration of the collection in seconds: - double duration; -}; - /* Running stats per generation */ struct gc_generation_stats { + PyTime_t ts; /* total number of collections */ Py_ssize_t collections; + /* total number of collected objects */ Py_ssize_t collected; /* total number of uncollectable objects (put into gc.garbage) */ @@ -199,9 +190,19 @@ struct gc_generation_stats { // Total number of objects considered for collection and traversed: Py_ssize_t candidates; // Duration of the collection in seconds: + + Py_ssize_t object_visits; + Py_ssize_t objects_transitively_reachable; + Py_ssize_t objects_not_transitively_reachable; + double duration; }; +struct gc_generation_stats_buffer { + struct gc_generation_stats items[11]; + int8_t index; +}; + enum _GCPhase { GC_PHASE_MARK = 0, GC_PHASE_COLLECT = 1 @@ -211,6 +212,10 @@ enum _GCPhase { signature of gc.collect and change the size of PyStats.gc_stats */ #define NUM_GENERATIONS 3 +struct gc_stats { + struct gc_generation_stats_buffer gen[NUM_GENERATIONS]; +}; + struct _gc_runtime_state { /* Is automatic collection enabled? */ int enabled; @@ -220,7 +225,7 @@ struct _gc_runtime_state { struct gc_generation old[2]; /* a permanent generation which won't be collected */ struct gc_generation permanent_generation; - struct gc_generation_stats generation_stats[NUM_GENERATIONS]; + struct gc_stats generation_stats; /* true if we are currently running the collector */ int collecting; // The frame that started the current collection. It might be NULL even when diff --git a/Modules/gcmodule.c b/Modules/gcmodule.c index 0da8cd5b418aca..f502d66268e5bc 100644 --- a/Modules/gcmodule.c +++ b/Modules/gcmodule.c @@ -347,8 +347,10 @@ gc_get_stats_impl(PyObject *module) /* To get consistent values despite allocations while constructing the result list, we use a snapshot of the running stats. */ GCState *gcstate = get_gc_state(); + struct gc_generation_stats_buffer *buffer; for (i = 0; i < NUM_GENERATIONS; i++) { - stats[i] = gcstate->generation_stats[i]; + buffer = &gcstate->generation_stats.gen[i]; + stats[i] = buffer->items[buffer->index]; } PyObject *result = PyList_New(0); diff --git a/Python/gc.c b/Python/gc.c index 2f373dcb402df3..5654f4bf94afda 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -1331,7 +1331,7 @@ static void gc_collect_region(PyThreadState *tstate, PyGC_Head *from, PyGC_Head *to, - struct gc_collection_stats *stats); + struct gc_generation_stats *stats); static inline Py_ssize_t gc_list_set_space(PyGC_Head *list, int space) @@ -1364,26 +1364,49 @@ gc_list_set_space(PyGC_Head *list, int space) * scans objects at 1% of the heap size */ #define SCAN_RATE_DIVISOR 10 +static struct gc_generation_stats * +gc_get_stats(GCState *gcstate, int gen) +{ + struct gc_generation_stats_buffer *buffer = &gcstate->generation_stats.gen[gen]; + buffer->index = (buffer->index + 1) % 11; + struct gc_generation_stats *stats = &buffer->items[buffer->index]; + return stats; +} + +static struct gc_generation_stats * +gc_get_prev_stats(GCState *gcstate, int gen) +{ + struct gc_generation_stats_buffer *buffer = &gcstate->generation_stats.gen[gen]; + struct gc_generation_stats *stats = &buffer->items[buffer->index]; + return stats; +} + static void -add_stats(GCState *gcstate, int gen, struct gc_collection_stats *stats) +add_stats(GCState *gcstate, int gen, struct gc_generation_stats *stats) { - gcstate->generation_stats[gen].duration += stats->duration; - gcstate->generation_stats[gen].collected += stats->collected; - gcstate->generation_stats[gen].uncollectable += stats->uncollectable; - gcstate->generation_stats[gen].candidates += stats->candidates; - gcstate->generation_stats[gen].collections += 1; + struct gc_generation_stats *prev_stats = gc_get_prev_stats(gcstate, gen); + struct gc_generation_stats *cur_stats = gc_get_stats(gcstate, gen); + + cur_stats->ts = stats->ts; + cur_stats->collections = prev_stats->collections + 1; + cur_stats->object_visits = prev_stats->object_visits + stats->object_visits; + cur_stats->collected = prev_stats->collected + stats->collected; + cur_stats->objects_transitively_reachable = prev_stats->objects_transitively_reachable + stats->objects_transitively_reachable; + cur_stats->objects_not_transitively_reachable = prev_stats->objects_not_transitively_reachable + stats->objects_not_transitively_reachable; + cur_stats->uncollectable = prev_stats->uncollectable + stats->uncollectable; + cur_stats->candidates = prev_stats->candidates + stats->candidates; + cur_stats->duration = prev_stats->duration + stats->duration; } static void gc_collect_young(PyThreadState *tstate, - struct gc_collection_stats *stats) + struct gc_generation_stats *stats) { GCState *gcstate = &tstate->interp->gc; validate_spaces(gcstate); PyGC_Head *young = &gcstate->young.head; PyGC_Head *visited = &gcstate->old[gcstate->visited_space].head; untrack_tuples(young); - GC_STAT_ADD(0, collections, 1); PyGC_Head survivors; gc_list_init(&survivors); @@ -1654,9 +1677,8 @@ assess_work_to_do(GCState *gcstate) } static void -gc_collect_increment(PyThreadState *tstate, struct gc_collection_stats *stats) +gc_collect_increment(PyThreadState *tstate, struct gc_generation_stats *stats) { - GC_STAT_ADD(1, collections, 1); GCState *gcstate = &tstate->interp->gc; gcstate->work_to_do += assess_work_to_do(gcstate); if (gcstate->work_to_do < 0) { @@ -1665,9 +1687,9 @@ gc_collect_increment(PyThreadState *tstate, struct gc_collection_stats *stats) untrack_tuples(&gcstate->young.head); if (gcstate->phase == GC_PHASE_MARK) { Py_ssize_t objects_marked = mark_at_start(tstate); - GC_STAT_ADD(1, objects_transitively_reachable, objects_marked); - gcstate->work_to_do -= objects_marked; + stats->objects_transitively_reachable += objects_marked; stats->candidates += objects_marked; + gcstate->work_to_do -= objects_marked; validate_spaces(gcstate); return; } @@ -1680,7 +1702,7 @@ gc_collect_increment(PyThreadState *tstate, struct gc_collection_stats *stats) scale_factor = 2; } intptr_t objects_marked = mark_stacks(tstate->interp, visited, gcstate->visited_space, false); - GC_STAT_ADD(1, objects_transitively_reachable, objects_marked); + stats->objects_transitively_reachable += objects_marked; gcstate->work_to_do -= objects_marked; gc_list_set_space(&gcstate->young.head, gcstate->visited_space); gc_list_merge(&gcstate->young.head, &increment); @@ -1697,7 +1719,7 @@ gc_collect_increment(PyThreadState *tstate, struct gc_collection_stats *stats) gc_set_old_space(gc, gcstate->visited_space); increment_size += expand_region_transitively_reachable(&increment, gc, gcstate); } - GC_STAT_ADD(1, objects_not_transitively_reachable, increment_size); + stats->objects_not_transitively_reachable += increment_size; validate_list(&increment, collecting_clear_unreachable_clear); gc_list_validate_space(&increment, gcstate->visited_space); PyGC_Head survivors; @@ -1715,9 +1737,8 @@ gc_collect_increment(PyThreadState *tstate, struct gc_collection_stats *stats) static void gc_collect_full(PyThreadState *tstate, - struct gc_collection_stats *stats) + struct gc_generation_stats *stats) { - GC_STAT_ADD(2, collections, 1); GCState *gcstate = &tstate->interp->gc; validate_spaces(gcstate); PyGC_Head *young = &gcstate->young.head; @@ -1749,7 +1770,7 @@ static void gc_collect_region(PyThreadState *tstate, PyGC_Head *from, PyGC_Head *to, - struct gc_collection_stats *stats) + struct gc_generation_stats *stats) { PyGC_Head unreachable; /* non-problematic unreachable trash */ PyGC_Head finalizers; /* objects with, & reachable from, __del__ */ @@ -1842,7 +1863,7 @@ gc_collect_region(PyThreadState *tstate, */ static void do_gc_callback(GCState *gcstate, const char *phase, - int generation, struct gc_collection_stats *stats) + int generation, struct gc_generation_stats *stats) { assert(!PyErr_Occurred()); @@ -1890,7 +1911,7 @@ do_gc_callback(GCState *gcstate, const char *phase, static void invoke_gc_callback(GCState *gcstate, const char *phase, - int generation, struct gc_collection_stats *stats) + int generation, struct gc_generation_stats *stats) { if (gcstate->callbacks == NULL) { return; @@ -2082,7 +2103,7 @@ _PyGC_Collect(PyThreadState *tstate, int generation, _PyGC_Reason reason) } gcstate->frame = tstate->current_frame; - struct gc_collection_stats stats = { 0 }; + struct gc_generation_stats stats = { 0 }; if (reason != _Py_GC_REASON_SHUTDOWN) { invoke_gc_callback(gcstate, "start", generation, &stats); } @@ -2093,8 +2114,7 @@ _PyGC_Collect(PyThreadState *tstate, int generation, _PyGC_Reason reason) if (PyDTrace_GC_START_ENABLED()) { PyDTrace_GC_START(generation); } - PyTime_t start, stop; - (void)PyTime_PerfCounterRaw(&start); + (void)PyTime_PerfCounterRaw(&stats.ts); PyObject *exc = _PyErr_GetRaisedException(tstate); switch(generation) { case 0: @@ -2109,8 +2129,9 @@ _PyGC_Collect(PyThreadState *tstate, int generation, _PyGC_Reason reason) default: Py_UNREACHABLE(); } + PyTime_t stop; (void)PyTime_PerfCounterRaw(&stop); - stats.duration = PyTime_AsSecondsDouble(stop - start); + stats.duration = PyTime_AsSecondsDouble(stop - stats.ts); add_stats(gcstate, generation, &stats); if (PyDTrace_GC_DONE_ENABLED()) { PyDTrace_GC_DONE(stats.uncollectable + stats.collected); From 2a04eb4c899c9809b038e97a12d9d692d05a59fa Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Wed, 18 Mar 2026 15:20:20 +0500 Subject: [PATCH 2/8] Add GC stats to _PyDebugOffsets --- Include/internal/pycore_debug_offsets.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Include/internal/pycore_debug_offsets.h b/Include/internal/pycore_debug_offsets.h index 66f14e69f33f44..c166f963da4f66 100644 --- a/Include/internal/pycore_debug_offsets.h +++ b/Include/internal/pycore_debug_offsets.h @@ -222,6 +222,8 @@ typedef struct _Py_DebugOffsets { uint64_t size; uint64_t collecting; uint64_t frame; + uint64_t generation_stats_size; + uint64_t generation_stats; } gc; // Generator object offset; @@ -373,6 +375,8 @@ typedef struct _Py_DebugOffsets { .size = sizeof(struct _gc_runtime_state), \ .collecting = offsetof(struct _gc_runtime_state, collecting), \ .frame = offsetof(struct _gc_runtime_state, frame), \ + .generation_stats_size = sizeof(struct gc_stats), \ + .generation_stats = offsetof(struct _gc_runtime_state, generation_stats), \ }, \ .gen_object = { \ .size = sizeof(PyGenObject), \ From 2b6cfaa7c394ede422a5a5b3e37806ef46bfec58 Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Wed, 18 Mar 2026 19:06:34 +0500 Subject: [PATCH 3/8] Fix object_visits calculation --- Python/gc.c | 105 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 69 insertions(+), 36 deletions(-) diff --git a/Python/gc.c b/Python/gc.c index 5654f4bf94afda..f8495fc1c0ca8a 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -525,12 +525,18 @@ update_refs(PyGC_Head *containers) return candidates; } +struct visit_decref_context { + PyObject *parent; + struct gc_generation_stats *stats; +}; + /* A traversal callback for subtract_refs. */ static int -visit_decref(PyObject *op, void *parent) +visit_decref(PyObject *op, void *arg) { - OBJECT_STAT_INC(object_visits); - _PyObject_ASSERT(_PyObject_CAST(parent), !_PyObject_IsFreed(op)); + struct visit_decref_context *ctx = (struct visit_decref_context *)arg; + ctx->stats->object_visits += 1; + _PyObject_ASSERT(ctx->parent, !_PyObject_IsFreed(op)); if (_PyObject_IS_GC(op)) { PyGC_Head *gc = AS_GC(op); @@ -577,25 +583,35 @@ _PyGC_VisitFrameStack(_PyInterpreterFrame *frame, visitproc visit, void *arg) * reachable from outside containers, and so can't be collected. */ static void -subtract_refs(PyGC_Head *containers) +subtract_refs(PyGC_Head *containers, struct gc_generation_stats *stats) { traverseproc traverse; PyGC_Head *gc = GC_NEXT(containers); for (; gc != containers; gc = GC_NEXT(gc)) { PyObject *op = FROM_GC(gc); traverse = Py_TYPE(op)->tp_traverse; + struct visit_decref_context ctx = { + .parent = op, + .stats = stats + }; (void) traverse(op, visit_decref, - op); + &ctx); } } +struct visit_reachable_context { + PyGC_Head *head; + struct gc_generation_stats *stats; +}; + /* A traversal callback for move_unreachable. */ static int visit_reachable(PyObject *op, void *arg) { - PyGC_Head *reachable = arg; - OBJECT_STAT_INC(object_visits); + struct visit_reachable_context *ctx = (struct visit_reachable_context *)arg; + ctx->stats->object_visits += 1; + PyGC_Head *reachable = ctx->head; if (!_PyObject_IS_GC(op)) { return 0; } @@ -667,7 +683,7 @@ visit_reachable(PyObject *op, void *arg) * So we can not gc_list_* functions for unreachable until we remove the flag. */ static void -move_unreachable(PyGC_Head *young, PyGC_Head *unreachable) +move_unreachable(PyGC_Head *young, PyGC_Head *unreachable, struct gc_generation_stats *stats) { // previous elem in the young list, used for restore gc_prev. PyGC_Head *prev = young; @@ -682,6 +698,11 @@ move_unreachable(PyGC_Head *young, PyGC_Head *unreachable) * or to the right have been scanned yet. */ + struct visit_reachable_context ctx = { + .head = young, + .stats = stats + }; + validate_consistent_old_space(young); /* Record which old space we are in, and set NEXT_MASK_UNREACHABLE bit for convenience */ uintptr_t flags = NEXT_MASK_UNREACHABLE | (gc->_gc_next & _PyGC_NEXT_MASK_OLD_SPACE_1); @@ -703,7 +724,7 @@ move_unreachable(PyGC_Head *young, PyGC_Head *unreachable) // young->_gc_prev == gc. Don't do gc = GC_NEXT(gc) before! (void) traverse(op, visit_reachable, - (void *)young); + &ctx); // relink gc_prev to prev element. _PyGCHead_SET_PREV(gc, prev); // gc is not COLLECTING state after here. @@ -831,8 +852,9 @@ clear_unreachable_mask(PyGC_Head *unreachable) static int visit_move(PyObject *op, void *arg) { - PyGC_Head *tolist = arg; - OBJECT_STAT_INC(object_visits); + struct visit_reachable_context *ctx = (struct visit_reachable_context *)arg; + PyGC_Head *tolist = ctx->head; + ctx->stats->object_visits += 1; if (_PyObject_IS_GC(op)) { PyGC_Head *gc = AS_GC(op); if (gc_is_collecting(gc)) { @@ -847,8 +869,12 @@ visit_move(PyObject *op, void *arg) * into finalizers set. */ static void -move_legacy_finalizer_reachable(PyGC_Head *finalizers) +move_legacy_finalizer_reachable(PyGC_Head *finalizers, struct gc_generation_stats *stats) { + struct visit_reachable_context ctx = { + .head = finalizers, + .stats = stats + }; traverseproc traverse; PyGC_Head *gc = GC_NEXT(finalizers); for (; gc != finalizers; gc = GC_NEXT(gc)) { @@ -856,7 +882,7 @@ move_legacy_finalizer_reachable(PyGC_Head *finalizers) traverse = Py_TYPE(FROM_GC(gc))->tp_traverse; (void) traverse(FROM_GC(gc), visit_move, - (void *)finalizers); + &ctx); } } @@ -1244,7 +1270,7 @@ flag is cleared (for example, by using 'clear_unreachable_mask' function or by a call to 'move_legacy_finalizers'), the 'unreachable' list is not a normal list and we can not use most gc_list_* functions for it. */ static inline Py_ssize_t -deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable) { +deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable, struct gc_generation_stats *stats) { validate_list(base, collecting_clear_unreachable_clear); /* Using ob_refcnt and gc_refs, calculate which objects in the * container set are reachable from outside the set (i.e., have a @@ -1252,7 +1278,7 @@ deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable) { * set are taken into account). */ Py_ssize_t candidates = update_refs(base); // gc_prev is used for gc_refs - subtract_refs(base); + subtract_refs(base, stats); /* Leave everything reachable from outside base in base, and move * everything else (in base) to unreachable. @@ -1289,7 +1315,7 @@ deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable) { * the reachable objects instead. But this is a one-time cost, probably not * worth complicating the code to speed just a little. */ - move_unreachable(base, unreachable); // gc_prev is pointer again + move_unreachable(base, unreachable, stats); // gc_prev is pointer again validate_list(base, collecting_clear_unreachable_clear); validate_list(unreachable, collecting_set_unreachable_set); return candidates; @@ -1310,7 +1336,8 @@ PREV_MARK_COLLECTING set, but the objects in this set are going to be removed so we can skip the expense of clearing the flag to avoid extra iteration. */ static inline void handle_resurrected_objects(PyGC_Head *unreachable, PyGC_Head* still_unreachable, - PyGC_Head *old_generation) + PyGC_Head *old_generation, + struct gc_generation_stats *stats) { // Remove the PREV_MASK_COLLECTING from unreachable // to prepare it for a new call to 'deduce_unreachable' @@ -1320,7 +1347,7 @@ handle_resurrected_objects(PyGC_Head *unreachable, PyGC_Head* still_unreachable, // have the PREV_MARK_COLLECTING set, but the objects are going to be // removed so we can skip the expense of clearing the flag. PyGC_Head* resurrected = unreachable; - deduce_unreachable(resurrected, still_unreachable); + deduce_unreachable(resurrected, still_unreachable, stats); clear_unreachable_mask(still_unreachable); // Move the resurrected objects to the old generation for future collection. @@ -1432,14 +1459,15 @@ struct container_and_flag { PyGC_Head *container; int visited_space; intptr_t size; + struct gc_generation_stats *stats; }; /* A traversal callback for adding to container) */ static int visit_add_to_container(PyObject *op, void *arg) { - OBJECT_STAT_INC(object_visits); struct container_and_flag *cf = (struct container_and_flag *)arg; + cf->stats->object_visits += 1; int visited = cf->visited_space; assert(visited == get_gc_state()->visited_space); if (!_Py_IsImmortal(op) && _PyObject_IS_GC(op)) { @@ -1455,12 +1483,16 @@ visit_add_to_container(PyObject *op, void *arg) } static intptr_t -expand_region_transitively_reachable(PyGC_Head *container, PyGC_Head *gc, GCState *gcstate) +expand_region_transitively_reachable(PyGC_Head *container, + PyGC_Head *gc, + GCState *gcstate, + struct gc_generation_stats *stats) { struct container_and_flag arg = { .container = container, .visited_space = gcstate->visited_space, - .size = 0 + .size = 0, + .stats = stats }; assert(GC_NEXT(gc) == container); while (gc != container) { @@ -1529,13 +1561,14 @@ move_to_reachable(PyObject *op, PyGC_Head *reachable, int visited_space) } static intptr_t -mark_all_reachable(PyGC_Head *reachable, PyGC_Head *visited, int visited_space) +mark_all_reachable(PyGC_Head *reachable, PyGC_Head *visited, int visited_space, struct gc_generation_stats *stats) { // Transitively traverse all objects from reachable, until empty struct container_and_flag arg = { .container = reachable, .visited_space = visited_space, - .size = 0 + .size = 0, + .stats = stats }; while (!gc_list_is_empty(reachable)) { PyGC_Head *gc = _PyGCHead_NEXT(reachable); @@ -1552,7 +1585,7 @@ mark_all_reachable(PyGC_Head *reachable, PyGC_Head *visited, int visited_space) } static intptr_t -mark_stacks(PyInterpreterState *interp, PyGC_Head *visited, int visited_space, bool start) +mark_stacks(PyInterpreterState *interp, PyGC_Head *visited, int visited_space, bool start, struct gc_generation_stats *stats) { PyGC_Head reachable; gc_list_init(&reachable); @@ -1605,13 +1638,13 @@ mark_stacks(PyInterpreterState *interp, PyGC_Head *visited, int visited_space, b ts = PyThreadState_Next(ts); HEAD_UNLOCK(runtime); } - objects_marked += mark_all_reachable(&reachable, visited, visited_space); + objects_marked += mark_all_reachable(&reachable, visited, visited_space, stats); assert(gc_list_is_empty(&reachable)); return objects_marked; } static intptr_t -mark_global_roots(PyInterpreterState *interp, PyGC_Head *visited, int visited_space) +mark_global_roots(PyInterpreterState *interp, PyGC_Head *visited, int visited_space, struct gc_generation_stats *stats) { PyGC_Head reachable; gc_list_init(&reachable); @@ -1628,19 +1661,19 @@ mark_global_roots(PyInterpreterState *interp, PyGC_Head *visited, int visited_sp objects_marked += move_to_reachable(types->for_extensions.initialized[i].tp_dict, &reachable, visited_space); objects_marked += move_to_reachable(types->for_extensions.initialized[i].tp_subclasses, &reachable, visited_space); } - objects_marked += mark_all_reachable(&reachable, visited, visited_space); + objects_marked += mark_all_reachable(&reachable, visited, visited_space, stats); assert(gc_list_is_empty(&reachable)); return objects_marked; } static intptr_t -mark_at_start(PyThreadState *tstate) +mark_at_start(PyThreadState *tstate, struct gc_generation_stats *stats) { // TO DO -- Make this incremental GCState *gcstate = &tstate->interp->gc; PyGC_Head *visited = &gcstate->old[gcstate->visited_space].head; - Py_ssize_t objects_marked = mark_global_roots(tstate->interp, visited, gcstate->visited_space); - objects_marked += mark_stacks(tstate->interp, visited, gcstate->visited_space, true); + Py_ssize_t objects_marked = mark_global_roots(tstate->interp, visited, gcstate->visited_space, stats); + objects_marked += mark_stacks(tstate->interp, visited, gcstate->visited_space, true, stats); gcstate->work_to_do -= objects_marked; gcstate->phase = GC_PHASE_COLLECT; validate_spaces(gcstate); @@ -1686,7 +1719,7 @@ gc_collect_increment(PyThreadState *tstate, struct gc_generation_stats *stats) } untrack_tuples(&gcstate->young.head); if (gcstate->phase == GC_PHASE_MARK) { - Py_ssize_t objects_marked = mark_at_start(tstate); + Py_ssize_t objects_marked = mark_at_start(tstate, stats); stats->objects_transitively_reachable += objects_marked; stats->candidates += objects_marked; gcstate->work_to_do -= objects_marked; @@ -1701,7 +1734,7 @@ gc_collect_increment(PyThreadState *tstate, struct gc_generation_stats *stats) if (scale_factor < 2) { scale_factor = 2; } - intptr_t objects_marked = mark_stacks(tstate->interp, visited, gcstate->visited_space, false); + intptr_t objects_marked = mark_stacks(tstate->interp, visited, gcstate->visited_space, false, stats); stats->objects_transitively_reachable += objects_marked; gcstate->work_to_do -= objects_marked; gc_list_set_space(&gcstate->young.head, gcstate->visited_space); @@ -1717,7 +1750,7 @@ gc_collect_increment(PyThreadState *tstate, struct gc_generation_stats *stats) increment_size++; assert(!_Py_IsImmortal(FROM_GC(gc))); gc_set_old_space(gc, gcstate->visited_space); - increment_size += expand_region_transitively_reachable(&increment, gc, gcstate); + increment_size += expand_region_transitively_reachable(&increment, gc, gcstate, stats); } stats->objects_not_transitively_reachable += increment_size; validate_list(&increment, collecting_clear_unreachable_clear); @@ -1781,7 +1814,7 @@ gc_collect_region(PyThreadState *tstate, assert(!_PyErr_Occurred(tstate)); gc_list_init(&unreachable); - stats->candidates = deduce_unreachable(from, &unreachable); + stats->candidates = deduce_unreachable(from, &unreachable, stats); validate_consistent_old_space(from); untrack_tuples(from); @@ -1803,7 +1836,7 @@ gc_collect_region(PyThreadState *tstate, * unreachable objects reachable *from* those are also uncollectable, * and we move those into the finalizers list too. */ - move_legacy_finalizer_reachable(&finalizers); + move_legacy_finalizer_reachable(&finalizers, stats); validate_list(&finalizers, collecting_clear_unreachable_clear); validate_list(&unreachable, collecting_set_unreachable_clear); /* Print debugging information. */ @@ -1826,7 +1859,7 @@ gc_collect_region(PyThreadState *tstate, * objects that are still unreachable */ PyGC_Head final_unreachable; gc_list_init(&final_unreachable); - handle_resurrected_objects(&unreachable, &final_unreachable, to); + handle_resurrected_objects(&unreachable, &final_unreachable, to, stats); /* Clear weakrefs to objects in the unreachable set. See the comments * above handle_weakref_callbacks() for details. From 2db2df646e74871999688d40650bdbfe93c0bd84 Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Sat, 28 Mar 2026 01:31:01 +0500 Subject: [PATCH 4/8] Add heap_size and work_to_do --- Include/internal/pycore_interp_structs.h | 4 ++++ Python/gc.c | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Include/internal/pycore_interp_structs.h b/Include/internal/pycore_interp_structs.h index 031f79c4e72266..a558b588d6d14d 100644 --- a/Include/internal/pycore_interp_structs.h +++ b/Include/internal/pycore_interp_structs.h @@ -195,7 +195,11 @@ struct gc_generation_stats { Py_ssize_t objects_transitively_reachable; Py_ssize_t objects_not_transitively_reachable; + Py_ssize_t heap_size; + Py_ssize_t work_to_do; + double duration; + double total_duration; }; struct gc_generation_stats_buffer { diff --git a/Python/gc.c b/Python/gc.c index f8495fc1c0ca8a..5b8aa95bcf7124 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -1422,7 +1422,10 @@ add_stats(GCState *gcstate, int gen, struct gc_generation_stats *stats) cur_stats->objects_not_transitively_reachable = prev_stats->objects_not_transitively_reachable + stats->objects_not_transitively_reachable; cur_stats->uncollectable = prev_stats->uncollectable + stats->uncollectable; cur_stats->candidates = prev_stats->candidates + stats->candidates; - cur_stats->duration = prev_stats->duration + stats->duration; + cur_stats->duration = stats->duration; + cur_stats->total_duration = prev_stats->total_duration + stats->duration; + cur_stats->heap_size = gcstate->heap_size; + cur_stats->work_to_do = gcstate->work_to_do; } static void From f86796deb44fa6705b97336d1156154a1f25c172 Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Sat, 28 Mar 2026 01:32:00 +0500 Subject: [PATCH 5/8] Reduce size of stats buffers --- Include/internal/pycore_interp_structs.h | 33 +++++++---- Modules/gcmodule.c | 12 ++-- Python/gc.c | 70 +++++++++++++++--------- 3 files changed, 73 insertions(+), 42 deletions(-) diff --git a/Include/internal/pycore_interp_structs.h b/Include/internal/pycore_interp_structs.h index a558b588d6d14d..0dabbf658fc366 100644 --- a/Include/internal/pycore_interp_structs.h +++ b/Include/internal/pycore_interp_structs.h @@ -179,31 +179,41 @@ struct gc_generation { /* Running stats per generation */ struct gc_generation_stats { - PyTime_t ts; + PyTime_t ts_start; + PyTime_t ts_stop; + + /* heap_size on the start of the collection */ + Py_ssize_t heap_size; + + /* work_to_do on the start of the collection */ + Py_ssize_t work_to_do; + /* total number of collections */ Py_ssize_t collections; + /* total number of visited objects */ + Py_ssize_t object_visits; + /* total number of collected objects */ Py_ssize_t collected; /* total number of uncollectable objects (put into gc.garbage) */ Py_ssize_t uncollectable; // Total number of objects considered for collection and traversed: Py_ssize_t candidates; - // Duration of the collection in seconds: - Py_ssize_t object_visits; Py_ssize_t objects_transitively_reachable; Py_ssize_t objects_not_transitively_reachable; +}; - Py_ssize_t heap_size; - Py_ssize_t work_to_do; - - double duration; - double total_duration; +#define GC_YOUNG_STATS_SIZE 11 +#define GC_OLD_STATS_SIZE 3 +struct gc_young_stats_buffer { + struct gc_generation_stats items[GC_YOUNG_STATS_SIZE]; + int8_t index; }; -struct gc_generation_stats_buffer { - struct gc_generation_stats items[11]; +struct gc_old_stats_buffer { + struct gc_generation_stats items[GC_OLD_STATS_SIZE]; int8_t index; }; @@ -217,7 +227,8 @@ enum _GCPhase { #define NUM_GENERATIONS 3 struct gc_stats { - struct gc_generation_stats_buffer gen[NUM_GENERATIONS]; + struct gc_young_stats_buffer young; + struct gc_old_stats_buffer old[2]; }; struct _gc_runtime_state { diff --git a/Modules/gcmodule.c b/Modules/gcmodule.c index f502d66268e5bc..88a7cde7a7d8ae 100644 --- a/Modules/gcmodule.c +++ b/Modules/gcmodule.c @@ -342,16 +342,15 @@ gc_get_stats_impl(PyObject *module) /*[clinic end generated code: output=a8ab1d8a5d26f3ab input=1ef4ed9d17b1a470]*/ { int i; + double duration; struct gc_generation_stats stats[NUM_GENERATIONS], *st; /* To get consistent values despite allocations while constructing the result list, we use a snapshot of the running stats. */ GCState *gcstate = get_gc_state(); - struct gc_generation_stats_buffer *buffer; - for (i = 0; i < NUM_GENERATIONS; i++) { - buffer = &gcstate->generation_stats.gen[i]; - stats[i] = buffer->items[buffer->index]; - } + stats[0] = gcstate->generation_stats.young.items[gcstate->generation_stats.young.index]; + stats[1] = gcstate->generation_stats.old[0].items[gcstate->generation_stats.old[0].index]; + stats[2] = gcstate->generation_stats.old[1].items[gcstate->generation_stats.old[1].index]; PyObject *result = PyList_New(0); if (result == NULL) @@ -360,12 +359,13 @@ gc_get_stats_impl(PyObject *module) for (i = 0; i < NUM_GENERATIONS; i++) { PyObject *dict; st = &stats[i]; + duration = PyTime_AsSecondsDouble(st->ts_stop - st->ts_start); dict = Py_BuildValue("{snsnsnsnsd}", "collections", st->collections, "collected", st->collected, "uncollectable", st->uncollectable, "candidates", st->candidates, - "duration", st->duration + "duration", duration ); if (dict == NULL) goto error; diff --git a/Python/gc.c b/Python/gc.c index 5b8aa95bcf7124..e2ceb9aa9460f7 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -1394,18 +1394,33 @@ gc_list_set_space(PyGC_Head *list, int space) static struct gc_generation_stats * gc_get_stats(GCState *gcstate, int gen) { - struct gc_generation_stats_buffer *buffer = &gcstate->generation_stats.gen[gen]; - buffer->index = (buffer->index + 1) % 11; - struct gc_generation_stats *stats = &buffer->items[buffer->index]; - return stats; + if (gen == 0) { + struct gc_young_stats_buffer *buffer = &gcstate->generation_stats.young; + buffer->index = (buffer->index + 1) % GC_YOUNG_STATS_SIZE; + struct gc_generation_stats *stats = &buffer->items[buffer->index]; + return stats; + } + else { + struct gc_old_stats_buffer *buffer = &gcstate->generation_stats.old[gen - 1]; + buffer->index = (buffer->index + 1) % GC_OLD_STATS_SIZE; + struct gc_generation_stats *stats = &buffer->items[buffer->index]; + return stats; + } } static struct gc_generation_stats * gc_get_prev_stats(GCState *gcstate, int gen) { - struct gc_generation_stats_buffer *buffer = &gcstate->generation_stats.gen[gen]; - struct gc_generation_stats *stats = &buffer->items[buffer->index]; - return stats; + if (gen == 0) { + struct gc_young_stats_buffer *buffer = &gcstate->generation_stats.young; + struct gc_generation_stats *stats = &buffer->items[buffer->index]; + return stats; + } + else { + struct gc_old_stats_buffer *buffer = &gcstate->generation_stats.old[gen - 1]; + struct gc_generation_stats *stats = &buffer->items[buffer->index]; + return stats; + } } static void @@ -1414,18 +1429,21 @@ add_stats(GCState *gcstate, int gen, struct gc_generation_stats *stats) struct gc_generation_stats *prev_stats = gc_get_prev_stats(gcstate, gen); struct gc_generation_stats *cur_stats = gc_get_stats(gcstate, gen); - cur_stats->ts = stats->ts; - cur_stats->collections = prev_stats->collections + 1; - cur_stats->object_visits = prev_stats->object_visits + stats->object_visits; - cur_stats->collected = prev_stats->collected + stats->collected; - cur_stats->objects_transitively_reachable = prev_stats->objects_transitively_reachable + stats->objects_transitively_reachable; - cur_stats->objects_not_transitively_reachable = prev_stats->objects_not_transitively_reachable + stats->objects_not_transitively_reachable; - cur_stats->uncollectable = prev_stats->uncollectable + stats->uncollectable; - cur_stats->candidates = prev_stats->candidates + stats->candidates; - cur_stats->duration = stats->duration; - cur_stats->total_duration = prev_stats->total_duration + stats->duration; - cur_stats->heap_size = gcstate->heap_size; - cur_stats->work_to_do = gcstate->work_to_do; + memcpy(cur_stats, prev_stats, sizeof(struct gc_generation_stats)); + + cur_stats->ts_start = stats->ts_start; + cur_stats->ts_stop = stats->ts_stop; + cur_stats->heap_size = stats->heap_size; + cur_stats->work_to_do = stats->work_to_do; + + cur_stats->collections += 1; + cur_stats->object_visits += stats->object_visits; + cur_stats->collected += stats->collected; + cur_stats->uncollectable += stats->uncollectable; + cur_stats->candidates += stats->candidates; + + cur_stats->objects_transitively_reachable += stats->objects_transitively_reachable; + cur_stats->objects_not_transitively_reachable += stats->objects_not_transitively_reachable; } static void @@ -1907,12 +1925,13 @@ do_gc_callback(GCState *gcstate, const char *phase, assert(PyList_CheckExact(gcstate->callbacks)); PyObject *info = NULL; if (PyList_GET_SIZE(gcstate->callbacks) != 0) { + double duration = PyTime_AsSecondsDouble(stats->ts_stop - stats->ts_start); info = Py_BuildValue("{sisnsnsnsd}", "generation", generation, "collected", stats->collected, "uncollectable", stats->uncollectable, "candidates", stats->candidates, - "duration", stats->duration); + "duration", duration); if (info == NULL) { PyErr_FormatUnraisable("Exception ignored while invoking gc callbacks"); return; @@ -2150,7 +2169,9 @@ _PyGC_Collect(PyThreadState *tstate, int generation, _PyGC_Reason reason) if (PyDTrace_GC_START_ENABLED()) { PyDTrace_GC_START(generation); } - (void)PyTime_PerfCounterRaw(&stats.ts); + stats.heap_size = gcstate->heap_size; + stats.work_to_do = gcstate->work_to_do; + (void)PyTime_PerfCounterRaw(&stats.ts_start); PyObject *exc = _PyErr_GetRaisedException(tstate); switch(generation) { case 0: @@ -2165,9 +2186,7 @@ _PyGC_Collect(PyThreadState *tstate, int generation, _PyGC_Reason reason) default: Py_UNREACHABLE(); } - PyTime_t stop; - (void)PyTime_PerfCounterRaw(&stop); - stats.duration = PyTime_AsSecondsDouble(stop - stats.ts); + (void)PyTime_PerfCounterRaw(&stats.ts_stop); add_stats(gcstate, generation, &stats); if (PyDTrace_GC_DONE_ENABLED()) { PyDTrace_GC_DONE(stats.uncollectable + stats.collected); @@ -2190,9 +2209,10 @@ _PyGC_Collect(PyThreadState *tstate, int generation, _PyGC_Reason reason) _Py_atomic_store_int(&gcstate->collecting, 0); if (gcstate->debug & _PyGC_DEBUG_STATS) { + double duration = PyTime_AsSecondsDouble(stats.ts_stop - stats.ts_start); PySys_WriteStderr( "gc: done, %zd unreachable, %zd uncollectable, %.4fs elapsed\n", - stats.collected + stats.uncollectable, stats.uncollectable, stats.duration + stats.collected + stats.uncollectable, stats.uncollectable, duration ); } From 5da880028354ec27ed83e364c8f02ff3111b27f9 Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Sat, 28 Mar 2026 12:31:21 +0500 Subject: [PATCH 6/8] Revert OBJECT_STAT_INC(object_visits) --- Python/gc.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Python/gc.c b/Python/gc.c index e2ceb9aa9460f7..39e3fdc4ff5d3d 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -534,6 +534,7 @@ struct visit_decref_context { static int visit_decref(PyObject *op, void *arg) { + OBJECT_STAT_INC(object_visits); struct visit_decref_context *ctx = (struct visit_decref_context *)arg; ctx->stats->object_visits += 1; _PyObject_ASSERT(ctx->parent, !_PyObject_IsFreed(op)); @@ -612,6 +613,7 @@ visit_reachable(PyObject *op, void *arg) struct visit_reachable_context *ctx = (struct visit_reachable_context *)arg; ctx->stats->object_visits += 1; PyGC_Head *reachable = ctx->head; + OBJECT_STAT_INC(object_visits); if (!_PyObject_IS_GC(op)) { return 0; } @@ -855,6 +857,7 @@ visit_move(PyObject *op, void *arg) struct visit_reachable_context *ctx = (struct visit_reachable_context *)arg; PyGC_Head *tolist = ctx->head; ctx->stats->object_visits += 1; + OBJECT_STAT_INC(object_visits); if (_PyObject_IS_GC(op)) { PyGC_Head *gc = AS_GC(op); if (gc_is_collecting(gc)) { @@ -1487,6 +1490,7 @@ struct container_and_flag { static int visit_add_to_container(PyObject *op, void *arg) { + OBJECT_STAT_INC(object_visits); struct container_and_flag *cf = (struct container_and_flag *)arg; cf->stats->object_visits += 1; int visited = cf->visited_space; From d09c49a1303cdbd681d5627bf88e026e6410f8ba Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Sat, 28 Mar 2026 14:20:44 +0500 Subject: [PATCH 7/8] Revert duration because we need total duration in tests --- Include/internal/pycore_interp_structs.h | 3 +++ Modules/gcmodule.c | 4 +--- Python/gc.c | 9 +++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Include/internal/pycore_interp_structs.h b/Include/internal/pycore_interp_structs.h index 0dabbf658fc366..11874ea184f816 100644 --- a/Include/internal/pycore_interp_structs.h +++ b/Include/internal/pycore_interp_structs.h @@ -203,6 +203,9 @@ struct gc_generation_stats { Py_ssize_t objects_transitively_reachable; Py_ssize_t objects_not_transitively_reachable; + + // Total duration of the collection in seconds: + double duration; }; #define GC_YOUNG_STATS_SIZE 11 diff --git a/Modules/gcmodule.c b/Modules/gcmodule.c index 88a7cde7a7d8ae..c21b61589bd261 100644 --- a/Modules/gcmodule.c +++ b/Modules/gcmodule.c @@ -342,7 +342,6 @@ gc_get_stats_impl(PyObject *module) /*[clinic end generated code: output=a8ab1d8a5d26f3ab input=1ef4ed9d17b1a470]*/ { int i; - double duration; struct gc_generation_stats stats[NUM_GENERATIONS], *st; /* To get consistent values despite allocations while constructing @@ -359,13 +358,12 @@ gc_get_stats_impl(PyObject *module) for (i = 0; i < NUM_GENERATIONS; i++) { PyObject *dict; st = &stats[i]; - duration = PyTime_AsSecondsDouble(st->ts_stop - st->ts_start); dict = Py_BuildValue("{snsnsnsnsd}", "collections", st->collections, "collected", st->collected, "uncollectable", st->uncollectable, "candidates", st->candidates, - "duration", duration + "duration", st->duration ); if (dict == NULL) goto error; diff --git a/Python/gc.c b/Python/gc.c index 39e3fdc4ff5d3d..7bca40f6e3f58e 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -1447,6 +1447,8 @@ add_stats(GCState *gcstate, int gen, struct gc_generation_stats *stats) cur_stats->objects_transitively_reachable += stats->objects_transitively_reachable; cur_stats->objects_not_transitively_reachable += stats->objects_not_transitively_reachable; + + cur_stats->duration += stats->duration; } static void @@ -1929,13 +1931,12 @@ do_gc_callback(GCState *gcstate, const char *phase, assert(PyList_CheckExact(gcstate->callbacks)); PyObject *info = NULL; if (PyList_GET_SIZE(gcstate->callbacks) != 0) { - double duration = PyTime_AsSecondsDouble(stats->ts_stop - stats->ts_start); info = Py_BuildValue("{sisnsnsnsd}", "generation", generation, "collected", stats->collected, "uncollectable", stats->uncollectable, "candidates", stats->candidates, - "duration", duration); + "duration", stats->duration); if (info == NULL) { PyErr_FormatUnraisable("Exception ignored while invoking gc callbacks"); return; @@ -2191,6 +2192,7 @@ _PyGC_Collect(PyThreadState *tstate, int generation, _PyGC_Reason reason) Py_UNREACHABLE(); } (void)PyTime_PerfCounterRaw(&stats.ts_stop); + stats.duration = PyTime_AsSecondsDouble(stats.ts_stop - stats.ts_start); add_stats(gcstate, generation, &stats); if (PyDTrace_GC_DONE_ENABLED()) { PyDTrace_GC_DONE(stats.uncollectable + stats.collected); @@ -2213,10 +2215,9 @@ _PyGC_Collect(PyThreadState *tstate, int generation, _PyGC_Reason reason) _Py_atomic_store_int(&gcstate->collecting, 0); if (gcstate->debug & _PyGC_DEBUG_STATS) { - double duration = PyTime_AsSecondsDouble(stats.ts_stop - stats.ts_start); PySys_WriteStderr( "gc: done, %zd unreachable, %zd uncollectable, %.4fs elapsed\n", - stats.collected + stats.uncollectable, stats.uncollectable, duration + stats.collected + stats.uncollectable, stats.uncollectable, stats.duration ); } From 0bac6ac5f3a56c61a5dc2978be5697eae574b02c Mon Sep 17 00:00:00 2001 From: Sergey Miryanov Date: Sat, 28 Mar 2026 14:21:48 +0500 Subject: [PATCH 8/8] Fix gc_free_threading compilation --- Include/internal/pycore_interp_structs.h | 5 +++++ Python/gc_free_threading.c | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Include/internal/pycore_interp_structs.h b/Include/internal/pycore_interp_structs.h index 11874ea184f816..f76d4f41c55119 100644 --- a/Include/internal/pycore_interp_structs.h +++ b/Include/internal/pycore_interp_structs.h @@ -208,8 +208,13 @@ struct gc_generation_stats { double duration; }; +#ifdef Py_GIL_DISABLED +#define GC_YOUNG_STATS_SIZE 1 +#define GC_OLD_STATS_SIZE 1 +#else #define GC_YOUNG_STATS_SIZE 11 #define GC_OLD_STATS_SIZE 3 +#endif struct gc_young_stats_buffer { struct gc_generation_stats items[GC_YOUNG_STATS_SIZE]; int8_t index; diff --git a/Python/gc_free_threading.c b/Python/gc_free_threading.c index 0ec9c58a792e6d..7ad60a73a56a69 100644 --- a/Python/gc_free_threading.c +++ b/Python/gc_free_threading.c @@ -2383,6 +2383,21 @@ gc_collect_internal(PyInterpreterState *interp, struct collection_state *state, handle_legacy_finalizers(state); } +static struct gc_generation_stats * +get_stats(GCState *gcstate, int gen) +{ + if (gen == 0) { + struct gc_young_stats_buffer *buffer = &gcstate->generation_stats.young; + struct gc_generation_stats *stats = &buffer->items[buffer->index]; + return stats; + } + else { + struct gc_old_stats_buffer *buffer = &gcstate->generation_stats.old[gen - 1]; + struct gc_generation_stats *stats = &buffer->items[buffer->index]; + return stats; + } +} + /* This is the main function. Read this to understand how the * collection process works. */ static Py_ssize_t @@ -2471,7 +2486,7 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) } /* Update stats */ - struct gc_generation_stats *stats = &gcstate->generation_stats[generation]; + struct gc_generation_stats *stats = get_stats(gcstate, generation); stats->collections++; stats->collected += m; stats->uncollectable += n;