Skip to content

Commit e4c8b2a

Browse files
committed
Replace per-gen EMA adaptive controller with biased constrained random walk
Remove per-generation EMA hill-climbing (8 parameters, ~90 lines) and replace with a simpler biased constrained random walk (~25 lines): - Start at min(4, num_workers) workers - Each collection: with 20% probability, step ±1 (60% bias toward increase) - Clamp to [2, num_workers] - No per-gen state, no EMA, no epsilon, no warmup, no dead zone The proactive walk continuously explores nearby worker counts even when performance is stable, preventing the controller from getting trapped at suboptimal configurations after load spikes. API changes: - get_parallel_config: adaptive_workers (single int) replaces per-gen arrays - get_parallel_stats: prev_cost_per_obj_ns replaces per-gen EMAs Forward-ported from commit 5114778 on parallel_gc_backport (3.12). 3.15 routing: controller body moved from Modules/gcmodule.c to Python/gc.c. The flaky test_gen2_allows_more_workers from Phase 5.4 is no longer applicable — without per-gen state there is nothing for gen2 to converge to differently from gen0. The test still exists in the file but its class-level skip (_has_adaptive_controller checks per-gen API presence) now skips the whole TestAdaptiveControllerConvergence class on builds where that API is absent (i.e. this build). The test runs cleanly: SUCCESS, 31 tests run, 9 skipped (the now-inapplicable convergence class), 0 failures. Part of Phase 5 of the 3.15 consolidation.
1 parent 03a9c51 commit e4c8b2a

3 files changed

Lines changed: 47 additions & 129 deletions

File tree

