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
10 changes: 6 additions & 4 deletions Doc/library/gc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,12 @@ The :mod:`!gc` module provides the following functions:
With the third generation, things are a bit more complicated,
see `Collecting the oldest generation <https://github.com/python/cpython/blob/ff0ef0a54bef26fc507fbf9b7a6009eb7d3f17f5/InternalDocs/garbage_collector.md#collecting-the-oldest-generation>`_ for more information.

In the free-threaded build, the increase in process memory usage is also
checked before running the collector. If the memory usage has not increased
by 10% since the last collection and the net number of object allocations
has not exceeded 40 times *threshold0*, the collection is not run.
In the free-threaded build, the effective collection threshold is adapted
based on how much cyclic trash the last collection found. If few trash
cycles were found, the threshold is adjusted higher, up to half the count
of live objects. If many were found, the threshold is adjusted lower, down
to a minimum of *threshold0*. Setting *threshold1* to zero disables this
adaptation and causes *threshold0* to be used directly.

See `Garbage collector design <https://github.com/python/cpython/blob/3.15/InternalDocs/garbage_collector.md>`_ for more information.

Expand Down
13 changes: 4 additions & 9 deletions Include/internal/pycore_interp_structs.h
Original file line number Diff line number Diff line change
Expand Up @@ -264,15 +264,10 @@ struct _gc_runtime_state {
/* True if gc.freeze() has been used. */
int freeze_active;

/* Memory usage of the process (RSS + swap) after last GC. */
Py_ssize_t last_mem;

/* This accumulates the new object count whenever collection is deferred
due to the RSS increase condition not being meet. Reset on collection. */
Py_ssize_t deferred_count;

/* Mutex held for gc_should_collect_mem_usage(). */
PyMutex mutex;
/* Adaptive threshold used to decide when to trigger a collection.
Adjusted after each collection based on the fraction of objects found to
be trash. */
int adaptive_threshold;
#else
PyGC_Head *generation0;
#endif
Expand Down
118 changes: 118 additions & 0 deletions InternalDocs/garbage_collector.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,124 @@ in the total number of objects (the effect of which can be summarized thusly:
grows, but we do fewer and fewer of them").


Adaptive collection threshold (free-threaded build)
===================================================

> [!NOTE]
> This section applies only to the free-threaded build. The default
> (GIL) build uses the generational thresholds described above.

The free-threaded GC is non-generational: every collection scans the entire
heap. It therefore needs a different mechanism than `threshold0` /
`threshold1` to decide when to run. Instead, it maintains an *adaptive*
trigger that scales with the size of the live heap and adjusts itself based
on how much trash recent collections actually found. The logic lives in
`update_adaptive_threshold()` in `Python/gc_free_threading.c`, which is
called after each collection that fired because the threshold was reached
(`reason == _Py_GC_REASON_HEAP`). Manual `gc.collect()` calls and shutdown
collections do not update the adaptive state — they aren't representative of
the steady-state trash rate.

Every allocation increments `young.count`. A collection is considered when
`count` exceeds `gcstate->adaptive_threshold` (subject to the quadratic
guard below). The job of `update_adaptive_threshold()` is to choose a good
value for `adaptive_threshold` for the *next* pass.

The cost model
--------------

A free-threaded GC pass is dominated by the mark-alive walk over the
mimalloc GC heap, whose cost is roughly `O(L)` where `L` is the count of
*surviving* live objects (this is what `long_lived_total` records — by the
time `update_adaptive_threshold()` runs it has already been decremented for
the unreachable objects identified this pass). If `T` is the number of
allocations between passes, the amortized GC cost per allocation is
proportional to `L / T`. To keep amortized cost roughly linear in total
allocations as the program grows, `T` should scale with `L`. This gives an
upper bound:

T_max = L / GC_THRESHOLD_MAX_DIVISOR

`T_max` alone is wrong, however: a program churning short-lived cycles
wants GC to run often, not just once per heap doubling. We also have a
user-configured pivot — the value of `gc.set_threshold()`, called `base`
below — and a derived lower bound:

T_min = base / GC_THRESHOLD_MIN_DIVISOR

The adaptive threshold lives in `[T_min, T_max]`, and `update_adaptive_threshold()`
chooses where in that range to sit based on recent trash productivity.

Trash ratio and hyperbolic decay
--------------------------------

After a threshold-triggered collection we know two numbers: how many
objects the pass collected, `C`, and the survivor count `L` (so the
pre-collection heap size was `N = L + C`). The trash ratio

r = C / L

measures trash freed per surviving live object — equivalently, how many
extra walk units the next pass would do as a multiple of the walk units
already paid for in survivors. A high ratio means the pass paid for
itself; a low ratio means the walk was largely wasted. We use `C/L`
rather than `C/N` because (a) `L` is what the *next* pass will walk, not
`N`, and (b) `C/L` is unbounded above (as `C` approaches `N`, `L` shrinks
toward zero and `r` grows without bound), which lets the curve drive the
threshold all the way to its floor in genuinely high-trash regimes.

We map `r` to a target threshold via a hyperbolic decay:

target = T_min + (T_max - T_min) / (1 + K * r)

with `K = GC_THRESHOLD_DECAY_K`. At `r = 0` (no trash) the target equals
`T_max`; as `r` grows the target decays smoothly toward the asymptote
`T_min`. In the implementation this is rearranged to keep the math in
integers:

target = T_min + (T_max - T_min) * L / (L + K * C)

`L` and `C` are scaled down (right-shifted) ahead of the multiply if `L`
exceeds 2^30, since only the ratio matters. If a pass somehow collects
everything (`L == 0`), the rearranged form would have a zero denominator;
in that case we fall back to `T_max`.

The new threshold is set directly to the computed target — there is no
EMA or weighted step. Software workloads can change abruptly (a program
may go from zero cyclic trash to millions per second and back within
seconds), and in that regime the most recent pass is a better predictor
of the next than a long-history average.

Tunables
--------

Three compile-time `#define`s in `Python/gc_free_threading.c` control the
shape of the curve. All three are `#ifndef`-guarded so a build can
override them with `-DGC_THRESHOLD_*=value`:

| Macro | Default | Meaning |
|---|---|---|
| `GC_THRESHOLD_MAX_DIVISOR` | 2 | `T_max = L / N`. Larger N collects less often on big heaps. |
| `GC_THRESHOLD_DECAY_K` | 8 | Decay rate of the hyperbolic curve. Larger K reaches `T_min` faster. |
| `GC_THRESHOLD_MIN_DIVISOR` | 1 | `T_min = base / N`. N=1 makes the user's `gc.set_threshold` value a hard minimum interval between collections. |

If `T_max` (i.e. `L / GC_THRESHOLD_MAX_DIVISOR`) falls below `base`, it is
clamped up to `base`: on a small heap the curve runs over `[T_min, base]`
rather than over `[T_min, L/N]` — which would otherwise collapse below
`base` for tiny heaps.

Quadratic-behavior guard
------------------------

Even if `count` exceeds `adaptive_threshold`, GC will not actually fire
unless `count >= long_lived_total / 4` (see `gc_should_collect()`). This
pre-existing guard prevents pathological behavior on heaps that are
growing in pure-non-trash regions: it gives `T` a second floor proportional
to the live heap so that no matter how aggressively the adaptive math
pushes the threshold down, we never collect so often that GC cost
dominates allocation cost.


Optimization: excluding reachable objects
=========================================

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
For the free-threaded build, the cyclic GC now adapts the collection
threshold based on how successful the last automatic collection was in
finding trash.
Loading
Loading