Skip to content

Commit d1049e6

Browse files
committed
gh-161204: Allow '-jN%' form of regrtest '-j' option
Adaptively target a given system load percentage.
1 parent eeafc73 commit d1049e6

8 files changed

Lines changed: 454 additions & 8 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Load-average-dependent worker admission using the existing load tracker."""
2+
3+
import math
4+
import os
5+
import threading
6+
7+
8+
class AdaptiveWorkerGate:
9+
# Heuristic: avoid rapid adjustments against a smoothed load measurement.
10+
update_interval = 5.0
11+
# Heuristic, not empirically tuned: hold concurrency within 3% of the
12+
# usable CPU count on either side of the target to reduce oscillation.
13+
target_tolerance = 0.03
14+
15+
def __init__(self, target, maximum, logger):
16+
self.target = target
17+
self.maximum = maximum
18+
self.get_load_avg = logger.get_load_avg
19+
self.cpu_count = os.process_cpu_count() or 1
20+
# Load is system-wide, so this only approximates affinity restrictions.
21+
self.load_target = target * self.cpu_count
22+
self.initial_limit = min(maximum, max(1, math.ceil(self.load_target)))
23+
self.limit = self.initial_limit
24+
self.active = 0
25+
self.stopped = False
26+
self.condition = threading.Condition()
27+
28+
def acquire(self):
29+
with self.condition:
30+
self.condition.wait_for(lambda: self.stopped or self.active < self.limit)
31+
if self.stopped:
32+
return False
33+
self.active += 1
34+
return True
35+
36+
def release(self):
37+
with self.condition:
38+
self.active -= 1
39+
self.condition.notify_all()
40+
41+
def get_active_workers(self):
42+
with self.condition:
43+
return self.active
44+
45+
def update(self):
46+
try:
47+
load = self.get_load_avg()
48+
except (OSError, ValueError):
49+
load = None
50+
with self.condition:
51+
if load is None or not math.isfinite(load) or load < 0:
52+
self.limit = self.initial_limit
53+
self.condition.notify_all()
54+
return
55+
tolerance = self.target_tolerance * self.cpu_count
56+
if load < self.load_target - tolerance:
57+
self.limit = min(self.maximum, self.limit + 1)
58+
elif load > self.load_target + tolerance:
59+
self.limit = max(1, self.limit - 1)
60+
self.condition.notify_all()
61+
62+
def stop(self):
63+
with self.condition:
64+
self.stopped = True
65+
self.condition.notify_all()

‎Lib/test/libregrtest/cmdline.py‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@
66
from .utils import ALL_RESOURCES, RESOURCE_NAMES, TestFilter
77

88

9+
def worker_count(value: str) -> int | float:
10+
if value.endswith('%'):
11+
percentage = value[:-1]
12+
if (not percentage.isascii() or not percentage.isdecimal()
13+
or not 1 <= int(percentage) <= 100):
14+
raise argparse.ArgumentTypeError('expected a percentage from 1% to 100%')
15+
return int(percentage) / 100
16+
try:
17+
return int(value)
18+
except ValueError:
19+
raise argparse.ArgumentTypeError(f'invalid int value: {value!r}') from None
20+
21+
922
USAGE = """\
1023
python -m test [options] [test_name1 [test_name2 ...]]
1124
python path/to/Lib/test/regrtest.py [options] [test_name1 [test_name2 ...]]
@@ -27,6 +40,14 @@
2740
EPILOG = """\
2841
Additional option details:
2942
43+
`-jN%` adjusts the running worker count adaptively to approximate N% total
44+
system load. At least one worker is always allowed to run, up to
45+
`os.process_cpu_count() * 2 + 4` (twice the limit used by `-j0`). Running
46+
tests are never suspended, so the target can remain exceeded for an extended
47+
period of time. At startup, `ceil(os.process_cpu_count() * (N/100))` workers
48+
are allowed, and failure to track load may result in reverting to this worker
49+
count. Adaptive adjustments resume when load tracking recovers.
50+
3051
`-r` randomizes test execution order. You can use `--randseed=int` to provide an
3152
int seed value for the randomizer. The `randseed` value will be used
3253
to set seeds for all random usages in tests
@@ -174,7 +195,7 @@ def __init__(self, **kwargs) -> None:
174195
self.verbose3 = False
175196
self.print_slow = False
176197
self.random_seed = None
177-
self.use_mp = None
198+
self.use_mp: int | float | None = None
178199
self.parallel_threads = None
179200
self.forever = False
180201
self.header = False
@@ -333,8 +354,10 @@ def _create_parser():
333354
help='search for reference leaks (needs debug build, '
334355
'very slow).' + more_details)
335356
group.add_argument('-j', '--multiprocess', metavar='PROCESSES',
336-
dest='use_mp', type=int,
337-
help='run `PROCESSES` processes at once')
357+
dest='use_mp', type=worker_count,
358+
help='run `PROCESSES` processes at once, or pass a '
359+
'percentage to adaptively target a level of load.'
360+
+ more_details)
338361
group.add_argument('--single-process', action='store_true',
339362
dest='single_process',
340363
help='always run all tests sequentially in '

‎Lib/test/libregrtest/logger.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@ def __init__(self, results: TestResults, quiet: bool, pgo: bool):
2121
self._quiet: bool = quiet
2222
self._pgo: bool = pgo
2323
self.get_mem_usage: Callable[[], int | None] | None = None
24+
self.get_active_workers: Callable[[], int] | None = None
2425

2526
def log(self, line: str = '') -> None:
2627
empty = not line
2728

29+
if self.get_active_workers is not None:
30+
line = f'running: {self.get_active_workers()} {line}'
31+
2832
# Add the memory usage: "mem: 1 GiB "
2933
if self.get_mem_usage is not None:
3034
mem = self.get_mem_usage()

‎Lib/test/libregrtest/main.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,13 @@ def __init__(self, ns: Namespace, _add_python_opts: bool = False):
9595

9696
# Workers
9797
self.single_process: bool = ns.single_process
98+
self.adaptive_target = ns.use_mp if isinstance(ns.use_mp, float) else None
9899
if self.single_process or ns.use_mp is None:
99100
num_workers = 0 # run sequentially in a single process
100-
elif ns.use_mp <= 0:
101+
elif self.adaptive_target or ns.use_mp <= 0:
101102
num_workers = -1 # run in parallel, use the number of CPUs
102103
else:
103-
num_workers = ns.use_mp # run in parallel
104+
num_workers = int(ns.use_mp) # run in parallel
104105
self.num_workers: int = num_workers
105106
if ns.single_process_per_case and ns.use_mp is None:
106107
# Each test case runs in its own worker subprocess;
@@ -465,7 +466,7 @@ def get_state(self) -> str:
465466
def _run_tests_mp(self, runtests: RunTests, num_workers: int) -> None:
466467
from .run_workers import RunWorkers
467468
RunWorkers(num_workers, runtests, self.logger, self.results,
468-
self.reporter).run()
469+
self.reporter, adaptive_target=self.adaptive_target).run()
469470

470471
def finalize_tests(self, coverage: trace.CoverageResults | None) -> None:
471472
if self.next_single_filename:
@@ -548,6 +549,9 @@ def _run_tests(self, selected: TestTuple, tests: TestList | None) -> int:
548549
# Use all CPUs + 2 extra worker processes for tests
549550
# that like to sleep
550551
self.num_workers = (os.process_cpu_count() or 1) + 2
552+
if self.adaptive_target is not None:
553+
# Allow more sleeping or I/O-bound tests in adaptive mode.
554+
self.num_workers *= 2
551555

552556
# For a partial run, we do not need to clutter the output.
553557
if (self.want_header

‎Lib/test/libregrtest/run_workers.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def __init__(self, worker_id: int, runner: "RunWorkers") -> None:
111111
self.worker_id = worker_id
112112
self.runtests = runner.runtests
113113
self.pending = runner.pending
114+
self.worker_gate = runner.worker_gate
114115
self.output = runner.output
115116
self.timeout = runner.worker_timeout
116117
self.log = runner.log
@@ -415,6 +416,8 @@ def run(self) -> None:
415416
for test_name in case_ids:
416417
if self._stopped:
417418
break
419+
if self.worker_gate is not None and not self.worker_gate.acquire():
420+
raise ExitThread
418421
self.start_time = time.monotonic()
419422
self.test_name = test_name
420423
try:
@@ -425,6 +428,8 @@ def run(self) -> None:
425428
mp_result = exc.mp_result
426429
finally:
427430
self.test_name = _NOT_RUNNING
431+
if self.worker_gate is not None:
432+
self.worker_gate.release()
428433
mp_result.result.duration = time.monotonic() - self.start_time
429434
if single_process_per_case:
430435
# Report the test case, not the test module
@@ -501,7 +506,7 @@ def get_running(workers: list[WorkerThread]) -> str | None:
501506
class RunWorkers:
502507
def __init__(self, num_workers: int, runtests: RunTests,
503508
logger: Logger, results: TestResults,
504-
reporter: Reporter) -> None:
509+
reporter: Reporter, adaptive_target: float | None = None) -> None:
505510
self.num_workers = num_workers
506511
self.runtests = runtests
507512
self.logger = logger
@@ -529,6 +534,13 @@ def __init__(self, num_workers: int, runtests: RunTests,
529534
# these worker threads would never get anything to do.
530535
self.num_workers = min(self.num_workers, jobs)
531536

537+
self.worker_gate = None
538+
if adaptive_target is not None:
539+
from .adaptive_workers import AdaptiveWorkerGate
540+
self.worker_gate = AdaptiveWorkerGate(
541+
adaptive_target, max(1, self.num_workers), logger)
542+
self.next_worker_update = time.monotonic() + self.worker_gate.update_interval
543+
532544
def start_workers(self) -> None:
533545
self.workers = [WorkerThread(index, self)
534546
for index in range(1, self.num_workers + 1)]
@@ -545,13 +557,18 @@ def start_workers(self) -> None:
545557
msg += (" (timeout: %s, worker timeout: %s)"
546558
% (format_duration(self.timeout),
547559
format_duration(self.worker_timeout)))
560+
if self.worker_gate is not None:
561+
msg += (f' (Load target: {self.worker_gate.target:.0%}, adaptive workers; '
562+
f'load average {self.worker_gate.load_target:.2f})')
548563
self.log(msg)
549564
for worker in self.workers:
550565
worker.start()
551566
self.live_worker_count += 1
552567

553568
def stop_workers(self) -> None:
554569
start_time = time.monotonic()
570+
if self.worker_gate is not None:
571+
self.worker_gate.stop()
555572
for worker in self.workers:
556573
worker.stop()
557574
for worker in self.workers:
@@ -568,9 +585,17 @@ def _get_result(self) -> QueueOutput | None:
568585
faulthandler.dump_traceback_later(MAIN_PROCESS_TIMEOUT,
569586
exit=True)
570587

588+
timeout = PROGRESS_UPDATE
589+
if self.worker_gate is not None:
590+
now = time.monotonic()
591+
if now >= self.next_worker_update:
592+
self.worker_gate.update()
593+
self.next_worker_update = now + self.worker_gate.update_interval
594+
timeout = min(timeout, self.next_worker_update - now)
595+
571596
# wait for a thread
572597
try:
573-
result = self.output.get(timeout=PROGRESS_UPDATE)
598+
result = self.output.get(timeout=timeout)
574599
if isinstance(result, WorkerThreadExited):
575600
self.live_worker_count -= 1
576601
continue
@@ -647,6 +672,8 @@ def run(self) -> None:
647672
fail_fast = self.runtests.fail_fast
648673
fail_env_changed = self.runtests.fail_env_changed
649674
self.logger.get_mem_usage = self.get_mem_usage
675+
if self.worker_gate is not None:
676+
self.logger.get_active_workers = self.worker_gate.get_active_workers
650677

651678
self.start_workers()
652679

@@ -672,3 +699,4 @@ def run(self) -> None:
672699
self.pending.stop()
673700
self.stop_workers()
674701
self.logger.get_mem_usage = None
702+
self.logger.get_active_workers = None

‎Lib/test/test_regrtest.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
r'[0-9]+:[0-9]+:[0-9]+ '
5353
r'(?:load avg: [0-9]+\.[0-9]{2} )?'
5454
r'(?:mem: +[0-9]+\.[0-9] (?:MiB|GiB) )?'
55+
r'(?:running: [0-9]+ )?'
5556
)
5657
RESULT_REGEX = (
5758
'passed',
@@ -380,9 +381,50 @@ def test_multiprocess(self):
380381
with self.subTest(opt=opt):
381382
ns = self.parse_args([opt, '2'])
382383
self.assertEqual(ns.use_mp, 2)
384+
self.assertIsInstance(ns.use_mp, int)
383385
self.checkError([opt], 'expected one argument')
384386
self.checkError([opt, 'foo'], 'invalid int value')
385387

388+
def test_percentage_jobs(self):
389+
for args in (['-j90%'], ['--multiprocess=90%'], ['-j', '90%']):
390+
with self.subTest(args=args):
391+
ns = self.parse_args(args)
392+
self.assertEqual(ns.use_mp, 0.9)
393+
self.assertIsInstance(ns.use_mp, float)
394+
regrtest = main.Regrtest(ns)
395+
self.assertEqual(regrtest.adaptive_target, 0.9)
396+
self.assertEqual(regrtest.num_workers, -1)
397+
for value, expected in (('1%', 0.01), ('100%', 1.0)):
398+
ns = self.parse_args(['-j', value])
399+
self.assertEqual(ns.use_mp, expected)
400+
self.assertIsInstance(ns.use_mp, float)
401+
for value in ('0%', '101%', '-1%', '1.5%', 'nan%', '%'):
402+
with self.subTest(value=value):
403+
self.checkError([f'-j{value}'], 'expected a percentage')
404+
405+
def test_percentage_jobs_without_load_average(self):
406+
with unittest.mock.patch.object(os, 'getloadavg', create=True):
407+
del os.getloadavg
408+
self.assertEqual(self.parse_args(['-j90%']).use_mp, 0.9)
409+
ns = self.parse_args(['-j90%', '--single-process'])
410+
self.assertIsNone(ns.use_mp)
411+
412+
def test_percentage_jobs_worker_ceiling(self):
413+
class StopRun(Exception):
414+
pass
415+
416+
for cpu_count in (1, 16, None):
417+
automatic = (cpu_count or 1) + 2
418+
for option, expected in (('-j90%', 2 * automatic),
419+
('-j0', automatic), ('-j4', 4)):
420+
with self.subTest(cpu_count=cpu_count, option=option):
421+
regrtest = self.create_regrtest([option, '--header'])
422+
with (unittest.mock.patch('os.process_cpu_count', return_value=cpu_count),
423+
unittest.mock.patch.object(main, 'display_header', side_effect=StopRun)):
424+
with self.assertRaises(StopRun):
425+
regrtest._run_tests((), [])
426+
self.assertEqual(regrtest.num_workers, expected)
427+
386428
def test_coverage_sequential(self):
387429
for opt in '-T', '--coverage':
388430
with self.subTest(opt=opt):
@@ -1072,6 +1114,13 @@ def test_test3(self):
10721114
self.check_executed_tests(output, tests,
10731115
stats=3 * len(tests))
10741116

1117+
def test_percentage_jobs(self):
1118+
tests = [self.create_test(f'cpu{i}') for i in range(3)]
1119+
output = self.run_tests('-j90%', *tests)
1120+
self.check_executed_tests(output, tests, stats=len(tests), parallel=True)
1121+
self.assertIn('Load target: 90%, adaptive workers', output)
1122+
self.assertRegex(output, r'running: \d+ \[\s*\d+/3\]')
1123+
10751124
def test_skip(self):
10761125
code = textwrap.dedent("""
10771126
import unittest

0 commit comments

Comments
 (0)