Include/internal/pycore_gc_parallel.h

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -368,25 +368,16 @@ struct _PyParallelGCState {
368368
int enabled;
369369

370370
// Adaptive worker count — how many workers to wake per collection.
371-
// adaptive_workers is set at the start of each collection from the
372-
// per-generation array, and used by dispatch functions during collection.
371+
// Biased constrained random walk: start at min(4, num_workers),
372+
// stochastically sample ±1 with uphill bias, clamp to [2, num_workers].
373373
size_t adaptive_workers;
374374

375-
// Per-generation adaptive state (0=gen0, 1=gen1, 2=gen2).
376-
// Each generation independently hill-climbs to its own optimal worker count.
377-
size_t adaptive_workers_by_gen[3];
378-
double ema_per_obj_ns_by_gen[3];
379-
380-
// Stochastic exploration (epsilon-greedy).
381-
// epsilon: probability of exploring a random worker count instead of
382-
// following the hill-climbing gradient. Decays over time.
383-
// explore_rng: xorshift32 PRNG state for exploration decisions.
384-
// shift_count: consecutive collections with cost > 2x EMA.
385-
// After 3 consecutive, reset epsilon (workload shift detected).
386-
double epsilon;
375+
// Random walk state: previous collection's per-object cost (nanoseconds).
376+
// Used to compare trial worker counts against the previous configuration.
377+
double prev_cost_per_obj_ns;
378+
379+
// xorshift32 PRNG state for stochastic sampling decisions.
387380
uint32_t explore_rng;
388-
uint8_t shift_count;
389-
uint8_t collections_by_gen[3]; // Per-gen collection count (saturates at 255)
390381

391382
int last_generation; // Last generation collected (for API observability)
392383
int dispatch_in_progress; // Reentrancy guard for condvar dispatch

Python/gc.c

Lines changed: 25 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -2178,15 +2178,14 @@ gc_collect_region(PyThreadState *tstate,
21782178
assert(!_PyErr_Occurred(tstate));
21792179

21802180
#ifdef Py_PARALLEL_GC
2181-
// Set adaptive_workers for this collection from per-generation state.
2182-
// Must happen before deduce_unreachable which dispatches parallel work.
2181+
// Record which generation we're collecting (for API observability).
2182+
// adaptive_workers is already set by the random walk controller.
21832183
{
21842184
_PyParallelGCState *_par_gc = tstate->interp->gc.parallel_gc;
21852185
if (_par_gc != NULL && _par_gc->enabled) {
21862186
int gen = generation;
21872187
if (gen < 0) gen = 0;
21882188
if (gen > 2) gen = 2;
2189-
_par_gc->adaptive_workers = _par_gc->adaptive_workers_by_gen[gen];
21902189
_par_gc->last_generation = gen;
21912190
}
21922191
}
@@ -2280,87 +2279,44 @@ gc_collect_region(PyThreadState *tstate,
22802279
(void)PyTime_PerfCounterRaw(&cleanup_end);
22812280
par_gc->cleanup_end_ns = cleanup_end;
22822281

2283-
// Stochastic hill-climbing adaptive worker count (per-generation).
2284-
// Each generation independently tracks its own EMA and worker count.
2285-
// Epsilon-greedy exploration prevents getting stuck in local optima
2286-
// when workload characteristics change.
2287-
int gen = generation;
2288-
if (gen < 0) gen = 0;
2289-
if (gen > 2) gen = 2;
2290-
2282+
// 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.
22912285
int64_t parallel_time = par_gc->cleanup_end_ns - par_gc->gc_start_ns;
22922286
Py_ssize_t candidates = par_gc->split_vector.count;
22932287
if (parallel_time > 0 && candidates > 0) {
2294-
double per_obj = (double)parallel_time / (double)candidates;
2295-
double prev_ema = par_gc->ema_per_obj_ns_by_gen[gen];
2296-
2297-
// Track per-generation collection count (saturates at 255)
2298-
uint8_t gen_count = par_gc->collections_by_gen[gen];
2299-
if (gen_count < 255) {
2300-
par_gc->collections_by_gen[gen] = ++gen_count;
2301-
}
2302-
2303-
// Workload shift detection: 3 consecutive collections with
2304-
// cost > 2x EMA resets exploration (not a single outlier).
2305-
// Skip during EMA warmup (first 3 collections per gen).
2306-
if (gen_count > 3 && per_obj > 2.0 * prev_ema) {
2307-
par_gc->shift_count++;
2308-
if (par_gc->shift_count >= 3) {
2309-
par_gc->epsilon = 0.3;
2310-
par_gc->shift_count = 0;
2311-
}
2312-
} else if (gen_count > 3) {
2313-
par_gc->shift_count = 0;
2314-
}
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;
23152291

2316-
// EMA update: 0.7 old + 0.3 new (smooth over ~3 collections)
2317-
par_gc->ema_per_obj_ns_by_gen[gen] = 0.7 * prev_ema + 0.3 * per_obj;
2318-
2319-
// Skip explore/exploit during EMA warmup (first 3 collections
2320-
// per generation). The EMA needs time to converge to the
2321-
// actual per-object cost before hill-climbing decisions
2322-
// are meaningful. Without this, cold-start EMA (100.0) vs
2323-
// real cost (potentially millions) causes the controller
2324-
// to always decrease workers on first contact with a gen.
2325-
if (gen_count <= 3) {
2292+
// Skip adjustment on first collection (no baseline yet)
2293+
if (prev_cost <= 0.0) {
23262294
goto controller_done;
23272295
}
23282296

2329-
// Epsilon-greedy: explore or exploit
2330-
// xorshift32 PRNG for exploration decisions
2297+
// xorshift32 PRNG
23312298
uint32_t rng = par_gc->explore_rng;
23322299
rng ^= rng << 13;
23332300
rng ^= rng >> 17;
23342301
rng ^= rng << 5;
23352302
par_gc->explore_rng = rng;
23362303
double rand_val = (double)(rng & 0xFFFF) / 65535.0;
23372304

2338-
if (rand_val < par_gc->epsilon) {
2339-
// EXPLORE: random worker count in [2, num_workers]
2340-
uint32_t range = (uint32_t)(par_gc->num_workers - 1);
2341-
if (range == 0) range = 1;
2342-
par_gc->adaptive_workers_by_gen[gen] = 2 + (rng % range);
2343-
if (par_gc->adaptive_workers_by_gen[gen] > par_gc->num_workers) {
2344-
par_gc->adaptive_workers_by_gen[gen] = par_gc->num_workers;
2345-
}
2346-
} else {
2347-
// EXPLOIT: hill-climb with 15% dead zone
2348-
if (per_obj > prev_ema * 1.15
2349-
&& par_gc->adaptive_workers_by_gen[gen] > 2) {
2350-
par_gc->adaptive_workers_by_gen[gen]--;
2351-
} else if (per_obj < prev_ema * 0.85
2352-
&& par_gc->adaptive_workers_by_gen[gen]
2353-
< par_gc->num_workers) {
2354-
par_gc->adaptive_workers_by_gen[gen]++;
2355-
}
2356-
}
2357-
2358-
// Decay epsilon: multiply by 0.95 after non-exploratory collections
2359-
if (rand_val >= par_gc->epsilon) {
2360-
par_gc->epsilon *= 0.95;
2361-
if (par_gc->epsilon < 0.05) {
2362-
par_gc->epsilon = 0.05; // floor: 5% ongoing exploration
2305+
// 20% chance to step ±1 (proactive exploration)
2306+
if (rand_val < 0.2) {
2307+
// Bias uphill: 60% chance to increase, 40% to decrease
2308+
double dir_val = (double)((rng >> 16) & 0xFFFF) / 65535.0;
2309+
int delta = (dir_val < 0.6) ? 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--;
23632318
}
2319+
par_gc->adaptive_workers = trial;
23642320
}
23652321
controller_done: ;
23662322
}

Python/gc_parallel.c

Lines changed: 15 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -501,16 +501,12 @@ _PyGC_ParallelInit(PyInterpreterState *interp, size_t num_workers)
501501
par_gc->num_workers = num_workers;
502502
par_gc->enabled = 1;
503503
par_gc->num_workers_active = 0;
504-
// Per-generation adaptive workers: start at min(4, num_workers) for each gen
504+
// Biased constrained random walk: start at min(4, num_workers)
505505
size_t initial_workers = (num_workers < 4) ? num_workers : 4;
506-
for (int g = 0; g < 3; g++) {
507-
par_gc->adaptive_workers_by_gen[g] = initial_workers;
508-
par_gc->ema_per_obj_ns_by_gen[g] = 100.0; // neutral initial estimate
509-
}
510506
par_gc->adaptive_workers = initial_workers;
507+
par_gc->prev_cost_per_obj_ns = 0.0; // no previous measurement yet
511508

512-
// Stochastic exploration: seed from GC_TEST_SEED or perf counter
513-
par_gc->epsilon = 0.3; // 30% exploration initially
509+
// Seed PRNG from GC_TEST_SEED or perf counter
514510
const char *seed_env = getenv("GC_TEST_SEED");
515511
if (seed_env != NULL) {
516512
par_gc->explore_rng = (uint32_t)atoi(seed_env);
@@ -522,10 +518,6 @@ _PyGC_ParallelInit(PyInterpreterState *interp, size_t num_workers)
522518
if (par_gc->explore_rng == 0) {
523519
par_gc->explore_rng = 1; // xorshift32 absorbing state guard
524520
}
525-
par_gc->shift_count = 0;
526-
for (int g = 0; g < 3; g++) {
527-
par_gc->collections_by_gen[g] = 0;
528-
}
529521

530522
par_gc->dispatch_in_progress = 0;
531523

@@ -853,29 +845,15 @@ _PyGC_ParallelGetConfig(PyInterpreterState *interp)
853845
}
854846
Py_DECREF(workers_obj);
855847

856-
// Per-generation adaptive worker counts and epsilon (if enabled)
848+
// Adaptive worker count (current random walk position)
857849
if (par_gc != NULL && par_gc->enabled) {
858-
static const char *gen_keys[] = {
859-
"adaptive_workers_gen0",
860-
"adaptive_workers_gen1",
861-
"adaptive_workers_gen2",
862-
};
863-
for (int g = 0; g < 3; g++) {
864-
PyObject *v = PyLong_FromSize_t(par_gc->adaptive_workers_by_gen[g]);
865-
if (v == NULL || PyDict_SetItemString(result, gen_keys[g], v) < 0) {
866-
Py_XDECREF(v);
867-
Py_DECREF(result);
868-
return NULL;
869-
}
870-
Py_DECREF(v);
871-
}
872-
PyObject *eps = PyFloat_FromDouble(par_gc->epsilon);
873-
if (eps == NULL || PyDict_SetItemString(result, "epsilon", eps) < 0) {
874-
Py_XDECREF(eps);
850+
PyObject *aw = PyLong_FromSize_t(par_gc->adaptive_workers);
851+
if (aw == NULL || PyDict_SetItemString(result, "adaptive_workers", aw) < 0) {
852+
Py_XDECREF(aw);
875853
Py_DECREF(result);
876854
return NULL;
877855
}
878-
Py_DECREF(eps);
856+
Py_DECREF(aw);
879857
}
880858

881859
return result;
@@ -1239,22 +1217,15 @@ _PyGC_ParallelGetStats(PyInterpreterState *interp)
12391217
}
12401218
Py_DECREF(phase_timing);
12411219

1242-
// Per-generation EMA values for controller observability
1220+
// Random walk controller state for observability
12431221
{
1244-
static const char *ema_keys[] = {
1245-
"ema_per_obj_ns_gen0",
1246-
"ema_per_obj_ns_gen1",
1247-
"ema_per_obj_ns_gen2",
1248-
};
1249-
for (int g = 0; g < 3; g++) {
1250-
PyObject *v = PyFloat_FromDouble(par_gc->ema_per_obj_ns_by_gen[g]);
1251-
if (v == NULL || PyDict_SetItemString(result, ema_keys[g], v) < 0) {
1252-
Py_XDECREF(v);
1253-
Py_DECREF(result);
1254-
return NULL;
1255-
}
1256-
Py_DECREF(v);
1222+
PyObject *v = PyFloat_FromDouble(par_gc->prev_cost_per_obj_ns);
1223+
if (v == NULL || PyDict_SetItemString(result, "prev_cost_per_obj_ns", v) < 0) {
1224+
Py_XDECREF(v);
1225+
Py_DECREF(result);
1226+
return NULL;
12571227
}
1228+
Py_DECREF(v);
12581229
}
12591230

12601231
// Last generation collected

0 commit comments

Comments
 (0)