Skip to content

Commit 77c1247

Browse files
committed
Extract random-walk worker controller into shared header
Phase 6 prep. The biased constrained random walk controller introduced in Phase 5 was only on the GIL parallel-GC code path; the free-threaded parallel GC (Python/gc_free_threading*.c) has no adaptive worker count at all. To share IDENTICAL controller behaviour across both builds, extract the controller logic into a static inline header function: Include/internal/pycore_gc_random_walk.h _PyGC_RandomWalkUpdate(parallel_time_ns, candidates, &prev_cost, &rng, &adaptive_workers, num_workers) _PyGC_RandomWalkSeed(void) -> initial xorshift32 seed (GC_TEST_SEED env var or perf counter) This commit is a pure refactor — no behaviour change. Python/gc.c's controller body is replaced with the helper call. Python/gc_parallel.c's seed code (also duplicated of the same logic) is replaced with _PyGC_RandomWalkSeed(). Next commit will add the controller call site + the corresponding adaptive_workers/prev_cost_per_obj_ns/explore_rng state and per-worker condvar dispatch to the free-threaded parallel GC, so both builds exhibit the same worker-count behaviour. Verified: GIL build, 124 tests pass (test_gc + test_gc_parallel_mark_alive + test_gc_ws_deque). No regression.
1 parent 3d4bf4b commit 77c1247

3 files changed

Lines changed: 129 additions & 51 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Shared random-walk adaptive worker count controller for parallel GC.
2+
//
3+
// Used by both the GIL parallel GC (Python/gc.c, Python/gc_parallel.c) and the
4+
// free-threaded parallel GC (Python/gc_free_threading*.c) so they exhibit
5+
// IDENTICAL worker-count adaptation behaviour.
6+
//
7+
// Algorithm:
8+
// - Each collection, with 20% probability, step adaptive_workers by ±1.
9+
// - 50/50 unbiased direction (no uphill bias).
10+
// - Clamp result to [2, num_workers].
11+
// - First collection (prev_cost <= 0) only records baseline; no step.
12+
//
13+
// State (caller-owned, must be initialised by caller):
14+
// - prev_cost_per_obj_ns: previous collection's per-object cost in ns; 0.0
15+
// means no measurement yet.
16+
// - explore_rng: xorshift32 PRNG state; must be non-zero (caller seeds from
17+
// GC_TEST_SEED env var or a perf counter, then clamps to 1 if zero).
18+
// - adaptive_workers: current worker count, in [2, num_workers]; caller
19+
// typically initialises to min(4, num_workers).
20+
21+
#ifndef Py_INTERNAL_GC_RANDOM_WALK_H
22+
#define Py_INTERNAL_GC_RANDOM_WALK_H
23+
24+
#ifndef Py_BUILD_CORE
25+
# error "this header requires Py_BUILD_CORE define"
26+
#endif
27+
28+
#ifdef __cplusplus
29+
extern "C" {
30+
#endif
31+
32+
#include "Python.h"
33+
#include <stdint.h>
34+
35+
// Update *adaptive_workers, *explore_rng, *prev_cost_per_obj_ns based on the
36+
// observed collection cost. Pure logic — no allocations, no locks, no globals.
37+
//
38+
// parallel_time_ns: wall-clock time the parallel work took (gc_start to
39+
// cleanup_end), in nanoseconds. Must be > 0 for the update to fire.
40+
// candidates: number of objects considered by the collection. Must be > 0 for
41+
// the update to fire.
42+
//
43+
// If parallel_time_ns <= 0 or candidates <= 0 (e.g. trivial collection),
44+
// state is not modified.
45+
static inline void
46+
_PyGC_RandomWalkUpdate(int64_t parallel_time_ns,
47+
Py_ssize_t candidates,
48+
double *prev_cost_per_obj_ns,
49+
uint32_t *explore_rng,
50+
size_t *adaptive_workers,
51+
size_t num_workers)
52+
{
53+
if (parallel_time_ns <= 0 || candidates <= 0) {
54+
return;
55+
}
56+
57+
double cost = (double)parallel_time_ns / (double)candidates;
58+
double prev_cost = *prev_cost_per_obj_ns;
59+
*prev_cost_per_obj_ns = cost;
60+
61+
// Skip adjustment on first collection (no baseline yet)
62+
if (prev_cost <= 0.0) {
63+
return;
64+
}
65+
66+
// xorshift32 PRNG
67+
uint32_t rng = *explore_rng;
68+
rng ^= rng << 13;
69+
rng ^= rng >> 17;
70+
rng ^= rng << 5;
71+
*explore_rng = rng;
72+
double rand_val = (double)(rng & 0xFFFF) / 65535.0;
73+
74+
// 20% chance to step ±1 (proactive exploration)
75+
if (rand_val < 0.2) {
76+
// No directional bias: 50/50 chance to increase or decrease
77+
double dir_val = (double)((rng >> 16) & 0xFFFF) / 65535.0;
78+
int delta = (dir_val < 0.5) ? 1 : -1;
79+
80+
// Always step when the dice fires. Good values stick because they
81+
// don't trigger further corrective steps.
82+
size_t trial = *adaptive_workers;
83+
if (delta > 0 && trial < num_workers) {
84+
trial++;
85+
} else if (delta < 0 && trial > 2) {
86+
trial--;
87+
}
88+
*adaptive_workers = trial;
89+
}
90+
}
91+
92+
// Seed an xorshift32 PRNG state. Reads GC_TEST_SEED env var if set
93+
// (for deterministic tests), otherwise uses PyTime_PerfCounterRaw().
94+
// Guarantees a non-zero seed (xorshift32 absorbing state).
95+
static inline uint32_t
96+
_PyGC_RandomWalkSeed(void)
97+
{
98+
uint32_t seed;
99+
const char *seed_env = getenv("GC_TEST_SEED");
100+
if (seed_env != NULL) {
101+
seed = (uint32_t)atoi(seed_env);
102+
} else {
103+
PyTime_t seed_time;
104+
(void)PyTime_PerfCounterRaw(&seed_time);
105+
seed = (uint32_t)seed_time;
106+
}
107+
if (seed == 0) {
108+
seed = 1; // xorshift32 absorbing state guard
109+
}
110+
return seed;
111+
}
112+
113+
#ifdef __cplusplus
114+
}
115+
#endif
116+
117+
#endif /* !Py_INTERNAL_GC_RANDOM_WALK_H */

Python/gc.c

Lines changed: 10 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include "pycore_interp.h" // PyInterpreterState.gc
1010
#ifdef Py_PARALLEL_GC
1111
#include "pycore_gc_parallel.h" // _PyGC_ParallelMoveUnreachable()
12+
#include "pycore_gc_random_walk.h" // _PyGC_RandomWalkUpdate()
1213
#include "pycore_time.h" // PyTime_PerfCounterRaw
1314
#endif
1415
#include "pycore_interpframe.h" // _PyFrame_GetLocalsArray()
@@ -2280,46 +2281,15 @@ gc_collect_region(PyThreadState *tstate,
22802281
par_gc->cleanup_end_ns = cleanup_end;
22812282

22822283
// Biased constrained random walk controller.
2283-
// Each collection: with 20% probability, step current±1 workers.
2284-
// Proactive: always step when dice fires. Good values stick naturally.
2285-
int64_t parallel_time = par_gc->cleanup_end_ns - par_gc->gc_start_ns;
2286-
Py_ssize_t candidates = par_gc->split_vector.count;
2287-
if (parallel_time > 0 && candidates > 0) {
2288-
double cost = (double)parallel_time / (double)candidates;
2289-
double prev_cost = par_gc->prev_cost_per_obj_ns;
2290-
par_gc->prev_cost_per_obj_ns = cost;
2291-
2292-
// Skip adjustment on first collection (no baseline yet)
2293-
if (prev_cost <= 0.0) {
2294-
goto controller_done;
2295-
}
2296-
2297-
// xorshift32 PRNG
2298-
uint32_t rng = par_gc->explore_rng;
2299-
rng ^= rng << 13;
2300-
rng ^= rng >> 17;
2301-
rng ^= rng << 5;
2302-
par_gc->explore_rng = rng;
2303-
double rand_val = (double)(rng & 0xFFFF) / 65535.0;
2304-
2305-
// 20% chance to step ±1 (proactive exploration)
2306-
if (rand_val < 0.2) {
2307-
// No directional bias: 50/50 chance to increase or decrease
2308-
double dir_val = (double)((rng >> 16) & 0xFFFF) / 65535.0;
2309-
int delta = (dir_val < 0.5) ? 1 : -1;
2310-
2311-
// Always step when the dice fires. Good values stick
2312-
// because they don't trigger further corrective steps.
2313-
size_t trial = par_gc->adaptive_workers;
2314-
if (delta > 0 && trial < par_gc->num_workers) {
2315-
trial++;
2316-
} else if (delta < 0 && trial > 2) {
2317-
trial--;
2318-
}
2319-
par_gc->adaptive_workers = trial;
2320-
}
2321-
controller_done: ;
2322-
}
2284+
// Shared with the free-threaded parallel GC for identical
2285+
// behaviour across builds — see pycore_gc_random_walk.h.
2286+
_PyGC_RandomWalkUpdate(
2287+
par_gc->cleanup_end_ns - par_gc->gc_start_ns,
2288+
par_gc->split_vector.count,
2289+
&par_gc->prev_cost_per_obj_ns,
2290+
&par_gc->explore_rng,
2291+
&par_gc->adaptive_workers,
2292+
par_gc->num_workers);
23232293
}
23242294
}
23252295
#endif

Python/gc_parallel.c

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#ifdef Py_PARALLEL_GC
66

77
#include "pycore_gc_parallel.h"
8+
#include "pycore_gc_random_walk.h" // _PyGC_RandomWalkSeed()
89
#include "pycore_pystate.h"
910
#include "pycore_interp.h"
1011
#include "pycore_gc.h" // For GC internals
@@ -507,17 +508,7 @@ _PyGC_ParallelInit(PyInterpreterState *interp, size_t num_workers)
507508
par_gc->prev_cost_per_obj_ns = 0.0; // no previous measurement yet
508509

509510
// Seed PRNG from GC_TEST_SEED or perf counter
510-
const char *seed_env = getenv("GC_TEST_SEED");
511-
if (seed_env != NULL) {
512-
par_gc->explore_rng = (uint32_t)atoi(seed_env);
513-
} else {
514-
PyTime_t seed_time;
515-
(void)PyTime_PerfCounterRaw(&seed_time);
516-
par_gc->explore_rng = (uint32_t)seed_time;
517-
}
518-
if (par_gc->explore_rng == 0) {
519-
par_gc->explore_rng = 1; // xorshift32 absorbing state guard
520-
}
511+
par_gc->explore_rng = _PyGC_RandomWalkSeed();
521512

522513
par_gc->dispatch_in_progress = 0;
523514

0 commit comments

Comments
 (0)